Stablecoin API webhooks: signature checks, duplicate events, and reconciliation
How to handle stablecoin API webhooks in production: verify signatures on raw bytes, dedupe retries, never regress a final status, reconcile daily.
A stablecoin API tells you what happened to a payment through webhooks: signed HTTP callbacks fired when a payin, payout, or account changes state. Handling them in production takes four habits. Verify the signature on the raw bytes. Deduplicate retried deliveries. Never let an event move a payment backwards. And reconcile against the API on a schedule, because webhooks are the fast path, not the source of truth.
Get those four right and most "where's my money?" tickets never get written.
Why use webhooks instead of polling?
Because payments are slow in unpredictable ways. A Pix payout finishes in minutes. An ACH deposit can take 5 business days. A SWIFT payout can sit in review for a while. Polling every payment every minute to catch that spread wastes requests and still reacts late.
Webhooks flip it: the provider calls you when something changes. At BlindPay you register an HTTPS endpoint per instance (up to 25 of them), choose which events to receive, or pass an empty events array to get everything. Setup is in the webhooks guide.
How do you verify a webhook signature?
Before you trust a payload, prove it came from the provider and wasn't altered. BlindPay signs every call with three headers: svix-id, svix-timestamp, and svix-signature.
The check, from the verification docs:
Three mistakes break this in practice:
- Parsing before verifying. Most frameworks parse JSON before your handler runs. Re-serializing the parsed body can reorder keys or change whitespace, and the signature fails. Grab the raw bytes.
- Using
===to compare. String comparison leaks timing. Use a constant-time compare. - Skipping the timestamp check. Without it, a captured valid payload can be replayed at you forever.
The header can carry more than one signature during secret rotation. Accept the request if any of them matches.
Why do you get the same event twice?
Because delivery is at-least-once. If your endpoint doesn't answer 2xx, BlindPay retries with backoff over the following hours. A slow handler that times out after doing the work gets the event again. A teammate replaying an event from the dashboard sends it again. None of this is a bug.
So dedupe. The svix-id header stays the same across every retry of one event:
Answer fast and do the work in a queue. A handler that calls three services before responding is a handler that times out and gets retried. The same principle applies on the write side, which is what idempotency keys are for.
Which events matter for a payout flow?
At BlindPay, the ones you'll handle most, from the event catalog:
| Event | Fires when |
|---|---|
payin.new | A payin is created, including a deposit into a virtual account |
payin.update | A payin moves through an intermediate step, like an arrival check or review |
payin.complete | A payin finishes: delivered, refunded, or failed |
payout.new | A payout is created and the source funds are captured |
payout.update | A payout moves through an intermediate step, like review or the bank send |
payout.complete | A payout finishes |
virtualAccount.complete | A virtual account is approved and its account number is issued |
One rule: don't infer the status from the event name. Read the status field in every payload, and subscribe to both payout.update and payout.complete. Then it doesn't matter which event carries a terminal state. Your state machine only cares about the status, and the full status list is in stablecoin payout statuses explained.
How do you keep events from moving a payment backwards?
Events can arrive out of order. A retried payout.update saying processing can land after payout.complete said completed. If your handler just writes whatever it receives, a paid payout goes back to "sending."
Two guards fix it:
- Terminal states are sticky. Once a payment is
completed,failed, orrefunded, ignore any later event for it. - Status beats hashes. A payin's on-chain transaction can get replaced during a gas spike, so the
transaction_hashyou see at the end may differ from the one first broadcast. Treatstatusas the source of truth, not a specific hash.
How do you reconcile webhooks against your ledger?
Webhooks tell you fast. Reconciliation tells you for sure. Run both.
Match on ids, not amounts. Every event carries the resource id (po_..., pi_...) and the customer id. Store them when you create the payment, and match events on them. Two payouts of the same amount to the same person on the same day are normal.
Store every amount field, in minor units. A payin carries sender_amount, receiver_amount, billing_fee_amount, transaction_fee_amount, and partner_fee. The recipient's number is receiver_amount, not the deposit. Keep them all, as integers, so the numbers add up later.
Don't count one payment twice. At BlindPay a payout can pay a registered bill instead of a bank account, and that bill fires its own payable.* events next to the payout.* ones. Correlate them through payable_id and payout_id. Two completes, one payment.
Run a daily sweep. Once a day, list or retrieve every payin and payout still non-terminal in your ledger through the API (GET /payouts/{id}, GET /payins/{id}), and compare. Anything that finished while your endpoint was down shows up here. Anything stuck past its expected window becomes a ticket.
Backfill with replay. When you fix a broken handler, replay the missed events from the dashboard. A replay resends the original payload as a new delivery attempt, not a new business event, so it's safe.
What should you test before production?
Webhooks behave the same on development and production instances, so there's no excuse to find these in prod:
- A tampered body fails verification.
- A timestamp older than 5 minutes fails verification.
- The same event replayed twice changes nothing.
- An old
processingaftercompletedchanges nothing. - Your endpoint returns
2xxin under a second, even when downstream work is slow. - The daily sweep catches a payout whose webhook you dropped on purpose.
What a sandbox won't show you is covered in stablecoin API sandbox vs production. The API's request and response types come from one OpenAPI contract, which stablecoin API SDKs explains.
Where does BlindPay fit?
BlindPay is a stablecoin API for cross-border payouts and collections: USDC or USDT in, local currency out over Pix, SPEI, ACH, RTP, SEPA, and SWIFT (POBO/COBO) to 100+ countries, with no pre-funding. Every webhook is signed, retried on failure, and replayable from the dashboard, and virtual USD accounts report each deposit as its own payin event.
What to do next
Point a webhook endpoint at your development instance, trigger one payin and one payout, and replay each event twice from the events dashboard. If your ledger changes on the replay, fix the dedupe before anything else. Then write the daily sweep. Start with the webhooks guide and the verification reference.
This article is for general information only and is not legal, tax, or financial advice.
FAQ
More payments articles
See all payments articlesIntegrating a stablecoin API: a developer's guide to cross-border payments with BlindPay
Step-by-step integration of a stablecoin API: authenticate, onboard a customer, create a virtual USD account, quote and send a cross-border payout, and handle signed webhooks. Endpoints, statuses, and SDKs for BlindPay.
How to integrate a crypto on-ramp API: payin quotes, deposit instructions, and webhooks
The on-ramp API flow step by step: verify the customer, pick the wallet, quote, create the payin, show deposit instructions, and handle webhooks.
Where is my off-ramp payout? How to track it rail by rail, and what to tell the recipient
Track an off-ramp payout from transaction hash to bank: the reference each rail gives you (Pix E2E ID, SPEI key, ACH trace, UETR) and status copy.