---
title: "How to schedule recurring payouts when every quote expires in 5 minutes"
seoTitle: "How to schedule recurring payouts with live quotes"
description: "Run monthly payroll or supplier payouts on a schedule when each quote lasts 5 minutes: what to store, when to fire per rail, and how to never pay twice."
date: "2026-10-15"
updated: "2026-10-15"
category: "payments"
author: "BlindPay Team"
howto:
  name: "How to schedule recurring stablecoin payouts with live quotes"
  steps:
    - name: "Store the obligation, not the quote"
      text: "For each recurring payout, save the bank account id, the amount, which side the amount is fixed on (sender or receiver), cover_fees, and the funding network and token. Never store a quote id for later use, because quotes expire in 5 minutes."
    - name: "Pick a fire time per rail"
      text: "Work backwards from payday using each rail's documented estimate and cut-off. Instant rails such as Pix and SPEI can fire on payday. ACH needs to fire business days earlier and before the 9:00 PM ET cut-off."
    - name: "Fund the wallet before the run"
      text: "Top up the funding wallet with enough USDC or USDT for the whole cycle plus a buffer for rate movement, so no payout fails on balance halfway through."
    - name: "Quote and execute each payout at fire time"
      text: "At fire time, create a payout quote per recipient, read expires_at in epoch milliseconds, authorize if the wallet needs it, and execute with the quote_id before it expires. One quote backs one payout."
    - name: "Record the cycle and reconcile from webhooks"
      text: "Write a row per schedule and cycle date with a unique constraint so a re-run cannot pay twice, send an Idempotency-Key on each execute call, and close the cycle from payout webhooks."
faq:
  - q: "Can I create a quote today and execute it on payday?"
    a: "No. A BlindPay payout quote expires 5 minutes after creation, and SEPA and Faster Payments quotes can be shorter. A scheduled payout stores the obligation (who, how much, which side is fixed) and creates a fresh quote at fire time, then executes it with the quote_id before expires_at passes."
  - q: "Should a recurring salary be fixed in USDC or in local currency?"
    a: "Fix the side the recipient cares about. For salaries and retainers agreed in BRL, MXN, or EUR, quote with currency_type receiver so the local amount is exact and your USDC cost moves with the rate. For contracts agreed in dollars, quote with currency_type sender and let the local amount float."
  - q: "How do I stop a scheduled payout from being sent twice?"
    a: "Use two layers. Your own database holds one row per schedule and cycle date with a unique constraint, so a retried job sees the cycle already exists. Each execute call also carries an Idempotency-Key header, which BlindPay keeps for 24 hours and uses to replay the original response instead of running it again."
  - q: "When should a monthly ACH payout fire if payday is the 1st?"
    a: "Earlier than payday. BlindPay estimates about 2 business days for an ACH payout, USD payouts pass through an on-hold compliance review step, and the ACH cut-off is 9:00 PM ET. Fire two to three business days before the 1st, and skip forward past weekends and Federal Reserve holidays."
  - q: "What happens if a scheduled payout fails?"
    a: "The cycle row stays open. A failed or refunded payout fires a webhook, your job marks that line for retry, and the next attempt requests a brand new quote at the current rate. Test this in development by quoting 66600 (forces failed) or 77700 (forces refunded) before you trust the schedule with real money."
---

To schedule recurring payouts with live quotes, store the obligation (recipient, amount, which side is fixed) instead of a quote, then create and execute a fresh quote at fire time. A BlindPay payout quote lasts 5 minutes, so the schedule decides when to price each payout, not what rate it gets.

This is the calendar side of paying people every month. The run itself, row by row, is covered in [how to run batch payouts with live quotes](/resources/more/batch-stablecoin-payouts-live-quotes). Here the questions are what to store, when to fire, and how to make sure a cron job that runs twice still pays once.

**Key takeaways**

- Never schedule a quote. Schedule the obligation and quote at fire time.
- Store which side is fixed: `currency_type: receiver` for local-currency salaries, `sender` for dollar contracts.
- Fire time depends on the rail. Pix and SPEI can go on payday; ACH has to go business days earlier.
- Cycle-level "pay once" lives in your database. The `Idempotency-Key` header only protects a single HTTP retry.
- Fund the wallet before the run, with a buffer for rate movement between quote time and payday.

## Why can't you schedule the quote itself?

Because the quote is a price, and prices expire. A BlindPay payout quote locks the rate and fees for 5 minutes, and `expires_at` comes back in epoch milliseconds ([payout quotes](/docs/payout-quotes)). SEPA and Faster Payments quotes can be shorter, per the [cut-off times reference](/docs/kb/cut-off-times).

A quote also backs exactly one payout. A second execute with the same `quote_id` fails.

So a schedule that says "pay Ana R$ 8,000 on the 1st" is an instruction to request a quote on the 1st. The rate Ana's payout gets is the rate at that moment. If you want to understand what is in that price, [how to read an FX quote](/resources/more/how-to-read-an-fx-quote) breaks down the two rates every quote returns.

## What should a recurring payout record store?

Store the inputs to a quote, never its outputs. A minimal schedule row:

| Field | Example | Why |
| --- | --- | --- |
| `bank_account_id` | `ba_...` | The approved recipient account |
| `request_amount` | `800000` | Minor units, R$ 8,000.00 |
| `currency_type` | `receiver` | Fixes the BRL side so the recipient gets exactly R$ 8,000.00 |
| `cover_fees` | `true` | Sender pays fees on top, typical for payroll per the docs |
| `network`, `token` | `polygon`, `USDC` | The funding leg the quote is priced for |
| `cadence` | Monthly, day 1 | Payday, not fire time |
| `timezone` | `America/Sao_Paulo` | Payday is a local date |

Two details matter more than they look.

**Which side is fixed.** On a payout quote, `currency_type: sender` means the amount is in the stablecoin, and `receiver` means it's in the fiat the bank account receives. Salaries agreed in reais belong on the receiver side. [Receiver-side quotes](/resources/more/pay-exact-local-amount-receiver-side-quote) explains the trade-off: the recipient's amount is stable, your USDC cost floats.

**The timezone.** Payday is a local date, and time zones change. The IANA Time Zone Database, which most servers rely on, shipped version 2026e on 2026-09-29, and that release notes Manitoba moving to permanent UTC-05 on 2026-10-31 ([IANA](https://www.iana.org/time-zones)). Store the zone name, not a fixed offset.

## When should each rail fire?

Fire early enough that the money lands on payday, using each rail's documented estimate and cut-off.

| Rail | BlindPay estimate | Cut-off to respect | Fire on |
| --- | --- | --- | --- |
| Pix (Brazil) | Instant | None, runs 24/7 | Payday |
| SPEI (Mexico) | Instant | Instant rail, no ACH-style cut-off | Payday |
| Transfers 3.0 (Argentina) | Instant | Instant rail, no ACH-style cut-off | Payday |
| RTP (US) | Instant | On-hold review step for USD payouts | Payday morning, or 1 business day early |
| Domestic wire (US) | About 1 business day | 3:00 PM ET | 1 to 2 business days before |
| ACH (US) | About 2 business days | 9:00 PM ET | 2 to 3 business days before |
| TED (Brazil) | About 1 business day | End of the Brazilian banking day | 1 banking day before |
| ACH Colombia | About 1 business day | Business days | 1 to 2 business days before |
| SEPA | About 1 business day | Quote can expire in under 5 minutes | 1 to 2 business days before |

Sources: the [bank accounts docs](/docs/bank-accounts) for estimates and the [cut-off times reference](/docs/kb/cut-off-times) for the windows. One more thing from that reference: every USD ACH, wire, RTP, and SWIFT payout passes through `on_hold` for compliance review after the crypto is collected. Build that into the lead time.

Then skip holidays. The Federal Reserve's 2026 calendar closes on November 26 (Thanksgiving) and December 25 (Christmas), and the 2027 calendar opens with January 1 ([Federal Reserve](https://www.federalreserve.gov/aboutthefed/k8.htm)). A schedule for December 1 that fires ACH on November 26 just waits a day. [Cut-offs, weekends, and holidays by rail](/resources/more/stablecoin-payout-cut-off-times-weekends-holidays) has the full calendar logic.

Size limits matter less than they used to. Nacha raised the Same Day ACH limit to $1 million per payment in 2022 ([Nacha](https://www.nacha.org/same-day-ach)), so cut-offs, not caps, usually set your fire time.

## How do you make sure a cycle pays exactly once?

Put cycle-level uniqueness in your own database and use idempotency keys for HTTP retries. They solve different problems.

Here's the trap. BlindPay's `Idempotency-Key` header replays a request only if the body matches byte for byte, and it keeps keys for 24 hours ([idempotency docs](/docs/learn/idempotency)). Your execute body contains a `quote_id`. If the quote expires and you re-quote, the body changes, so the same key would return `422 idempotency_key_payload_mismatch`. The header can't know that both attempts are "Ana's November salary".

So:

1. **Cycle row.** Insert one row per `(schedule_id, cycle_date)` with a unique constraint before quoting. A second cron run hits the constraint and stops.
2. **Attempt key.** Each execute call gets its own `Idempotency-Key`, for example `{schedule_id}:{cycle_date}:{quote_id}`. A network timeout retries the same bytes and gets the original response back with `Idempotency-Replayed: true`.
3. **Close from webhooks.** Mark the cycle row done when `payout.complete` arrives with `completed`. Watch `payout.update` too, since failures surface there.

A `409 idempotency_key_in_flight` means the first attempt is still running. Wait for the `Retry-After` seconds, then retry. [Idempotency keys in a stablecoin API](/resources/more/stablecoin-api-idempotency-keys) goes deeper on the header.

## How much should you fund before the run?

Enough for the whole cycle at a slightly worse rate than today. If the amounts are fixed on the receiver side, your USDC need moves with the exchange rate between funding day and fire time.

Illustrative example. A team pays 40 contractors in Brazil a total of R$ 400,000.00 on the 1st, fixed in reais with `cover_fees: true`.

| Line | Value |
| --- | --- |
| Rate when funding (illustrative) | 5.40 BRL per USDC |
| USDC at that rate | 400,000 / 5.40 = 74,074.07 |
| Rate on payday if BRL strengthens | 5.25 BRL per USDC |
| USDC at payday rate | 400,000 / 5.25 = 76,190.48 |
| Gap | 76,190.48 - 74,074.07 = 2,116.41 USDC (2.9%) |
| Fees, sender-paid | Added per quote, read from each response |

Funding exactly 74,074.07 USDC would leave the last few contractors unpaid. A 3% buffer covers this move, and the rest stays in the wallet for next month. [How much USDC to hold for payouts](/resources/more/how-much-stablecoin-float-for-payouts) has the sizing formula.

If you fix amounts on the sender side instead, the USDC total is known in advance and the recipients absorb the movement. That's a business choice. [The FX gap between a sale and a payout](/resources/more/fx-gap-between-sale-and-payout) walks through who should carry it.

## What should the job do when a quote expires or a payout fails?

Re-quote, never retry the old quote. An expired quote is rejected, not executed at a new rate, so the job requests a new quote and executes that one.

Failures follow the same rule. A payout that ends `failed` or `refunded` leaves the cycle row open; the job flags the line, a person checks the bank details, and the retry gets a fresh quote. If stablecoins were already collected, refunds return them to the quote's refund wallet, or to the sending wallet when none is set. On Pix and other non-USD local rails that refund is automatic, while USD payouts and payouts whose review times out are refunded after an operator review.

Test both paths before the first live cycle. On a development instance, a quote for `66600` forces `failed` and `77700` forces `refunded` ([payouts docs](/docs/payouts)). [Handling expired quotes in TypeScript and Python](/resources/more/handle-expired-quotes-typescript-python) has the re-quote loop.

## What to do next

Write one schedule row for a single Pix recipient on a development instance, fire it from a cron job twice in a row, and confirm the second run stops at your unique constraint. The [payout quotes docs](/docs/payout-quotes) have the request body.
