[{"data":1,"prerenderedAt":949},["ShallowReactive",2],{"content-\u002Fresources\u002Fmore\u002Fpayin-webhooks-idempotency-reconciliation":3,"resources-category-payin-webhooks-idempotency-reconciliation":934},{"title":4,"seoTitle":5,"description":6,"date":7,"updated":8,"category":9,"faq":10,"howto":8,"body":29},"Payin webhooks, idempotency, and reconciliation for stablecoin payins","Payin webhooks, idempotency and reconciliation","Payin webhooks reconciliation: verify signatures, dedupe events, model a payin state machine, and match each virtual account deposit to your ledger.","2026-10-27",null,"payments",[11,14,17,20,23,26],{"q":12,"a":13},"Which webhook events fire during a BlindPay payin?","Three payin events: payin.new when the payin is created or a virtual account deposit arrives, payin.update at intermediate steps such as an arrival check or manual review, and payin.complete when it finishes as delivered, refunded, or failed. Virtual accounts add virtualAccount.new and virtualAccount.complete, and managed wallets add wallet.inbound.",{"q":15,"a":16},"How do I stop processing the same payin webhook twice?","Store the svix-id header of every event you handle and skip any id you have seen. BlindPay keeps svix-id the same across redeliveries of one event, and retries any delivery your endpoint does not answer with a 2xx status. Record the id in the same database transaction that credits the customer so a crash cannot split the two.",{"q":18,"a":19},"Can a payin status go backwards after it reaches a final state?","Mostly no. Completed, failed, and refunded are terminal, so ignore later events that would move a payment back. There is one documented exception: if funding for an Argentine transfers payin arrives after it was marked failed and no stablecoins were sent, BlindPay revives it to processing. Alert on failed payins that change, instead of silently ignoring them.",{"q":21,"a":22},"How do I match a virtual account deposit to a customer?","Every deposit creates a payin that carries a customer_id and the blindpay_bank_details of the exact virtual account it landed on. Match on those and on the payin id, not on the amount. Two deposits of the same amount on the same day are normal, so amount alone is never a safe key.",{"q":24,"a":25},"How often should I reconcile stablecoin payins?","Run a reconciliation job at least daily. List payins from the API, compare each one with your ledger on id, status, and amounts, and flag every difference. Webhooks are the fast path, and the daily job is the safety net for missed deliveries, handler bugs, and events that arrived while your endpoint was down.",{"q":27,"a":28},"What should I alert on for payin webhooks?","Alert on invalid signatures, handler errors, a payin stuck in processing past its rail's documented window, an on_hold payin older than 24 hours, a ledger total that does not match delivered stablecoins, and any terminal payin that changes state. Alert on the age of a state, not only on errors, because a payment that never moves throws no error.",{"type":30,"value":31,"toc":921},"minimark",[32,45,54,59,108,112,120,207,270,274,300,310,337,351,355,381,508,511,564,593,597,617,620,655,659,696,699,720,723,761,768,772,775,854,861,865,885,892,896,915],[33,34,35,36,40,41,44],"p",{},"To handle stablecoin payins safely, verify the signature on every webhook, process each event once using the ",[37,38,39],"code",{},"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 ",[37,42,43],{},"payin.*"," events, and the payin API is the source of truth.",[33,46,47,48,53],{},"This guide goes deeper on the payin side than the general guide to ",[49,50,52],"a",{"href":51},"\u002Fresources\u002Fmore\u002Fstablecoin-api-webhooks-reconciliation","stablecoin API webhooks and reconciliation",", which covers signature code and payout events.",[55,56,58],"h2",{"id":57},"key-takeaways","Key takeaways",[60,61,62,81,91,94,105],"ul",{},[63,64,65,66,69,70,69,73,76,77,80],"li",{},"A payin has three events (",[37,67,68],{},"payin.new",", ",[37,71,72],{},"payin.update",[37,74,75],{},"payin.complete",") and five statuses. Read ",[37,78,79],{},"status"," from the payload, not the event name.",[63,82,83,84,86,87,90],{},"Delivery is at least once. Deduplicate on ",[37,85,39],{},", and use an ",[37,88,89],{},"Idempotency-Key"," on every create call.",[63,92,93],{},"Terminal statuses are sticky, with one documented exception worth an alert.",[63,95,96,97,100,101,104],{},"Map deposits to customers with ",[37,98,99],{},"customer_id",", the payin id, and ",[37,102,103],{},"blindpay_bank_details",", never with the amount.",[63,106,107],{},"Webhooks are the fast path. A daily job that lists payins and compares them with your ledger is the truth.",[55,109,111],{"id":110},"what-events-fire-during-a-payin","What events fire during a payin?",[33,113,114,115,119],{},"Three payin events fire, plus a few neighbors you will also want. They come from the ",[49,116,118],{"href":117},"\u002Fdocs\u002Flearn\u002Fwebhooks-events","webhook event catalog",".",[121,122,123,136],"table",{},[124,125,126],"thead",{},[127,128,129,133],"tr",{},[130,131,132],"th",{},"Event",[130,134,135],{},"Fires when",[137,138,139,149,158,167,177,187,197],"tbody",{},[127,140,141,146],{},[142,143,144],"td",{},[37,145,68],{},[142,147,148],{},"A payin is created, by any payment method, or a deposit lands in a virtual account",[127,150,151,155],{},[142,152,153],{},[37,154,72],{},[142,156,157],{},"The payin moves through an intermediate step, such as an arrival check or manual review",[127,159,160,164],{},[142,161,162],{},[37,163,75],{},[142,165,166],{},"The payin finishes: sent to the destination wallet, refunded, or failed",[127,168,169,174],{},[142,170,171],{},[37,172,173],{},"virtualAccount.new",[142,175,176],{},"A virtual account is created or requested",[127,178,179,184],{},[142,180,181],{},[37,182,183],{},"virtualAccount.complete",[142,185,186],{},"The account is approved and an account number is issued",[127,188,189,194],{},[142,190,191],{},[37,192,193],{},"wallet.inbound",[142,195,196],{},"A stablecoin deposit is detected in a managed wallet",[127,198,199,204],{},[142,200,201],{},[37,202,203],{},"customer.update",[142,205,206],{},"A customer changes, including a KYC status change",[33,208,209,210,213,214,216,217,69,220,69,222,69,224,69,227,69,230,69,233,69,236,69,239,242,243,69,245,69,248,69,251,254,255,258,259,262,263,266,267,269],{},"Every payload includes a ",[37,211,212],{},"webhook_event"," field, then the resource's own fields. A ",[37,215,75],{}," payload carries ",[37,218,219],{},"id",[37,221,79],{},[37,223,99],{},[37,225,226],{},"sender_amount",[37,228,229],{},"receiver_amount",[37,231,232],{},"payment_method",[37,234,235],{},"partner_fee",[37,237,238],{},"billing_fee_amount",[37,240,241],{},"transaction_fee_amount",", the instruction fields (",[37,244,103],{},[37,246,247],{},"memo_code",[37,249,250],{},"pix_code",[37,252,253],{},"clabe","), and three ",[37,256,257],{},"tracking_*"," objects with a ",[37,260,261],{},"step"," and a ",[37,264,265],{},"completed_at"," time. Note that ",[37,268,75],{}," covers three outcomes, so it does not mean success by itself.",[55,271,273],{"id":272},"how-do-i-verify-and-deduplicate-webhooks","How do I verify and deduplicate webhooks?",[33,275,276,277,69,279,282,283,286,287,291,292,295,296,299],{},"Verify first, deduplicate second, and do the work last. BlindPay signs every call with ",[37,278,39],{},[37,280,281],{},"svix-timestamp",", and ",[37,284,285],{},"svix-signature",". The ",[49,288,290],{"href":289},"\u002Fdocs\u002Flearn\u002Fwebhooks-verification","verification docs"," give the full HMAC-SHA256 procedure: build ",[37,293,294],{},"id.timestamp.rawBody",", decode the ",[37,297,298],{},"whsec_"," secret, compare in constant time, and reject timestamps outside a window (5 minutes is the documented default).",[33,301,302,303,306,307,309],{},"Then deduplicate. If your endpoint does not return a ",[37,304,305],{},"2xx",", BlindPay retries with backoff over the following hours, and ",[37,308,39],{}," stays the same across retries. A teammate replaying an event from the dashboard resends the exact original payload too. So:",[311,312,313,316,319,329,332],"ol",{},[63,314,315],{},"Verify the signature on the raw bytes.",[63,317,318],{},"Open a database transaction.",[63,320,321,322,324,325,328],{},"Insert the ",[37,323,39],{}," into a processed-events table with a unique constraint. If the insert fails, return ",[37,326,327],{},"200"," and stop.",[63,330,331],{},"Apply the state change and any ledger entry in the same transaction.",[63,333,334,335,119],{},"Commit, then return ",[37,336,327],{},[33,338,339,340,342,343,346,347,119],{},"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 ",[37,341,89],{}," 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 ",[37,344,345],{},"422 idempotency_key_payload_mismatch",", and keys are kept for 24 hours. The mechanics are in ",[49,348,350],{"href":349},"\u002Fresources\u002Fmore\u002Fstablecoin-api-idempotency-keys","idempotency keys in a stablecoin API",[55,352,354],{"id":353},"how-do-i-design-a-payin-state-machine","How do I design a payin state machine?",[33,356,357,358,360,361,69,364,69,367,69,370,69,373,286,376,380],{},"Model the payin's top-level ",[37,359,79],{},": ",[37,362,363],{},"processing",[37,365,366],{},"on_hold",[37,368,369],{},"completed",[37,371,372],{},"failed",[37,374,375],{},"refunded",[49,377,379],{"href":378},"\u002Fdocs\u002Fpayins","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.",[121,382,383,399],{},[124,384,385],{},[127,386,387,390,393,396],{},[130,388,389],{},"State",[130,391,392],{},"Trigger",[130,394,395],{},"Your action",[130,397,398],{},"Alert if stuck for",[137,400,401,423,442,459,477,493],{},[127,402,403,409,415,418],{},[142,404,405,408],{},[37,406,407],{},"pending"," (yours)",[142,410,411,412,414],{},"You created a quote or payin, ",[37,413,68],{}," not yet seen",[142,416,417],{},"Wait, show instructions to the payer",[142,419,420,421],{},"10 minutes without ",[37,422,68],{},[127,424,425,429,436,439],{},[142,426,427],{},[37,428,363],{},[142,430,431,433,434],{},[37,432,68],{}," or ",[37,435,72],{},[142,437,438],{},"Show \"waiting for deposit\"",[142,440,441],{},"Past the rail window: 30 minutes for Pix, SPEI, Transfers, PSE; 5 business days for ACH and wire",[127,443,444,448,453,456],{},[142,445,446],{},[37,447,366],{},[142,449,450,452],{},[37,451,72],{}," with a review step",[142,454,455],{},"Keep pending, answer any request for information",[142,457,458],{},"24 hours (an unanswered request may end in a refund)",[127,460,461,465,469,474],{},[142,462,463],{},[37,464,369],{},[142,466,467],{},[37,468,75],{},[142,470,471,472],{},"Credit the customer once, record ",[37,473,229],{},[142,475,476],{},"Never; alert if it changes",[127,478,479,483,487,490],{},[142,480,481],{},[37,482,372],{},[142,484,485],{},[37,486,75],{},[142,488,489],{},"Close the payment, investigate before messaging",[142,491,492],{},"Alert on any change after this state",[127,494,495,499,503,506],{},[142,496,497],{},[37,498,375],{},[142,500,501],{},[37,502,75],{},[142,504,505],{},"Close the payment, notify the customer",[142,507,492],{},[33,509,510],{},"Three rules keep the machine honest.",[60,512,513,528,547],{},[63,514,515,519,520,522,523,525,526,119],{},[516,517,518],"strong",{},"Only move forward."," Events can arrive out of order, so a late ",[37,521,72],{}," saying ",[37,524,363],{}," must not overwrite ",[37,527,369],{},[63,529,530,533,534,537,538,540,541,543,544,546],{},[516,531,532],{},"Know the exception."," If funding for a ",[37,535,536],{},"transfers"," payin (Argentina) arrives after it was marked ",[37,539,372],{}," and no stablecoins were sent, BlindPay revives it to ",[37,542,363],{}," and completes it once the sender's tax ID and amount match. So log a ",[37,545,372],{}," payin that changes as an incident-worthy event instead of dropping it.",[63,548,549,555,556,559,560,563],{},[516,550,551,552,554],{},"Treat ",[37,553,79],{}," as truth, not the hash."," If the on-chain delivery transaction is replaced, for example during a gas spike, the ",[37,557,558],{},"transaction_hash"," in ",[37,561,562],{},"tracking_complete"," can differ from the one first broadcast.",[33,565,566,569,570,572,573,69,576,578,579,581,582,584,585,588,589,592],{},[37,567,568],{},"tracking_transaction"," also has its own ",[37,571,79],{}," (",[37,574,575],{},"null",[37,577,372],{},", or ",[37,580,369],{},"), separate from the payin's. Use ",[37,583,261],{}," values such as ",[37,586,587],{},"pending_review"," and ",[37,590,591],{},"pending_refund_review"," for status screens, not for your ledger.",[55,594,596],{"id":595},"how-do-i-map-deposits-to-customers","How do I map deposits to customers?",[33,598,599,600,602,603,572,605,608,609,611,612,616],{},"Map on identifiers, never on amounts. Each payin carries ",[37,601,99],{}," and its own ",[37,604,219],{},[37,606,607],{},"pi_...","). For a virtual account, a deposit creates a payin automatically, and ",[37,610,103],{}," 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 ",[49,613,615],{"href":614},"\u002Fresources\u002Fmore\u002Fvirtual-accounts-for-stablecoin-payins","virtual accounts for stablecoin payins"," for the account models.",[33,618,619],{},"Keep these rules in mind:",[311,621,622,631,637,643],{},[63,623,624,627,628,630],{},[516,625,626],{},"One-time payins"," use a ",[37,629,247],{}," for matching when the customer has no virtual account. With a virtual account, the memo code is ignored.",[63,632,633,636],{},[516,634,635],{},"SPEI CLABE and Bre-B virtual accounts"," create their own payins without a quote, and a deposit never pays an open quote-based payin.",[63,638,639,642],{},[516,640,641],{},"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.",[63,644,645,648,649,651,652,654],{},[516,646,647],{},"Fees differ by size."," On virtual account deposits below $100.00, the BlindPay fee accrues to your invoice in ",[37,650,238],{},". At $100.00 or more, it is charged at the time of the transaction in ",[37,653,241],{},", 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.",[55,656,658],{"id":657},"how-do-i-reconcile-daily","How do I reconcile daily?",[33,660,661,662,665,666,69,668,282,670,672,673,676,677,69,679,578,682,685,686,689,690,693,694,119],{},"List payins from the API, compare them with your ledger, and review every difference. Use ",[37,663,664],{},"GET \u002Fv1\u002Finstances\u002F{instance_id}\u002Fpayins"," with filters such as ",[37,667,99],{},[37,669,79],{},[37,671,232],{},". Pass ",[37,674,675],{},"limit",": with no ",[37,678,675],{},[37,680,681],{},"starting_after",[37,683,684],{},"ending_before",", the endpoint returns the full unpaginated array instead of a ",[37,687,688],{},"{ data, pagination }"," envelope. Pages come newest first, and ",[37,691,692],{},"next_page"," is an object ID you feed back as ",[37,695,681],{},[33,697,698],{},"Here is the daily checklist.",[311,700,701,704,707,710,717],{},[63,702,703],{},"Pull every payin updated since the last run, with pagination.",[63,705,706],{},"Run the three checks below.",[63,708,709],{},"Write each exception to a review queue with the payin id.",[63,711,712,713,716],{},"Re-fetch single payins with ",[37,714,715],{},"GET ...\u002Fpayins\u002F{id}"," when a webhook was missed.",[63,718,719],{},"Close the day only when the queue is empty or assigned.",[33,721,722],{},"The three checks, written in plain SQL-style language:",[60,724,725,731,749],{},[63,726,727,730],{},[516,728,729],{},"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.",[63,732,733,736,737,739,740,742,743,69,745,282,747,119],{},[516,734,735],{},"Check 2, amount match."," For each ",[37,738,369],{}," payin, compare ",[37,741,229],{}," to the amount you credited, in minor units. Flag any difference, and explain it with ",[37,744,238],{},[37,746,241],{},[37,748,235],{},[63,750,751,754,755,757,758,760],{},[516,752,753],{},"Check 3, delivery match."," Sum ",[37,756,229],{}," for completed payins per destination wallet and compare it with the stablecoins that arrived at that wallet on a block explorer. For managed wallets, ",[37,759,193],{}," 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.",[33,762,763,764,119],{},"For a worked ledger example, see ",[49,765,767],{"href":766},"\u002Fresources\u002Fmore\u002Fvirtual-account-reconciliation","how virtual accounts automate payment reconciliation",[55,769,771],{"id":770},"what-should-i-alert-on","What should I alert on?",[33,773,774],{},"Alert on silence and age, not only on errors. A payment that never moves throws no exception. Start with these:",[121,776,777,787],{},[124,778,779],{},[127,780,781,784],{},[130,782,783],{},"Alert",[130,785,786],{},"Why it matters",[137,788,789,797,808,819,830,838,846],{},[127,790,791,794],{},[142,792,793],{},"Invalid signature on a webhook",[142,795,796],{},"Possible forged call or a rotated secret",[127,798,799,805],{},[142,800,801,802,804],{},"Handler error or non-",[37,803,305],{}," rate above zero",[142,806,807],{},"BlindPay will retry, so duplicates and delays follow",[127,809,810,816],{},[142,811,812,813,815],{},"Payin in ",[37,814,363],{}," past its rail window",[142,817,818],{},"A payer who never paid, or a missed event",[127,820,821,827],{},[142,822,823,824,826],{},"Payin ",[37,825,366],{}," older than 24 hours",[142,828,829],{},"An unanswered request for information may end in a refund",[127,831,832,835],{},[142,833,834],{},"Terminal payin changes state",[142,836,837],{},"The Transfers revival case, or a handler bug",[127,839,840,843],{},[142,841,842],{},"Daily check 1, 2, or 3 returns a row",[142,844,845],{},"Ledger and platform disagree",[127,847,848,851],{},[142,849,850],{},"Webhook endpoint silent for an hour during business hours",[142,852,853],{},"Endpoint down, or subscription wrong",[33,855,856,857,860],{},"You can also give a teammate read access to deliveries without dashboard access: the hosted webhook portal link from ",[37,858,859],{},"GET ...\u002Fwebhook-endpoints\u002Fportal-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.",[55,862,864],{"id":863},"where-does-blindpay-fit","Where does BlindPay fit?",[33,866,867,868,870,871,875,876,880,881,119],{},"BlindPay offers signed webhooks on every payin event, up to 25 endpoints per instance, a replayable events dashboard, an ",[37,869,89],{}," 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 ",[49,872,874],{"href":873},"\u002Fresources\u002Fmore\u002Fstablecoin-payin-api-integration-quickstart","integration quickstart",". The overall lifecycle is explained in ",[49,877,879],{"href":878},"\u002Fresources\u002Fmore\u002Fhow-a-stablecoin-payin-works-step-by-step","how a stablecoin payin works step by step",", and the category is defined in ",[49,882,884],{"href":883},"\u002Fresources\u002Fmore\u002Fwhat-is-a-stablecoin-payin-api","what a stablecoin payin API is",[33,886,887,888,891],{},"BlindPay's fees are disclosed in the quote and on the payin, and the plan details are at ",[49,889,890],{"href":890},"\u002Fpricing",". The docs list the webhook source IPs you can allowlist, but those addresses are shared with other senders, so the signature check stays mandatory.",[55,893,895],{"id":894},"sources","Sources",[60,897,898,905,910],{},[63,899,900,901,904],{},"BlindPay docs, ",[49,902,903],{"href":117},"Webhook events"," (checked on 2026-10-27)",[63,906,900,907,904],{},[49,908,909],{"href":289},"Webhook verification",[63,911,900,912,904],{},[49,913,914],{"href":378},"Payins",[33,916,917],{},[918,919,920],"em",{},"This article is for general information only and is not legal, tax, or financial advice.",{"title":922,"searchDepth":923,"depth":923,"links":924},"",2,[925,926,927,928,929,930,931,932,933],{"id":57,"depth":923,"text":58},{"id":110,"depth":923,"text":111},{"id":272,"depth":923,"text":273},{"id":353,"depth":923,"text":354},{"id":595,"depth":923,"text":596},{"id":657,"depth":923,"text":658},{"id":770,"depth":923,"text":771},{"id":863,"depth":923,"text":864},{"id":894,"depth":923,"text":895},[935,940,945],{"path":936,"title":937,"description":938,"date":939},"\u002Fresources\u002Fmore\u002Ffailed-late-or-mismatched-stablecoin-payins","What 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.","2026-10-28",{"path":941,"title":942,"description":943,"date":944},"\u002Fresources\u002Fmore\u002Fpayin-api-sandbox-testing-checklist","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.","2026-10-29",{"path":946,"title":947,"description":948,"date":7},"\u002Fresources\u002Fmore\u002Fbuild-stablecoin-payouts-in-house","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.",1794399382651]