Migrating EasyPost Webhooks From Basic Auth to HMAC v2
Step-by-step: move an EasyPost webhook receiver from Basic Auth to HMAC validation, verify the x-hmac-signature-v2 header, and fix 401 timestamp errors.
If your EasyPost webhook receiver still authenticates via https://user:secret@host/path, this walkthrough gets you to x-hmac-signature-v2 validation without breaking delivery in the process. We ran this cutover against our own test harness in October 2026 after EasyPost's September 28, 2026 release notes flagged "Webhook security — Strengthened webhook secret validation." That line alone is reason enough to stop treating HMAC migration as a someday task.
Why Basic-Auth-in-URL isn't enough anymore
Basic Auth in the URL proves the sender knew a shared secret at connection time. It proves nothing about the body that arrived. No signature, no timestamp, no defense against a replayed request hitting your endpoint twice. EasyPost's own FAQ is blunt about the alternative people reach for instead: IP allowlisting doesn't work here, because EasyPost does not provide a fixed or guaranteed list of source IP addresses, CIDR ranges, or ASNs for outbound webhook delivery, and because delivery infrastructure may change, IP-based allowlisting is not a supported method for authenticating EasyPost webhooks. The vendor's recommendation is explicit: use HMAC signature validation to verify the authenticity and integrity of incoming webhook requests, per the Webhook HMAC Validation Guide.
Worth noting: EasyPost's docs currently describe the X-Hmac-Signature header in the general webhooks guide, while the dedicated Webhook HMAC Validation article documents the v2 scheme with the x-hmac-signature-v2 header name and the fuller hmac-sha256-hex= prefix format. Treat the Help Center article as the source of truth for the current implementation; it's the one that spells out the timestamp-and-path checks this guide is built around.
What you need before starting
You need four things in place before touching production traffic:
- An active EasyPost webhook you can identify by ID, with its current auth mode noted (Basic Auth URL or none)
- Deploy access to the receiving service, since you're adding header parsing and signature logic, not just a config flag
- Permission to generate or rotate a
webhook_secreton that webhook object - A sandbox EasyPost environment to dry-run the whole cutover with a second, parallel webhook before you touch the production one
Every official EasyPost client library ships a validate_webhook()-style function. The exact name varies: Use validate_webhook() from EasyPost's client libraries (the function name may vary depending on the programming language; refer to the respective library documentation for the exact name). Check your SDK's changelog too — EasyPost's Node library, for instance, already checks for X-Hmac-Signature header during webhook validation regardless of casing, which matters if your framework lowercases headers before your code sees them.
The migration, step by step
- Generate a
webhook_secretand patch the existing webhook. UsePATCH /v2/webhooks/:idrather than creating a new webhook object — the endpoint exists specifically to update the webhook_secret and custom_headers without losing delivery history tied to the original webhook ID. - Strip the embedded credentials from the URL. Change the webhook's
urlfield from theusername:secret@pattern to a plain HTTPS endpoint. Leaving the old credentials in place alongside HMAC headers just means you're maintaining two auth paths for no benefit. - Add header parsing on the receiver for
x-hmac-signature-v2, plus the timestamp and path headers EasyPost includes on each delivery: each webhook event includes headers with a timestamp, request path, and HMAC signature. - Call
validate_webhook()with the secret, headers, and raw body rather than hand-rolling the HMAC math where you can avoid it — the recommended way to validate webhooks is by using validate_webhook() in EasyPost client libraries, a function that accepts the webhook secret, headers, and request body, and automatically verifies the signature. If you do need to implement it manually (older SDK, non-standard stack), the algorithm is documented as HMAC-SHA256 (RFC 2104 compliant), compared against the value inside "hmac-sha256-hex=<character-hex-string>". - Reject on mismatch with your own 401, not EasyPost's, and log which stage failed. EasyPost's own server-side process runs two checks in sequence — EasyPost validates these headers in two stages before forwarding the request to the application: Timestamp Validation, which confirms that the request was sent within an acceptable time window, and HMAC Signature Validation, which recalculates the signature and compares it to the one included in the request. Mirror that same two-stage order in your own receiver logic so your logs tell you which check actually failed.
- Deploy to staging first, and point a second EasyPost test-mode webhook at it. Don't touch production until the staging endpoint has processed a handful of real sandbox events cleanly.
- Cut production over, then tail logs for the next live event. Tracker updates fire frequently enough to confirm the pipeline within minutes rather than waiting on label creation volume.
How you know the cutover worked
Three signals, in order of usefulness. First, trigger a manual test ping from the EasyPost dashboard and confirm your receiver logs show a verified signature plus a 2xx response. Second, check the webhook object's status in the EasyPost account page — a receiver that's silently rejecting valid requests eventually gets flagged, since EasyPost notes that endpoints that repeatedly return failures may be automatically disabled, and disabled webhooks can be re-enabled using the Webhook update endpoint. If your webhook shows disabled after the cutover, that's your first clue something in the header parsing broke, not a billing or network issue.
Third, do a manual replay sanity check. Capture one real payload and its headers from your logs, then recompute the HMAC independently:
echo -n "$TIMESTAMP.$PATH.$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET"Compare the hex output against the value after hmac-sha256-hex= in the captured x-hmac-signature-v2 header. EasyPost's own guidance for anyone implementing this by hand is to create an HMAC instance using the shared secret key and SHA-256, and protect the secret key in memory and storage — don't log the raw secret anywhere in this debugging process, only the computed digest.
Failure mode: timestamp-drift 401s
This is the one that burns people after a seemingly clean deploy. The validation flow enforces a window specifically to stop replay attacks: EasyPost's HMAC validation process verifies the integrity of the request body, prevents replay attacks using a timestamp tolerance window, and returns clear error codes when validation fails. If your container's clock has drifted (common on images without NTP sync, or on cold-started serverless functions), a genuinely valid signature gets rejected because the timestamp falls outside tolerance.
Two things to check before you assume the secret is wrong:
- Sync server clocks via NTP and confirm drift is under a few seconds, not minutes
- Verify the timestamp check runs before any body transformation or middleware buffering — a proxy or framework that delays request processing by even a couple of seconds can push a valid event outside the window
Log the raw x-timestamp delta on every rejection. A consistent small drift points to clock skew. A wildly stale timestamp points to something actually replaying old traffic. Don't conflate either of those with a genuine secret mismatch — a wrong webhook_secret deployed after rotation fails at the signature stage, not the timestamp stage, and EasyPost's two-stage process means your logs should already tell them apart if you're logging per-stage failures as recommended in step 5 above. Debugging a timestamp failure as if it were a secret problem (rotating the secret again) wastes a deploy cycle and doesn't fix anything.
How this compares to other carrier and tracking webhooks
Every provider names its header differently, which is the quiet tax of integrating more than one. AfterShip's tracking product signs with a aftership-hmac-sha256 header, where the signature is a base64-encoded HMAC generated using sha256 algorithm with webhook request body and webhook secret of your account — notably base64, not hex, and computed over the body alone with no timestamp component. InPost takes a different path again: its current recommended method is HMAC-SHA256 to generate a signature of the message hash, included in the x-inpost-signature header, alongside a now-deprecated RSA digital-signature option that some longer-running integrations still carry.
None of these schemes are wrong, they're just incompatible with each other by design. If you're maintaining EasyPost alongside direct carrier APIs and tracking platforms, that per-vendor variance in header names, encodings, and timestamp handling is exactly the plumbing that multi-carrier connectivity layers — nShift, Sendcloud, ShipEngine, ClickPost, AfterShip, and Cargoson among them — exist to absorb into one normalized, already-verified event stream. Whether that trade is worth it depends on how many carrier webhook formats you're actually juggling versus how much you'd rather own the verification code yourself.
What we haven't tested yet
We haven't run this migration through a live secret rotation with dual-signature overlap, where both old and new secrets validate simultaneously during a cutover window. EasyPost's current documentation doesn't describe a space-delimited or multi-secret rotation mechanism the way some other webhook providers do, so a rotation today likely means a hard cutover rather than a grace period. That's worth testing in sandbox before anyone relies on it in production, and we'll follow up once we've run it.
Next step if you're doing this now: patch one low-traffic webhook first, watch it for 24 hours of real event volume, then move the rest. Don't batch-migrate every webhook on the account in one deploy — if there's a header-casing bug in your receiver, you want to find it on one endpoint, not all of them at once.