Webhook HMAC Replay Protection: Timestamps, Retries, and Idempotency
Webhook HMAC replay protection needs two separate controls. First, verify HMAC-SHA256 over the exact timestamp plus raw request body and reject deliveries outside your own freshness window. Second, deduplicate the stable event ID because an authentic delivery can still be retried. Signature verification proves integrity and knowledge of the shared secret; it does not make delivery exactly-once.
How webhook HMAC replay protection works
A safe receiver answers four questions in order:
- Are the signing headers present and well formed? Reject missing timestamps, signatures, and event IDs when the endpoint is configured for signing.
- Is the request recent enough? Parse the RFC 3339 timestamp and apply the freshness window chosen for your infrastructure.
- Do the exact bytes match the signature? Compute HMAC-SHA256 over
<timestamp>.<raw body>and compare the digest in constant time. - Has this event already been accepted? Store the stable event ID with a unique constraint before acknowledging the delivery.
The last check matters because retry and replay are not the same thing. A retry is a legitimate redelivery after a connection failure or non-2xx response. A replay is the reuse of a previously valid signed request outside the processing path you intended. Timestamp freshness limits how long a captured request remains acceptable; durable idempotency stops a valid retry from creating the same ticket, reply, or workflow twice.
Key takeaways
- Verify the raw request bytes before JSON parsing or re-serialization.
- Treat timestamp freshness as an application policy; UnifyPort does not prescribe one fixed tolerance for every deployment.
- Use a constant-time comparison for equal-length digest buffers.
- Deduplicate
X-Device-Event-Id, because UnifyPort delivery is at-least-once. - Return 2xx only after the event has been durably accepted, not after all downstream work finishes.
The exact UnifyPort signature contract
When a webhook endpoint has a signing_secret, UnifyPort sends a hex-encoded X-Device-Signature. The signed value is:
<X-Device-Timestamp>.<raw request body>
X-Device-Timestamp is an RFC 3339 UTC value, not a Unix integer. X-Device-Event-Id remains stable across retries of the same event, while X-Device-Delivery-Id identifies an individual delivery attempt. If signing_secret is omitted or empty, signing is disabled and the signature header is not sent.
This contract follows the purpose of HMAC defined in RFC 2104: two parties sharing a secret can check message integrity and authenticate the sender that knows that secret. It does not encrypt the body, establish freshness by itself, or promise one-time delivery. Those guarantees come from HTTPS, timestamp policy, and idempotent storage around the HMAC check.
If you are building a complete inbound workflow, the n8n WhatsApp webhook tutorial shows how a signed event enters an automation, while the TikTok live-DM queue tutorial shows why the same verified envelope should be stored before routing.
Verify the timestamp and raw body in Node.js
The receiver below keeps the body as a Buffer, reads its freshness tolerance from deployment configuration, compares binary digests with Node.js crypto.timingSafeEqual, and hands the verified event to a durable inbox. durableInbox.insertIfAbsent represents a database insert protected by a unique key on the event ID; implement it with the datastore already used by your service.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.WEBHOOK_SIGNING_SECRET;
const maxAgeMs = Number(process.env.WEBHOOK_MAX_AGE_MS);
if (!secret || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
throw new Error('Configure WEBHOOK_SIGNING_SECRET and WEBHOOK_MAX_AGE_MS');
}
app.post(
'/webhooks/unifyport',
express.raw({ type: 'application/json' }),
async (req, res) => {
const timestamp = req.get('X-Device-Timestamp') ?? '';
const signature = req.get('X-Device-Signature') ?? '';
const eventId = req.get('X-Device-Event-Id') ?? '';
if (!timestamp || !signature || !eventId) {
return res.sendStatus(401);
}
const signedAtMs = Date.parse(timestamp);
const ageMs = Math.abs(Date.now() - signedAtMs);
if (!Number.isFinite(signedAtMs) || ageMs > maxAgeMs) {
return res.sendStatus(401);
}
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.')
.update(req.body)
.digest();
const validHex = /^[0-9a-f]{64}$/i.test(signature);
const provided = validHex ? Buffer.from(signature, 'hex') : Buffer.alloc(0);
const validSignature =
provided.length === expected.length &&
crypto.timingSafeEqual(provided, expected);
if (!validSignature) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
const accepted = await durableInbox.insertIfAbsent({
id: eventId,
occurredAt: event.occurred_at,
payload: event,
});
return res.sendStatus(accepted ? 202 : 200);
},
);
The freshness check appears before HMAC comparison, but the timestamp is trusted only after both checks pass. The receiver merely rejects obviously stale input early. The value of WEBHOOK_MAX_AGE_MS should reflect your clock synchronization, normal delivery latency, incident procedures, and risk model. Do not copy a tolerance from an unrelated provider and assume it fits your queue.
Node.js documents crypto.timingSafeEqual as suitable for comparing HMAC digests, while also warning that the surrounding code must avoid introducing timing leaks. Validate the hex format and byte length first, because timingSafeEqual requires equal-length inputs.
Make the acknowledgement path retry-safe
UnifyPort treats any 2xx response as an acknowledgement and discards the response body. Connection errors plus HTTP 408, 429, and 5xx responses are retryable up to the endpoint’s configured retry_policy.max_attempts; the default is three. Other 4xx responses are not retried and the event is dead-lettered.
That behavior leads to a practical receiver design:
| Receiver outcome | Response | Why |
|---|---|---|
| Missing, stale, or invalid signature | 401 | The request should not enter the trusted queue. |
| Verified event already stored | 200 | The retry is safe to acknowledge without repeating work. |
| Verified event durably inserted | 202 | The worker can continue asynchronously after acceptance. |
| Durable inbox temporarily unavailable | 503 | A retry is safer than acknowledging an event that was not stored. |
Use a unique index on X-Device-Event-Id rather than a process-local Set. A local cache disappears on restart and does not coordinate multiple receiver instances. Keep downstream actions idempotent too: a queue consumer can crash after calling a CRM or sending a reply but before recording completion.
Delivery order is not guaranteed. Sort state-changing work by the event payload’s occurred_at, using the event ID as a tiebreaker, instead of assuming HTTP arrival order. This is especially important when a read receipt can reach your endpoint before the message it references.
Where UnifyPort fits
UnifyPort delivers the same standard event envelope across supported channels, including id, type, provider, account_id, occurred_at, and event-specific data. The receiver above therefore protects one ingress path rather than six channel-specific handlers. Register the endpoint once, enable signing_secret, subscribe to the events you need, and apply the same timestamp, signature, and idempotency checks before routing by provider or type.
The important boundary is storage: UnifyPort does not persist message history for later backfill. Webhook events are the traffic record, so a receiver should durably accept them before returning 2xx. Signature verification protects the handoff; your inbox table or queue preserves it.
Limitations and trade-offs
- HMAC authenticates and protects integrity; it does not encrypt the JSON body. Keep HTTPS enabled and protect logs and queues separately.
- A valid HMAC does not stop duplicate processing. Timestamp freshness and an idempotency key are still required.
- UnifyPort does not publish one universal timestamp tolerance. A shorter window limits reuse but is less forgiving of clock drift or delayed delivery.
- If signing is disabled on the endpoint,
X-Device-Signatureis absent. Production receivers that require authentication should fail closed when it is missing. - Official provider webhooks may use different headers, encodings, or canonical strings. Follow each provider’s own contract instead of applying the UnifyPort string format to every webhook source.
Frequently asked questions
Why does my webhook HMAC signature mismatch?
The most common cause is verifying parsed or re-serialized JSON instead of the exact raw bytes. Also check the RFC 3339 timestamp, the literal dot separator, the correct signing_secret, hex decoding, and whether middleware consumed the body before verification.
Does HMAC alone prevent webhook replay?
No. HMAC proves that the signed bytes match a shared secret. Add a freshness check for X-Device-Timestamp and durable deduplication for X-Device-Event-Id to limit reuse and repeated processing.
Should a duplicate event return an error?
No. If the same event ID was already durably accepted, return 2xx. An error would invite another legitimate retry without improving correctness.
Should I acknowledge before processing the event?
Acknowledge after durable acceptance but before slow downstream work. Insert the event into a database-backed inbox or durable queue, return 2xx, and let a worker handle CRM writes, AI processing, or replies idempotently.
What freshness window should I use?
Choose and document a window based on synchronized clocks, observed delivery latency, incident handling, and your risk model. The UnifyPort signature contract requires rejecting timestamps too far from your clock but does not prescribe one fixed value.
Next step
Implement the exact header and retry contract in the Webhook delivery and signature verification guide. For a quick byte-for-byte check while diagnosing a mismatch, use the HMAC Signature Generator as the one supporting tool.
Sources
Official sources checked on July 17, 2026: