Payroll
Pay people — runs, items, recipients, templates.
Jump to an operation:
- GET /payroll_runs
- POST /payroll_runs
- GET /payroll_runs/{id}
- POST /payroll_runs/{id}/approve
- POST /payroll_runs/{id}/cancel
- POST /payroll_runs/{id}/execute
- GET /payroll_runs/{id}/readiness
- GET /payroll_runs/{id}/funding_instructions
- POST /payroll_runs/{id}/funding_instructions
- GET /payroll_runs/{id}/items
- GET /payroll_runs/{id}/items/{item_id}
- POST /payroll_runs/{id}/items/{item_id}/reissue_link
- GET /payroll_runs/{id}/attempts
- GET /payroll_runs/{id}/events
- GET /payroll_runs/{id}/export
- GET /payroll_recipients
- GET /payroll_recipients/{id}
- POST /payroll_recipients/{id}/adopt_pending_destination
- GET /payroll_templates
- POST /payroll_templates
- GET /payroll_templates/{id}
- GET /payroll_recipient_sessions/{token}
- POST /payroll_recipient_sessions/{token}/destination
List pay runs — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
List this employer's pay runs, newest first. status_group mirrors the Today inbox
grouping rather than the raw 8-status enum, so a caller does not have to re-derive it.
query Parameters
limitDefault 25. Out of range is never rejected: a value outside 1..100, a fraction, or a non-number is not a 400 — 0, a negative number, a non-integer or anything non-numeric falls back to the default (25); anything above 100 is capped to 100. A documented clamp, not a silent one; minimum/maximum are deliberately absent from this schema so a generated client (the MCP tool included) does not reject a value the API itself accepts.
cursorOpaque cursor from a previous response's next_cursor.
status_groupThis resource's own bucket set (needs_you, in_progress, done), shared with payouts and orders only — not a universal enum. needs_you = reviewed, funding_pending; in_progress = funded, executing; done = completed, partial, failed, cancelled. draft and approved (BL-15, G decision 2026-09-14) match NONE of these three buckets — a run in either status is published on the unfiltered list but is invisible to every status_group-filtered read, including needs_you even though approved genuinely is money-pending. Known gap, not yet bucketed (a G decision, since it changes tab counts).
expandComma-separated. summary (LIST-SUMMARY-1) adds summary: counts computed server-side over EVERY row matching this request's filters, not over the returned page, so a client never adds up a paginated page and calls it a total. limit/cursor never change it. An unrecognised value is ignored rather than rejected (the GET /account expand rule).
Headers
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
List pay runs — available › Responses
A page of pay runs.
has_morenext_cursorA1-2: required (still nullable) — every producer already emits null rather than omitting the field when there is no next page, so this only removes a third, unused wire state (absent) a strict client had to handle for nothing.
Present only with ?expand=summary (LIST-SUMMARY-1). Computed over every run matching the request's status_group (never cursor/limit). The three counts are the Payroll hub's own sections, NOT the status_group buckets: needs_action = draft, approved, reviewed, funding_pending, funded, partial, failed; in_progress = executing; completed = completed, cancelled. A status this version does not know counts in total only.
Draft a pay run — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.write· Test mode: unavailable
Create a pay run with its recipients and amounts. This is a draft only — nothing is
funded and nothing is paid. runs_create writes reviewed directly; there is no draft
state on /v1. Default amount caps are 25,000 on the funding total (including any enabled
fee) and 10,000 per recipient amount, in the run currency without FX conversion.
Exceeding either configured amount cap answers 422; amount caps are rechecked at
execute. The 500-row limit is create request validation (400), not a repeated
execute check. funding_method: crypto drafts a run
the employer funds by sending USDC to their Bridge payroll wallet — the same path the
dashboard uses. create refuses it with 409 capability_unavailable only while crypto
funding is switched off (crypto_funding.reason: crypto_funding_not_enabled) and never
substitutes fiat. For the other reasons (bridge_funding_currency_unsupported,
verification_required) the run is created but cannot be funded: approve refuses it or
its funding instructions read blocked — check crypto_funding.available first.
Headers
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Draft a pay run — available › Request Body
titlecurrencypay_period_startpay_period_endfunding_methodOffer crypto only when GET /v1/capabilities?product=payroll shows crypto_funding.available for this currency. create refuses it with 409 capability_unavailable only while crypto funding is switched off (crypto_funding.reason: crypto_funding_not_enabled). For bridge_funding_currency_unsupported or verification_required the run is accepted and cannot be funded: approve refuses it or its funding instructions read blocked.
memoDraft a pay run — available › Responses
The created run.
id^pr_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectlivemodecreated_atstatusA1-2: open — an unrecognized member is an opaque string, never a deserialization failure.
The run's lifecycle. draft and approved are DB-legal but not reachable through a
/v1 action of this caller's own: runs_create writes reviewed directly and
runs_approve writes funding_pending directly, so neither state is ever produced by
this API — only an internal ops webhook (or a seeded row) can leave one in draft or
approved. Both are published in this enum regardless (G decision 2026-09-14):
GET /v1/payroll_runs reads real rows, and a caller-unreachable status is still a
status a real row can carry — omitting it 500'd the entire list the moment one such row
existed, rather than 200ing every row but that one. funded: an inbound funding event
was observed — it does not mean the run is fully funded; read funding_state,
funding_confirmed_amount and needs_attention. Execution is refused for a funded run
whose funding_state is not funded or overfunded.
titlecurrencyISO 4217 code the run pays in.
pay_period_startpayroll_runs.pay_period_start is a NULLABLE DB column — the internal create path (optionalDate) writes null whenever the caller omits it, and most real rows do (prod: 59 of 74 rows, 21 of 25 employers, have a null pay period on at least one side). respondValidated's .strict() parse used to require a string here, so that dominant row shape 500'd the entire list the same way the missing draft/approved enum values did (BL-15) — the key stays present (never omitted), the value is null when the run has no pay period on record.
pay_period_endNullable for the same reason as pay_period_start — see that field.
funding_stateThe funding classifier's verdict, independent of status. unfunded: nothing attributable arrived. partially_funded: money arrived and is short of funding_amount — payroll_runs.execute answers 409 payroll_run_underfunded naming the shortfall. funded: the confirmed amount matches. overfunded: more arrived than was asked for; the run may execute and the surplus is recorded.
needs_attentionDerived from funding_state: true when money was observed but the amount is short, is in surplus, or
was never confirmed at all. Advisory — the refusal that actually protects the money is on execute.
funding_methodHow the employer funds the run: fiat by bank transfer to a Bridge virtual account,
crypto by sending USDC to the employer's Bridge payroll wallet. crypto is offered
per currency by GET /v1/capabilities?product=payroll
(funding_currencies[].crypto_funding); while crypto funding is switched off it
answers 409 capability_unavailable on create, approve and the funding call — it
is never silently substituted with a fiat instruction.
Counts over this run's items, by destination readiness.
updated_atmemoA money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
The inbound amount the funding classifier attributed to this run and compared against funding_amount. Denominated in the currency the money actually arrived in: the run's own currency on the bank rail, USDC on the crypto rail, where the requirement itself was converted before it was compared. It is never restated into another currency. Null means no amount was ever confirmed — and a run with a null value here cannot execute.
pool_assetThe asset this run's funding pool holds — what the deposit becomes in the employer's Bridge payroll wallet and what every payout is paid out of. USDC for every run, or EURC for an EUR run whose recipients chose EURC wallets, where the EURC pool is enabled for the account. Chosen at approve from the wallet destinations (one pool per run: a run that mixes EURC and USDC wallets answers 409 run_mixed_pool) and never changed once money was observed (409 pool_asset_locked). Null while the run's funding records no pool: before approve (and on a run cancelled before it), or on an older approved run whose funding recorded no wallet. Read-only.
Present once a funding attempt has been requested; null before that.
approved_atfunded_atA funding event was observed for this run — not proof the run is fully funded.
completed_atGet a pay run — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
Return one pay run this caller owns.
path Parameters
id^pr_ · requiredHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Get a pay run — available › Responses
The run.
id^pr_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectlivemodecreated_atstatusA1-2: open — an unrecognized member is an opaque string, never a deserialization failure.
The run's lifecycle. draft and approved are DB-legal but not reachable through a
/v1 action of this caller's own: runs_create writes reviewed directly and
runs_approve writes funding_pending directly, so neither state is ever produced by
this API — only an internal ops webhook (or a seeded row) can leave one in draft or
approved. Both are published in this enum regardless (G decision 2026-09-14):
GET /v1/payroll_runs reads real rows, and a caller-unreachable status is still a
status a real row can carry — omitting it 500'd the entire list the moment one such row
existed, rather than 200ing every row but that one. funded: an inbound funding event
was observed — it does not mean the run is fully funded; read funding_state,
funding_confirmed_amount and needs_attention. Execution is refused for a funded run
whose funding_state is not funded or overfunded.
titlecurrencyISO 4217 code the run pays in.
pay_period_startpayroll_runs.pay_period_start is a NULLABLE DB column — the internal create path (optionalDate) writes null whenever the caller omits it, and most real rows do (prod: 59 of 74 rows, 21 of 25 employers, have a null pay period on at least one side). respondValidated's .strict() parse used to require a string here, so that dominant row shape 500'd the entire list the same way the missing draft/approved enum values did (BL-15) — the key stays present (never omitted), the value is null when the run has no pay period on record.
pay_period_endNullable for the same reason as pay_period_start — see that field.
funding_stateThe funding classifier's verdict, independent of status. unfunded: nothing attributable arrived. partially_funded: money arrived and is short of funding_amount — payroll_runs.execute answers 409 payroll_run_underfunded naming the shortfall. funded: the confirmed amount matches. overfunded: more arrived than was asked for; the run may execute and the surplus is recorded.
needs_attentionDerived from funding_state: true when money was observed but the amount is short, is in surplus, or
was never confirmed at all. Advisory — the refusal that actually protects the money is on execute.
funding_methodHow the employer funds the run: fiat by bank transfer to a Bridge virtual account,
crypto by sending USDC to the employer's Bridge payroll wallet. crypto is offered
per currency by GET /v1/capabilities?product=payroll
(funding_currencies[].crypto_funding); while crypto funding is switched off it
answers 409 capability_unavailable on create, approve and the funding call — it
is never silently substituted with a fiat instruction.
Counts over this run's items, by destination readiness.
updated_atmemoA money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
The inbound amount the funding classifier attributed to this run and compared against funding_amount. Denominated in the currency the money actually arrived in: the run's own currency on the bank rail, USDC on the crypto rail, where the requirement itself was converted before it was compared. It is never restated into another currency. Null means no amount was ever confirmed — and a run with a null value here cannot execute.
pool_assetThe asset this run's funding pool holds — what the deposit becomes in the employer's Bridge payroll wallet and what every payout is paid out of. USDC for every run, or EURC for an EUR run whose recipients chose EURC wallets, where the EURC pool is enabled for the account. Chosen at approve from the wallet destinations (one pool per run: a run that mixes EURC and USDC wallets answers 409 run_mixed_pool) and never changed once money was observed (409 pool_asset_locked). Null while the run's funding records no pool: before approve (and on a run cancelled before it), or on an older approved run whose funding recorded no wallet. Read-only.
Present once a funding attempt has been requested; null before that.
approved_atfunded_atA funding event was observed for this run — not proof the run is fully funded.
completed_atApprove a reviewed run — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.write· Test mode: unavailable
Approve a reviewed run. runs_approve writes funding_pending directly — there is no
published approved state to observe afterwards. Refused when the readiness ladder is not
clear; read GET .../readiness first to show why, rather than discovering it from this
call's error. A run with zero rows answers 409 run_has_no_rows before any provider call
or funding-account provisioning. Approving prepares the run's funding instructions for its
own funding_method; a crypto run answers 409 capability_unavailable while crypto
funding is switched off, before anything is provisioned — it is never approved onto a bank
route silently.
Approving chooses the run's funding pool (pool_asset) from its recipients' wallet
destinations: USDC, or EURC for an EUR run whose recipients chose EURC wallets where the
EURC pool is enabled for the account; a bank account casts no vote and keeps its own
blocker. One pool per run, decided before any provider call and any write: a run that mixes
EURC and USDC wallets answers 409 run_mixed_pool (details.items_by_pool counts the wallet
rows per asset; split it into two runs), and an EURC pool on a crypto run answers 409 crypto_funding_pool_unsupported (an EURC pool is funded by bank transfer only).
A USD run whose recipients saved local bank accounts (BRL by Pix, MXN by SPEI, COP by Bre-B —
PayrollRunItem.local_payout) has each such row priced at Bridge's rate before anything is
provisioned: a row below Bridge's minimum for its rail answers 409 local_payout_below_minimum
(details: item_id, basis — destination for the rail's minimum in the local currency,
route_source for Bridge's USDC minimum — minimum, minimum_currency), and an unreadable rate
503 temporarily_unavailable; nothing is written either way. A row whose rail is not enabled for
the payer leaves the funding instructions blocked with failure_reason: local_payout_not_enabled.
Every row's crypto destination is checked again, before any provider call and any write, as every
inbound door checks a Swaps Wallet: a row that pays one Swaps cannot route new money to refuses the
whole run with that row's code — 422 destination_rail_keyless, destination_wallet_disabled,
destination_wallet_key_reported_lost, destination_wallet_signer_at_risk or
destination_wallet_signer_unconfirmed (the first of these, in this order, that the rows carry; cancel
the run and create a new one, which asks the recipient again) — and 503 destination_risk_unverifiable
when the check could not run for a row and no row is refused outright: nothing was written, retry after
Retry-After with the same Idempotency-Key.
Those codes go only to the wallet's own holder; an employer never is one, so it hears
422 destination_not_accepted for any of them, and every caller hears
503 destination_check_unavailable when the wallet or key-mark lookup itself failed.
path Parameters
id^pr_ · requiredHeaders
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Approve a reviewed run — available › Responses
The approved run.
id^pr_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectlivemodecreated_atstatusA1-2: open — an unrecognized member is an opaque string, never a deserialization failure.
The run's lifecycle. draft and approved are DB-legal but not reachable through a
/v1 action of this caller's own: runs_create writes reviewed directly and
runs_approve writes funding_pending directly, so neither state is ever produced by
this API — only an internal ops webhook (or a seeded row) can leave one in draft or
approved. Both are published in this enum regardless (G decision 2026-09-14):
GET /v1/payroll_runs reads real rows, and a caller-unreachable status is still a
status a real row can carry — omitting it 500'd the entire list the moment one such row
existed, rather than 200ing every row but that one. funded: an inbound funding event
was observed — it does not mean the run is fully funded; read funding_state,
funding_confirmed_amount and needs_attention. Execution is refused for a funded run
whose funding_state is not funded or overfunded.
titlecurrencyISO 4217 code the run pays in.
pay_period_startpayroll_runs.pay_period_start is a NULLABLE DB column — the internal create path (optionalDate) writes null whenever the caller omits it, and most real rows do (prod: 59 of 74 rows, 21 of 25 employers, have a null pay period on at least one side). respondValidated's .strict() parse used to require a string here, so that dominant row shape 500'd the entire list the same way the missing draft/approved enum values did (BL-15) — the key stays present (never omitted), the value is null when the run has no pay period on record.
pay_period_endNullable for the same reason as pay_period_start — see that field.
funding_stateThe funding classifier's verdict, independent of status. unfunded: nothing attributable arrived. partially_funded: money arrived and is short of funding_amount — payroll_runs.execute answers 409 payroll_run_underfunded naming the shortfall. funded: the confirmed amount matches. overfunded: more arrived than was asked for; the run may execute and the surplus is recorded.
needs_attentionDerived from funding_state: true when money was observed but the amount is short, is in surplus, or
was never confirmed at all. Advisory — the refusal that actually protects the money is on execute.
funding_methodHow the employer funds the run: fiat by bank transfer to a Bridge virtual account,
crypto by sending USDC to the employer's Bridge payroll wallet. crypto is offered
per currency by GET /v1/capabilities?product=payroll
(funding_currencies[].crypto_funding); while crypto funding is switched off it
answers 409 capability_unavailable on create, approve and the funding call — it
is never silently substituted with a fiat instruction.
Counts over this run's items, by destination readiness.
updated_atmemoA money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
The inbound amount the funding classifier attributed to this run and compared against funding_amount. Denominated in the currency the money actually arrived in: the run's own currency on the bank rail, USDC on the crypto rail, where the requirement itself was converted before it was compared. It is never restated into another currency. Null means no amount was ever confirmed — and a run with a null value here cannot execute.
pool_assetThe asset this run's funding pool holds — what the deposit becomes in the employer's Bridge payroll wallet and what every payout is paid out of. USDC for every run, or EURC for an EUR run whose recipients chose EURC wallets, where the EURC pool is enabled for the account. Chosen at approve from the wallet destinations (one pool per run: a run that mixes EURC and USDC wallets answers 409 run_mixed_pool) and never changed once money was observed (409 pool_asset_locked). Null while the run's funding records no pool: before approve (and on a run cancelled before it), or on an older approved run whose funding recorded no wallet. Read-only.
Present once a funding attempt has been requested; null before that.
approved_atfunded_atA funding event was observed for this run — not proof the run is fully funded.
completed_atCancel a pay run — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.write· Test mode: unavailable
Cancel a run that has not finished executing. Cancelling leaves any already-paid items
exactly where they were — there is no item-level cancelled status, and this never claws
back a payment already in flight.
path Parameters
id^pr_ · requiredHeaders
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Cancel a pay run — available › Responses
The cancelled run.
id^pr_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectlivemodecreated_atstatusA1-2: open — an unrecognized member is an opaque string, never a deserialization failure.
The run's lifecycle. draft and approved are DB-legal but not reachable through a
/v1 action of this caller's own: runs_create writes reviewed directly and
runs_approve writes funding_pending directly, so neither state is ever produced by
this API — only an internal ops webhook (or a seeded row) can leave one in draft or
approved. Both are published in this enum regardless (G decision 2026-09-14):
GET /v1/payroll_runs reads real rows, and a caller-unreachable status is still a
status a real row can carry — omitting it 500'd the entire list the moment one such row
existed, rather than 200ing every row but that one. funded: an inbound funding event
was observed — it does not mean the run is fully funded; read funding_state,
funding_confirmed_amount and needs_attention. Execution is refused for a funded run
whose funding_state is not funded or overfunded.
titlecurrencyISO 4217 code the run pays in.
pay_period_startpayroll_runs.pay_period_start is a NULLABLE DB column — the internal create path (optionalDate) writes null whenever the caller omits it, and most real rows do (prod: 59 of 74 rows, 21 of 25 employers, have a null pay period on at least one side). respondValidated's .strict() parse used to require a string here, so that dominant row shape 500'd the entire list the same way the missing draft/approved enum values did (BL-15) — the key stays present (never omitted), the value is null when the run has no pay period on record.
pay_period_endNullable for the same reason as pay_period_start — see that field.
funding_stateThe funding classifier's verdict, independent of status. unfunded: nothing attributable arrived. partially_funded: money arrived and is short of funding_amount — payroll_runs.execute answers 409 payroll_run_underfunded naming the shortfall. funded: the confirmed amount matches. overfunded: more arrived than was asked for; the run may execute and the surplus is recorded.
needs_attentionDerived from funding_state: true when money was observed but the amount is short, is in surplus, or
was never confirmed at all. Advisory — the refusal that actually protects the money is on execute.
funding_methodHow the employer funds the run: fiat by bank transfer to a Bridge virtual account,
crypto by sending USDC to the employer's Bridge payroll wallet. crypto is offered
per currency by GET /v1/capabilities?product=payroll
(funding_currencies[].crypto_funding); while crypto funding is switched off it
answers 409 capability_unavailable on create, approve and the funding call — it
is never silently substituted with a fiat instruction.
Counts over this run's items, by destination readiness.
updated_atmemoA money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
The inbound amount the funding classifier attributed to this run and compared against funding_amount. Denominated in the currency the money actually arrived in: the run's own currency on the bank rail, USDC on the crypto rail, where the requirement itself was converted before it was compared. It is never restated into another currency. Null means no amount was ever confirmed — and a run with a null value here cannot execute.
pool_assetThe asset this run's funding pool holds — what the deposit becomes in the employer's Bridge payroll wallet and what every payout is paid out of. USDC for every run, or EURC for an EUR run whose recipients chose EURC wallets, where the EURC pool is enabled for the account. Chosen at approve from the wallet destinations (one pool per run: a run that mixes EURC and USDC wallets answers 409 run_mixed_pool) and never changed once money was observed (409 pool_asset_locked). Null while the run's funding records no pool: before approve (and on a run cancelled before it), or on an older approved run whose funding recorded no wallet. Read-only.
Present once a funding attempt has been requested; null before that.
approved_atfunded_atA funding event was observed for this run — not proof the run is fully funded.
completed_atExecute a funded run — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.write· Test mode: unavailable · Money boundary: per-transaction human confirmation required
Pay every ready item in one funded run. This is the money boundary — require a per-run
human confirmation before calling it, never chain it automatically from approve or from
a funding webhook. Execution is a sequential, unbatched server loop with no per-item push;
re-read the run or its items to watch progress, do not assume "N of M" from this response
alone. REST-only — structurally excluded from the MCP tool set because a generated tool
would let an agent cross the boundary without the human confirmation the flow requires.
A funded run only means an inbound funding event was observed — it does not mean the run
is fully funded; read funding_state and funding_confirmed_amount on the run before
confirming this call with a human.
Readiness refusals include payroll_verified_customer_required (the employer's Bridge
customer is not yet verified) and amount-cap errors payroll_run_limit_exceeded /
payroll_item_limit_exceeded (422). Default amount caps are 25,000 on the funding
total and 10,000 per recipient amount, in the run currency without FX conversion;
server configuration may change them. The 500-row limit is enforced at create, not
repeated by the execute amount-cap check. Other readiness refusals include
funding_not_verified (no confirmed funding event yet),
payroll_run_underfunded (an event was observed but the confirmed amount is short of
funding_amount, or was never confirmed — the message names the shortfall),
recipient_destination_not_ready (an item still needs a destination),
rail_not_ready, provider_adapter_disabled, bridge_wallet_missing and
bridge_customer_missing. None of these clear by retrying without changing the
underlying state — re-read GET .../readiness after acting on the cause.
A run with zero rows answers 409 run_has_no_rows before any of these rungs and is
never moved to executing.
Every row's crypto destination is checked again before any payout attempt and any provider
call, as every inbound door checks a Swaps Wallet: a row that pays one Swaps cannot route new
money to blocks the whole run with that row's code — 422 destination_rail_keyless,
destination_wallet_disabled, destination_wallet_key_reported_lost,
destination_wallet_signer_at_risk or destination_wallet_signer_unconfirmed; no payout is
started and Swaps support changes the destination — and 503 destination_risk_unverifiable
when the check could not run for a row and no row is refused outright. Either way the block is
recorded on the run (execution_blocked), so this 503 carries no Retry-After: a replay of
the same Idempotency-Key answers 409 idempotency_failed; read the run, then retry with a
new key.
Those codes go only to the wallet's own holder; an employer never is one, so it hears
422 destination_not_accepted for any of them, and every caller hears
503 destination_check_unavailable when the wallet or key-mark lookup itself failed.
A run not denominated in the asset its payout wallet holds (a EUR run funded into USDC)
pays its USDC wallet items from the converted pool at the rate the deposit actually
realized — each item converted and rounded down to the provider's two-decimal transfer
precision, the employer-funded fee converted the same way — and refuses, before any
payout, with funding_realized_rate_unavailable when that rate cannot be read from the
run's own funding evidence, funding_pool_insufficient when the converted payouts plus
fees would overdraw the pool, or funding_pool_asset_unknown when nothing states the
wallet's asset. Before its first transfer such a run is rehearsed with the provider at
the exact converted amounts (a dry_run, creating nothing): payout_preflight_rejected
refuses the run whole when the provider does not validate that body — its details carry
preflight_reason (provider_refused, route_invalid, or dry_run_returned_transfer
when the provider answered the dry run with a transfer object: the account is then on
operator hold, 403 payroll_operator_hold on approve, fund and execute, until Swaps has
reconciled with the provider), item_id, and an allowlisted classification of the
provider's answer (provider_status, provider_error_class, invalid_fields,
notes_count, notes_classes), never its text — and a provider outage during the
rehearsal is 503 temporarily_unavailable — in both cases nothing is queued and the run
stays funded. Bank items of such a run are rail_not_ready
(bank_payout_fx_unsupported). A USD run is unaffected.
An EUR run whose pool is EURC is settled the same way: its EURC wallet items are paid at
the rate its deposit realized (EURC credited ÷ EUR deposited — never assumed 1:1), rounded
down, from the pool and under the same guard, rehearsed with the provider before the first
transfer, with the same refusals; its bank items are rail_not_ready
(bank_payout_fx_unsupported).
A USD run's local bank rows (BRL by Pix, MXN by SPEI, COP by Bre-B) are paid from its USDC, 1:1,
and Bridge converts each once, into the account's currency at its own rate. Before the first
transfer each such row is priced again at Bridge's rate — 409 local_payout_below_minimum (with
details) or 503 temporarily_unavailable refuse the run whole, nothing queued, the run stays
funded — and its exact body is rehearsed with the provider as a dry_run, with the same
payout_preflight_rejected refusal as above. A row whose rail was disabled for the payer since
approval is rail_not_ready (local_payout_not_enabled). What Bridge delivered is recorded from its
receipt on the item (PayrollRunItem.local_payout.delivered).
path Parameters
id^pr_ · requiredHeaders
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Execute a funded run — available › Request Body
confirmExecute a funded run — available › Responses
The run, now executing or further along.
id^pr_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectlivemodecreated_atstatusA1-2: open — an unrecognized member is an opaque string, never a deserialization failure.
The run's lifecycle. draft and approved are DB-legal but not reachable through a
/v1 action of this caller's own: runs_create writes reviewed directly and
runs_approve writes funding_pending directly, so neither state is ever produced by
this API — only an internal ops webhook (or a seeded row) can leave one in draft or
approved. Both are published in this enum regardless (G decision 2026-09-14):
GET /v1/payroll_runs reads real rows, and a caller-unreachable status is still a
status a real row can carry — omitting it 500'd the entire list the moment one such row
existed, rather than 200ing every row but that one. funded: an inbound funding event
was observed — it does not mean the run is fully funded; read funding_state,
funding_confirmed_amount and needs_attention. Execution is refused for a funded run
whose funding_state is not funded or overfunded.
titlecurrencyISO 4217 code the run pays in.
pay_period_startpayroll_runs.pay_period_start is a NULLABLE DB column — the internal create path (optionalDate) writes null whenever the caller omits it, and most real rows do (prod: 59 of 74 rows, 21 of 25 employers, have a null pay period on at least one side). respondValidated's .strict() parse used to require a string here, so that dominant row shape 500'd the entire list the same way the missing draft/approved enum values did (BL-15) — the key stays present (never omitted), the value is null when the run has no pay period on record.
pay_period_endNullable for the same reason as pay_period_start — see that field.
funding_stateThe funding classifier's verdict, independent of status. unfunded: nothing attributable arrived. partially_funded: money arrived and is short of funding_amount — payroll_runs.execute answers 409 payroll_run_underfunded naming the shortfall. funded: the confirmed amount matches. overfunded: more arrived than was asked for; the run may execute and the surplus is recorded.
needs_attentionDerived from funding_state: true when money was observed but the amount is short, is in surplus, or
was never confirmed at all. Advisory — the refusal that actually protects the money is on execute.
funding_methodHow the employer funds the run: fiat by bank transfer to a Bridge virtual account,
crypto by sending USDC to the employer's Bridge payroll wallet. crypto is offered
per currency by GET /v1/capabilities?product=payroll
(funding_currencies[].crypto_funding); while crypto funding is switched off it
answers 409 capability_unavailable on create, approve and the funding call — it
is never silently substituted with a fiat instruction.
Counts over this run's items, by destination readiness.
updated_atmemoA money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
The inbound amount the funding classifier attributed to this run and compared against funding_amount. Denominated in the currency the money actually arrived in: the run's own currency on the bank rail, USDC on the crypto rail, where the requirement itself was converted before it was compared. It is never restated into another currency. Null means no amount was ever confirmed — and a run with a null value here cannot execute.
pool_assetThe asset this run's funding pool holds — what the deposit becomes in the employer's Bridge payroll wallet and what every payout is paid out of. USDC for every run, or EURC for an EUR run whose recipients chose EURC wallets, where the EURC pool is enabled for the account. Chosen at approve from the wallet destinations (one pool per run: a run that mixes EURC and USDC wallets answers 409 run_mixed_pool) and never changed once money was observed (409 pool_asset_locked). Null while the run's funding records no pool: before approve (and on a run cancelled before it), or on an older approved run whose funding recorded no wallet. Read-only.
Present once a funding attempt has been requested; null before that.
approved_atfunded_atA funding event was observed for this run — not proof the run is fully funded.
completed_atPreview the payer-compliance gate — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
Preview the payer-compliance gate approve, funding and execute each check FIRST,
before their own state-specific checks — the same Bridge-customer verification, KYC, ToS
and rail-endorsement ladder (PayrollBlockerCode), without executing anything. It also
reports one run-state rung, run_has_no_rows, so a run with zero rows is never ready.
ready: true means THIS gate is clear; it is not a promise the mutation will succeed — a
run this reports ready can still be refused by a state check this preview does not run
(missing recipient destinations, unconfirmed funding, an unresolved rail, the private-beta
caps, the execution-adapter flag) and answer its own 409/422/503 with a curated code
from execute's own error responses. A future, separate preview over that state machine is
not ruled out — it needs its own reason-code vocabulary, which PayrollBlockerCode does
not carry.
path Parameters
id^pr_ · requiredHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Preview the payer-compliance gate — available › Responses
Whether the run can proceed, and why not.
readyblockersGet funding instructions — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
Read the run's current funding instructions and their status. The status describes the
instructions, not the money: it stays verified after a deposit is observed — read the
run's status and funding_state for whether it has been funded. Detection runs on a
poll, not a webhook: every five minutes, and on the crypto route each poll reads the
wallet only up to a minute before it began, so the run can show a real transfer about six
minutes late — do not treat an unchanged funding_state as proof nothing arrived. A crypto
run whose deposit details were issued more than 14 days ago reads blocked with
failure_reason: deposit_window_expired and no deposit details: a deposit to them is no
longer counted automatically — contact support.
path Parameters
id^pr_ · requiredHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Get funding instructions — available › Responses
The funding instructions.
statusrun_referencePAYROLL- plus the run id, stripped of non-alphanumerics, first 12 characters, upper-cased.
retryablefunding_route_typeThe rail family behind these instructions (bank or crypto), driving which of bank/crypto below is populated. A run whose funding_method is crypto reads crypto_deposit_to_wallet in every state before verified.
Present when funding_route_type is bank. beneficiary_name carries what was previously
beneficiary; rail is the single settlement rail for this instruction (the run's own
run_reference above stays on the parent object, not nested here).
Present when funding_route_type is crypto_deposit_to_wallet and status is
verified: the employer's Bridge payroll wallet — address (case-preserved, send to it
verbatim), chain (the wallet's chain, e.g. solana), asset (USDC) and amount, the run's
funding_amount (fee included) converted to USDC when the instructions were prepared.
Send the whole amount in ONE transfer of asset on chain: deposits on this route are
not summed — a short deposit leaves the run partially_funded, and a top-up does not
fund it (contact support). A later funding call without a method switch returns this
same amount; it is never re-priced under a deposit already sent. This whole object is
null (never a zero-valued object) when the amount cannot be priced; status then reads
failed with failure_reason: fx_unconverted. This route answers capability_unavailable on
create/approve/refresh while crypto funding is switched off — it is never returned
as a silent substitute for the bank shape, and the bank shape is never returned as a
silent substitute for it.
failure_reasonNormalized only — never the raw Bridge or provider error text. A snake_case code (for example bridge_wallet_address_unavailable); provider_error when the provider failed with text that is not published. On blocked, the first blocker's code — for example bank_payout_fx_unsupported, a bank row on a run whose pool is not paid out 1:1 in the run currency: a non-USD run paid out of the USDC its funding converted into, or an EUR run's EURC pool (bank payouts from an EURC pool are not offered yet) — or deposit_window_expired, a crypto run whose deposit details were issued more than 14 days ago (held for review; not retryable). A BRL (Pix) or MXN (SPEI) bank funding account opens only under the employer's own deposit approval at Bridge, checked before the account is requested: funding_endorsement_required (a verification step is left — finish it, then retry), funding_endorsement_in_review (Bridge is reviewing it; retry later) or funding_rail_not_available (Bridge does not offer that deposit rail to this employer, e.g. Pix to a non-Brazilian resident; not retryable) on blocked, and funding_endorsement_unverifiable (the approval could not be read; retryable) on failed.
Create or refresh funding instructions — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.write· Test mode: unavailable · Money boundary: per-transaction human confirmation required
Create the run's funding instructions on the first call; every later call is an idempotent
refresh of the same attempt's estimate, never a second provider transfer. This is the
money boundary — show it to a human and confirm before wiring or sending funds; never
chain it automatically from create or approve. funding_method in the body switches
the run between the bank route (fiat) and a USDC deposit to the employer's Bridge
payroll wallet (crypto); omitted, the run keeps its own method. A crypto run or override
answers 409 capability_unavailable while crypto funding is switched off — it never falls
back to the bank shape silently. On a crypto run whose instructions are verified with an
amount, a call that does not switch method returns those same instructions unchanged: it
never re-prices the amount or restarts the deposit window — a run whose deposit details
were issued more than 14 days ago therefore keeps reading blocked with failure_reason: deposit_window_expired. Switching method once money has
been observed on the run (funding_state other than unfunded, or a
funding_confirmed_amount) answers 409 funding_method_locked. A run with zero rows
answers 409 run_has_no_rows before any provider call. The run's funding pool
(pool_asset) is chosen again from its destinations, as at approve — 409 run_mixed_pool,
and 409 crypto_funding_pool_unsupported for a crypto method on an EURC pool; a pool that
would change once money has been observed answers 409 pool_asset_locked. All three answer
before any provider call and any write.
path Parameters
id^pr_ · requiredHeaders
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Create or refresh funding instructions — available › Request Body optional
funding_methodCreate or refresh funding instructions — available › Responses
The funding instructions, created or refreshed.
statusrun_referencePAYROLL- plus the run id, stripped of non-alphanumerics, first 12 characters, upper-cased.
retryablefunding_route_typeThe rail family behind these instructions (bank or crypto), driving which of bank/crypto below is populated. A run whose funding_method is crypto reads crypto_deposit_to_wallet in every state before verified.
Present when funding_route_type is bank. beneficiary_name carries what was previously
beneficiary; rail is the single settlement rail for this instruction (the run's own
run_reference above stays on the parent object, not nested here).
Present when funding_route_type is crypto_deposit_to_wallet and status is
verified: the employer's Bridge payroll wallet — address (case-preserved, send to it
verbatim), chain (the wallet's chain, e.g. solana), asset (USDC) and amount, the run's
funding_amount (fee included) converted to USDC when the instructions were prepared.
Send the whole amount in ONE transfer of asset on chain: deposits on this route are
not summed — a short deposit leaves the run partially_funded, and a top-up does not
fund it (contact support). A later funding call without a method switch returns this
same amount; it is never re-priced under a deposit already sent. This whole object is
null (never a zero-valued object) when the amount cannot be priced; status then reads
failed with failure_reason: fx_unconverted. This route answers capability_unavailable on
create/approve/refresh while crypto funding is switched off — it is never returned
as a silent substitute for the bank shape, and the bank shape is never returned as a
silent substitute for it.
failure_reasonNormalized only — never the raw Bridge or provider error text. A snake_case code (for example bridge_wallet_address_unavailable); provider_error when the provider failed with text that is not published. On blocked, the first blocker's code — for example bank_payout_fx_unsupported, a bank row on a run whose pool is not paid out 1:1 in the run currency: a non-USD run paid out of the USDC its funding converted into, or an EUR run's EURC pool (bank payouts from an EURC pool are not offered yet) — or deposit_window_expired, a crypto run whose deposit details were issued more than 14 days ago (held for review; not retryable). A BRL (Pix) or MXN (SPEI) bank funding account opens only under the employer's own deposit approval at Bridge, checked before the account is requested: funding_endorsement_required (a verification step is left — finish it, then retry), funding_endorsement_in_review (Bridge is reviewing it; retry later) or funding_rail_not_available (Bridge does not offer that deposit rail to this employer, e.g. Pix to a non-Brazilian resident; not retryable) on blocked, and funding_endorsement_unverifiable (the approval could not be read; retryable) on failed.
List a run's items — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
List the per-recipient items on one run, in the run's own frozen row order (the order
recipients were added to the run, never re-sorted newest-first the way most other list
operations default). This is the run detail screen's own source for its frozen-rows
table — payroll_runs.get does not embed items — and that table's own row order must
match the run's, not an activity-feed order.
path Parameters
id^pr_ · requiredquery Parameters
limitDefault 25. Out of range is never rejected: a value outside 1..100, a fraction, or a non-number is not a 400 — 0, a negative number, a non-integer or anything non-numeric falls back to the default (25); anything above 100 is capped to 100. A documented clamp, not a silent one; minimum/maximum are deliberately absent from this schema so a generated client (the MCP tool included) does not reject a value the API itself accepts.
cursorOpaque cursor from a previous response's next_cursor.
Headers
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
List a run's items — available › Responses
A page of run items.
has_morenext_cursorA1-2: required (still nullable) — every producer already emits null rather than omitting the field when there is no next page, so this only removes a third, unused wire state (absent) a strict client had to handle for nothing.
Get one run item — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
Return one item on a run this caller owns.
path Parameters
id^pr_ · requireditem_id^pri_ · requiredHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Get one run item — available › Responses
The item.
id^pri_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectlivemodecreated_atrun_id^pr_ · requiredFrozen at run creation — does not track later edits to the recipient record.
destination_statusstatusThe item's payout lifecycle inside its run (R14-payroll §4). No item-level cancelled exists — cancelling a run leaves its items wherever they were.
A money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
updated_atNull while destination_status is missing.
destination_changed_after_approvalPlanned — no producer exists yet (payroll_run_items carries no such column and
payroll_events mints no such event today); a caller must not read this as a live
signal until a producer ships. Always false until then.
paid_atPresent only when this item pays a local bank account in another currency than the run's — a USD run paying BRL by Pix, MXN by SPEI or COP by Bre-B. The item's amount stays in the run currency; Bridge converts it once, at payout, at its own rate (its FX spread is inside that rate, never a second deduction). estimate is Bridge's non-binding figure from when the account was saved; delivered is exactly what Bridge's receipt says arrived, set only once the payout is paid and cleared if it is returned — never the estimate in its place.
Rotate and re-send a recipient's link — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.write· Test mode: unavailable · Money boundary: per-transaction human confirmation required
The employer's way out of an expired, lost or never-delivered destination-request e-mail: rotates the
item's recipient token, re-sends the destination-request e-mail, and returns the fresh URL directly — the
only place the token reaches the wire, and only to this run's own employer (never a bare field, never in a
response to the recipient's own read of this item). Refused 409 conflict once the item can no longer
accept a destination change (the same gate the recipient-facing destination write itself checks — reissuing
past it would mail a link that can never do anything), and rate-limited per item over a rolling window
shared with that same recipient-facing ceiling. A call inside the short idempotent reissue window right
after a genuine rotation returns the SAME still-usable link without rotating or mailing again —
email.sent is false for that call, never a claim that mail went out twice. email.reason names the
transport's own failure class (never the raw provider body) so a caller can show why nothing arrived
instead of a silent no-op.
path Parameters
id^pr_ · requireditem_id^pri_ · requiredHeaders
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Rotate and re-send a recipient's link — available › Responses
The rotated link and whether the re-send mailed.
objectOne recipient's line inside a run — a masked destination projection, never the raw bank or wallet detail (mirrors the payouts beneficiary projection).
linkThe employer's own copy of the recipient's onboarding link. The token appears ONLY inside url, never as a bare field, and only to this run's own employer (the same trust boundary the operation description states). Null on an Idempotent-Replayed response — the token is shown once, never re-served from the idempotency store (AuthenticatedRoute.secretFields, the same mechanism api_keys' own secret uses).
List a run's payout attempts — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
List the provider-side attempts behind a run's items, paginated. handleRunsGet already
returns every attempt embedded and unbounded — this is the paginated, independently
readable form of the same data.
path Parameters
id^pr_ · requiredquery Parameters
limitDefault 25. Out of range is never rejected: a value outside 1..100, a fraction, or a non-number is not a 400 — 0, a negative number, a non-integer or anything non-numeric falls back to the default (25); anything above 100 is capped to 100. A documented clamp, not a silent one; minimum/maximum are deliberately absent from this schema so a generated client (the MCP tool included) does not reject a value the API itself accepts.
cursorOpaque cursor from a previous response's next_cursor.
Headers
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
List a run's payout attempts — available › Responses
A page of attempts.
has_morenext_cursorA1-2: required (still nullable) — every producer already emits null rather than omitting the field when there is no next page, so this only removes a third, unused wire state (absent) a strict client had to handle for nothing.
List a run's events — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: sandbox
List one run's immutable event timeline, paginated. Same underlying data
handleRunsGet already returns unbounded — this is the paginated, independently
readable form. Test mode: sandbox (K11-6, §4.2 drift, was declared full) — a test run's events are written
by DEV handlers into DEV's own api_events outbox; a prod-local read would return an empty page forever.
path Parameters
id^pr_ · requiredquery Parameters
limitDefault 25. Out of range is never rejected: a value outside 1..100, a fraction, or a non-number is not a 400 — 0, a negative number, a non-integer or anything non-numeric falls back to the default (25); anything above 100 is capped to 100. A documented clamp, not a silent one; minimum/maximum are deliberately absent from this schema so a generated client (the MCP tool included) does not reject a value the API itself accepts.
cursorOpaque cursor from a previous response's next_cursor.
Headers
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
List a run's events — available › Responses
A page of events.
has_morenext_cursorA1-2: required (still nullable) — every producer already emits null rather than omitting the field when there is no next page, so this only removes a third, unused wire state (absent) a strict client had to handle for nothing.
Export a run as CSV — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
Download the run as a CSV — recipients, amounts and masked destinations. Destinations stay masked in the export exactly as they are on every other read; this is a report, not a beneficiary-detail disclosure.
Columns, in order: row_number, recipient_name, recipient_email, worker_type,
amount, currency, destination_type, destination_display, payout_status,
provider_id, rail_type, provider_reference, provider_transfer_id, failure_reason,
retry_eligible, paid_at. rail_type carries the same provider-scoped rail identifier
as PayrollAttempt.rail_type — bank_bridge, bridge_crypto_wallet or tempo_wallet
today, an open set, and not a Rail value; like provider_id it names the provider. A
provider-neutral rail_type vocabulary is an A1-10 (naming freeze) follow-up. The header
row itself is part of the contract — do not parse this file positionally without checking it.
Cell neutralisation. Any cell whose first character is =, +, -, @, a tab or a
carriage return is emitted with a leading apostrophe ('). Those characters start a
formula in Excel, Sheets and Numbers, and the values here are supplied by third parties
(a recipient's own name or email), so an un-neutralised export would execute a stranger's
formula on the employer's machine. Quoting alone does not prevent this — a spreadsheet
unquotes the field first and evaluates it second. Strip the leading apostrophe if you are
parsing the file programmatically rather than opening it in a spreadsheet.
path Parameters
id^pr_ · requiredHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Export a run as CSV — available › Responses
The CSV file.
List recipients — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
List this employer's payees across every run. A recipient exists only as a byproduct of a
run's creation — there is no create, update or archive here, because nothing in the backend
writes to a recipient outside that path. The one write is adopt_pending_destination: a
recipient with a non-null pending_destination saved a destination through their payout
link that is not their default yet.
query Parameters
limitDefault 25. Out of range is never rejected: a value outside 1..100, a fraction, or a non-number is not a 400 — 0, a negative number, a non-integer or anything non-numeric falls back to the default (25); anything above 100 is capped to 100. A documented clamp, not a silent one; minimum/maximum are deliberately absent from this schema so a generated client (the MCP tool included) does not reject a value the API itself accepts.
cursorOpaque cursor from a previous response's next_cursor.
Headers
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
List recipients — available › Responses
A page of recipients.
has_morenext_cursorA1-2: required (still nullable) — every producer already emits null rather than omitting the field when there is no next page, so this only removes a third, unused wire state (absent) a strict client had to handle for nothing.
Get a recipient — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
Return one recipient this caller owns.
path Parameters
id^prcp_ · requiredHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Get a recipient — available › Responses
The recipient.
id^prcp_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectlivemodecreated_atdisplay_namedestination_statusupdated_atemailcountryworker_typedestination_kindNon-null only once the employer has adopted a recipient-supplied destination onto the roster (payroll_recipients.adopt_pending_destination) — for where a specific payment actually lands, read payroll_run_items.destination_snapshot instead; null for every recipient with no adopted destination, including rows created before this field existed, which are never backfilled.
null when nothing is waiting. Independent of destination_status: a recipient with a ready default can also have a newer destination waiting.
Save a recipient's submitted destination as their default — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.write· Test mode: unavailable
Confirm the destination a recipient saved through their own payout link (pending_destination on the
recipient) and make it their default: new pay runs copy it from then on. A destination a recipient submits
fills that one run's row and waits in pending_destination — it never replaces the employer's default on its
own. Moves no money.
Send expected_submitted_at = the pending_destination.submitted_at you showed. If the recipient saved a
different destination since, the answer is 409 pending_destination_changed and the default is unchanged:
read the recipient again and show the new one. In a rare race — the recipient saves a new destination while
this call runs — rows this call already filled with the destination you confirmed keep it; the next read of
those runs shows them. 409 no_pending_destination means nothing is waiting.
The same call fills this recipient's rows that still wait for a destination in pay runs not yet approved
(draft, reviewed), where the destination can pay the run's currency: those rows are listed in
updated_run_items. A row the destination cannot pay keeps asking the recipient and is listed in
skipped_run_items with a reason. A row that already has a destination, and every approved run, is left
alone; approval checks every row again. A Swaps Wallet address on a network other than Tempo, which nothing
can sign for, is refused 422 destination_rail_keyless before anything is written. On Tempo a Swaps Wallet is
admitted only with a key Swaps can confirm, as at every inbound door: 422 destination_wallet_disabled, 422 destination_wallet_key_reported_lost (its key was reported lost), 422 destination_wallet_signer_at_risk or
422 destination_wallet_signer_unconfirmed (its key cannot be confirmed) otherwise. Those codes go only to the
wallet's own holder: a payer adopting a recipient's wallet hears 422 destination_not_accepted («This address
can't take this transfer. Use another address.») for any of them — never the reason. When the wallet or
key-mark lookup
itself fails, every caller hears 503 destination_check_unavailable (temporarily_unavailable; a holder whose
later read fails, 503 destination_risk_unverifiable): nothing was written, retry after Retry-After with the
same Idempotency-Key.
path Parameters
id^prcp_ · requiredHeaders
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Save a recipient's submitted destination as their default — available › Request Body
expected_submitted_atThe pending_destination.submitted_at value you read. If the recipient saved another destination since, the call answers 409 pending_destination_changed and the default is unchanged.
Save a recipient's submitted destination as their default — available › Responses
The recipient with its new default, and the run rows this call filled or left asking.
objectA payee referenced by one or more runs. Created only as a byproduct of create — there is no direct create, update or archive on /v1; the one write is adopt_pending_destination, which saves the destination in pending_destination as the default. email is genuinely optional: existing rows can be email-less — from data that predates this field's own validation, or from the legacy dashboard-v1 action handler (payroll/actions.ts's own optionalEmail) — even though the public POST /v1/payroll_runs itself always requires recipient.email and accepts no destination input of its own (PayrollRunCreateRequest; a /v1 caller cannot currently create an email-less row this way — Codex review finding, round 12, correcting an earlier description here that implied it could). The sibling PayrollRunItem.recipient_snapshot.email and PayrollTemplate.items_snapshot[].recipient.email already model the identical optionality for the identical field on those resources (Codex review finding, round 7 — email was required here only, out of step with every other resource carrying the same field, and the list route's .strict() validation of every returned row meant one such legitimately-email-less recipient failed the ENTIRE list response with a 500, not just its own row).
List templates — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
List this employer's saved run templates.
query Parameters
limitDefault 25. Out of range is never rejected: a value outside 1..100, a fraction, or a non-number is not a 400 — 0, a negative number, a non-integer or anything non-numeric falls back to the default (25); anything above 100 is capped to 100. A documented clamp, not a silent one; minimum/maximum are deliberately absent from this schema so a generated client (the MCP tool included) does not reject a value the API itself accepts.
cursorOpaque cursor from a previous response's next_cursor.
Headers
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
List templates — available › Responses
A page of templates.
has_morenext_cursorA1-2: required (still nullable) — every producer already emits null rather than omitting the field when there is no next page, so this only removes a third, unused wire state (absent) a strict client had to handle for nothing.
Save a run as a template — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.write· Test mode: unavailable
Save a completed run's current items as a reusable template. There is no operation to
start a new run from a template — nothing in the backend consumes items_snapshot back
into a run yet, so do not advertise that capability to a caller.
Headers
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Save a run as a template — available › Request Body
source_run_id^pr_ · requirednameDefaults server-side to "
Save a run as a template — available › Responses
The created template.
id^prt_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectlivemodecreated_atnamecurrencyThe source run's items at the moment the template was saved.
source_run_id^pr_ · requiredupdated_atGet a template — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
payroll.read· Test mode: unavailable
Return one saved template this caller owns.
path Parameters
id^prt_ · requiredHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Get a template — available › Responses
The template.
id^prt_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectlivemodecreated_atnamecurrencyThe source run's items at the moment the template was saved.
source_run_id^pr_ · requiredupdated_atGet a recipient's own session — available
Status: Available · Callers: public token · Test mode: unavailable
Resolve one run item's payer-facing projection from its capability token. No identity is resolved and none is needed — the token is unguessable and scoped to exactly this item. Pure: unlike a payment session, reading this never advances the item's status.
The token has a stated lifetime (14 days from issue) and is retired the moment it is used
to change a destination. An unknown token, an expired one and a retired one are
indistinguishable from here: all three answer 404, so this endpoint cannot be used to
confirm that a leaked link was ever real. Ask the employer to re-issue the link.
path Parameters
token^pyr_ · requiredA capability token, never an object id — do not log it, store it beyond the session, or treat it as a stable identifier.
Headers
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Get a recipient's own session — available › Responses
The recipient's session.
run_titleA money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
for_namestatusHumanised item state. locked covers every status past ready — the page shows one line, never a per-status detail.
can_update_destinationWhether payroll_recipient_sessions.destination.set will currently accept a submission from this token. status alone stopped predicting this once a ready item's parent run could also lock it: a ready item reads can_update_destination: false once the run has moved past draft/reviewed (approved onward), and a needs_destination item reads false once the run is cancelled/completed/failed — nothing will ever be approved against it. Check this before rendering the destination form rather than inferring it from status.
employer_display_nameThe employer's real business name, not an internal handle.
rotated_tokenOnly present on payroll_recipient_sessions.destination.set, and only for the holder that just used the old token. Changing a destination retires the token that did it and mints a replacement, so a leaked copy of a payslip link cannot redirect the same payout twice. Use this value for every subsequent call; the token in the path you just called is dead.
Null until a destination is saved for this run item.
Present only when this item pays a local bank account in another currency than the run's — a USD run paying BRL by Pix, MXN by SPEI or COP by Bre-B. The item's amount stays in the run currency; Bridge converts it once, at payout, at its own rate (its FX spread is inside that rate, never a second deduction). estimate is Bridge's non-binding figure from when the account was saved; delivered is exactly what Bridge's receipt says arrived, set only once the payout is paid and cleared if it is returned — never the estimate in its place.
Set a destination — available
Status: Available · Callers: public token · Test mode: unavailable
Set where this item's payment lands — a bank rail or a crypto address. Only reachable
while the item is needs_destination or ready; once it moves past that, the session is
locked and this is refused with 409. A CHANGE to an already-ready item is additionally
refused with 409 once the run itself has moved past draft/reviewed (approved
onward) — an approved/funded run's payout destination can no longer be swapped through
this link. A FIRST submission on a still-needs_destination item is unaffected by the
run's stage. The crypto branch answers capability_unavailable while that rail is closed
behind a flag, whatever fields are supplied. A bank destination on a run whose currency
the payout wallet does not hold (a EUR run funded into USDC) answers
409 bank_payout_fx_unsupported before anything is read or written — the session's
capabilities.bank.reason carries the same code, so a client never has to discover it
here. While the bank rail is off, or does not pay the run currency, a bank destination
answers 409 capability_unavailable the same way (capabilities.bank.reason
bridge_bank_rail_disabled / bank_currency_unsupported). A crypto destination is a USDC
wallet unless crypto.asset is EURC; an EURC wallet is taken only where the session lists
it in capabilities.crypto_external_wallet.assets (otherwise 400 invalid_request, as for
any asset the run is not paid in), and only on a network EURC is paid out on — EURC on
Polygon or Arbitrum answers 409 asset_network_unsupported before anything is written.
A USD run may pay a local bank account instead, where the session lists its currency in
capabilities.bank.local_currencies: bank.pix_key (Brazil, BRL), bank.clabe (Mexico, MXN by
SPEI) or bank.bre_b_key (Colombia, COP), each with bank.account_holder only. The key is checked
before anything is sent (a CPF/CNPJ's or a CLABE's check digits); Bridge's rate is then read and
the item weighed against Bridge's minimum for the rail — 409 local_payout_below_minimum (with
details) refuses it before the account is created. The item stays in USD; what the recipient
receives in the local currency is Bridge's to fix at payout — the session publishes the estimate
(local_payout.estimate) and, once paid, the delivered amount from Bridge's receipt. A local
currency the session does not list answers 409 capability_unavailable.
A USD run's bank destination is a US bank account (ACH): bank.account_number (4 to 17
digits), bank.routing_number (9 digits with a valid ABA check digit), bank.bank_account_type
and the holder's US mailing address including state. The account is created with the
payout provider before anything is stored; only its last four digits are kept and
published back (destination.last4). A refusal by the provider answers
422 beneficiary_invalid — param names the one request field it refused, when it names
exactly one — and never repeats the provider's text. The same code with no param answers
details already on file with this payer that cannot be adopted: no single account matched,
or the one on file has the other account type or another holder name (it would be paid as
it is on file, so a correction is refused, not dropped). A provider outage or timeout answers
503 temporarily_unavailable with nothing stored. The same details sent again reach the
provider under the same idempotency key, so a retry never creates a second account.
This call moves money's destination, so it is bounded on three axes. The token must be
live: expired, retired and unknown tokens all answer 404, never a distinguishable error.
The change is rate-limited per token — five in any rolling 24 hours, then 429. And the
token is retired on success and replaced: read rotated_token off the response and use it
for every subsequent call.
bank must carry the identifiers of exactly one rail (iban, clabe, pix_key,
sort_code, or account_number + routing_number); an indeterminate set answers
400 destination_rail_indeterminate rather than being guessed at, and the rail is then
validated against the item's currency.
A crypto address that is a Swaps Wallet is taken only as every inbound door takes one — on
Tempo, with a key Swaps can confirm — before anything is written: 422 destination_rail_keyless,
destination_wallet_disabled, destination_wallet_key_reported_lost,
destination_wallet_signer_at_risk or destination_wallet_signer_unconfirmed
(invalid_request; ask for another destination), and 503 destination_risk_unverifiable
(temporarily_unavailable, Retry-After) when the check could not run — nothing was stored
and the token is not rotated. The recipient's link carries no sign-in, so it hears
422 destination_not_accepted for any of these codes, and 503 destination_check_unavailable
when the wallet or key-mark lookup itself failed.
The destination set here applies to THIS run item. It does not change the employer's stored default for the recipient — that stays an employer decision, and the employer is notified that a change is waiting.
No Idempotency-Key is accepted: this route is dispatched off the public-token table,
which never reaches the account-scoped idempotency reservation an authenticated route
gets. The token itself is the real idempotency boundary — it is retired and rotated to
rotated_token on every successful submission. A concurrent duplicate submission with the
same token loses the compare-and-swap on that token and answers 409 recipient_locked
(indistinguishable from a genuine state-machine conflict); exactly one write lands. If the
success response itself is lost in transit, the caller never receives rotated_token, the
old token is already dead, and neither a further update nor a GET with it succeeds —
there is no replay store, so the recipient can only get a fresh link from the employer.
path Parameters
token^pyr_ · requiredA capability token, never an object id.
Headers
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Set a destination — available › Request Body
destination_kindRequired when destination_kind is bank. Supply exactly the rail's own field set: SEPA (iban + bic) or, for a USD run, ACH (account_number + routing_number) or a local account the session lists (pix_key, clabe or bre_b_key, with account_holder only). address is required for every bank rail, and bank_name for every one except ACH (the server's own normalizePayrollBankDestinationInput refuses destination_bank_name_required / destination_beneficiary_address_required without them, the same beneficiary-address requirement the authenticated payouts beneficiary shape already carries). On ACH the provider resolves the bank from the routing number, so bank_name is optional there.
Required when destination_kind is crypto.
Set a destination — available › Responses
The updated session.
run_titleA money value. amount is a minor-unit integer as a string; decimals states the scale so a client never re-derives it (USDC is 6). Floats never appear on a money path.
for_namestatusHumanised item state. locked covers every status past ready — the page shows one line, never a per-status detail.
can_update_destinationWhether payroll_recipient_sessions.destination.set will currently accept a submission from this token. status alone stopped predicting this once a ready item's parent run could also lock it: a ready item reads can_update_destination: false once the run has moved past draft/reviewed (approved onward), and a needs_destination item reads false once the run is cancelled/completed/failed — nothing will ever be approved against it. Check this before rendering the destination form rather than inferring it from status.
employer_display_nameThe employer's real business name, not an internal handle.
rotated_tokenOnly present on payroll_recipient_sessions.destination.set, and only for the holder that just used the old token. Changing a destination retires the token that did it and mints a replacement, so a leaked copy of a payslip link cannot redirect the same payout twice. Use this value for every subsequent call; the token in the path you just called is dead.
Null until a destination is saved for this run item.
Present only when this item pays a local bank account in another currency than the run's — a USD run paying BRL by Pix, MXN by SPEI or COP by Bre-B. The item's amount stays in the run currency; Bridge converts it once, at payout, at its own rate (its FX spread is inside that rate, never a second deduction). estimate is Bridge's non-binding figure from when the account was saved; delivered is exactly what Bridge's receipt says arrived, set only once the payout is paid and cleared if it is returned — never the estimate in its place.