Skip to content

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. Read status from the payload, not the event name.
  • Delivery is at least once. Deduplicate on svix-id, and use an Idempotency-Key on every create call.
  • Terminal statuses are sticky, with one documented exception worth an alert.
  • Map deposits to customers with customer_id, the payin id, and blindpay_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.

EventFires when
payin.newA payin is created, by any payment method, or a deposit lands in a virtual account
payin.updateThe payin moves through an intermediate step, such as an arrival check or manual review
payin.completeThe payin finishes: sent to the destination wallet, refunded, or failed
virtualAccount.newA virtual account is created or requested
virtualAccount.completeThe account is approved and an account number is issued
wallet.inboundA stablecoin deposit is detected in a managed wallet
customer.updateA 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:

  1. Verify the signature on the raw bytes.
  2. Open a database transaction.
  3. Insert the svix-id into a processed-events table with a unique constraint. If the insert fails, return 200 and stop.
  4. Apply the state change and any ledger entry in the same transaction.
  5. 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.

StateTriggerYour actionAlert if stuck for
pending (yours)You created a quote or payin, payin.new not yet seenWait, show instructions to the payer10 minutes without payin.new
processingpayin.new or payin.updateShow "waiting for deposit"Past the rail window: 30 minutes for Pix, SPEI, Transfers, PSE; 5 business days for ACH and wire
on_holdpayin.update with a review stepKeep pending, answer any request for information24 hours (an unanswered request may end in a refund)
completedpayin.completeCredit the customer once, record receiver_amountNever; alert if it changes
failedpayin.completeClose the payment, investigate before messagingAlert on any change after this state
refundedpayin.completeClose the payment, notify the customerAlert 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.update saying processing must not overwrite completed.
  • Know the exception. If funding for a transfers payin (Argentina) arrives after it was marked failed and no stablecoins were sent, BlindPay revives it to processing and completes it once the sender's tax ID and amount match. So log a failed payin that changes as an incident-worthy event instead of dropping it.
  • Treat status as truth, not the hash. If the on-chain delivery transaction is replaced, for example during a gas spike, the transaction_hash in tracking_complete can 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:

  1. One-time payins use a memo_code for matching when the customer has no virtual account. With a virtual account, the memo code is ignored.
  2. SPEI CLABE and Bre-B virtual accounts create their own payins without a quote, and a deposit never pays an open quote-based payin.
  3. 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.
  4. 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 in transaction_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.

  1. Pull every payin updated since the last run, with pagination.
  2. Run the three checks below.
  3. Write each exception to a review queue with the payin id.
  4. Re-fetch single payins with GET .../payins/{id} when a webhook was missed.
  5. 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 completed payin, compare receiver_amount to the amount you credited, in minor units. Flag any difference, and explain it with billing_fee_amount, transaction_fee_amount, and partner_fee.
  • Check 3, delivery match. Sum receiver_amount for completed payins per destination wallet and compare it with the stablecoins that arrived at that wallet on a block explorer. For managed wallets, wallet.inbound events 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:

AlertWhy it matters
Invalid signature on a webhookPossible forged call or a rotated secret
Handler error or non-2xx rate above zeroBlindPay will retry, so duplicates and delays follow
Payin in processing past its rail windowA payer who never paid, or a missed event
Payin on_hold older than 24 hoursAn unanswered request for information may end in a refund
Terminal payin changes stateThe Transfers revival case, or a handler bug
Daily check 1, 2, or 3 returns a rowLedger and platform disagree
Webhook endpoint silent for an hour during business hoursEndpoint 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

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

FAQ

One API.
Multiple payment rails.

Blind Pay, Inc. (“BlindPay”) is a United States-based financial technology and payments infrastructure company, incorporated in the State of Delaware on May 2, 2024. BlindPay is duly registered as a Money Services Business (“MSB”) with the Financial Crimes Enforcement Network (“FinCEN”) under registration number 31000277566153. BlindPay is not a state-licensed money transmitter and does not, in its own capacity, engage in regulated money transmission services. All such regulated activities are conducted exclusively through, and under the licenses of, duly authorized financial institution partners. BlindPay's role is limited to providing financial technology, virtual account management, payment processing, and related payments infrastructure services that facilitate payment flows and account functionalities. These services are provided in coordination with its licensed partners and in compliance with applicable federal MSB requirements and relevant state regulatory frameworks.

Blind Pay, LLC (“BlindPay”) is a Delaware limited liability company formed on June 24, 2025. BlindPay is duly registered as a Money Services Business (“MSB”) with the Financial Crimes Enforcement Network (“FinCEN”) under registration number 31000318334247 and is listed in the Nationwide Multistate Licensing System (“NMLS”) under NMLS ID #2745309.

BLIND PAY SOCIEDADE PRESTADORA DE SERVIÇOS DE ATIVOS VIRTUAIS LTDA. (“BlindPay”) is a Virtual Asset Service Provider (“VASP”) operating in Brazil. The company is presently completing the regulatory adaptation process required of Virtual Asset Service Provider Companies (SPSAV) under Central Bank of Brazil Resolution No. 520/2025, and remains subject to the transitional regime set forth in Article 88 of that Resolution. Registered address: Avenida Princesa Isabel, nº 15, Sala 1405, Edifício Martinho de Freitas, Centro, Vitória/ES, 29010-361.

BlindPay is a stablecoin API that enables individuals and businesses to move money worldwide.