Quote-then-execute in TypeScript and Python: how to handle expired quotes and re-quotes
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_atis epoch milliseconds. Compare it withDate.now(), ortime.time() * 1000in Python.- Check the time left before the user confirms and again before the on-chain approval. Re-quote instead of racing the clock.
- An expired quote returns 400
QUOTES_EXPIRED. No funds move, so re-quoting is always safe. - Use one
Idempotency-Keyper quote and send byte-identical bodies on retry. - Every re-quote is a new price. Show the new
receiver_amountbefore executing again.
What does the quote-then-execute flow look like?
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.
How do you create a quote and read the expiry in TypeScript?
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).
How do you approve and execute without racing the clock?
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.
What does the same flow look like in Python?
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.
How do retries and idempotency work with quotes?
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: how much of a 5-minute window does one payout use?
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.
How do you test expiry in development?
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).
- Create a quote and log
expires_at. - Wait until it passes.
- Execute it and assert your code gets
QUOTES_EXPIREDand re-quotes. - Execute a fresh quote twice with the same key and body, and assert the second response carries
Idempotency-Replayed: true. - Pace the loop. Development instances allow roughly 100 requests per minute and answer 429 with
Retry-Afterabove that.
Sandbox vs production for stablecoin APIs lists what else a sandbox can and cannot exercise.
What should happen after the payout executes?
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.
What to do next
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.
To see how other providers set their windows, how long a payment quote lasts compares quote windows across providers in 2026.
Expiry is one of several quote failures. Stablecoin quote API errors lists the rest, from thin liquidity to limits and used quotes, and which ones are safe to retry.
FAQ
More payments articles
See all payments articlesHow to run batch payouts with live quotes: one quote per payout, expiry, and rate drift
How to pay 100+ recipients from USDC with live quotes: quote and execute per payout, size the funding, handle expiry and retries, and track the batch.
Stablecoin FX slippage: how live quotes turn the quoted rate into the rate you get
FX slippage is the gap between the rate a stablecoin on-ramp or off-ramp shows and the rate that settles. Why quote freshness is a liquidity signal, and how to check a quote-then-execute flow.
ACH pull vs ACH push: two ways to fund a USD to USDC on-ramp
Push means the payer sends an ACH transfer. Pull means you debit a connected account. How each funds a USD to USDC on-ramp, the fees, and the return risk.