Payin webhooks, idempotency, and reconciliation for stablecoin payins
Payin webhooks reconciliation: verify signatures, dedupe events, model a payin state machine, and match each virtual account deposit to your ledger.
To handle stablecoin payins safely, verify the signature on every webhook, process each event once using the svix-id header, and store every state change. Then reconcile daily: match each virtual account deposit to a ledger entry and to the stablecoin amount delivered. BlindPay sends signed payin.* events, and the payin API is the source of truth.
This guide goes deeper on the payin side than the general guide to stablecoin API webhooks and reconciliation, which covers signature code and payout events.
Key takeaways
- A payin has three events (
payin.new,payin.update,payin.complete) and five statuses. Readstatusfrom the payload, not the event name. - Delivery is at least once. Deduplicate on
svix-id, and use anIdempotency-Keyon every create call. - Terminal statuses are sticky, with one documented exception worth an alert.
- Map deposits to customers with
customer_id, the payin id, andblindpay_bank_details, never with the amount. - Webhooks are the fast path. A daily job that lists payins and compares them with your ledger is the truth.
What events fire during a payin?
Three payin events fire, plus a few neighbors you will also want. They come from the webhook event catalog.
| Event | Fires when |
|---|---|
payin.new | A payin is created, by any payment method, or a deposit lands in a virtual account |
payin.update | The payin moves through an intermediate step, such as an arrival check or manual review |
payin.complete | The payin finishes: sent to the destination wallet, refunded, or failed |
virtualAccount.new | A virtual account is created or requested |
virtualAccount.complete | The account is approved and an account number is issued |
wallet.inbound | A stablecoin deposit is detected in a managed wallet |
customer.update | A customer changes, including a KYC status change |
Every payload includes a webhook_event field, then the resource's own fields. A payin.complete payload carries id, status, customer_id, sender_amount, receiver_amount, payment_method, partner_fee, billing_fee_amount, transaction_fee_amount, the instruction fields (blindpay_bank_details, memo_code, pix_code, clabe), and three tracking_* objects with a step and a completed_at time. Note that payin.complete covers three outcomes, so it does not mean success by itself.
How do I verify and deduplicate webhooks?
Verify first, deduplicate second, and do the work last. BlindPay signs every call with svix-id, svix-timestamp, and svix-signature. The verification docs give the full HMAC-SHA256 procedure: build id.timestamp.rawBody, decode the whsec_ secret, compare in constant time, and reject timestamps outside a window (5 minutes is the documented default).
Then deduplicate. If your endpoint does not return a 2xx, BlindPay retries with backoff over the following hours, and svix-id stays the same across retries. A teammate replaying an event from the dashboard resends the exact original payload too. So:
- Verify the signature on the raw bytes.
- Open a database transaction.
- Insert the
svix-idinto a processed-events table with a unique constraint. If the insert fails, return200and stop. - Apply the state change and any ledger entry in the same transaction.
- Commit, then return
200.
Putting the dedupe insert and the ledger write in one transaction closes the gap where a crash happens between them. The write side needs the same care. Send an Idempotency-Key header when you create quotes and payins. The same key with an identical body replays the stored response, the same key with a different body returns 422 idempotency_key_payload_mismatch, and keys are kept for 24 hours. The mechanics are in idempotency keys in a stablecoin API.
How do I design a payin state machine?
Model the payin's top-level status: processing, on_hold, completed, failed, refunded. The payins docs define them, and the last three are terminal. Use the table below as a starting point. The "alert if stuck" column is a suggested threshold built from the documented windows, not a BlindPay guarantee.
| State | Trigger | Your action | Alert if stuck for |
|---|---|---|---|
pending (yours) | You created a quote or payin, payin.new not yet seen | Wait, show instructions to the payer | 10 minutes without payin.new |
processing | payin.new or payin.update | Show "waiting for deposit" | Past the rail window: 30 minutes for Pix, SPEI, Transfers, PSE; 5 business days for ACH and wire |
on_hold | payin.update with a review step | Keep pending, answer any request for information | 24 hours (an unanswered request may end in a refund) |
completed | payin.complete | Credit the customer once, record receiver_amount | Never; alert if it changes |
failed | payin.complete | Close the payment, investigate before messaging | Alert on any change after this state |
refunded | payin.complete | Close the payment, notify the customer | Alert on any change after this state |
Three rules keep the machine honest.
- Only move forward. Events can arrive out of order, so a late
payin.updatesayingprocessingmust not overwritecompleted. - Know the exception. If funding for a
transferspayin (Argentina) arrives after it was markedfailedand no stablecoins were sent, BlindPay revives it toprocessingand completes it once the sender's tax ID and amount match. So log afailedpayin that changes as an incident-worthy event instead of dropping it. - Treat
statusas truth, not the hash. If the on-chain delivery transaction is replaced, for example during a gas spike, thetransaction_hashintracking_completecan differ from the one first broadcast.
tracking_transaction also has its own status (null, failed, or completed), separate from the payin's. Use step values such as pending_review and pending_refund_review for status screens, not for your ledger.
How do I map deposits to customers?
Map on identifiers, never on amounts. Each payin carries customer_id and its own id (pi_...). For a virtual account, a deposit creates a payin automatically, and blindpay_bank_details shows the exact virtual account the deposit landed on. That is the account-level separation that makes virtual accounts useful for ledgers: one account per customer, brand, or invoice stream, with every deposit tied to it. See virtual accounts for stablecoin payins for the account models.
Keep these rules in mind:
- One-time payins use a
memo_codefor matching when the customer has no virtual account. With a virtual account, the memo code is ignored. - SPEI CLABE and Bre-B virtual accounts create their own payins without a quote, and a deposit never pays an open quote-based payin.
- Store your own reference (order, invoice, or customer record) next to the payin id when you create it, so you never need to guess later.
- Fees differ by size. On virtual account deposits below $100.00, the BlindPay fee accrues to your invoice in
billing_fee_amount. At $100.00 or more, it is charged at the time of the transaction intransaction_fee_amount, deducted from the delivered amount. Store both fields, because your expected stablecoin amount depends on which one applies. Details are in the payins and virtual accounts docs.
How do I reconcile daily?
List payins from the API, compare them with your ledger, and review every difference. Use GET /v1/instances/{instance_id}/payins with filters such as customer_id, status, and payment_method. Pass limit: with no limit, starting_after, or ending_before, the endpoint returns the full unpaginated array instead of a { data, pagination } envelope. Pages come newest first, and next_page is an object ID you feed back as starting_after.
Here is the daily checklist.
- Pull every payin updated since the last run, with pagination.
- Run the three checks below.
- Write each exception to a review queue with the payin id.
- Re-fetch single payins with
GET .../payins/{id}when a webhook was missed. - Close the day only when the queue is empty or assigned.
The three checks, written in plain SQL-style language:
- Check 1, status match. For each payin from the API, find the ledger entry with the same payin id. Flag payins with no entry, entries with no payin, and any pair whose status differs.
- Check 2, amount match. For each
completedpayin, comparereceiver_amountto the amount you credited, in minor units. Flag any difference, and explain it withbilling_fee_amount,transaction_fee_amount, andpartner_fee. - Check 3, delivery match. Sum
receiver_amountfor completed payins per destination wallet and compare it with the stablecoins that arrived at that wallet on a block explorer. For managed wallets,wallet.inboundevents and the wallet deposits list give the other side. This check catches the case where your ledger says paid and the chain says otherwise.
For a worked ledger example, see how virtual accounts automate payment reconciliation.
What should I alert on?
Alert on silence and age, not only on errors. A payment that never moves throws no exception. Start with these:
| Alert | Why it matters |
|---|---|
| Invalid signature on a webhook | Possible forged call or a rotated secret |
Handler error or non-2xx rate above zero | BlindPay will retry, so duplicates and delays follow |
Payin in processing past its rail window | A payer who never paid, or a missed event |
Payin on_hold older than 24 hours | An unanswered request for information may end in a refund |
| Terminal payin changes state | The Transfers revival case, or a handler bug |
| Daily check 1, 2, or 3 returns a row | Ledger and platform disagree |
| Webhook endpoint silent for an hour during business hours | Endpoint down, or subscription wrong |
You can also give a teammate read access to deliveries without dashboard access: the hosted webhook portal link from GET .../webhook-endpoints/portal-access lets them browse delivery attempts and replay events. The link is scoped to the whole instance, so share it carefully. Replaying resends the original payload and is safe for backfilling a handler you just fixed.
Where does BlindPay fit?
BlindPay offers signed webhooks on every payin event, up to 25 endpoints per instance, a replayable events dashboard, an Idempotency-Key header on mutating calls, and a payin list endpoint with filters for the daily job. Webhooks behave the same on development and production instances, so you can build the whole state machine in the sandbox, and force the failure paths with the sandbox amounts described in the integration quickstart. The overall lifecycle is explained in how a stablecoin payin works step by step, and the category is defined in what a stablecoin payin API is.
BlindPay's fees are disclosed in the quote and on the payin, and the plan details are at /pricing. The docs list the webhook source IPs you can allowlist, but those addresses are shared with other senders, so the signature check stays mandatory.
Sources
- BlindPay docs, Webhook events (checked on 2026-10-27)
- BlindPay docs, Webhook verification (checked on 2026-10-27)
- BlindPay docs, Payins (checked on 2026-10-27)
This article is for general information only and is not legal, tax, or financial advice.
FAQ
More payments articles
See all payments articlesWhat happens when a stablecoin payin fails, arrives late, or does not match?
Failed stablecoin deposit refund: why payins fail, late or mismatched deposits, and the support runbook, using BlindPay's real payin statuses.
Stablecoin payin API sandbox testing checklist: 20 cases before you go live
A payin API testing checklist with 20 sandbox cases: happy path, failed and refunded payins, duplicate webhooks, expired quotes, plus a go-live list.
Build vs buy stablecoin payouts: a 12-component checklist and scorecard
What building stablecoin payouts in-house involves: 12 components, a weighted scorecard you finish in 10 minutes, and clear rules for when to buy or build.