Verifying webhook signatures and handling retries correctly
Verify the V2 signature over the raw request bytes, reject timestamps outside the replay window, record the event ID under a unique constraint before doing any work, and return 2xx only once the event is durably stored.

Charles Archibong, Co-founder
· 6 min read

Key takeaways
- Compute the HMAC over the raw request bytes, never over re-serialised JSON.
- The signed timestamp plus the replay window is what stops an old, validly signed delivery being replayed.
- Deduplicate on the event ID, not the delivery ID: a manual resend is a new delivery of the same event.
- Return 2xx only after the event is durably stored, and do the slow work afterwards.
A webhook receiver has three jobs, and they happen in a fixed order. Prove the request came from Myaza by checking the signature over the exact bytes received. Prove it is fresh by checking the signed timestamp against the replay window. Then prove you have not already acted on it by recording the event ID under a unique constraint. Only after those three do you return 2xx, and only after that do you do the real work.
Get the order wrong and each failure is quiet. Parse before verifying and a forged body reaches your business logic. Skip the timestamp and a captured request can be replayed indefinitely. Deduplicate on the wrong key and a resend onboards a customer twice. This article walks through each step for Myaza Trust webhooks, with a receiver you can adapt.
What arrives with every delivery
Each delivery is an HTTP POST with a JSON body and a set of headers. The ones that matter for verification, as documented under webhook authentication:
Header | Example | What to do with it |
|---|---|---|
|
| Parse the timestamp and every |
|
| The endpoint's replay window in seconds. Not signed, so never let it widen your check. |
|
| Stable key for deduplication. |
|
| The delivery record. Useful for tracing, not for deduplication. |
|
| For routing. The signed body stays authoritative. |
|
| Legacy compatibility signature over the body alone. |
If you built against the older X-Myaza-Signature header, it still works. It signs the body without a timestamp, which means it cannot tell you whether a request is fresh. Move to the V2 header.
Step 1: verify the signature over the raw bytes
The signature is lowercase hex HMAC-SHA256, keyed with your endpoint secret, over the string <t>.<rawBody>: the timestamp from the header, a full stop, then the request body exactly as it arrived.
"Exactly as it arrived" is the part that breaks integrations. Most web frameworks parse JSON before your handler runs. If you verify against JSON.stringify(req.body), you are signing a re-serialisation: whitespace, key order and number formatting can all differ from what was signed, and verification fails intermittently in ways that look like a bad secret. Configure the route to hand you the raw buffer.
Compare using a constant-time function, and compare against every v1 value, not only the first. During secret rotation Myaza signs with both the active secret and the one being retired, so the header carries two candidates. Accept the request if either matches, and remove the old secret only after its grace period ends in the dashboard.
Step 2: reject stale timestamps
A valid signature proves who sent a request, not when. Anyone who captured a delivery (from a proxy log, say) could resend it, and it would still verify. The signed timestamp closes that gap: because t is inside the signed string, an attacker cannot change it without breaking the signature.
Reject any delivery whose timestamp is missing, unparseable, older than the replay window, or implausibly far in the future. X-Myaza-Replay-Window tells you the endpoint's configured window, but that header sits outside the signed string, so anyone replaying a request could change it. Use it to tighten your check if you like, never to widen it: cap it at the window you have configured for the endpoint. Make sure your server clock is synchronised; a drifting clock turns this check into random rejections.
Return 401 for a missing, stale or invalid signature. Do not return 200 to make the retries stop: that tells the sender the event was accepted.
Step 3: deduplicate on the event, not the delivery
Webhooks are delivered at least once. A receiver that times out, a network blip after you responded, or a teammate pressing Resend in the dashboard will all put the same event in front of you again.
The question is which identifier means "the same thing". There are two candidates, and they behave differently:
The event ID (
X-Myaza-Event-Id, andidin the envelope) identifies the business event. It stays the same across automatic retries and across a manual resend.The delivery ID (
X-Myaza-Delivery) identifies one delivery record. A manual resend creates another delivery record, so it gets a new delivery ID.
Key your deduplication on the event ID. The webhook testing guide says this directly and suggests an insert that does nothing on conflict:
insert into received_myaza_events (event_id, event_type, payload)
values (:id, :type, :payload)
on conflict (event_id) do nothing;One event needs care. verification.status_updated is sent flat, not wrapped in the { id, event, createdAt, data } envelope the other events use, so the body has no id field. Taking the event ID from the header works for every event type, which is the simpler rule.
Step 4: acknowledge fast, then do the work
Return 2xx once the event is durably stored, and not before. Then process it outside the request: update the customer record, notify the applicant, open an internal ticket. Slow processing inside the request invites timeouts, and a timeout is treated as a failure and retried.
Unknown event types should be stored or ignored and still acknowledged. New events are added to the catalogue over time; rejecting one you do not recognise produces a retry loop for something you never intended to handle.
A receiver that does all four
Adapted from the Node.js example in the documentation, with the replay window capped and the deduplicating insert added. saveEventOnce and queue stand in for your own storage and job runner.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/webhooks/myaza", express.raw({ type: "application/json" }), async (req, res) => {
const parts = String(req.header("X-Myaza-Signature-V2") || "").split(",");
const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
const candidates = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
const MAX_WINDOW = 300; // the replay window configured for this endpoint
const windowSeconds = Math.min(Number(req.header("X-Myaza-Replay-Window")) || MAX_WINDOW, MAX_WINDOW);
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > windowSeconds) {
return res.status(401).send("Expired signature");
}
const expected = crypto
.createHmac("sha256", process.env.MYAZA_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest("hex");
const valid = candidates.some((c) =>
c.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(c), Buffer.from(expected))
);
if (!valid) return res.status(401).send("Invalid signature");
const eventId = req.header("X-Myaza-Event-Id");
const payload = JSON.parse(req.body.toString("utf8"));
const isNew = await saveEventOnce(eventId, req.header("X-Myaza-Event"), payload);
res.status(200).send("OK");
if (isNew) queue.add("myaza-event", { eventId });
});Because the event is stored before the response, a crash between the response and queue.add loses nothing: have a small sweeper pick up stored events that were never processed. During a rotation, keep both secrets and compute an expected value for each; the check then passes if any candidate matches any secret.
How retries behave, and what they mean for you
A delivery that does not receive a 2xx, or times out, is retried with backoff:
Attempt | Delay after previous attempt |
|---|---|
1 | 30 seconds |
2 | 5 minutes |
3 | 30 minutes |
4 | 2 hours |
5 | 24 hours |
After five failed attempts the delivery is marked FAILED and can be retried manually from the dashboard. Two consequences follow.
First, an outage on your side of a day or two is recoverable without data loss, provided you fix it before the last attempt and handle the burst of retries idempotently when you come back. Alert on repeated delivery failures so you learn about the outage from your monitoring rather than from a customer.
Second, deliveries retry independently, so they can arrive out of order. Suppose your workflow routes a verification to review and a compliance officer approves it an hour later: two verification.status_updated events, each with its own retry schedule. If the first delivery failed and succeeded on retry, you may receive approved before in_review. The documented rule is to treat the latest changedAt as current, so store it and ignore an update older than the one you hold. Where the stakes are high, read the current state from the API before acting.
Test it before production
Create a sandbox endpoint and use the simulator on the endpoint's page to send correctly signed sample events through the real delivery path. Then work through the failure cases on purpose:
Send a simulated event and confirm the receiver verifies the V2 signature and returns
2xxquickly.Change one byte of a captured body and confirm a
401.Replay a captured request after the window has passed and confirm a
401.Resend a delivery from the dashboard and confirm your side effect happens once.
Return a non-
2xxtemporarily and confirm a retry is scheduled.Rotate the secret and confirm both signatures are accepted during the grace period.
A receiver that passes all six will behave correctly on the day something goes wrong, which is the only day it matters.
Sources

Charles Archibong
Co-founder
Charles Archibong co-founded Myaza Trust. He writes about identity verification, financial technology, and the practical work of building trusted digital services.


