Back to blog

How to verify Shippo and EasyPost webhook signatures in Node

RateShip Team7 min read

If you've ever wired up a webhook endpoint for a shipping provider and spent an afternoon staring at signature mismatches, you know the pain. The signature algorithm is documented but the implementation gotchas usually aren't. This post is the code you actually need, plus the traps that bite half of integrations.

The one rule that breaks everything if you miss it

HMAC is computed over the raw bytes of the request body. Not the parsed JSON. Not a re-serialized version. The exact bytes that came off the wire.

Most Node frameworks (Express, Next.js, Fastify) parse JSON bodies automatically. Once that happens, the bytes change. Whitespace, property order, and unicode escaping can all differ between the provider's serialization and yours. Your HMAC will silently mismatch.

Fix: grab the raw body before parsing. In Express:

app.post(
  "/webhooks/easypost",
  express.raw({ type: "application/json" }),   // <-- not express.json()
  (req, res) => {
    const rawBody = req.body;  // Buffer, unparsed
    // ... verify, then parse JSON yourself after verification passes
  },
);

In a Next.js App Router route handler:

export async function POST(req: Request) {
  const rawBody = await req.text();  // string, unparsed
  // verify with rawBody, then JSON.parse after
}

EasyPost

HMAC-SHA256, hex-encoded, sent as the X-Hmac-Signature header with the format hmac-sha256-hex=<hex>. One quirk worth noting: EasyPost's official client libraries (Python, Ruby, Go) NFKD-normalize the secret before hashing. If your secret contains unicode characters (unlikely but possible), match that or you'll mismatch.

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyEasyPost(rawBody: string | Buffer, header: string, secret: string) {
  const expectedPrefix = "hmac-sha256-hex=";
  const received = header.startsWith(expectedPrefix)
    ? header.slice(expectedPrefix.length)
    : header;

  const normalizedSecret = secret.normalize("NFKD");
  const computed = createHmac("sha256", normalizedSecret)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(received, "utf8");
  const b = Buffer.from(computed, "utf8");
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

The timing-safe comparison matters. A naive === lets attackers use response-time to probe the signature one byte at a time. timingSafeEqual takes constant time regardless of how early a mismatch occurs.

Shippo

HMAC-SHA256 too, but over the concatenation of timestamp plus body (separated by a dot), sent as the Shippo-Auth-Signature header in the format t=<unix>,v1=<hex>. The timestamp enables replay protection. Shippo doesn't document a specific window, so use a reasonable default like 5 minutes (matches Stripe and GitHub norms).

function verifyShippo(rawBody: string, header: string, secret: string) {
  const parts = header.split(",").map((p) => p.trim());
  let timestamp: number | null = null;
  let signature: string | null = null;
  for (const part of parts) {
    const [key, value] = part.split("=");
    if (key === "t") timestamp = Number(value);
    else if (key === "v1") signature = value;
  }
  if (!timestamp || !signature) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) return false;  // 5-min replay window

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(signature, "utf8");
  const b = Buffer.from(expected, "utf8");
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

One caveat with Shippo: HMAC signing is opt-in. Default webhook deliveries are unsigned. To enable signing, email your account manager or support@goshippo.com. Setup takes a few business days.

ShipEngine

ShipEngine uses RSA-SHA256 with a JWKS key-rotation endpoint rather than HMAC with a shared secret. That's more secure in principle (no shared secret to leak), but the implementation is non-trivial: you fetch the public keys from https://api.shipengine.com/.well-known/jwks.json, cache them, match the X-ShipEngine-Signature-Rsa-Sha256-Key-Id header against a cached key, then verify with crypto.verify.

We're not going to spell out the full code here because it depends heavily on your JWK-to-KeyObject conversion path and how you cache the JWKS. The ShipEngine docs have a working Node example that covers it end to end.

The shortcut

If you don't want to write and maintain three different verification implementations, our rateship SDK includes verified helpers:

import { RateShip, easypost, shippo } from "rateship";

const client = new RateShip({
  providers: [
    easypost({ apiKey: process.env.EASYPOST_KEY! }),
    shippo({ apiKey: process.env.SHIPPO_KEY! }),
  ],
});

// In your route handler:
try {
  const event = client.webhooks.verify({
    provider: "easypost",              // or "shippo"
    rawBody: await req.text(),
    signature: req.headers.get("x-hmac-signature")!,
    secret: process.env.EASYPOST_WEBHOOK_SECRET!,
  });
  // event.type is "tracking.updated" or "tracking.delivered"
  // event is fully typed, signature is verified, body is parsed and normalized
} catch (err) {
  // WebhookVerificationError on signature mismatch
  return new Response(null, { status: 401 });
}

ShipEngine verification is coming in v2.1 (we need the async JWKS fetch path). For now, the two HMAC providers are covered.