TypeScript and Python code for a stablecoin payout API: read expires_at, approve, execute with an idempotency key, and re-quote on QUOTES_EXPIRED.
To handle quote expiry, read expires_at (epoch milliseconds) from every quote, check the time left before each slow step, and execute with an Idempotency-Key. If the API answers 400 with code QUOTES_EXPIRED, nothing moved: request a new quote, show the new numbers, and execute against the new id.
That's the whole pattern. The code below is the part people get wrong: the approval transaction that eats the window, the retry that creates a duplicate, and the countdown that is off by a factor of 1,000.
Key takeaways
expires_at is epoch milliseconds. Compare it with Date.now(), or time.time() * 1000 in Python.QUOTES_EXPIRED. No funds move, so re-quoting is always safe.Idempotency-Key per quote and send byte-identical bodies on retry.receiver_amount before executing again.Four calls, one clock. The quote locks the rate and fees for a window, and everything after it has to fit inside that window. At BlindPay a payout can only execute against a valid, unexpired quote (payout quotes).
| Step | Call | What to read | Time it takes |
|---|---|---|---|
| 1. Quote | POST /v1/instances/{instance_id}/quotes | id, expires_at, receiver_amount, contract | Under a second |
| 2. User confirms | Your UI | Countdown from expires_at | Seconds to minutes |
| 3. Approve (external EVM wallet) | ERC-20 approve on-chain | Transaction receipt | One block confirmation |
| 4. Execute | POST /v1/instances/{instance_id}/payouts/evm | Payout status, or error code | Under a second |
A managed wallet skips step 3, which removes the slowest part of the budget. Either way the funding wallet needs the stablecoins at execute time, which is what sizing a payout float is about. For the terms used here, see the on/off-ramp liquidity glossary, and for why the window exists at all, who carries FX risk in a stablecoin payout.
Create the quote on the confirmation screen, not earlier. A quote fetched while the user is still choosing a bank account has already spent part of its window.
Render the countdown from msLeft(quote), not from a 300-second constant. Most windows are 5 minutes, but some rails return a shorter one (cut-off times).
Check the time left before the approval and again before the execute call. The approval is an on-chain transaction, so it is the step most likely to push you past expires_at.
Two details worth copying. The confirm callback runs on every attempt, so a re-quote always shows the user the new receiver_amount before money moves. And ensureAllowance reads the existing allowance first, so a re-quote for a similar amount does not need a second approval.
One ERC-20 gotcha: the EIP-20 standard recommends setting an allowance to 0 before changing it to another value for the same spender, and USDT on Ethereum enforces that. If you fund from USDT on Ethereum, approve 0 first when an old allowance is still open.
The approval code follows BlindPay's EVM payout tutorial, which uses ethers v6.
Same logic, with requests for HTTP and web3.py v7 for the approval. Note the explicit data=body: the idempotency check hashes the raw bytes, so the body is serialized once and reused.
Retries are safe when the key and the body match. BlindPay accepts an Idempotency-Key header of up to 255 characters on any mutating request under /v1 and keeps a finished key for 24 hours (idempotency docs).
| Retry situation | What BlindPay returns | What your code does |
|---|---|---|
| Same key, same body, first request finished | The original response, with Idempotency-Replayed: true | Treat it as the result |
| Same key, same body, first request still running | 409 idempotency_key_in_flight with Retry-After | Wait, then resend |
| Same key, different body | 422 idempotency_key_payload_mismatch | Bug: you rebuilt the body |
| First attempt returned a 5xx | Key released, not stored | Resend; it runs as a fresh attempt |
| Quote expired | 400 QUOTES_EXPIRED | New quote, new key |
Keying on the quote id gives you the right behavior for free: a timeout retry replays, and a re-quote starts clean. The response envelope, with code, message, description, and trace_id, is listed in the API reference. Build on code, since message is a legacy string. The idempotency keys guide covers the pattern beyond quotes.
Illustrative example. A finance user approves one payout funded from an external wallet on an EVM chain.
| Step | Time used | Window left (of 300,000 ms) |
|---|---|---|
| Quote returned | 400 ms | 299,600 ms |
| User reads amount and confirms | 45,000 ms | 254,600 ms |
| Approval transaction confirms | 20,000 ms | 234,600 ms |
| Execute call | 600 ms | 234,000 ms |
Total: 400 + 45,000 + 20,000 + 600 = 66,000 ms, or 22% of the window. Comfortable.
Now shrink the window to 60,000 ms, as a short-window rail might return. The same steps need 66,000 ms, so the quote expires before execute. That's the case APPROVAL_BUDGET_MS catches: with 30,000 ms budgeted and only 14,600 ms left after confirmation, the loop re-quotes before sending a doomed approval. The user sees a refreshed amount instead of an error.
Clock drift matters at this scale. expires_at is set by BlindPay's clock and compared with yours, so keep servers on NTP and leave a few seconds of margin.
On a development instance, use USDB on a testnet such as base_sepolia. Development instances expose the same endpoints, fields, and response shapes as production, so the expiry path you test is the one you ship (sandbox vs production).
expires_at.QUOTES_EXPIRED and re-quotes.Idempotency-Replayed: true.Retry-After above that.Sandbox vs production for stablecoin APIs lists what else a sandbox can and cannot exercise.
Stop polling the quote and listen for webhooks. payout.new fires when the payout is created and the funds are captured, payout.update on intermediate steps, and payout.complete when it is confirmed at the destination or refunded. A failed payout fires payout.update, not payout.complete, so route on the status field (webhook events).
Webhooks and reconciliation shows how to store the quote id next to the payout id so finance can match quoted and delivered amounts later. If you are new to the flow, stablecoin API quotes explained covers fee direction and minor units, and what goes on a quote screen covers the countdown UI.
Request a development instance key, paste the TypeScript or Python loop above, and run the expiry test in step 3 before writing any UI. The EVM payout tutorial has the full approval script these examples follow.
Seven places blockchain payments beat bank rails: contractor payroll, remittances, B2B suppliers, marketplaces, 24/7 treasury, bill pay, and PSP payouts.
AP2, ACP, and x402 each verify that an AI agent had permission to spend. Here is what every protocol covers, who backs it, and the reconciliation gap none of them close.
The three roles behind every card program, stablecoin cards included: what each one does, who carries liability, and what to ask a provider.