How to Verify Carrier API Webhook Signatures

Verify webhook signatures from EasyPost, Shippo, and DHL step by step, with HMAC code, exact header names, and the raw-body bug that breaks it.

How to Verify Carrier API Webhook Signatures

Three carriers, three different webhook signature schemes, and one bug that breaks all of them the same way. If you're building a handler that trusts inbound POSTs from EasyPost, Shippo, or DHL's Push API, carrier webhook signature verification is the difference between a real event and something an attacker typed into curl. This walkthrough builds a working handler for each provider and shows exactly where verification silently fails in production, even when your code looks correct.

Why unverified carrier webhooks are a production risk

An endpoint that accepts any POST without checking its origin will process whatever arrives, including replayed or forged tracking and label events. HMAC validation ensures webhook data is untampered during transmission, and providers generate a signature using a secret set when the webhook is created. Skip that check and a delivery-confirmation webhook you didn't actually receive can trigger auto-invoicing, a premature customer notification, or a warehouse release trigger. This isn't a checkbox for a security audit. It's a reliability problem that shows up as "why did we invoice this order twice" three weeks later.

Signature verification also has a hard limit worth knowing upfront. An HMAC signature proves the payload wasn't modified in transit and that whoever sent it holds the shared secret. It does not prove the event is recent, that you haven't seen it before, or that the sender's business logic is correct. That's why timestamp checks and idempotency keys still matter even after signature verification passes.

What you need before starting

Before writing any verification code, get the plumbing in place. You need a publicly reachable endpoint (ngrok tunnel or a mock catcher) and, critically, access to the request body before any JSON-parsing middleware touches it. You'll also need:

  • An EasyPost webhook_secret set at webhook creation time, stored server-side, never in client code.
  • Shippo's HMAC secret from the dashboard's webhook security settings.
  • Header authentication credentials configured on your DHL Push API subscription (this is separate from your DHL Developer Account API key).
  • Access to each provider's sandbox or test event trigger, plus curl for manual replay of captured payloads.

Step-by-step: building a signature verification handler

The steps below apply across providers, but the specifics diverge at steps 3 and 4, which is exactly where most hand-rolled implementations break.

  1. Capture the raw, unparsed body. Configure your route to read bytes before any body-parser runs, for example express.raw({ type: 'application/json' }) in Express, or reading the raw stream directly in Flask/Django before calling request.get_json(). This is the step most tutorials gloss over, and it's the root cause of the failure mode below.
  2. Extract the signature header. EasyPost sends it as X-Hmac-Signature via the X-Hmac-Signature header in each event. Shippo uses shippo-signature in the format t=<timestamp>,v1=<hex signature>, as shown in Shippo's own verification script, which extracts the timestamp and signature from the header before comparing.
  3. Reconstruct the exact string the provider signed. EasyPost signs the raw request body directly. Shippo concatenates timestamp and payload first: the signed payload string is built as "${timestamp}.${payload}" before hashing. Get this concatenation wrong (extra period, wrong order) and every signature fails even with the correct secret.
  4. Compute HMAC-SHA256 and compare using a timing-safe function. Never use === or basic string equality for the comparison; use crypto.timingSafeEqual in Node, hmac.compare_digest in Python, or the equivalent in your stack.
  5. Add a timestamp tolerance check. EasyPost's HMAC validation process prevents replay attacks using a timestamp tolerance window, giving both authenticity and freshness in one pass. If you're building this by hand rather than using a client library, reject anything outside a five-minute window as a starting point.
  6. Test both failure and success paths. Send a payload with a tampered field or a wrong secret and confirm you get a 401 or 403. Then fire a real sandbox event and confirm a 2xx response inside the provider's retry window.

Register the webhook and store the secret. For EasyPost, create the webhook with the secret included in the same call:

curl -X POST https://api.easypost.com/v2/webhooks \
  -u "EASYPOST_API_KEY": \
  -H 'Content-Type: application/json' \
  -d '{
    "webhook": {
      "url": "https://yourapp.example.com/hooks/easypost",
      "webhook_secret": "A1B2C3"
    }
  }'

Include a webhook_secret when creating or updating a webhook, and EasyPost generates a signature with this secret, sent via the X-Hmac-Signature header in each event.

For EasyPost specifically, skip steps 3 through 6 entirely if you can. The recommended way to validate webhooks is by using validate_webhook() in EasyPost client libraries, which accepts the webhook secret, headers, and request body and automatically verifies the signature. Hand-rolled logic is worth understanding, not necessarily worth shipping.

Provider quirks worth knowing

The three providers don't just differ in header names. They differ in whether there's a hash to compute at all.

ProviderSignature headerSigned stringReplay protectionRetry behavior on failure
EasyPostX-Hmac-SignatureRaw request bodyTimestamp header validated before HMAC, rejecting requests outside an acceptable windowNon-2XX responses are retried
Shipposhippo-signature (t=…,v1=…)timestamp + "." + payloadTimestamp embedded in header, checked client-sideNot published in retrieved docs
DHL Push API (Unified)Custom header token, not a signatureN/A — no HMAC schemeA "secret" delivered in the subscription-confirmation header must be echoed back to activate the subscriptionRetries after 6 hours on second failure; deactivates and notifies the technical contact after a third failure or 10,000 consecutive failures

DHL is the outlier here. When creating a subscription you can add header authentication, commonly an API key to your webhook system, and this token is different from your DHL Developer Account app API key. There's no digest to recompute; "verification" means checking that the header token or basic-auth credential on the incoming request matches what you configured. DHL's eCommerce Americas tracking webhooks work similarly but with an extra step: before the webhook is created, the URL is checked for validity by sending an HTTP GET request, and the server must reply with an HTTP 200 to indicate the endpoint is valid and active, with username and password fields set directly in the subscription body for basic auth on every delivery.

The failure mode: signature mismatch after body re-parsing

This is the bug that passes code review and fails at 2am. HMAC is byte-exact: it hashes the literal bytes the provider sent, not the logical content. If a framework's body-parser middleware runs first, converts the body to a JSON object, and your verification code then calls JSON.stringify() on that object to "reconstruct" the payload, you get different bytes. Key order changes, whitespace collapses, numbers get re-serialized. The rule is raw body always: never compute HMAC against re-parsed JSON.

Wrong order, the kind that works in every local test and fails intermittently in production depending on which middleware runs first:

// WRONG: body-parser already ran, JSON is re-serialized
app.use(express.json());
app.post('/hooks/easypost', (req, res) => {
  const body = JSON.stringify(req.body); // bytes no longer match what was signed
  verifySignature(body, req.headers['x-hmac-signature']);
});

Correct order, capturing bytes before anything touches them:

// RIGHT: raw bytes captured before JSON parsing
app.post('/hooks/easypost', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body; // Buffer, untouched
  verifySignature(rawBody, req.headers['x-hmac-signature']);
  const event = JSON.parse(rawBody); // parse only after verification passes
});

Capture the raw body before any JSON parser middleware runs — this single ordering issue accounts for most "the secret is definitely correct but verification still fails" tickets we've seen in the wild. If you're on a framework that parses the body globally (some Django or Rails setups do this by default), you'll need a raw-body capture middleware inserted ahead of the global parser specifically for webhook routes.

How you know it worked

Run through this checklist before calling the integration done:

  • A valid sandbox test event returns a 2xx and your logs show a "signature verified" line, not just "processed."
  • A payload with one character changed after signing returns 401 or 403, not a silent pass.
  • A request signed with the wrong secret is rejected the same way.
  • A timestamp outside your tolerance window (replay an old captured event) gets rejected even with a correct signature.
  • Verification adds low single-digit milliseconds to request handling; if it's adding more, you're probably doing something expensive like fetching the secret from a remote store on every request instead of caching it.

Once this is live, track verification failure rate in whatever webhook monitoring you already run. A sudden spike is usually a secret rotation you weren't told about, not an attack.

Where this fits in your integration stack

Building and maintaining this logic per carrier is exactly the kind of plumbing that multi-carrier abstraction layers exist to remove. Platforms like EasyPost, Shippo, nShift, ShipEngine, and Cargoson generally normalize inbound events behind their own pre-verified webhook layer, so you're trusting one abstraction instead of three separate signature schemes. That's a fair trade once you're integrating five or more carriers directly, but it's worth measuring the abstraction's own webhook latency and reliability before assuming it's a net win over hand-rolled verification. If you're only dealing with EasyPost, Shippo, and DHL today, the code above is a day of work, not a platform migration.