---
title: "Quote-then-execute in TypeScript and Python: how to handle expired quotes and re-quotes"
seoTitle: "Handle expired payout quotes in TypeScript and Python"
description: "TypeScript and Python code for a stablecoin payout API: read expires_at, approve, execute with an idempotency key, and re-quote on QUOTES_EXPIRED."
date: "2026-10-08"
updated: "2026-10-08"
category: "payments"
author: "BlindPay Team"
howto:
  name: "How to execute a stablecoin payout quote and handle expiry"
  steps:
    - name: "Create the quote as late as possible"
      text: "Call POST /v1/instances/{instance_id}/quotes on the confirmation screen, with bank_account_id, currency_type, cover_fees, request_amount in minor units, network, and token. Save id and expires_at, which is in epoch milliseconds."
    - name: "Check the time left before every slow step"
      text: "Compute expires_at minus the current time before showing the confirm button and before sending the on-chain approval. If less time remains than the step needs, request a new quote instead of racing the clock."
    - name: "Approve the exact quoted amount"
      text: "For an external EVM wallet, call approve on the token contract with contract.blindpayContractAddress and contract.amount from the quote, and wait for the transaction to confirm. A managed wallet skips this step."
    - name: "Execute with an idempotency key"
      text: "POST /v1/instances/{instance_id}/payouts/evm with quote_id and sender_wallet_address, plus an Idempotency-Key header derived from the quote id. Send the exact same body bytes on every retry."
    - name: "Re-quote when the API says the quote expired"
      text: "If the response is 400 with code QUOTES_EXPIRED, nothing moved. Request a new quote, show the user the new receiver_amount, and execute against the new id. Cap the loop at a few attempts."
faq:
  - q: "What happens if I execute a payout with an expired quote?"
    a: "The API rejects it and no funds move. BlindPay returns a 400 with the error code QUOTES_EXPIRED and the legacy message quote_expired. Request a new quote, show the user the new numbers, and execute against the new quote id. Never retry the same expired id, because it will fail every time."
  - q: "Is expires_at in seconds or milliseconds?"
    a: "Milliseconds. BlindPay returns expires_at as epoch milliseconds on payin, payout, and transfer quotes. Compare it with Date.now() in JavaScript, or with time.time() x 1000 in Python. Dividing by 1,000 is only needed when a date library expects seconds, and forgetting the unit puts your countdown thousands of years in the future."
  - q: "Should the idempotency key be the same when I re-quote?"
    a: "No. Tie the key to the quote, for example payout-qu_123. Retries of the same quote reuse the key and the same body, so a network timeout cannot create two payouts. A new quote is a new request, so it gets a new key. Reusing a key with a different body returns 422 idempotency_key_payload_mismatch."
  - q: "How long is a BlindPay payout quote valid?"
    a: "Five minutes by default. Some rails can return a shorter window, and OTC payin quotes last 10 seconds. That is why the code reads expires_at from each response instead of hardcoding 300 seconds. Budget the time your user needs to read the amount plus the time your approval transaction needs to confirm."
  - q: "How do I test quote expiry before going live?"
    a: "Use a development instance with USDB on a testnet such as base_sepolia. Create a quote, wait until expires_at passes, then execute it and confirm your code receives QUOTES_EXPIRED and re-quotes. Development instances allow roughly 100 requests per minute, so add a pause between test loops to avoid 429 responses."
---

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](/docs/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](/docs/payout-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](/resources/more/how-much-stablecoin-float-for-payouts) is about. For the terms used here, see the [on/off-ramp liquidity glossary](/resources/more/onramp-offramp-liquidity-glossary), and for why the window exists at all, [who carries FX risk in a stablecoin payout](/resources/more/who-carries-fx-risk-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.

```ts
// 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](/docs/kb/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`.

```ts
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](https://eips.ethereum.org/EIPS/eip-20) 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](/docs/payout-evm), which uses [ethers v6](https://docs.ethers.org/v6/).

## What does the same flow look like in Python?

Same logic, with `requests` for HTTP and [web3.py](https://web3py.readthedocs.io/) 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](/docs/learn/idempotency)).

| 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](https://api.blindpay.com/reference). Build on `code`, since `message` is a legacy string. The [idempotency keys guide](/resources/more/stablecoin-api-idempotency-keys) 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](/docs/learn/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](/resources/more/stablecoin-api-sandbox-vs-production) 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](/docs/learn/webhooks-events)).

[Webhooks and reconciliation](/resources/more/stablecoin-api-webhooks-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](/resources/more/stablecoin-api-quotes-explained) covers fee direction and minor units, and [what goes on a quote screen](/resources/more/cross-border-quote-screen-fee-disclosure) 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](/docs/payout-evm) has the full approval script these examples follow.
