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:

JavaScript

Three mistakes break this in practice:

  1. 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.
  2. Using === to compare. String comparison leaks timing. Use a constant-time compare.
  3. 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:

text

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:

EventFires when
payin.newA payin is created, including a deposit into a virtual account
payin.updateA payin moves through an intermediate step, like an arrival check or review
payin.completeA payin finishes: delivered, refunded, or failed
payout.newA payout is created and the source funds are captured
payout.updateA payout moves through an intermediate step, like review or the bank send
payout.completeA payout finishes
virtualAccount.completeA 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, or refunded, 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_hash you see at the end may differ from the one first broadcast. Treat status as 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:

  1. A tampered body fails verification.
  2. A timestamp older than 5 minutes fails verification.
  3. The same event replayed twice changes nothing.
  4. An old processing after completed changes nothing.
  5. Your endpoint returns 2xx in under a second, even when downstream work is slow.
  6. 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