---
title: "Stablecoin API webhooks: signature checks, duplicate events, and reconciliation"
seoTitle: "Stablecoin API webhooks and reconciliation"
description: "How to handle stablecoin API webhooks in production: verify signatures on raw bytes, dedupe retries, never regress a final status, reconcile daily."
date: "2026-09-12"
category: "payments"
author: "BlindPay Team"
faq:
  - q: "How do I verify a BlindPay webhook signature?"
    a: "Concatenate the svix-id header, the svix-timestamp header, and the raw request body with a period between each. Compute an HMAC-SHA256 of that string using the base64-decoded part of your whsec_ signing secret, then compare it in constant time against each v1 signature in the svix-signature header. Reject timestamps outside a tolerance window, 5 minutes is a reasonable default."
  - q: "Why do I receive the same webhook more than once?"
    a: "Webhook delivery is at-least-once. If your endpoint does not answer with a 2xx status, BlindPay retries with backoff over the following hours, and a manual replay from the dashboard resends the same event. The svix-id header stays the same across retries of one event, so use it to deduplicate."
  - q: "Which webhook events matter for stablecoin payouts?"
    a: "payout.new when the payout is created and the source funds are captured, payout.update as it moves through intermediate steps like review or the bank send, and payout.complete when it finishes. Subscribe to both update and complete, and always read the status field in the payload rather than inferring it from the event name."
  - q: "Should I rely only on webhooks for payment status?"
    a: "No. Webhooks are the fast path. A scheduled reconciliation job that retrieves payins and payouts from the API and compares them with your ledger is the safety net for missed deliveries, handler bugs, and events that arrived while your endpoint was down."
  - q: "Can I test webhooks before going to production?"
    a: "Yes. Webhooks behave the same on BlindPay development and production instances: same event names, payloads, and signatures. The events dashboard shows every delivery attempt and lets you replay any event, which is also safe for backfilling a handler you just fixed."
---

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](/docs/learn/webhooks).

## 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](/docs/learn/webhooks-verification):

```javascript
const crypto = require('node:crypto')

function verifyWebhook(secret, rawBody, svixId, svixTimestamp, svixSignature) {
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - Number(svixTimestamp)) > 5 * 60) {
    throw new Error('Webhook timestamp outside tolerance window')
  }

  const signedContent = `${svixId}.${svixTimestamp}.${rawBody}`
  const secretBytes = Buffer.from(secret.split('_')[1], 'base64')
  const expected = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64')

  const isValid = svixSignature
    .split(' ')
    .map(sig => sig.split(',')[1])
    .some((sig) => {
      try {
        return crypto.timingSafeEqual(Buffer.from(sig, 'base64'), Buffer.from(expected, 'base64'))
      }
      catch {
        return false
      }
    })

  if (!isValid)
    throw new Error('Invalid webhook signature')
}
```

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:

```
on_webhook(request):
    verify(request)                          # raw bytes, first
    if seen(request.headers["svix-id"]):
        return 200                           # already handled
    enqueue(request.body)                    # do the work async
    mark_seen(request.headers["svix-id"])
    return 200                               # answer fast
```

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](/resources/more/stablecoin-api-idempotency-keys) are for.

## Which events matter for a payout flow?

At BlindPay, the ones you'll handle most, from the [event catalog](/docs/learn/webhooks-events):

| 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](/resources/more/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](/resources/more/stablecoin-api-sandbox-vs-production). The API's request and response types come from one OpenAPI contract, which [stablecoin API SDKs](/resources/more/stablecoin-api-openapi-sdks) explains.

## Where does BlindPay fit?

[BlindPay](/global-payments) 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](/virtual-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](/docs/learn/webhooks) and the [verification reference](/docs/learn/webhooks-verification).

*This article is for general information only and is not legal, tax, or financial advice.*
