Crypto Payment Webhooks, Done Right
Verify the signature header, check the timestamp, make your handler idempotent, and return 2xx fast. The four rules that keep a payment webhook from lying to your database.
The short answer
To handle a SwapSS Pay webhook safely, do four things in order. Read the raw request body before any framework parses it. Recompute the HMAC-SHA256 of t + "." + raw_body using your webhook signing secret and compare it to the v1 value in the Swap-Pay-Signature header. Reject the request if the t timestamp is more than five minutes old. Then look up the event by its id, and if you have already processed it, do nothing and return 200. If all four hold, apply the state change once and return 2xx quickly.
That is the whole contract. The rest of this guide is why each step matters and the exact way each one breaks in production.
What a webhook is, and why you verify it
A webhook is a URL on your server that we call when something happens to a payment: it confirms, a refund completes, a payout settles. Instead of polling us every few seconds, you get told. The catch is that your endpoint is public. Anyone who finds the URL can POST to it. So before your code trusts a single byte, it has to prove the call came from us and not from someone forging a payment.confirmed to ship goods for free.
We sign every delivery. You verify the signature. That is the only thing standing between your order table and a stranger with curl.
Step 1: verify the signature
Every delivery carries this header:
Swap-Pay-Signature: t=1717430400,v1=8f3c...
t is the unix timestamp of when we signed the event. v1 is a hex HMAC-SHA256. To check it:
- Capture the raw body. Read the exact bytes of the request before your JSON parser touches them. This is the single most common bug. If you verify against a re-serialized object, key order and whitespace shift and the signature will never match. Frameworks that auto-parse JSON need a raw-body hook for this route.
- Build the signed string. Concatenate the
tvalue, a literal., and the raw body:t + "." + raw_body. - Compute and compare. Take HMAC-SHA256 of that string with your webhook signing secret as the key, hex-encode it, and compare to
v1. Use a constant-time comparison, not==, so you do not leak the answer one byte at a time through timing.
If the values do not match, return 401 and stop. Do not log the body as trusted. Do not parse it for "just the id."
You get the signing secret once, when you create the webhook. We cannot show it to you again. If you lose it, revoke the endpoint and create a new one.
Step 2: check the timestamp
A valid signature proves the message is authentic. It does not prove the message is fresh. Someone who captured one real delivery could replay it later, and the signature would still verify, because it is still our signature.
The t value closes that hole. Reject any event where now - t is greater than five minutes. A real delivery reaches you in well under a second; a five-minute window is generous slack for clock skew and slow networks, and tight enough that a captured copy goes stale fast.
Keep your server clock synced with NTP. If your clock drifts ten minutes ahead, you will reject every legitimate event and never know why.
Step 3: make the handler idempotent
Delivery is at-least-once, not exactly-once. We will retry on any non-2xx response or timeout, on a backoff that stretches over the following day. If your server returns 200 but your reverse proxy ate it, or your handler crashed after writing the order but before responding, we will send the same event again. That is by design: it is how you never silently miss a confirmed payment. The cost is that your handler must survive seeing the same event twice.
Every event has a stable id. Use it as the idempotency key.
- Open a transaction.
- Try to insert the event id into a
processed_eventstable with a unique constraint. - If the insert fails on a duplicate, the event is already handled. Commit nothing, return
200. - If it succeeds, apply your state change in the same transaction and commit.
The unique constraint is what makes this safe under concurrent retries. Two copies of the same event arriving at once will race for the insert; exactly one wins, the other gets the duplicate error and exits clean. Idempotency built only from an if exists check in application code has a window between the check and the write where both copies pass. Let the database enforce it.
Never treat a webhook as the trigger to mark something paid based on amount alone. Match on the event id and the invoice id, and trust the state we send, not a number you recompute.
Step 4: return 2xx fast
Your endpoint has a short timeout. If you do heavy work inline (send the confirmation email, generate the license, call three internal services) you will blow the timeout, we will record a failed delivery, and we will retry an event you actually handled.
Do the minimum synchronously: verify, dedupe, persist the new state, return 200. Push the slow work (email, fulfillment, downstream calls) onto your own queue and let it run after you have answered. The webhook's only job is to durably record that the event happened. Acting on it is your job, on your time.
Also return 200 for events you do not care about. If you subscribed to more event types than you handle, acknowledge the unknown ones instead of erroring. An error looks like a failure to us and triggers retries.
What can go wrong
- Verifying against parsed JSON. The classic. Re-serialized bodies never match the signature. Capture raw bytes.
- No replay window. A correct signature on a stale message is still a replay. Always check
t. ==on the signature. A timing side channel. Use constant-time comparison.- Idempotency in app code, not the DB. The check-then-write gap double-credits under concurrent retries. Use a unique constraint.
- Slow handler. Inline fulfillment blows the timeout and turns one event into many retries. Answer fast, work later.
- Trusting the amount, not the state. Decide on our event state and the invoice id, never on a number your code reconstructs.
- A clock that drifts. Unsynced time silently rejects every event. Run NTP.
Get these four steps right and your payment webhook is boring, which is exactly what a money path should be. If a delivery is failing and you cannot see why, the delivery log in your merchant cabinet shows every attempt and the response we got, and a real human reads @swappsy.
Start from the merchant docs, and read the rest of the developer guides when you wire up refunds and payouts.
Next step
Turn this into action
Use the related SwapSS Pay docs before you build or test an integration.
- Webhook docs Start with the event and delivery model.
- Verify signatures Check raw-body verification examples.
- Reliability guide Plan retries, replay protection, and fallback checks.