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_at is epoch milliseconds. Compare it with Date.now(), or time.time() * 1000 in 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-Key per quote and send byte-identical bodies on retry.
  • Every re-quote is a new price. Show the new receiver_amount before 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).

StepCallWhat to readTime it takes
1. QuotePOST /v1/instances/{instance_id}/quotesid, expires_at, receiver_amount, contractUnder a second
2. User confirmsYour UICountdown from expires_atSeconds to minutes
3. Approve (external EVM wallet)ERC-20 approve on-chainTransaction receiptOne block confirmation
4. ExecutePOST /v1/instances/{instance_id}/payouts/evmPayout status, or error codeUnder 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.

TypeScript
// Node 18+ (global fetch), ethers v6. Placeholder ids; key from env.
import { ethers } from 'ethers'

const API = 'https://api.blindpay.com/v1'
const INSTANCE_ID = 'in_000000000000'
const headers = {
  'Authorization': `Bearer ${process.env.BLINDPAY_API_KEY}`,
  'Content-Type': 'application/json',
}

type Quote = {
  id: string
  expires_at: number // epoch MILLISECONDS
  commercial_quotation: number
  blindpay_quotation: number
  sender_amount: number // minor units
  receiver_amount: number // minor units
  flat_fee: number
  partner_fee_amount: number
  contract: { address: string, abi: any[], blindpayContractAddress: string, amount: string } | null
}

async function createQuote(bankAccountId: string, requestAmount: number): Promise<Quote> {
  const res = await fetch(`${API}/instances/${INSTANCE_ID}/quotes`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      bank_account_id: bankAccountId,
      currency_type: 'sender', // request_amount is the stablecoin side
      cover_fees: false,
      request_amount: requestAmount, // 10000 = 100.00
      network: 'base_sepolia', // production: 'base', 'polygon', ...
      token: 'USDB', // production: 'USDC' or 'USDT'
    }),
  })
  if (!res.ok)
    throw new Error(`quote failed: ${res.status} ${await res.text()}`)
  return res.json()
}

const msLeft = (quote: Quote) => quote.expires_at - Date.now()

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.

TypeScript
const APPROVAL_BUDGET_MS = 30_000 // how long your chain usually needs to confirm an approve

async function ensureAllowance(quote: Quote, wallet: ethers.Wallet) {
  const c = quote.contract!
  const token = new ethers.Contract(c.address, c.abi, wallet)
  const current: bigint = await token.allowance(wallet.address, c.blindpayContractAddress)
  if (current >= BigInt(c.amount))
    return // an earlier approval already covers this quote
  const tx = await token.approve(c.blindpayContractAddress, c.amount)
  await tx.wait()
}

type ExecResult =
  | { ok: true, payout: unknown }
  | { ok: false, expired: boolean, code?: string }

async function executePayout(quote: Quote, senderAddress: string): Promise<ExecResult> {
  const body = JSON.stringify({ quote_id: quote.id, sender_wallet_address: senderAddress })
  const res = await fetch(`${API}/instances/${INSTANCE_ID}/payouts/evm`, {
    method: 'POST',
    headers: { ...headers, 'Idempotency-Key': `payout-${quote.id}` },
    body, // reuse these exact bytes if you retry this quote
  })
  const json = await res.json()
  if (res.ok)
    return { ok: true, payout: json }
  return { ok: false, expired: json.code === 'QUOTES_EXPIRED', code: json.code }
}

export async function payWithFreshQuote(
  bankAccountId: string,
  requestAmount: number,
  wallet: ethers.Wallet,
  confirm: (quote: Quote) => Promise<boolean>, // shows receiver_amount and fees
) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    const quote = await createQuote(bankAccountId, requestAmount)
    if (!(await confirm(quote)))
      return null // user declined the numbers
    if (msLeft(quote) < APPROVAL_BUDGET_MS)
      continue // too little time left: get a fresh price
    await ensureAllowance(quote, wallet)
    if (msLeft(quote) <= 0)
      continue
    const result = await executePayout(quote, wallet.address)
    if (result.ok)
      return result.payout
    if (!result.expired)
      throw new Error(`payout failed: ${result.code}`)
  }
  throw new Error('quote expired 3 times: check approval time and clock drift')
}

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.

Python
import json, os, time
import requests
from web3 import Web3

API = "https://api.blindpay.com/v1"
INSTANCE_ID = "in_000000000000"
HEADERS = {
    "Authorization": f"Bearer {os.environ['BLINDPAY_API_KEY']}",
    "Content-Type": "application/json",
}

def create_quote(bank_account_id: str, request_amount: int) -> dict:
    r = requests.post(f"{API}/instances/{INSTANCE_ID}/quotes", headers=HEADERS, timeout=10, json={
        "bank_account_id": bank_account_id,
        "currency_type": "sender",
        "cover_fees": False,
        "request_amount": request_amount,  # 10000 = 100.00
        "network": "base_sepolia",
        "token": "USDB",
    })
    r.raise_for_status()
    return r.json()

def ms_left(quote: dict) -> int:
    return quote["expires_at"] - int(time.time() * 1000)  # expires_at is epoch ms

def ensure_allowance(quote: dict, w3: Web3, account) -> None:
    c = quote["contract"]
    token = w3.eth.contract(address=Web3.to_checksum_address(c["address"]), abi=c["abi"])
    spender = Web3.to_checksum_address(c["blindpayContractAddress"])
    if token.functions.allowance(account.address, spender).call() >= int(c["amount"]):
        return
    tx = token.functions.approve(spender, int(c["amount"])).build_transaction({
        "from": account.address,
        "nonce": w3.eth.get_transaction_count(account.address),
    })
    signed = account.sign_transaction(tx)
    w3.eth.wait_for_transaction_receipt(w3.eth.send_raw_transaction(signed.raw_transaction))

def execute_payout(quote: dict, sender: str) -> dict:
    body = json.dumps({"quote_id": quote["id"], "sender_wallet_address": sender})
    headers = {**HEADERS, "Idempotency-Key": f"payout-{quote['id']}"}
    for _ in range(3):
        try:
            r = requests.post(f"{API}/instances/{INSTANCE_ID}/payouts/evm",
                              headers=headers, data=body, timeout=30)
        except requests.Timeout:
            continue  # same key, same bytes: safe to resend
        if r.status_code == 409:  # idempotency_key_in_flight
            time.sleep(int(r.headers.get("Retry-After", "1")))
            continue
        payload = r.json()
        if r.ok:
            return {"ok": True, "payout": payload}
        return {"ok": False, "expired": payload.get("code") == "QUOTES_EXPIRED", "code": payload.get("code")}
    return {"ok": False, "expired": False, "code": "retries_exhausted"}

def pay_with_fresh_quote(bank_account_id, request_amount, w3, account, confirm, approval_budget_ms=30_000):
    for _ in range(3):
        quote = create_quote(bank_account_id, request_amount)
        if not confirm(quote):
            return None
        if ms_left(quote) < approval_budget_ms:
            continue
        ensure_allowance(quote, w3, account)
        if ms_left(quote) <= 0:
            continue
        result = execute_payout(quote, account.address)
        if result["ok"]:
            return result["payout"]
        if not result["expired"]:
            raise RuntimeError(f"payout failed: {result['code']}")
    raise RuntimeError("quote expired 3 times: check approval time and clock drift")

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 situationWhat BlindPay returnsWhat your code does
Same key, same body, first request finishedThe original response, with Idempotency-Replayed: trueTreat it as the result
Same key, same body, first request still running409 idempotency_key_in_flight with Retry-AfterWait, then resend
Same key, different body422 idempotency_key_payload_mismatchBug: you rebuilt the body
First attempt returned a 5xxKey released, not storedResend; it runs as a fresh attempt
Quote expired400 QUOTES_EXPIREDNew 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.

StepTime usedWindow left (of 300,000 ms)
Quote returned400 ms299,600 ms
User reads amount and confirms45,000 ms254,600 ms
Approval transaction confirms20,000 ms234,600 ms
Execute call600 ms234,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).

  1. Create a quote and log expires_at.
  2. Wait until it passes.
  3. Execute it and assert your code gets QUOTES_EXPIRED and re-quotes.
  4. Execute a fresh quote twice with the same key and body, and assert the second response carries Idempotency-Replayed: true.
  5. Pace the loop. Development instances allow roughly 100 requests per minute and answer 429 with Retry-After above 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.

FAQ