Zyrvix
Back to blog

Webhook idempotency and signature verification: the part most integrations get wrong

By ZyrvixΒ·Β·8 min read

Webhooks are signals, not truth

The most common bug in a webhook integration isn't a missing field or a bad signature β€” it's an assumption baked into the handler on day one: that each event arrives exactly once, in order, and only when something genuinely new has happened. None of that is part of the contract. Webhook delivery is at-least-once, not exactly-once. If your endpoint doesn't respond with a 2xx in time β€” a slow database call, a deploy that landed mid-request, a transient network blip on either end β€” the same event gets retried. Retries, duplicates, and events arriving slightly out of order aren't bugs in Zyrvix's delivery system or yours; they're the normal, expected behavior of any at-least-once system, and the full retry schedule is documented at the retry reference rather than repeated here.

A handler that treats the raw event as an instruction β€” "an invoice.paid event arrived, so ship the order" β€” will eventually ship it twice. Not because anything went wrong upstream, but because that's what a retried delivery looks like from the receiving end: a second, identical POST to the same URL. The fix isn't trying to make delivery exactly-once β€” no webhook system genuinely guarantees that end to end β€” it's building a handler that doesn't care how many times the same event shows up.

Idempotency in practice

The reframe that fixes most of this: stop thinking of a webhook handler as a trigger and start thinking of it as a state-machine transition. The event isn't "do the thing" β€” it's "here's a claim about this invoice's status; apply it if it isn't already applied." Keyed on invoice ID plus target status, that becomes a single idempotent check: if this invoice is not already marked paid in your own database, mark it paid and run the side effects that follow from that transition. If it's already paid, the second (or fifth) delivery of the same event is a no-op β€” correct behavior, not an error to log and investigate.

Two implementation details make this reliable in practice. First, dedupe before anything irreversible runs β€” the check against your own stored status has to happen before you unlock a download, call a shipping API, or send a confirmation email, not after. Every delivery also carries an X-Event-Id header (the same value as the event body's id field) specifically so you can key a deduplication table on it if a status check alone isn't precise enough for your case. Second, respond fast, then do the real work asynchronously. Your endpoint needs to return a 2xx within a short window β€” acknowledge receipt, enqueue the actual processing, and return. A handler that does its full workload synchronously inside the request is the single most common reason deliveries start timing out and retrying in the first place, which then compounds the duplicate-handling problem you're trying to avoid.

Why signature verification is non-optional

An endpoint that accepts webhook POSTs without verifying who sent them isn't really listening for Zyrvix events β€” it's listening for anyone who finds the URL. Webhook endpoints are just public HTTP endpoints; nothing about the URL itself is secret, and plenty of them get discovered by accident (log aggregators, browser history, a misconfigured CORS proxy) long before anyone goes looking on purpose. Without signature verification, a single unauthenticated POST shaped like an invoice.paid event is indistinguishable from a real one β€” which means anyone who finds the URL can mark any invoice paid from your integration's point of view, whether or not any TON ever moved.

Zyrvix's scheme is deliberately simple to implement correctly: every signed delivery includes an X-Timestamp header and an X-Signature header. The signature is an HMAC-SHA256 hex digest of the string "{X-Timestamp}.{raw body}", computed with your webhook secret. Verifying it means recomputing that same digest on your end and comparing it against the header β€” using a constant-time comparison, not == β€” plus rejecting deliveries whose timestamp is too old, which is the piece a signature check alone doesn't cover. All three pieces (raw-body signing, constant-time compare, timestamp tolerance) are covered in the one working example below.

The four mistakes that account for most broken integrations

The verification scheme above is short enough to look trivial, which is exactly why it gets implemented incorrectly more often than the rest of an integration combined. Four mistakes cover almost every broken case:

  • Signing the reserialized JSON instead of the raw bytes. The signature is computed over the exact bytes Zyrvix sent, not a JSON object. If your framework parses the body into an object before your verification code runs, and you reserialize that object to check the signature, any difference in whitespace or key order produces a completely different digest β€” a real, correctly-signed delivery will fail verification. Capture and sign the raw request body, before any JSON parsing.
  • Comparing signatures with == instead of a constant-time compare. A naive string comparison returns as soon as it finds the first differing character, which means the time it takes to fail leaks information about how many leading characters were correct. It's a slow way to find a real signature by brute force β€” but "slow" and "impossible" aren't the same guarantee. Use your language's constant-time comparison primitive (hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js, and the equivalents shown per-language in the docs) every time.
  • Skipping the timestamp check. A signature alone proves a delivery was genuinely signed with your secret at some point β€” it says nothing about when. Without a timestamp-tolerance check, anyone who ever captures one valid, correctly-signed delivery β€” from a proxy log, a browser network tab, a misconfigured debug endpoint β€” can replay that exact request indefinitely, and it will keep passing signature verification forever. Reject anything outside a reasonable tolerance window (the sample below uses five minutes) before checking the signature at all.
  • Trusting the payload's status field for irreversible actions. The event body is a snapshot at the moment it was generated β€” useful for most handlers, but for anything you can't undo (releasing funds, shipping physical goods, granting something that can't be revoked), re-fetch the invoice directly via GET /invoices/{id} and act on that response instead of the webhook payload. This isn't about distrusting a correctly verified signature β€” it's about not acting on a value that could be stale by the time your handler actually runs, however briefly.

Verifying a signature, in code

One full, working implementation below β€” the same logic already documented and execution-verified on the docs page. The other five languages aren't repeated here; the HMAC verification section of the docs has the identical function written out in Node.js, PHP, C#, and Go, plus the curl-level pseudocode if you're wiring this into something else entirely.

# Full, execution-verified shell/curl walkthrough implementation of this same function:
# https://zyrvix.com/docs#webhooks-hmac
Test your handler against both a genuinely valid delivery and a deliberately broken one (wrong secret, stale timestamp, hand-edited body) before trusting it in production β€” a verification function that always returns true fails silently and looks identical to a correct one until the first spoofed request arrives.

None of this is specific to webhooks as a concept β€” it's the same idempotency and authenticity discipline any at-least-once delivery system requires. If you're setting up your first Zyrvix integration rather than hardening an existing one, start with how to accept TON payments for the end-to-end flow this handler sits at the end of.

Back to blog

We use cookies for authentication only. No tracking or analytics cookies.