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. Refused with 422 over the caps ($25,000 per run, $10,000 per item, 500
rows) — the same caps are re-checked at execute. 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.
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.
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.
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.
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.
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.
A 409 conflict names which rung of the readiness ladder blocked it, one of eight stable
codes: payroll_verified_customer_required (the employer's Bridge customer is not yet
verified), a caps check when the run is over $25,000, over $10,000 on an item, or over 500
rows, 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.
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.
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. Detection runs on a
five-minute poll, not a webhook, so verified can lag a real transfer by up to that
window — do not treat an unchanged preparing/pending status as proof nothing arrived.
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.
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. 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.
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.
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_atRotate 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.
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.
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.
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.
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 — today only SEPA (IBAN + BIC) is reachable end to end. bank_name and address are also required for every reachable bank rail (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) — this pair of fields was missing from the wire schema at K7 ship time, which made the ONLY reachable rail (SEPA) always 400 (code-fix prerequisite, found while building the public recipient page, R6).
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.