---
title: "How to run batch payouts with live quotes: one quote per payout, expiry, and rate drift"
seoTitle: "Batch stablecoin payouts with live quotes: how to run them"
description: "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."
date: "2026-10-04"
updated: "2026-10-04"
category: "payments"
author: "BlindPay Team"
faq:
  - q: "Can I use one quote for several payouts in a batch?"
    a: "No. At BlindPay a quote_id backs exactly one payout, and a second payout against the same quote fails. A batch of 120 recipients needs 120 quotes. Each quote locks its own rate, fees, and receiver amount for its own window, which is what lets every recipient see a firm number."
  - q: "Should I request every quote in a batch before executing any of them?"
    a: "Only for very small batches. Payout quotes expire 5 minutes after creation by default, so a batch that quotes everything first and executes later will hit expired quotes. Pipeline it instead: quote one payout, execute it, then move to the next, or run a few of those pairs in parallel."
  - q: "How do I avoid paying someone twice if a request times out mid-batch?"
    a: "Send an Idempotency-Key header on every payout call and keep the exact request body for retries. At BlindPay, a retry with the same key and byte-identical body replays the original response instead of creating a second payout. A different body under the same key returns 422 idempotency_key_payload_mismatch. Keys are kept for 24 hours."
  - q: "Why did the last payouts in my batch cost more USDC than the first ones?"
    a: "Because each payout was quoted at a different moment. If you fix the local amount and the currency weakens against the dollar during the run, later quotes need slightly less USDC; if it strengthens, they need more. Over a long run that drift adds up, so fund the batch with a buffer and keep runs short."
  - q: "How do I know when every payout in a batch has landed?"
    a: "Track each payout id through webhooks. At BlindPay, payout.complete fires when a payout is confirmed at the destination or refunded, and payout.update fires on intermediate steps, including reaching failed. A batch is done when every payout id has a terminal status: completed, failed, or refunded."
howto:
  name: "How to run a batch of stablecoin payouts with live quotes"
  steps:
    - name: "Validate the batch file"
      text: "Check every row has an approved bank account, a positive amount in minor units, and the quote side you intend: receiver for fixed local amounts, sender for fixed USDC amounts."
    - name: "Size and fund the source wallet"
      text: "Estimate total USDC from one indicative quote, add a buffer for rate drift and fees, and fund the wallet before the run starts."
    - name: "Quote and execute each payout as a pair"
      text: "For each row, request a payout quote, authorize the quoted amount if the wallet needs it, and execute the payout with the quote_id before expires_at."
    - name: "Retry safely"
      text: "Send an Idempotency-Key per payout and reuse the same key and byte-identical body on retries. Re-quote any row whose quote expired."
    - name: "Track every payout to a terminal status"
      text: "Listen for payout.update and payout.complete webhooks and mark each row completed, failed, or refunded."
    - name: "Close the batch"
      text: "Reconcile quoted receiver amounts against the batch file, report the USDC actually spent, and requeue failed rows after fixing their cause."
---

To run a batch of payouts with live quotes, treat each payout as its own quote-then-execute pair. A quote backs exactly one payout and expires after 5 minutes by default, so a batch of 120 recipients is 120 quotes, each executed right after it's created. Fund the wallet first, send an idempotency key per payout, and track each one to a terminal status.

The batch is a loop. What makes it production-grade is everything around the loop.

**Key takeaways**

- One `quote_id`, one payout. A second payout against the same quote fails.
- Don't quote the whole batch up front. Quotes expire in 5 minutes by default; pipeline quote and execute per row.
- Rows quoted later get later rates. Fund with a buffer for drift across the run.
- An `Idempotency-Key` per payout turns a timeout retry into a replay instead of a double payment.
- A batch is finished when every payout id has a terminal status, not when the loop ends.

## Why can't one quote cover a whole batch?

Because a quote is a price for one conversion to one bank account. It names one `bank_account_id`, one amount, and one fee calculation, and it backs one payout.

That is a feature. Every recipient in the batch gets a firm number: the `receiver_amount` on their quote is what lands. A single blended rate for 120 people would make each recipient's amount an estimate until the batch finished.

It also means the batch is only as good as its slowest row. If row 87 sits in a queue for six minutes, its quote expires, and you re-quote it. Nothing executes at a stale rate. The [glossary](/resources/more/onramp-offramp-liquidity-glossary) defines quote window, re-quote, and binding quote if you need the vocabulary.

## How should you structure a batch run?

Pipeline it: quote, authorize if needed, execute, next row. Three patterns show up in practice, and only one holds up at size.

| Pattern | How it works | Expiry risk | Rate exposure | When it fits |
| --- | --- | --- | --- | --- |
| Quote and execute per row | Each row gets its own quote, executed within seconds | Low | Each row priced at its own moment | Any batch size. The default. |
| Approve on an estimate, then quote per row | Finance approves totals from one indicative quote, with a tolerance; the run quotes and executes each row | Low | Bounded by the approved tolerance | Batches that need sign-off before money moves |
| Quote everything, then execute everything | All quotes first, all payouts after | High once the batch passes a few minutes | Locked per row, but many will expire | Tiny batches only |

The second pattern is how most payroll teams run. Someone approves "about 97,800 USDC, plus or minus 1%" in the morning. The run quotes each row live. If the total drifts past the tolerance, the run pauses and asks again.

## How much USDC should you fund before the run?

Enough for the total at current rates, plus a buffer for drift and fees. You won't know the exact total until the last row is quoted, because every row is priced at its own moment.

**Illustrative example.** A company pays 120 contractors in Mexico over SPEI, each a fixed $15,000.00 MXN, quoted on the receiver side. The numbers below are made up for the arithmetic, not BlindPay pricing.

- Batch total: 120 x 15,000 = $1,800,000.00 MXN.
- Effective rate on the first quote: 18.4075 MXN per USDC.
- USDC for the batch at that rate: 1,800,000 ÷ 18.4075 = 97,786.23 USDC.
- Suppose the peso strengthens 0.3% during a long run, so the effective rate falls to 18.3523. Each row then needs 15,000 ÷ 18.3523 = 817.34 USDC instead of 814.89.
- If every row priced at the later rate, the batch would need 1,800,000 ÷ 18.3523 = 98,080.47 USDC. That's 294.24 USDC more than the first estimate.

A 0.5% to 1% buffer covers a move like that. Anything left stays in your wallet. Short runs shrink the problem: the less time between the first and last quote, the less room for drift. For how big that float should be month to month, see [no pre-funding in a stablecoin API](/resources/more/no-pre-funding-stablecoin-payouts).

## How do you authorize funds for each payout?

It depends on where the USDC sits. The payout endpoint is the same either way; what changes is the step before it.

- **External EVM wallet.** Each quote returns a `contract` payload. You call `approve` on the token contract for that quote's exact amount, then execute with `quote_id` and `sender_wallet_address`. That's one on-chain approval per row, so 120 rows means 120 approvals and their gas. The [EVM payout guide](/docs/payout-evm) has the full script.
- **Managed wallet.** No client-side signing. You pass the wallet address as `sender_wallet_address` and execute. BlindPay-managed wallets are in beta and custodial, so this path trades the approval step for BlindPay holding the balance.
- **Solana and Stellar.** Each has its own signing step per payout: a token delegation on Solana, an authorize-and-sign XDR on Stellar.

For large recurring batches, the per-row approval cost on EVM is worth pricing in. [Who pays gas fees](/resources/more/who-pays-gas-fees-stablecoin-payments) breaks it down by network.

## How do you make retries safe?

Send an `Idempotency-Key` header on every payout call, one key per row, and resend the byte-identical body on retry. That's the whole defense against double payments when a request times out.

BlindPay's [idempotency rules](/docs/learn/idempotency) are specific:

| Retry situation | Response |
| --- | --- |
| Same key, same body, original finished | Original response replayed with `Idempotency-Replayed: true`. No second payout. |
| Same key, different body | `422` `idempotency_key_payload_mismatch` |
| Same key, original still running | `409` `idempotency_key_in_flight` with `Retry-After` |
| Original returned `5xx` | Key released, so the retry runs fresh |

Two practical rules. Derive the key from the batch and row, for example `batch-2026-10-04-row-087`, so a restarted job reuses it. And serialize the body once and keep the bytes: re-serializing JSON with a different key order counts as a different body. The IETF's [Idempotency-Key header draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) describes the same pattern across APIs, and the [idempotency keys explainer](/resources/more/stablecoin-api-idempotency-keys) covers it in more depth.

If a row's quote expired before the retry, the retry should request a new quote first. A new quote means a new payout body, so give it a new key.

## How do you track a batch to the end?

By payout id, through webhooks. The loop ending tells you every request was sent, not that every peso landed.

BlindPay fires `payout.new` when a payout is created and its source funds are captured, `payout.update` on intermediate steps (including reaching `failed`), and `payout.complete` when a payout is confirmed at the destination or refunded. The [webhook events reference](/docs/learn/webhooks-events) lists them.

Keep a row-level status table and close the batch only when every row is terminal:

| Row status | Means | Action |
| --- | --- | --- |
| `quoted` | Quote created, not executed | Execute before `expires_at` or re-quote |
| `processing` | Payout created | Wait for webhooks |
| `completed` | Funds landed | Mark paid |
| `failed` | Payout didn't complete | Fix the cause, requeue as a new row |
| `refunded` | Stablecoins returned instead of paid out | Check the bank details, requeue |

On Pix and other non-USD local rails, a payout that can't be delivered after the stablecoins are collected is refunded automatically to the quote's refund wallet, or to the sending wallet when none is set. Recipients in Mexico can also check a SPEI transfer through Banco de México's [CEP lookup](https://www.banxico.org.mx/cep/), which helps when a contractor asks where their money is. More on the states in [stablecoin payout statuses explained](/resources/more/stablecoin-payout-statuses-explained).

## What should you test before the first real batch?

Run the whole loop on a development instance with test bank accounts and USDB, BlindPay's test stablecoin. Payouts there are free.

Test the unhappy paths on purpose: let one quote expire, retry one payout with the same idempotency key, and kill the job halfway and restart it. Development instances are capped at roughly 100 requests per minute, so pace a large test batch or you'll see `429` with a `Retry-After` header. The [sandbox vs production guide](/docs/learn/sandbox-vs-production) lists what else differs.

Since SPEI runs around the clock per [Banco de México](https://www.banxico.org.mx/services/interbanking-electronic-payme.html), a Mexico batch doesn't have to wait for a business day. For a single exact-amount payout, start with [paying an exact BRL, MXN, or EUR amount](/resources/more/pay-exact-local-amount-receiver-side-quote), and for marketplace-style runs see [marketplace payouts in Latin America](/resources/more/marketplace-stablecoin-payouts-latam).

## What to do next

Take last month's payout file, pick ten rows, and run them through a development instance with the quote-and-execute loop, one idempotency key per row. Then read the [payouts reference](/docs/payouts) for the status lifecycle before you scale it to the full batch.
