{"openapi":"3.0.3","info":{"title":"FastPay API","version":"1.0.0","description":"Combined machine-readable reference for both FastPay HTTP surfaces, merged 2026-09-07 from two previously separate specs (openapi/mass-disbursement.yaml, openapi/payment-gateway.yaml) into this single canonical file - the earlier split risked exactly the drift a two-file setup invites, and this repo's own docs-sync discipline (root CLAUDE.md) treats one source of truth as the correct fix rather than a documentation nicety.\n\nThese are two independently deployed processes, sharing this one document for discovery convenience. Across them sit three trust models (2026-10-05; /docs/README.md \"Three products, three trust models\"), decided by who signs the transaction that moves the money:\n\n- **Custodial (interim)** - the Disbursement Engine's `POST /disburse`: a FastPay-controlled\n  wallet signs every payout.\n\n- **FastPay-started (interim; closed since 2026-10-18)** - the Relay Gateway's mass payout,\n  `POST /v1/instructions`: the funds are the tenant's own smart account, but FastPay's relay\n  signed every payout with a session key it held (the tenant can revoke it). Every write of\n  this path - with the custody onboarding and the payment-channel mint - now answers 410\n  custodial_path_closed (CustodialPathClosed below; owner decisions D1 and D6), and its\n  operations are marked deprecated; its reads stay.\n\n- **Client-signed (the target)** - checkout, refunds and client-signed payroll\n  (`/v1/payroll-payer-wallets`, `/v1/payroll-batches`): the payer's own wallet signs and\n  broadcasts; FastPay only co-signs a fund-free disburser authorization bound to that payer and\n  verifies the outcome on chain. It holds no key over the payer's funds. The one exception to\n  \"broadcasts\" is opt-in Stacks fee sponsorship (not available yet), where FastPay adds a\n  fee-payer signature to the transaction the payer signed and broadcasts it.\n\n\nThe interim paths are marked `x-fastpay-interim: true`: the engine's keeps working unchanged, the relay's is closed (above); new payout features go only to client-signed payroll.\n\n- **Disbursement Engine** (`services/server.js`) - a single custodial engine (interim). A\n  FastPay-controlled wallet pays out to caller-supplied addresses; admin-key-authenticated,\n  server-to-server. See /docs/mass-disbursement-api.md for the full narrative reference.\n\n- **Relay Gateway** (`relay/server.js`) - multi-tenant non-custodial checkout, client-signed\n  payroll, and per-tenant mass payout (interim, FastPay-signed; closed since 2026-10-18). Per-tenant\n  API-key-authenticated. See /docs/payment-gateway-api.md for the full narrative reference.\n\n\n`servers` below lists both base URLs; every operation is tagged `Disbursement Engine` or `Relay Gateway` (or both) so a reader - or a tool like Redoc, which groups its sidebar by tag - can tell which process to call. Two relative paths, `/health` and `/admin/stats`, exist identically on BOTH processes with slightly different response shapes; each is documented once below with a combined schema covering both variants rather than being forced to pick one - see each operation's own description.\n\nEvery non-2xx Relay Gateway response is a JSON body matching the ErrorEnvelope schema below - see /docs/errors.md for the full reference (which fields are stable to branch on, per-route error_type/error_slug tables, and the history of this API's two now-converged error shapes). On every route that reads a JSON body, a malformed body answers 400 invalid_json and a body over the route's size limit answers 413 request_body_too_large. A path parameter that is not valid percent-encoding (/v1/payments/%E0%A4%A) answers 400 invalid_request. A typed error a route does not answer itself keeps its own status and slug; anything else answers 500 internal_error with the message \"Internal server error.\" - the cause goes to the operator's log only. The Disbursement Engine's own error shape is DisbursementErrorEnvelope - genuinely different (a numeric error_code, no request_id) and kept as its own schema rather than folded into the Relay Gateway's, since the two engines were never meant to share a wire-level error contract.\n"},"servers":[{"url":"http://localhost:3000","description":"Disbursement Engine (Mass Disbursement API)"},{"url":"http://localhost:4001","description":"Relay Gateway (Payment Gateway API)"}],"security":[{"BearerApiKey":[]}],"tags":[{"name":"Disbursement Engine","description":"Single custodial engine (interim) - a FastPay-controlled wallet pays out, webhook-driven."},{"name":"Relay Gateway","description":"Multi-tenant non-custodial checkout and client-signed payroll, plus mass payout (interim, signed by FastPay's relay with a tenant session key; closed since 2026-10-18, 410 custodial_path_closed)."}],"paths":{"/health":{"get":{"summary":"Liveness check","description":"Identical relative path on both separately-deployed processes (Disbursement Engine, default port 3000; Relay Gateway, default port 4001 - see `servers` above). The Disbursement Engine's own response additionally reports broadcast-ledger reachability and pause state (DisbursementHealth below); the Relay Gateway's is a simpler up/down check with no fixed schema. Rate limiting differs too (added 2026-09-08): the Disbursement Engine's own /health has its own dedicated, generous limiter (see /docs/rate-limits.md) so a load balancer/uptime monitor doesn't share a budget with real API traffic; the Relay Gateway's /health remains genuinely unthrottled.\n","tags":["Disbursement Engine","Relay Gateway"],"security":[],"responses":{"200":{"description":"Healthy","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/DisbursementHealth"},{"type":"object","description":"Relay Gateway's own shape - a bare liveness signal, no ledger/pause detail."}]}}}},"429":{"description":"Rate limited (Disbursement Engine only)"},"503":{"description":"Broadcast ledger unreachable (Disbursement Engine) or database unreachable (Relay Gateway)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DisbursementHealth"}}}}}}},"/disburse":{"post":{"summary":"Trigger a disbursement job","description":"Validates the request synchronously and returns 202 immediately; processing and webhook delivery happen asynchronously. See /docs/webhooks.md for the per-payment status callbacks this job generates.\n\nInterim (2026-10-05): a FastPay-controlled wallet signs every payout, so this route is custodial. It keeps working unchanged; payroll from a client's own funds belongs on client-signed payroll (`/v1/payroll-batches`).\n","x-fastpay-interim":true,"tags":["Disbursement Engine"],"security":[{"AdminApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DisburseRequest"}}}},"responses":{"200":{"description":"Every payment in the request was a duplicate and was skipped","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DisburseAccepted"}}}},"202":{"description":"Job accepted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DisburseAccepted"}}}},"400":{"description":"Request validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DisbursementErrorEnvelope"}}}},"403":{"description":"Missing or invalid x-admin-api-key"},"503":{"description":"System paused by an operator (POST /admin/pause)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"pausedAt":{"type":"string","format":"date-time"},"pausedReason":{"type":"string","nullable":true}}}}}}}}},"/verify-recipients":{"post":{"summary":"Dry run of the recipient check","description":"Takes the same payments array as /disburse and returns, per payment, whether the destination chain's own rules mean that payment cannot succeed there. Records nothing and sends nothing - no deduplication entry, no ledger row, no webhook, nothing signed, no daily-cap use - and answers while new payments are paused.\n","tags":["Disbursement Engine"],"security":[{"AdminApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["payments"],"properties":{"payments":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/DisbursementPayment"}}}}}}},"responses":{"200":{"description":"One verdict per payment, in request order","content":{"application/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/RecipientVerdict"}},"summary":{"type":"object","properties":{"ok":{"type":"integer"},"rejected":{"type":"integer"},"unchecked":{"type":"integer"}}}}}}}},"400":{"description":"Request validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DisbursementErrorEnvelope"}}}},"403":{"description":"Missing or invalid x-admin-api-key"},"500":{"description":"The check itself could not run"}}}},"/status/{jobId}":{"get":{"summary":"Best-effort job status snapshot","description":"Not a substitute for webhooks. Falls back to checking for a CSV report file once the job has left memory (e.g. after a restart) - see the \"What's NOT authoritative: CSV reports\" section of the root CLAUDE.md.\n","tags":["Disbursement Engine"],"security":[],"parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Job status","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"jobId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["running"]},"progress":{"type":"object","properties":{"total":{"type":"integer"},"completed":{"type":"integer"},"failed":{"type":"integer"},"pending":{"type":"integer"}}}}},{"type":"object","properties":{"jobId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["completed"]},"message":{"type":"string"}}}]}}}},"400":{"description":"jobId is not a valid UUID"},"404":{"description":"Job not found (not in memory, no report file either)"}}}},"/payments":{"get":{"summary":"List every allocation this server has ever accepted (paginated)","description":"Added 2026-09-05. Sourced from utils/deduplication.js's own record of every accepted allocation - a pre-flight rejection is listed here even though it never reaches utils/broadcastLedger.js (which only gets a row once a broadcast attempt is actually made). Each entry uses the same status values GET /payments/{allocationId} returns.\n","tags":["Disbursement Engine"],"security":[{"AdminApiKey":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"starting_after","in":"query","required":false,"schema":{"type":"string"},"description":"A previous page's last item's allocation_id."},{"name":"ending_before","in":"query","required":false,"schema":{"type":"string"},"description":"Added 2026-09-12. A previous page's first allocation_id - the page before it (newer), still newest first; mutually exclusive with starting_after."},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["processing","processed"]},"description":"Filter by the underlying deduplication state."}],"responses":{"200":{"description":"Allocations","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"type":"object","properties":{"allocation_id":{"type":"string"},"status":{"type":"string","enum":["in_process","completed","failed","unknown"]},"tx_hash":{"type":"string","nullable":true},"chain":{"type":"string","nullable":true},"wallet":{"type":"string","nullable":true},"amount":{"type":"string","nullable":true},"currency":{"type":"string","nullable":true}}}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"Invalid limit, starting_after, or status"},"403":{"description":"Missing or invalid x-admin-api-key"}}}},"/payments/{allocationId}":{"get":{"summary":"Retrieve one payment's current state by ALLOCATION_ID","description":"Added 2026-09-04. Reads directly from the durably-persisted deduplication store and broadcast ledger - see root CLAUDE.md's \"What IS authoritative\" section - rather than a separate Payment object this API doesn't otherwise track. Requires x-admin-api-key (unlike GET /status/{jobId}'s coarser, unauthenticated progress counts) since this returns real per-payment financial detail.\n","tags":["Disbursement Engine"],"security":[{"AdminApiKey":[]}],"parameters":[{"name":"allocationId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Payment status","content":{"application/json":{"schema":{"type":"object","properties":{"allocation_id":{"type":"string"},"status":{"type":"string","enum":["in_process","completed","failed","unknown"]},"tx_hash":{"type":"string","nullable":true},"chain":{"type":"string","nullable":true},"wallet":{"type":"string","nullable":true},"amount":{"type":"string","nullable":true},"currency":{"type":"string","nullable":true},"protocol_fee":{"type":"string","nullable":true},"treasury":{"type":"string","nullable":true},"attempts":{"type":"array","items":{"type":"object","properties":{"tx_hash":{"type":"string","nullable":true},"status":{"type":"string","enum":["SIGNED","SUBMITTED","SUBMIT_ERRORED","CONFIRMED","FAILED_ONCHAIN","SUPERSEDED","DROPPED"]},"nonce":{"type":"integer","nullable":true},"broadcast_started_at":{"type":"string","format":"date-time"},"broadcast_resolved_at":{"type":"string","format":"date-time","nullable":true},"submit_error":{"type":"string","nullable":true}}}},"webhooks":{"type":"array","description":"Added 2026-09-10. Every webhook owed to this allocation's callbackUrl, oldest first, from the durable outbox - attempt count, last error, and the receiver's most recent HTTP response (response_status/response_body, capped at WEBHOOK_RESPONSE_BODY_MAX_CHARS; both null when no HTTP response was received).\n","items":{"type":"object","properties":{"id":{"type":"integer"},"event_id":{"type":"string","nullable":true},"status":{"type":"string"},"attempts":{"type":"integer"},"delivered":{"type":"boolean"},"delivered_at":{"type":"string","format":"date-time","nullable":true},"last_error":{"type":"string","nullable":true},"response_status":{"type":"integer","nullable":true},"response_body":{"type":"string","nullable":true},"next_attempt_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"}}}}}}}}},"400":{"description":"Invalid allocationId (too long, or contains non-printable-ASCII characters)"},"403":{"description":"Missing or invalid x-admin-api-key"},"404":{"description":"This ALLOCATION_ID has never been accepted by this server"}}}},"/cancel":{"post":{"summary":"Abort an in-flight job","description":"In-memory only - does not survive a restart or un-broadcast an already-sent transaction.","tags":["Disbursement Engine"],"security":[{"AdminApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["job_id"],"properties":{"job_id":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"Cancellation initiated"},"400":{"description":"Missing job_id"},"403":{"description":"Missing or invalid x-admin-api-key"},"404":{"description":"Job not found or already completed"}}}},"/admin/pause":{"post":{"summary":"System-wide kill switch - stop accepting new payments","description":"The Disbursement Engine refuses new /disburse payments. The Relay Gateway's own /admin/pause (same body) makes it sign nothing new - payroll batches, intents and fee quotes, and checkout intents answer 503 service_paused, and x402 settle answers errorReason facilitator_paused. /v1/instructions answered 503 service_paused too until 2026-10-18; it now answers 410 custodial_path_closed first, paused or not, and so does the job approve. A live relay-signed job from before 2026-10-18 that has not started (accepted, awaiting approval or awaiting onboarding) waits until /admin/resume and is then cancelled, never started (custodial_path_closed); a sandbox one is cancelled at once. Work already in flight, confirmations and the sweeps continue.","tags":["Disbursement Engine"],"security":[{"AdminApiKey":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string"}}}}}},"responses":{"200":{"description":"Paused"},"403":{"description":"Missing or invalid x-admin-api-key"}}}},"/admin/resume":{"post":{"summary":"Reverse /admin/pause","tags":["Disbursement Engine"],"security":[{"AdminApiKey":[]}],"responses":{"200":{"description":"Resumed"},"403":{"description":"Missing or invalid x-admin-api-key"}}}},"/admin/reset":{"post":{"summary":"Wipe all deduplication state (destructive)","tags":["Disbursement Engine"],"security":[{"AdminApiKey":[]}],"responses":{"200":{"description":"Reset complete"},"403":{"description":"Missing or invalid x-admin-api-key"},"500":{"description":"Reset failed"}}}},"/admin/remove-processed-ids":{"post":{"summary":"Re-admit specific allocation IDs without wiping everything","tags":["Disbursement Engine"],"security":[{"AdminApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["allocation_ids"],"properties":{"allocation_ids":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"Removal result","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"removed":{"type":"array","items":{"type":"string"}},"notFound":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"Missing/empty allocation_ids","or more ids than config.server.maxPaymentsPerRequest allows":null},"403":{"description":"Missing or invalid x-admin-api-key"}}}},"/admin/stats":{"get":{"summary":"RPC/bundler/connector provider usage snapshot for this process","description":"Same relative path on both processes, each reporting its OWN process's provider usage (see services/connectors/usageSnapshot.js for the Disbursement Engine; the Relay Gateway's own connector registry - see root CLAUDE.md's \"Provider/connector layer\" section - for the other). Response shape below reflects the Disbursement Engine's own fields (success/deduplication/connectors/timestamp); the Relay Gateway's own response carries the same {vendor, tier, usage, budget} connectors array via a separate registry instance, without a `deduplication` field (that concept is Disbursement-Engine-only).\n","tags":["Disbursement Engine","Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/AdminOperator"}],"security":[{"AdminApiKey":[]}],"responses":{"200":{"description":"Stats","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"deduplication":{"type":"object","description":"Disbursement Engine only."},"connectors":{"type":"array","items":{"type":"object"}},"turnkey":{"type":"object","description":"2026-10-15. The process's Turnkey request gate: mode (configured | passthrough) and passthrough_reason, share {per_minute, burst, payroll_reserve}, tokens, promised, live_tickets, rate_factor, depth, cooldown_ms_left, counters; lanes[] {lane, kind, depth, oldest_wait_ms, p95_ms, p99_ms}; classes {payroll, interactive, jobs, admin, background: {sent, refused, depth, wait_p50_ms, wait_p95_ms}}; counters_1h.\n"},"solana_rpc":{"type":"object","description":"2026-10-15. The process's Solana RPC wrapper: timeout_ms, per_second, read_retries, endpoints[] {host, tokens, depth, cooldown_ms_left}, counters (calls, requests, user, sweep, rateLimited, retries, timeouts), counters_1h, methods {<JSON-RPC method>: {calls, rate_limited, timeouts}}.\n"},"timestamp":{"type":"string","format":"date-time"}}}}}},"401":{"description":"Missing or invalid x-admin-api-key (Relay Gateway)"},"403":{"description":"Missing or invalid x-admin-api-key (Disbursement Engine)"}}}},"/v1/gas-tokens":{"get":{"summary":"Which currencies a payout's network fee can be paid in on a given network","description":"Added 2026-09-13. A payout normally spends the chain's native coin on gas, which means a tenant holding only stablecoins cannot pay out at all. This reports, per network, whether the fee can instead be charged in a token, by which mechanism, and in which currencies. The answer is read live from the chain or the paymaster rather than from a table here, so a currency listed is one that was quotable at the moment of the call. A network that cannot do it answers with a reason rather than an empty list, because \"no, because we never configured a bundler here\" and \"no, because the paymaster refuses your tokens\" have different fixes. REPORTING ONLY - accepting a payout that uses this is not wired up yet.\n","tags":["Relay Gateway"],"parameters":[{"name":"network","in":"query","required":true,"schema":{"type":"string"},"description":"Any payout network, including ones that cannot do this."}],"responses":{"200":{"description":"Gas payment options","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["gas_payment_options"]},"network":{"type":"string"},"mechanism":{"type":"string","enum":["erc4337_paymaster","fee_payer_sponsor","none"],"description":"erc4337_paymaster - an ERC-20 paymaster fronts the native gas and is repaid in the token. fee_payer_sponsor - the transaction names a fee payer who is not the sender, so a sponsor pays and the tenant repays in the same transaction. none - the network cannot, or this deployment cannot yet.\n"},"supported":{"type":"boolean"},"reason":{"type":"string","nullable":true,"enum":["no_mechanism","bundler_not_configured","no_paymaster_tokens","wallet_upgrade_required","sponsor_not_configured","no_token_currencies"]},"note":{"type":"string","description":"What would have to change","where that is knowable.":null},"data":{"type":"array","items":{"type":"object","properties":{"object":{"type":"string","enum":["gas_token"]},"currency":{"type":"string"},"exchange_rate":{"type":"string","nullable":true,"description":"Present only where the mechanism publishes one before a transaction exists. A sponsor-based network prices the fee from the real transaction, so a rate here would be invented rather than quoted.\n"},"paymaster":{"type":"string","nullable":true},"payout_currency":{"type":"boolean","description":"False for a token an operator declared as gas-only. It can pay a fee on this network but no payout may name it as a currency.\n"},"selectable":{"type":"boolean","description":"Added 2026-09-14. Whether a payout may name this currency in POST /v1/instructions' gas_token. Since 2026-10-18 that route answers 410 custodial_path_closed, so no payout can name it; the flag still reports the capability. False for a token only a third-party paymaster quotes - real capability, but its paymasterAndData layout is that vendor's own protocol and the payout runner does not build it. True for FastPay's own paymaster.\n"},"provider":{"type":"string","nullable":true,"description":"Which configured paymaster provider quoted it."}}}}}}}}},"400":{"description":"network missing","or not a payout network":null}}}},"/admin/paymaster-float":{"get":{"summary":"How much native gas our own paymaster has left to front, per chain","description":"Added 2026-09-13. Read live from each chain rather than from our own records, because the number that matters is what the EntryPoint will actually let the paymaster spend. `operations_remaining` is a guaranteed floor, the deposit divided by the paymaster's own per-operation ceiling, so real headroom is always at least this. `swap_due` is the operating signal - the float is low AND the treasury holds tokens that would refill it - as distinct from `low` with nothing collected, which is a funding problem. Read-only; nothing here moves funds.\n","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/AdminOperator"}],"security":[{"AdminApiKey":[]}],"responses":{"200":{"description":"One entry per chain with a paymaster configured","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"has_more":{"type":"boolean","enum":[false],"description":"Not paged: always false (audit O15, 2026-10-17; additive)."},"data":{"type":"array","items":{"type":"object","properties":{"object":{"type":"string","enum":["paymaster_float"]},"network":{"type":"string"},"configured":{"type":"boolean"},"paymaster":{"type":"string","nullable":true},"paused":{"type":"boolean","nullable":true},"deposit":{"type":"string","nullable":true},"stake_wei":{"type":"string","nullable":true},"operations_remaining":{"type":"integer","nullable":true},"min_operations":{"type":"integer","nullable":true},"low":{"type":"boolean"},"swap_due":{"type":"boolean"},"treasury":{"type":"string","nullable":true},"collected":{"type":"array","items":{"type":"object"}},"error":{"type":"string","nullable":true}}}}}}}}}}}},"/v1/networks":{"get":{"summary":"List every network this API accepts, and whether checkout can actually fulfill payments on it yet","description":"Added 2026-09-07, closing a real gap found comparing this API's documentation against a mature crypto payment API's public reference: nothing exposed which networks/currencies are supported programmatically before this. Public, unauthenticated, static reference data - relay/catalog.js.\n","tags":["Relay Gateway"],"security":[],"responses":{"200":{"description":"Networks","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"has_more":{"type":"boolean","enum":[false],"description":"Not paged: always false (audit O15, 2026-10-17; additive)."},"data":{"type":"array","items":{"type":"object","properties":{"object":{"type":"string","enum":["network"]},"network":{"type":"string"},"live":{"type":"boolean","description":"Whether checkout can actually fulfill a payment on this network today - see LIVE_NETWORKS in relay/paymentRequests/fulfillment.js. A network can be accepted at payment-request creation (this list) without yet being live."}}}}}}}}}}}},"/v1/currencies":{"get":{"summary":"List every currency/network pair this API accepts","description":"Added 2026-09-07. Public, unauthenticated, static reference data - relay/catalog.js.","tags":["Relay Gateway"],"security":[],"responses":{"200":{"description":"Currencies","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"has_more":{"type":"boolean","enum":[false],"description":"Not paged: always false (audit O15, 2026-10-17; additive)."},"data":{"type":"array","items":{"type":"object","properties":{"object":{"type":"string","enum":["currency"]},"currency":{"type":"string"},"network":{"type":"string"},"decimals":{"type":"integer"},"type":{"type":"string","enum":["native","token"]},"live":{"type":"boolean"}}}}}}}}}}}},"/v1/rates":{"get":{"summary":"Look up the current fiat<->crypto exchange rate for a pair","description":"Added 2026-09-07. Wraps the same relay/paymentRequests/rateProvider.js#getExchangeRate the Checkout Sessions quoting engine uses internally (POST /v1/checkout/sessions) - not a second rate source. This is NOT a locked quote; only creating a checkout session locks one. Public, unauthenticated, and (added 2026-09-08) rate-limited per-IP - see /docs/rate-limits.md.\n","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"from","in":"query","required":true,"schema":{"type":"string"},"description":"Fiat or crypto currency code, case-insensitive, e.g. EUR, USDC or USDe."},{"name":"to","in":"query","required":true,"schema":{"type":"string"},"description":"Same form as from."}],"responses":{"200":{"description":"Rate","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["rate"]},"from":{"type":"string","description":"The code as the relay spells it: upper case, except a mixed-case stablecoin (USDe)."},"to":{"type":"string","description":"Same form as from."},"rate":{"type":"string"},"source":{"type":"string","enum":["live","live_stale","static_fallback","identity"]},"fetched_at":{"type":"string","format":"date-time","nullable":true}}}}}},"400":{"description":"from or to missing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited"},"503":{"description":"No rate available for this pair (live fetch failed, no static fallback configured)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/payroll-payer-wallets":{"post":{"summary":"Register a payroll payer wallet (client-signed payroll, 2026-10-05)","description":"Registers the wallet the tenant will pay payroll FROM and returns the challenge it must sign. FastPay never holds a key for it; the challenge authorizes no payment. `address` is required except on TON (proof_timing at_connect: the address comes from the ton_proof). A member key needs the owner or admin role. Scope payroll:write. Live keys pass the network's payroll gates (RELAY_PAYROLL_LIVE_ENABLED plus the family switch); sandbox keys always use the simulated chain. See /docs/payment-gateway-api.md#client-signed-payroll--added-2026-10-05.\n","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["network"],"properties":{"network":{"type":"string","description":"A payroll network: an EVM network name, tron, solana, aptos, sui, ton or stacks (sBTC; gated off)."},"address":{"type":"string","description":"Required unless the network proves at connect (TON)."},"label":{"type":"string","maxLength":100},"chain_options":{"type":"object","description":"At most 8 KiB. Only for a network whose adapter takes registration options - Bitcoin's P2WSH multisig vaults (when enabled): { multisig: { witness_script, signer_set: hardware|software, cosigners: [{ pubkey, xfp, path }] } }. Refused with invalid_chain_options elsewhere. Every member key it declares is checked against FastPay's held keys, in either parity, before a challenge exists."}}}}}},"responses":{"201":{"description":"Registered, pending verification, with the challenge","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollPayerWallet"}}}},"400":{"description":"`unsupported_network` / `invalid_address` / `invalid_request` / `invalid_chain_options` (Shape B)"},"403":{"description":"`team_capability_required` / `payer_wallet_screening_refused` (a Bitcoin P2WSH vault's address or a member's address form failed wallet sanctions screening; no other multisig kind is screened at registration) (Shape B)"},"409":{"description":"`payer_wallet_already_registered` / `payer_wallet_is_fastpay_key` / `payer_wallet_kind_unsupported` / `payer_multisig_not_enabled` (Shape B)"},"503":{"description":"`payroll_network_not_enabled` / `payroll_network_not_configured` / `payroll_deployment_rotation_pending` / `payer_verification_unavailable` / `payer_screening_unavailable` (a Bitcoin P2WSH vault under RELAY_PAYROLL_PAYER_SCREENING_REQUIRED and the screen cannot run) (Shape B)"}}},"get":{"summary":"List payroll payer wallets (paginated)","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["pending_verification","verified","revoked"]}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Payer wallets, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayrollPayerWallet"}},"has_more":{"type":"boolean"}}}}}}}}},"/v1/payroll-payer-wallets/{id}":{"get":{"summary":"Retrieve a payroll payer wallet","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollPayerWallet"}}}},"404":{"description":"`resource_missing`"}}},"delete":{"summary":"Revoke a payroll payer wallet","description":"Stops every NEW intent for this wallet at once. Authorizations already issued cannot be revoked: they stay executable by the wallet until they expire, and live_authorizations says how many there are.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Revoked","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PayrollPayerWallet"},{"type":"object","properties":{"live_authorizations":{"type":"integer","description":"This tenant's authorizations still executable by this wallet until their expiry."}}}]}}}},"403":{"description":"`team_capability_required` (Shape B)"},"404":{"description":"`resource_missing`"},"409":{"description":"`payer_wallet_already_revoked`"}}}},"/v1/payroll-payer-wallets/{id}/challenge":{"post":{"summary":"Issue a fresh challenge for a wallet pending verification","description":"The previous challenge stops working. Expires after RELAY_PAYROLL_PAYER_CHALLENGE_TTL_SECONDS (default 900).","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The wallet with its new challenge","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollPayerWallet"}}}},"403":{"description":"`team_capability_required` (Shape B)"},"404":{"description":"`resource_missing`"},"409":{"description":"`payer_wallet_not_pending`"},"503":{"description":"`payroll_network_not_enabled` / `payroll_network_not_configured` / `payroll_deployment_rotation_pending` - a live TON network with no ton_proof domain configured is payroll_network_not_configured (Shape B)"}}}},"/v1/payroll-payer-wallets/{id}/verify":{"post":{"summary":"Prove control of a payer wallet","description":"Body { proof: {...} } (EVM, Tron, Solana and Sui also accept a flat { signature }). EVM: { signature } over the challenge with personal_sign (EIP-191, or EIP-1271 for a contract account such as a Safe). Tron: { signature } from TronLink signMessageV2 (TIP-191 v2), or { signatures: [...] } one per key of a multisig account (at most 5); every key of every permission counts as a controlling key. Solana: { signature } from signMessage over the UTF-8 challenge (64 bytes, base58, base64 or 0x hex); an off-curve, program-owned or data-holding account is refused as delegated. Aptos: { full_message, signature, public_key, scheme? } from AIP-62 signMessage. Sui: { signature } from signPersonalMessage. TON: TonConnect's { account, proof } with a ton_proof over the challenge nonce. Stacks: { signature, public_key } from stx_signStructuredMessage over the challenge under FastPay's fixed SIP-018 domain, or { kind: stacks_control_tx, tx_id } for the self-paid, fund-free prove-control call (never sponsored); single-key SP/ST payers only - contract, SM/SN multisig and uncompressed-key addresses are payer_wallet_delegated. The challenge is single-use. A wallet controlled by a key or address in FastPay's held-key registry is refused, as far as FastPay can read who controls it: by key where the registry knows the key, but a tenant's custodial wallet on Aptos, Sui or TON only at its own address. An EVM contract account that is not a Safe is refused (payer_wallet_kind_unsupported, kind contract in the error details: FastPay cannot read who controls it); one used as a Safe owner is checked only by its own address, and a Safe whose opaque contract owners can reach its threshold on their own is refused too (kind safe_unreadable_owners). See /docs/payment-gateway-api.md#refused-payer-wallets. A wallet that delegates control in a way payroll does not accept is refused too (payer_wallet_delegated: an unlisted 7702 delegate or Safe module, or a Safe fallback handler that is neither an official CompatibilityFallbackHandler nor allowlisted; on a Safe owner, only when the unreadable owners reach the threshold; or a Safe past the 50 Safe owners payroll reads per check, nested Safes' owners included, at any threshold), and that refusal, then a held-key refusal, comes before a kind refusal.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"proof":{"type":"object","description":"The chain's proof object (see the description)."},"signature":{"type":"string","description":"EVM / Tron / Solana / Sui shorthand for { proof: { signature } }."}}}}}},"responses":{"200":{"description":"Verified","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollPayerWallet"}}}},"202":{"description":"`payer_proof_pending` (`details: {retry_after_seconds, challenge_expires_at}`) - only on a network whose proof the chain must confirm first (none today): nothing failed, the challenge stays open; send the same proof again later (Shape B)"},"400":{"description":"`invalid_proof` / `invalid_payer_signature` (Shape B)"},"403":{"description":"`team_capability_required` (Shape B)"},"404":{"description":"`resource_missing`"},"409":{"description":"`payer_challenge_missing` / `payer_address_mismatch` / `payer_wallet_delegated` / `payer_wallet_is_fastpay_key` / `payer_wallet_kind_unsupported` / `payer_wallet_not_pending` / `payer_wallet_already_registered` (Shape B)"},"410":{"description":"`payer_challenge_expired`"},"503":{"description":"`payroll_network_not_enabled` / `payroll_network_not_configured` / `payroll_deployment_rotation_pending` / `payer_verification_unavailable` - the node could not answer or the held-key registry was never built; never reported as an invalid signature (Shape B)"}}}},"/v1/payroll-batches":{"post":{"summary":"Create a client-signed payroll batch","description":"A batch of (recipient, amount) items paid from one verified payer wallet in one token on one network. Nothing is built or signed here. The tenant's payout approval policy (or approval \"required\") decides awaiting_approval vs open. Recipients are canonicalised by the chain's strict rule; the zero address, the disburser, the token and the deployment's own addresses are refused. Tokens only. Scope payroll:write.\n","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollBatchCreate"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollBatch"}}}},"400":{"description":"`unsupported_network` / `invalid_currency` / `invalid_payer_wallet` / `invalid_items` (details.problems lists every problem; a recipient that is the batch's own payer wallet or a FastPay account is `cannot be paid: ...`; an amount above the per-item ceiling is `at most <ceiling> <currency> per item (RELAY_PAYROLL_MAX_AMOUNT_PER_ITEM)`) / `too_many_items` / `invalid_reference` / `invalid_url` / `invalid_approval` / `native_currency_not_supported_for_payroll` / for an item's `recipient_id`: `recipient_not_found` (unknown, or another tenant's), `recipient_archived`, `recipient_missing_address`, `recipient_wallet_mismatch` - checked separately, one slug per response, `details` a bare array of {index, recipient_id, ...} (a mismatch adds network, given_address - the item's recipient_address - and the recipient's registered_address), not details.problems (Shape B)"},"404":{"description":"`payer_wallet_not_found`"},"409":{"description":"`payer_wallet_not_verified` / `payer_wallet_network_mismatch` / `duplicate_item_reference` / `currency_not_allowlisted` (details {network, currency}: no token for the currency on this deployment) (Shape B)"},"503":{"description":"`service_paused` / `payroll_network_not_enabled` / `payroll_network_not_configured` / `payroll_deployment_rotation_pending` / `chain_unavailable` / `payroll_restore_reconciliation_pending` (details.network, details.retryable false: after a restore of FastPay's payroll records, new live batches wait until FastPay has reconciled the chain since the restore point; not on Bitcoin, whose hold is at the quote) (Shape B)"}}},"get":{"summary":"List payroll batches (paginated)","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["awaiting_approval","open","closing","completed","closed","cancelled"]}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Batches, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayrollBatch"}},"has_more":{"type":"boolean"}}}}}}}}},"/v1/payroll-batches/{id}":{"get":{"summary":"Retrieve a payroll batch","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollBatch"}}}},"404":{"description":"`resource_missing`"}}}},"/v1/payroll-batches/{id}/items":{"get":{"summary":"List a payroll batch's items (paginated)","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["pending","authorized","completed","failed","cancelled"]}}],"responses":{"200":{"description":"Items","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayrollItem"}},"has_more":{"type":"boolean"}}}}}},"404":{"description":"`resource_missing`"}}}},"/v1/payroll-batches/{id}/approve":{"post":{"summary":"Approve a payroll batch awaiting approval","description":"Needs payroll:read plus a member whose role can approve (owner, admin, approver); never the member whose key created the batch. One vote each; the quorum opens the batch.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The batch after this vote","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollBatch"}}}},"403":{"description":"`self_approval_forbidden` / `member_required` / `approval_not_permitted` (a suspended member's key is refused before, 401 `member_suspended`)"},"404":{"description":"`resource_missing`"},"409":{"description":"`batch_not_awaiting_approval` / `already_approved` / `payroll_batch_expired` (past its expires_at: the sweep closes it as cancelled)"}}}},"/v1/payroll-batches/{id}/reject":{"post":{"summary":"Reject a payroll batch awaiting approval (cancels it and every item)","description":"Needs payroll:read plus a member whose role can approve (owner, admin, approver). Four eyes guards only the approve: a creator whose role can approve (owner, admin) may reject its own batch; a developer creator, whose role cannot, gets approval_not_permitted and withdraws the batch with cancel. The reject wins: from an approver who already approved the batch (before its quorum), it replaces that approver's approve vote and rejects the batch, so the batch's approvals list one decision for them, reject.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string"}}}}}},"responses":{"200":{"description":"The cancelled batch, its approvals listing this member's decision as reject","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollBatch"}}}},"400":{"description":"`invalid_reason`"},"403":{"description":"`member_required` / `approval_not_permitted` (a suspended member's key is refused before, 401 `member_suspended`)"},"404":{"description":"`resource_missing`"},"409":{"description":"`batch_not_awaiting_approval` (already rejected or cancelled, or after the quorum opened it: cancel it instead, with a key holding payroll:write (owner, admin or developer); an approver-role key cannot)"}}}},"/v1/payroll-batches/{id}/cancel":{"post":{"summary":"Cancel a payroll batch (stops new co-signatures at once)","description":"With nothing issued the batch finalises now. With authorizations out it moves to closing: an authorization already handed out cannot be revoked and stays executable by the payer wallet until it expires (closes_at); anything it pays before then is completed.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string"}}}}}},"responses":{"200":{"description":"The batch, with closes_at and a note","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PayrollBatch"},{"type":"object","properties":{"closes_at":{"type":"string","format":"date-time","nullable":true,"description":"The latest expiry of an authorization still out, or null."},"note":{"type":"string"}}}]}}}},"400":{"description":"`invalid_reason`"},"404":{"description":"`resource_missing`"},"409":{"description":"`payroll_batch_already_closed`"}}}},"/v1/payroll-batches/{id}/intents":{"post":{"summary":"Issue a payroll intent - FastPay co-signs, your wallet signs and sends","description":"Takes the batch's pending items in order, splits them into authorizations within the per-intent caps, quotes the chain, co-signs every authorization with the fund-free payment authorizer and returns the UNSIGNED transactions the payer wallet signs and broadcasts itself (EVM: the exact approve in pre_steps first). Each authorization stays executable by the payer wallet until its expires_at and cannot be revoked. One holding intent per (network, payer wallet, token). Review every recipient and amount before signing. Any refusal signs nothing. Scope payroll:write. Bitcoin (sandbox keys; live keys only once its family switch is on): FastPay signs nothing and never broadcasts. The intent is ONE unsigned PSBT over reserved inputs (auth_scheme none), within the per-intent and daily value caps of the relay's trust tier. In the tenant's fee mode in_tx_debt (the default) it carries one output to a fresh FastPay fee address with its attestation, and the caps trim the items with a warning; in stacks_prepaid it carries no FastPay output and needs the fee_quote_id of a fee quote paid and final on Stacks (POST /v1/payroll-batches/{id}/fee-quotes), whose items it pays exactly. Each transaction also serves a cancel_payload (a PSBT that spends the same inputs back to the payer), and POST .../requote serves a new version at another fee rate. Each Bitcoin refusal's details: /docs/errors.md.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"valid_for_seconds":{"type":"integer","description":"RELAY_PAYROLL_AUTHORIZATION_MIN_VALIDITY_SECONDS (300) to ..._MAX_VALIDITY_SECONDS (86400); a Bitcoin multisig vault up to RELAY_PAYROLL_MULTISIG_AUTHORIZATION_MAX_VALIDITY_SECONDS (259200). Default RELAY_PAYROLL_AUTHORIZATION_VALIDITY_SECONDS (3600). Clamped to the batch's lifetime."},"max_items":{"type":"integer","minimum":1},"chain_options":{"type":"object","description":"Tron { permission_id: 2..9 } (a multisig payer's active permission); Solana { mode: single | batch } (batch needs RELAY_PAYROLL_SOLANA_MODE=batch and the deployed upgrade); Sui { transport: json | bcs }; TON { max_messages: 1..255 }; Stacks { fee_payment: self | sponsored } (sponsored is refused until fee sponsorship opens); Bitcoin { fee_rate_sat_vb: RELAY_PAYROLL_BITCOIN_MIN_FEE_RATE (2) to ..._MAX_FEE_RATE (500), max_items, include_non_witness_utxo: boolean }; none elsewhere."},"fee_quote_id":{"type":"string","pattern":"^pfs_[0-9a-f]{32}$","description":"Bitcoin stacks_prepaid only: the batch's fee quote, paid and final on Stacks. It fixes the intent's items (exactly the quoted ones) and makes the PSBT carry no FastPay output, even if the tenant's fee mode changed since. Any other network answers 409 payroll_fee_mode_mismatch."}}}}}},"responses":{"201":{"description":"The payroll intent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollIntent"}}}},"400":{"description":"`invalid_validity` / `invalid_max_items` / `invalid_chain_options` / `invalid_fee_quote_id` (Shape B)"},"402":{"description":"`payroll_fee_prepayment_required` (Bitcoin stacks_prepaid: no fee_quote_id, or no payment of it on Stacks yet; `details.fee_quote_id`) (Shape B)"},"403":{"description":"`tenant_not_active`"},"404":{"description":"`resource_missing` (the batch, or a fee_quote_id that is not this batch's)"},"409":{"description":"`payroll_batch_not_open` / `payroll_batch_expired` / `payer_wallet_not_verified` / `payer_wallet_reverification_required` / `payer_wallet_delegated` / `payer_wallet_is_fastpay_key` / `payer_wallet_kind_unsupported` / `payer_multisig_not_enabled` / `payroll_intent_already_live` / `no_pending_items` / `insufficient_payer_balance` / `invalid_recipient_state` / `invalid_payer_state` (Solana: the pre-flight simulation failed on the payer's side, details {network, cause}) / `currency_not_allowlisted` (details {network, token}: the disburser does not allowlist the token; Solana, details.reason: `mint_paused` - the Token-2022 issuer paused transfers, the token is still allowlisted, retry once the pause is lifted - or `mint_non_transferable`) / `payroll_items_not_pending` / `payroll_chain_ref_mismatch` / `payroll_fee_changed` / `payer_token_account_unusable` / `payroll_expiry_too_far` (TON: an expiry more than 86400 s ahead) / `payroll_ton_capacity` (TON: no room under the disburser's shared live-id cap); Bitcoin: `payroll_fee_outstanding` / `payroll_value_cap_reached` / `payroll_fee_address_quota` / `payroll_fee_mode_mismatch` / `payroll_fee_mode_unavailable` / `payroll_fee_prepayment_pending` / `payroll_fee_settlement_anomaly` / `payroll_prepaid_plan_changed` / `payroll_transaction_too_large` (Shape B)"},"503":{"description":"`service_paused` / `payroll_network_not_enabled` / `payroll_network_not_configured` / `payroll_deployment_rotation_pending` / `chain_unavailable` / `payroll_signing_unavailable` (details.retryable false when retrying will not help: do not re-run the intent) / `payroll_signing_rate_limited` (the authorizer's signing service is at its request limit: nothing reserved or signed; a `Retry-After` header and details.retry_after_seconds say when to retry) / `payroll_signing_busy` (the relay's share of that service's request budget cannot sign this intent in time: nothing reserved or signed; a `Retry-After` header and details.retry_after_seconds say when it would fit) / `payroll_signature_cap` / `payroll_disburser_paused` / `payroll_disburser_version_mismatch` (Sui) / `payroll_deployment_not_audited` (Aptos, Sui, TON, Tron, Solana) / `payment_authorizer_mismatch` / `payment_authorizer_not_fund_free` / `token_decimals_mismatch` / `payroll_token_not_ready` (EVM, Tron: a read-only `transferFrom` of the token, made as the disburser, did not succeed with true or nothing; details.reason, and details.retryable false for every reason but unavailable) / `authorizer_cluster_mismatch` / `authorizer_network_mismatch` (Aptos, Sui, TON) / `payroll_restore_reconciliation_pending` (details.network, details.retryable false: after a restore of FastPay's payroll records, new live intents wait until FastPay has reconciled the chain since the restore point - on Bitcoin, per payer wallet, until FastPay lifts that wallet's hold once it has verified a final self-spend of every coin the wallet holds, including any received since the loss, in a block stamped at least two hours after the loss (a block may be stamped minutes before it is mined, so broadcast a little later) (send FastPay the transaction ids: the spend alone does not lift it) or recorded the payer's written attestation; refused before any chain read, nothing reserved or signed); Bitcoin: `payroll_fee_address_unavailable` / `payroll_psbt_refused` (Shape B)"}}},"get":{"summary":"List a payroll batch's intents (paginated, no payloads)","description":"The batch's intents, newest first, with each authorization's status and items; the cursor is an intent id. Payloads are omitted (one chain call per authorization); GET an intent by id for them.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"}],"responses":{"200":{"description":"Intents","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayrollIntent"}},"has_more":{"type":"boolean"}}}}}},"404":{"description":"`resource_missing`"}}}},"/v1/payroll-batches/{id}/intents/{intentId}":{"get":{"summary":"Re-serve a payroll intent from the stored rows (never re-signs)","description":"Payloads only for live authorizations on the current deployment and only for groups that still hold an unpaid item; a rotated deployment's authorization comes back as deployment_retired with no payloads (decided by the current deployment; when the current adapter cannot be built, on Solana from the stored program id alone, on other networks when the stored deployment can still be read; while the relay cannot tell, 503 chain_unavailable - retry, never an outcome). Once the batch's cancel was requested (cancel_requested_at set) no payload and no pre-step is served, only a warning (a Bitcoin cancel_payload still is). An intent with any flagged authorization is 409 payroll_authorization_flagged and nothing is served - do not resend any of its payloads.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"intentId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The payroll intent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollIntent"}}}},"404":{"description":"`resource_missing`"},"409":{"description":"`payroll_authorization_flagged` / `payroll_authorization_expiring` (Tron)"},"503":{"description":"`chain_unavailable` (the network could not be read: a payload could not be built, or whether an authorization's deployment is still current cannot be told right now; retry) / `payroll_network_not_configured` (the current adapter refused to start on a setting) / `payroll_deployment_not_audited` (Aptos, Sui, TON, Tron, Solana: the disburser no longer matches the audited deployment the relay pins; on Solana, the program was upgraded since its audited build, to other code or redeployed without an extension of its account)"}}}},"/v1/payroll-batches/{id}/intents/{intentId}/requote":{"post":{"summary":"Requote a live Bitcoin payroll intent at another fee rate","description":"Bitcoin only (a chain whose payloads carry no FastPay signature). Builds a new version of the intent's PSBT over the IDENTICAL reserved inputs at fee_rate_sat_vb, the difference coming out of the payer's change, verifies it as the first one was and stores it before serving it. Only while the intent is live and no transaction of it was seen on chain, at most RELAY_PAYROLL_BITCOIN_MAX_REQUOTES_PER_AUTHORIZATION (20) times. An earlier version that was fully signed stays executable until its inputs are spent: to stop it, sign and broadcast the cancel payload. Signs nothing. Scope payroll:write.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"intentId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["fee_rate_sat_vb"],"properties":{"fee_rate_sat_vb":{"type":"number","description":"RELAY_PAYROLL_BITCOIN_MIN_FEE_RATE (2) to RELAY_PAYROLL_BITCOIN_MAX_FEE_RATE (500) sat/vB."}}}}}},"responses":{"200":{"description":"The payroll intent, serving the new version","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollIntent"}}}},"400":{"description":"`invalid_fee_rate` (Shape B)"},"404":{"description":"`resource_missing` (the batch or the intent)"},"409":{"description":"`payroll_authorization_flagged` / `payroll_requote_unavailable` (another network, an intent no longer live or already seen on chain, or the requote limit: `details.max_requotes`) / `payroll_requote_insufficient_change` (Shape B)"},"503":{"description":"`service_paused` / `chain_unavailable` / `payroll_psbt_refused` (Shape B)"}}}},"/v1/payroll-batches/{id}/fee-quotes":{"post":{"summary":"Quote a Bitcoin payroll fee to prepay on Stacks (stacks_prepaid)","description":"For a tenant whose Bitcoin fee mode is stacks_prepaid. Runs the same gates and plan as POST .../intents for the batch's next intent (one PSBT, the value caps checked without trimming) but reserves nothing, then returns the fee to pay with its Stacks pay-service-fee call and reference. Pay it on Stacks with that call, wait for required_confirmations, then POST .../intents with this fee_quote_id. One open quote per batch; it expires at expires_at (RELAY_PAYROLL_BITCOIN_PREPAID_QUOTE_TTL_SECONDS, 3600; a multisig vault RELAY_PAYROLL_BITCOIN_MULTISIG_PREPAID_QUOTE_TTL_SECONDS). Webhook payroll_fee_settlement.paid once a payment is found final. Scope payroll:write.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"max_items":{"type":"integer","minimum":1},"chain_options":{"type":"object","description":"The Bitcoin chain_options of POST .../intents."},"stacks_payer":{"type":"string","maxLength":200,"description":"The Stacks address (SP/ST or SM/SN) that pays the fee in sBTC. The pay-service-fee authorization FastPay signs names it, so the contract refuses a payment from any other address. Required with RELAY_PAYROLL_FEE_SETTLEMENT_PROVIDER=stacks; 400 invalid_request without it."}}}}}},"responses":{"201":{"description":"The fee quote, with the planned intent's balances","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PayrollFeeQuote"},{"type":"object","properties":{"required_confirmations":{"type":"integer","description":"Stacks confirmations before the payment counts as final."},"balances":{"type":"array","items":{"type":"object","additionalProperties":true}}}}]}}}},"400":{"description":"`invalid_max_items` / `invalid_chain_options` (Shape B)"},"403":{"description":"`tenant_not_active`"},"404":{"description":"`resource_missing`"},"409":{"description":"`payroll_batch_not_open` / `payroll_batch_expired` / `payer_wallet_not_verified` / `payer_wallet_reverification_required` / `payer_wallet_delegated` / `payer_wallet_is_fastpay_key` / `payer_multisig_not_enabled` / `payroll_intent_already_live` / `payroll_fee_outstanding` / `payroll_fee_mode_mismatch` (an in_tx_debt tenant, or a network with no fee modes) / `payroll_fee_mode_unavailable` / `payroll_fee_quote_open` (`details.fee_quote_id`) / `no_pending_items` / `payroll_value_cap_reached` / `invalid_recipient_state` / `insufficient_payer_balance` (Shape B)"},"503":{"description":"`service_paused` / `payroll_network_not_enabled` / `payroll_network_not_configured` / `payroll_deployment_rotation_pending` / `chain_unavailable` / `payroll_restore_reconciliation_pending` (details.network, details.retryable false: after a restore of FastPay's payroll records, this payer wallet is on hold until FastPay lifts it, once it has verified a final self-spend of every coin the wallet holds, including any received since the loss, in a block stamped at least two hours after the loss (a block may be stamped minutes before it is mined, so broadcast a little later) (send FastPay the transaction ids: the spend alone does not lift it) or recorded the payer's written attestation) (Shape B)"}}}},"/v1/payroll-batches/{id}/fee-quotes/{feeQuoteId}":{"get":{"summary":"Retrieve a Bitcoin payroll fee quote","description":"The batch's own fee quote and its payment state (quoted, paid, issuing, consumed, expired, abandoned). Scope payroll:read.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"feeQuoteId","in":"path","required":true,"schema":{"type":"string","description":"pfs_..."}}],"responses":{"200":{"description":"The fee quote","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollFeeQuote"}}}},"404":{"description":"`resource_missing` (the batch, or a quote that is not this batch's)"}}}},"/v1/payroll-batches/{id}/confirm":{"post":{"summary":"Report a payroll transaction the payer wallet sent","description":"Optional - the settlement sweep finds unreported transactions too. Body { tx_hash } (EVM, Aptos, Stacks 0x+64 hex; Sui the base58 digest; TON '<lt>:<hash hex>') or, on TON, { boc }. The outcome is read from the chain, never from the report; a report never fails an item by itself. A tx_hash already recorded as paying items of the batch answers applied again, with no webhook (also once the batch closed), from the stored items with no chain call unless an authorization of the batch is flagged (then it is read from the chain first). A TON boc is always read from the chain. Works while the kill switch is on.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tx_hash":{"type":"string"},"boc":{"type":"string","description":"TON only: the signed external message the wallet returned."}}}}}},"responses":{"200":{"description":"The confirmation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollConfirmation"}}}},"400":{"description":"`invalid_tx_ref` / `invalid_report`"},"404":{"description":"`resource_missing`"},"503":{"description":"`chain_unavailable` (the network could not be read, including when no adapter can be built for it; retry) / `payroll_network_not_configured`"}}}},"/v1/payroll-batches/{id}/sponsor":{"post":{"summary":"Stacks fee sponsorship - FastPay adds a fee-payer signature and broadcasts (not available yet)","description":"Stacks only (2026-10-06). CLOSED in this build: it is on by default once the Stacks family is enabled, but refuses to sponsor until a wallet sanctions-screening provider is configured (none is), and needs the sponsor key and fee caps. Once open: post the group transaction your wallet signed as SPONSORED (origin fee 0, not broadcast) for an intent issued with chain_options.fee_payment sponsored. FastPay checks it against what it authorized (registered single-key payer as origin, the exact contract call, Deny mode, the payer's next nonce with nothing else pending), screens the payer and the group's recipients, adds its SIP-005 sponsor signature (its own fee, nonce and key only; tx-sender stays your wallet) and broadcasts it. FastPay can delay but never redirect it. Before FastPay broadcasts it, any transaction of yours at the same nonce cancels it; after broadcast, only one at that nonce with a fee strictly above the response's fee_ustx replaces it, and it cancels only if it is mined first. Paying the fee yourself always works. See /docs/payment-gateway-api.md#stacks-fee-sponsorship-not-available-yet.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["authorization_id","group_index","signed_tx"],"properties":{"authorization_id":{"type":"string","description":"pya_..."},"group_index":{"type":"integer","minimum":0},"signed_tx":{"type":"string","description":"The serialized sponsored transaction, hex."}}}}}},"responses":{"200":{"description":"The sponsored transaction was broadcast; FastPay follows it like a reported tx_hash.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["payroll_sponsorship"]},"id":{"type":"string"},"batch_id":{"type":"string"},"authorization_id":{"type":"string"},"group_index":{"type":"integer"},"tx_hash":{"type":"string"},"fee_ustx":{"type":"string","description":"The STX fee FastPay's sponsor pays, in micro-STX. A transaction of yours at the same nonce replaces this one only with a fee strictly above it."}}}}}},"400":{"description":"`invalid_request` / `sponsorship_tx_invalid` (Shape B)"},"403":{"description":"`sponsorship_screening_refused` / `tenant_not_active` (Shape B)"},"404":{"description":"`resource_missing`"},"409":{"description":"`sponsorship_unavailable` (the only answer while closed, with sponsorship_screening_unavailable) / `sponsorship_not_live` / `sponsorship_not_requested` / `sponsorship_origin_nonce_used` / `sponsorship_origin_nonce_future` / `sponsorship_origin_nonce_conflict` / `sponsorship_fee_above_cap` / `sponsorship_preflight_refused` / `sponsorship_broadcast_rejected` / `sponsorship_pending` (one sponsored transaction in flight per group and per account; wait for it to be mined or dropped) / `payer_wallet_not_verified` (Shape B)"},"429":{"description":"`sponsorship_daily_cap` (Shape B, type invalid_request_error - not a rate limit: the account's sponsored transactions for the UTC day are used up; pay the fee from the payer wallet)"},"503":{"description":"`sponsorship_screening_unavailable` / `sponsorship_capacity` / `sponsorship_signing_unavailable` / `sponsorship_broadcast_unconfirmed` (details.tx_hash - watch it, do not sign another) / `service_paused` (Shape B)"}}}},"/admin/turnkey-gate":{"post":{"summary":"Switch the relay's Turnkey request gate to pass-through, or back (in memory, audited)","description":"Every Turnkey request the relay makes goes through one gate (rate-limit retry, per-call timeout, a minute of payroll_signing_rate_limited after a rate limit, and the relay's share of the request budget with live payroll first). passthrough sends every call straight to the Turnkey SDK, the behaviour before the gate; configured returns to the mode RELAY_TURNKEY_GATE_ENABLED set. It never turns on a gate the configuration turned off, lasts until the next restart, and is recorded in the admin audit log (turnkey_gate.mode_set). The response is the gate's status. An Idempotency-Key is ignored and every call applies the switch: a stored answer would outlive the in-memory override across a restart and replay a mode the gate is no longer in.\n","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/AdminOperator"}],"security":[{"AdminApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["mode"],"properties":{"mode":{"type":"string","enum":["passthrough","configured"]},"reason":{"type":"string"}}}}}},"responses":{"200":{"description":"The gate's status: mode (configured | passthrough), passthrough_reason (configuration | override | failed_open | null), call_timeout_ms, rate_limit_retries, share, tokens, promised, live_tickets, rate_factor, depth, cooldown_ms_left, last_rate_limited_ms_ago, counters"},"400":{"description":"`invalid_turnkey_gate_mode`"},"401":{"description":"Missing or invalid x-admin-api-key"}}}},"/admin/payroll/authorizations/{id}/resolve":{"post":{"summary":"Resolve a flagged payroll authorization (operator only)","description":"The ONLY way out of a flagged authorization; never automatic. One verdict per item still held. 'completed' needs the paying tx_ref. On basis `chain` (the default) the relay reads that transaction itself, on the authorization's own deployment, and records it only when it carries the item's own delivered outcome - the disburser's Disbursed for the item's allocation id (never its DisbursementFailed: a batch transaction can succeed while one entry was refunded), with the item's recipient, token and amount, no failed or bounced follow-up, final - else 409 `completion_not_proven` and nothing changes. A payment the disburser did not make (a Bitcoin payer paying from coins FastPay never reserved; a bounced TON jetton leg resent by the admin multisig) is completed only on basis `operator_attestation`, with the operator's written statement and its reference; the relay reads no chain for it and audits it as payroll_authorization.completion_attested. 'released' needs the relay's own proof, on the authorization's own deployment, that it can no longer execute and that none of those ids was consumed. Audited; the tenant gets payroll_authorization.resolved.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["resolved_by","reason","evidence","items"],"properties":{"resolved_by":{"type":"string","maxLength":100,"description":"The operator taking the decision."},"reason":{"type":"string"},"evidence":{"description":"What the operator checked on chain (string or object, at most 16 KiB)."},"items":{"type":"array","items":{"type":"object","required":["item_id","verdict"],"properties":{"item_id":{"type":"string"},"verdict":{"type":"string","enum":["completed","released"]},"tx_ref":{"type":"string","description":"Required for completed: the transaction that paid the item."},"basis":{"type":"string","enum":["chain","operator_attestation"],"default":"chain","description":"completed only. chain: the relay proves tx_ref delivered the item. operator_attestation: a payment made outside the disburser, on the operator's attestation (no chain read)."},"attestation":{"type":"object","description":"Required with basis operator_attestation, refused otherwise.","required":["statement","reference"],"properties":{"statement":{"type":"string","maxLength":500,"description":"How the recipient was paid outside the disburser."},"reference":{"type":"string","maxLength":500,"description":"Where the written record of that payment is kept."}}}}}}}}}}},"responses":{"200":{"description":"Resolved","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["payroll_authorization_resolution"]},"authorization_id":{"type":"string"},"status":{"type":"string","enum":["settled","expired"]},"resolved_at":{"type":"string","format":"date-time"},"resolved_by":{"type":"string"},"completed_item_ids":{"type":"array","items":{"type":"string"}},"released_item_ids":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"`invalid_resolution`"},"404":{"description":"`resource_missing`"},"409":{"description":"`authorization_not_flagged` / `release_not_proven` (details: reason, used_allocation_ids; reason `unresolved_watch` also when a transaction of the authorization started confirming during the proof - re-checked in the resolution's own write) / `completion_not_proven` (details: reason, the first refused item's; items, each {item_id, tx_ref, reason}: entry_absent - the transaction names no outcome for the item's allocation id on this deployment; entry_not_disbursed with outcome - its outcome is failed (the item's own DisbursementFailed: refunded), suspect or another kind; entry_mismatch with field - recipient, token or amount is not the item's; tx_not_found; tx_reverted; tx_not_final with confirmations and required; tx_reorged; delivery_suspect - a follow-up of the payment failed or bounced. Nothing changed)"},"503":{"description":"`chain_unavailable` (the expiry proof or the completion proof could not read the chain; nothing changed - retry)"}}}},"/admin/payroll/restore-fence":{"get":{"summary":"The payroll restore fence (operator only)","description":"Added 2026-10-18 (replay-guard plan batch 6). `set: false` while no fence is set: payroll runs as it always does. Once a restore of the relay database set it: the restore point, the data loss, who set it, and per family whether it is cleared - `clears: family` with `clearable_after` (the data loss plus the longest authorization an intent on the family can have asked for - the configured maximum, never below its 1-day default, or 72 h on a family with a multisig payer kind - plus one hour), or `clears: per_payer` (Bitcoin) with the payer wallets cleared. Read-only.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"}],"responses":{"200":{"description":"The fence","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollRestoreFence"}}}},"401":{"description":"Missing or wrong admin key"}}},"post":{"summary":"Set the payroll restore fence (operator only; the restore runbook step)","description":"Added 2026-10-18 (replay-guard plan batch 6). Run only by the restore runbook step (relay-production-fly.md section 15, step 8), while the restored relay is still paused. A restored database lacks every authorization issued after its restore point, whose signature still executes on chain, and an item re-created after it gets a new allocation id the disburser's guard cannot match. While set, live payroll refuses new batches and intents (503 payroll_restore_reconciliation_pending) on every family until it is cleared - Bitcoin its intents and fee quotes, per payer wallet; confirm and settlement go on; sandbox keys are not held. Setting it again (another restore) while a family other than Bitcoin is still held only widens it: the earlier restore point, the later data loss, and every family on hold again. Once every family other than Bitcoin is cleared, a set is a new incident: its own restore point, the later data loss, every family on hold again. The same values change nothing. Audited.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["restore_point","data_loss_at","set_by"],"properties":{"restore_point":{"type":"string","format":"date-time","description":"RFC 3339 with its offset: the time the restored data runs to."},"data_loss_at":{"type":"string","format":"date-time","description":"RFC 3339 with its offset: the latest time an authorization lost with the data could have been issued (later when in doubt); not before restore_point, not in the future."},"set_by":{"type":"string","maxLength":100,"description":"The operator running the restore."},"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The fence","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollRestoreFence"}}}},"400":{"description":"`invalid_restore_fence` (Shape B)"},"401":{"description":"Missing or wrong admin key"}}}},"/admin/payroll/restore-fence/clear":{"post":{"summary":"Clear the payroll restore fence for one family, or one Bitcoin payer wallet (operator only)","description":"Added 2026-10-18 (replay-guard plan batch 6). Every family but Bitcoin clears as a whole, only once `clearable_after` has passed (every authorization lost with the data has expired) and with the operator's reconciliation of the family's Disbursed events against the restored rows: `from` no later than the restore point, `through` no earlier than `clearable_after`, `payers` naming every live payer wallet the restored database holds on the family (revoked ones included), and the allocation ids found paid but unknown to the relay. The relay proves the reconciliation per entry: it reads every transaction `transactions` cites through that network's adapter and requires the outcome claimed for each entry (`disbursed`, or `failed` for the entry's own DisbursementFailed - a batch transaction can succeed while one entry was refunded, and a refunded entry is never counted as paid), from the cited payer, final; every lost id must be one a cited transaction shows paid, and every id a cited transaction shows paid by a payer the reconciliation names that no item of the restored database holds must be listed lost - every entry of a cited transaction is read for this, listed or not, each in the token its own event names (the relay looks for no transaction the reconciliation does not cite). A transaction is cited once per payer and currency, each with the entries of that payer and currency it claims, so one carrying payments of two payers or two tokens is cited once for each. A TON entry cannot be proven this way (its decode needs the lost authorization's signed terms). Bitcoin clears one payer wallet at a time: basis `self_spend` (each of `spend_txids` must be final on the provider quorum, spend one of the payer's own coins and have been mined after `data_loss_at` - its block's timestamp less two hours, the most a block may be stamped ahead; the payer may hold no unspent coin, on any provider, mined no later than the earliest of them, their own outputs aside; and no such coin may be spent only by a transaction not yet final, in the mempool or short of the finality depth, which the payer's history after the earliest spend, read in full on the provider quorum, shows; the coins are read before that history and again after it, so a spend that leaves every mempool between the reads shows its coin) or basis `attestation` (the payer's written attestation, recorded by reference, `attested_at` no earlier than `data_loss_at` and not in the future). Audited with the reconciliation or the basis.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["family","cleared_by","evidence"],"properties":{"family":{"type":"string","enum":["evm","tron","solana","aptos","sui","ton","stacks","bitcoin"]},"cleared_by":{"type":"string","maxLength":100,"description":"The operator lifting the hold."},"evidence":{"description":"What the operator checked, and where (text or JSON, at most 16 KiB)."},"reconciliation":{"type":"object","description":"Every family but bitcoin.","required":["from","through","payers","lost_allocation_ids"],"properties":{"from":{"type":"string","format":"date-time"},"through":{"type":"string","format":"date-time"},"payers":{"type":"array","maxItems":1000,"items":{"type":"string"}},"lost_allocation_ids":{"type":"array","maxItems":1000,"items":{"type":"string","pattern":"^0x[0-9a-fA-F]{64}$"},"description":"Each must be claimed disbursed by a cited transaction."},"transactions":{"type":"array","maxItems":1000,"description":"The transactions the reconciliation read (default none), each proved on chain entry by entry; at most 1000 entries in all, each transaction once per payer and currency, each entry claimed once.","items":{"type":"object","required":["network","tx_ref","payer","currency","entries"],"properties":{"network":{"type":"string","description":"A payroll network of the family."},"tx_ref":{"type":"string","maxLength":200},"payer":{"type":"string","maxLength":128,"description":"The payer wallet that sent it: one of payers."},"currency":{"type":"string","description":"The token symbol it paid in, such as USDC."},"entries":{"type":"array","minItems":1,"items":{"type":"object","required":["allocation_id","outcome"],"properties":{"allocation_id":{"type":"string","pattern":"^0x[0-9a-fA-F]{64}$"},"outcome":{"type":"string","enum":["disbursed","failed"],"description":"failed: the entry's own DisbursementFailed (refunded, not paid)."}}}}}}}}},"payer_address":{"type":"string","maxLength":128,"description":"bitcoin only: the payer wallet the hold is lifted for."},"basis":{"type":"string","enum":["self_spend","attestation"],"description":"bitcoin only."},"spend_txids":{"type":"array","minItems":1,"maxItems":1000,"items":{"type":"string","pattern":"^[0-9a-fA-F]{64}$"},"description":"basis self_spend: every final self-spend that together leaves no coin from before the data loss."},"attestation":{"type":"object","description":"basis attestation.","required":["reference","attested_by","attested_at"],"properties":{"reference":{"type":"string","maxLength":500,"description":"Where the written attestation is kept."},"attested_by":{"type":"string","maxLength":100},"attested_at":{"type":"string","format":"date-time","description":"When the payer signed it: not before the fence's data_loss_at (409 attestation_predates_loss), not in the future (400)."}}}}}}}},"responses":{"200":{"description":"The fence","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollRestoreFence"}}}},"400":{"description":"`invalid_restore_fence` (Shape B)"},"401":{"description":"Missing or wrong admin key"},"409":{"description":"`payroll_restore_fence_not_set` / `payroll_restore_fence_not_clearable` (details.reason: validity_window_open with clearable_after, reconciliation_starts_late, reconciliation_ends_early, reconciliation_in_future, payers_not_reconciled with missing_payers, lost_payment_not_cited and lost_payment_not_listed with allocation_ids and allocation_ids_total (lost_payment_not_listed found among a cited transaction's entries also with network and tx_ref), transaction_not_found, transaction_reverted, transaction_not_final, entry_outcome_not_proven with network, tx_ref and entries (allocation_id, claimed, read: absent, another_payer, another_token, suspect or the outcome read), spend_not_found, spend_not_final, spend_not_from_payer, spend_before_loss, spend_not_covering with outpoints and outpoints_total, spend_pending with txids and txids_total, spend_history_incomplete, attestation_predates_loss, already_cleared, fence_changed; nothing changed) (Shape B)"},"503":{"description":"`chain_unavailable` (a cited transaction or a spend could not be read: nothing cleared, retry) / `payroll_network_not_configured` (no adapter to read a cited transaction's network with; bitcoin: none to read the payer wallet and its spends with, or one whose read providers are misconfigured, such as serving another chain) (Shape B)"}}}},"/admin/payroll/tenants/{tenantId}/fee-modes/{network}":{"put":{"summary":"Set a tenant's Bitcoin payroll fee mode (operator only)","description":"A commercial term: in_tx_debt (the default, RELAY_PAYROLL_BITCOIN_FEE_MODE_DEFAULT - the fee is an output of the payroll transaction) or stacks_prepaid (the fee is prepaid on Stacks per intent; 409 payroll_fee_mode_unavailable unless the relay can build its fee-settlement verifier: RELAY_PAYROLL_FEE_SETTLEMENT_PROVIDER and, for stacks, the Stacks deployment). Refused while the tenant has open prepaid quotes unless force with a reason. Applies to new intents only: each intent keeps the mode it was issued under. Audited.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"tenantId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"path","required":true,"schema":{"type":"string","description":"A Bitcoin payroll network; another payroll network answers 409 payroll_fee_mode_unavailable, a name that is not one 400 unsupported_network."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["fee_mode"],"properties":{"fee_mode":{"type":"string","enum":["in_tx_debt","stacks_prepaid"]},"reason":{"type":"string","maxLength":500,"description":"Required with force. Longer is refused (400 invalid_fee_mode), never cut."},"force":{"type":"boolean","description":"Switch despite open prepaid quotes (they stay payable and usable)."},"set_by":{"type":"string","maxLength":100,"description":"The operator taking the decision; 'admin' when absent. Longer is refused (400 invalid_fee_mode)."}}}}}},"responses":{"200":{"description":"The tenant's fee mode","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["payroll_tenant_fee_mode"]},"tenant_id":{"type":"string"},"network":{"type":"string"},"fee_mode":{"type":"string","enum":["in_tx_debt","stacks_prepaid"]},"previous_fee_mode":{"type":"string","nullable":true},"open_fee_quote_ids":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"`unsupported_network` / `invalid_fee_mode` (also a reason or set_by over its maxLength) (Shape B)"},"404":{"description":"`resource_missing` (the tenant)"},"409":{"description":"`payroll_fee_mode_unavailable` / `payroll_fee_mode_change_blocked` (`details.open_fee_quote_ids`) (Shape B)"}}}},"/admin/payroll/fee-shortfalls/{id}/settle":{"post":{"summary":"Settle a Bitcoin payroll fee shortfall (operator only)","description":"An in_tx_debt transaction paid less than its quoted FastPay fee: the items are paid, the difference is owed, and the tenant's and the payer wallet's next intents and fee quotes answer 409 payroll_fee_outstanding until it is settled here (while RELAY_PAYROLL_FEE_SHORTFALL_BLOCKS_BITCOIN is on, the default). waived_disputed needs evidence {address, signature}: an attestation that verifies under a FastPay attestation key for an address outside the fee-address pool (proof the key was misused); it alerts the operator. Audited.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["resolution","settled_by"],"properties":{"resolution":{"type":"string","enum":["paid","invoiced","waived","waived_disputed"]},"settled_by":{"type":"string","maxLength":100,"description":"The operator taking the decision."},"evidence":{"type":"object","description":"waived_disputed: {address, signature}. At most 16 KiB as JSON."},"note":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The settled shortfall","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["payroll_fee_shortfall"]},"id":{"type":"string"},"tenant_id":{"type":"string"},"network":{"type":"string"},"status":{"type":"string"},"resolution":{"type":"string","enum":["paid","invoiced","waived","waived_disputed"]},"shortfall":{"type":"string","description":"Sats owed."},"settled_by":{"type":"string"}}}}}},"400":{"description":"`invalid_resolution` (no settled_by, a field over its bound, or waived_disputed evidence that proves nothing) (Shape B)"},"404":{"description":"`resource_missing`"},"409":{"description":"`payroll_fee_shortfall_not_open` (Shape B)"}}}},"/admin/payroll/switches":{"get":{"summary":"The payroll switches and lanes as the running relay sees them (operator only)","description":"Added 2026-10-15; lanes, holds, config faults and held-key readiness since 2026-10-21. The master switch (RELAY_PAYROLL_LIVE_ENABLED), each chain family's own switch (RELAY_PAYROLL_ENABLED_<FAMILY>) with `live_open` = master AND family, and the Solana signing mode (RELAY_PAYROLL_SOLANA_MODE). Per family: `tenant_ids` (deprecated, never null) - the tenants whose live keys pass every tenant and network gate of the family but the two switches (an enabled lane, inside the ceiling RELAY_PAYROLL_LANE_NETWORKS, the network not held) on at least one of its networks; read a tenant's lanes with GET /admin/tenants/{id}/payroll-lanes instead. `networks` - each network's hold, ceiling and lane counts; `config_faults` - a malformed setting only this family reads, by name (the family is closed until it is fixed). `lane_networks` - the ceiling's faults: each dropped entry with the network it names, or null (a dropped entry narrows its own network only and closes no family). `held_keys` - each held-key source's readiness (one per payroll network: a source not ready holds only the payer checks of its own domains). Booleans, modes, counts, setting names and tenant ids only - no secret, no RPC URL. Not the whole live gate: a configured deployment, an adapter that starts and no pending rotation are checked per intent.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"}],"responses":{"200":{"description":"The switches","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["payroll_switches"]},"live_enabled":{"type":"boolean"},"solana_mode":{"type":"string","enum":["single","batch"]},"families":{"type":"array","items":{"type":"object","properties":{"family":{"type":"string","enum":["evm","tron","solana","aptos","sui","ton","stacks","bitcoin"]},"enabled":{"type":"boolean"},"live_open":{"type":"boolean"},"tenant_ids":{"type":"array","deprecated":true,"items":{"type":"string"},"description":"The tenants with an enabled lane on one of the family's networks, inside the ceiling, the network not held. Never null."},"networks":{"type":"array","items":{"type":"object","properties":{"network":{"type":"string"},"held":{"type":"boolean"},"ceiling":{"type":"string","enum":["all","listed","none"]},"ceiling_tenant_ids":{"type":"array","nullable":true,"items":{"type":"string"}},"lanes":{"type":"object","properties":{"requested":{"type":"integer"},"enabled":{"type":"integer"},"paused":{"type":"integer"},"closed":{"type":"integer"}}}}}},"config_faults":{"type":"array","items":{"type":"object","properties":{"setting":{"type":"string"},"requirement":{"type":"string"}}}}}}},"lane_networks":{"type":"object","properties":{"setting":{"type":"string","enum":["RELAY_PAYROLL_LANE_NETWORKS"]},"all":{"type":"boolean","description":"true when the setting is unset: every network's lanes may be opened."},"faults":{"type":"array","items":{"type":"object","properties":{"network":{"type":"string","nullable":true},"requirement":{"type":"string"}}}}}},"held_keys":{"type":"object","nullable":true,"properties":{"built_at":{"type":"string","format":"date-time","nullable":true},"sources":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"A local source, or adapters:<network>."},"ready":{"type":"boolean"},"stale_since":{"type":"string","format":"date-time","nullable":true},"domains":{"type":"array","nullable":true,"items":{"type":"string"},"description":"key:<curve> or addr:<class>; ['*'] for a local source."}}}},"unready_domains":{"type":"array","items":{"type":"string"}}}}}}}}},"401":{"description":"Missing or wrong admin key"}}}},"/admin/tenants/{id}/payroll-lanes":{"get":{"summary":"A tenant's chain lanes, one per payroll network (operator only)","description":"Added 2026-10-21 (owner ruling LOG:1342). A live key works on a payroll network only while its tenant's lane there is enabled, inside the ceiling RELAY_PAYROLL_LANE_NETWORKS, the network not held and the switches on (`open`). One object per payroll network, status `none` and version 0 where no lane exists. The panel never reads the admin key's credential fingerprint.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Every payroll network's lane","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayrollLane"}},"has_more":{"type":"boolean"}}}}}},"401":{"description":"Missing or wrong admin key"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/payroll/lanes":{"get":{"summary":"Chain lanes across tenants (operator only, paginated) - the approvers' queue","description":"Added 2026-10-21. Newest first by creation, with `status` (requested for the queue, enabled for the read-back) and `network` filters. Each lane carries `requested.expires_at` and `expired`, so a panel sorts and ages the queue.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["requested","enabled","paused","closed"]}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Lanes, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayrollLane"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"`invalid_pagination_parameter` / `invalid_filter_parameter`"},"401":{"description":"Missing or wrong admin key"}}}},"/admin/tenants/{id}/payroll-lanes/{network}/request":{"post":{"summary":"Request a tenant's lane on a network (one person)","description":"Added 2026-10-21 (LOG:1342). Every lane write names its person by email in lower case (x-admin-operator with the admin key, x-panel-operator with the panel credential: an account id is refused here), is one compare-and-set with its audit row (payroll_lane.* on the tenant), raises the alert payroll.lane_changed, applies at the next request with no deploy, and is never replayed under an Idempotency-Key.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/PanelOperatorEmail"},{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, not the person's email in lower case, or undefined or null - 400 invalid_admin_operator), ignored with the panel credential: the person making the change.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"},"description":"A payroll network (each EVM chain on its own)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reason"],"properties":{"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The lane","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollLane"}}}},"400":{"description":"`unsupported_network` / `invalid_reason` / `invalid_request` (version) / `admin_operator_required` / `invalid_admin_operator` / the panel's own 400s (components/parameters/PanelOperatorEmail)"},"401":{"description":"Missing or wrong admin key"},"403":{"description":"`payroll_lane_not_openable` (Shape B): the lane is outside the ceiling RELAY_PAYROLL_LANE_NETWORKS"},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"`payroll_lane_state_conflict` (details kind, network, status, version) / `tenant_not_active` (Shape B)"}}}},"/admin/tenants/{id}/payroll-lanes/{network}/approve":{"post":{"summary":"Approve a requested lane (a second person, never the requester)","description":"Added 2026-10-21 (LOG:1342). Every lane write names its person by email in lower case (x-admin-operator with the admin key, x-panel-operator with the panel credential: an account id is refused here), is one compare-and-set with its audit row (payroll_lane.* on the tenant), raises the alert payroll.lane_changed, applies at the next request with no deploy, and is never replayed under an Idempotency-Key.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/PanelOperatorEmail"},{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, not the person's email in lower case, or undefined or null - 400 invalid_admin_operator), ignored with the panel credential: the person making the change.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"},"description":"A payroll network (each EVM chain on its own)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["version"],"properties":{"version":{"type":"integer","minimum":1,"description":"The version the lane object shows: the compare-and-set's expected version."},"reason":{"type":"string","maxLength":500,"nullable":true}}}}}},"responses":{"200":{"description":"The lane","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollLane"}}}},"400":{"description":"`unsupported_network` / `invalid_reason` / `invalid_request` (version) / `admin_operator_required` / `invalid_admin_operator` / the panel's own 400s (components/parameters/PanelOperatorEmail)"},"401":{"description":"Missing or wrong admin key"},"403":{"description":"`approval_requires_second_operator` (the requester approves) / `payroll_lane_not_openable` (the ceiling no longer reaches the lane) (Shape B)"},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"`payroll_lane_state_conflict` (details kind, network, status, version) / `payroll_lane_request_expired` (details kind, network, requested_at, expires_at) / `tenant_not_active` (Shape B)"}}}},"/admin/tenants/{id}/payroll-lanes/{network}/reject":{"post":{"summary":"Reject (or withdraw) a lane request","description":"Added 2026-10-21 (LOG:1342). Every lane write names its person by email in lower case (x-admin-operator with the admin key, x-panel-operator with the panel credential: an account id is refused here), is one compare-and-set with its audit row (payroll_lane.* on the tenant), raises the alert payroll.lane_changed, applies at the next request with no deploy, and is never replayed under an Idempotency-Key.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/PanelOperatorEmail"},{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, not the person's email in lower case, or undefined or null - 400 invalid_admin_operator), ignored with the panel credential: the person making the change.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"},"description":"A payroll network (each EVM chain on its own)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["version","reason"],"properties":{"version":{"type":"integer","minimum":1,"description":"The version the lane object shows: the compare-and-set's expected version."},"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The lane","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollLane"}}}},"400":{"description":"`unsupported_network` / `invalid_reason` / `invalid_request` (version) / `admin_operator_required` / `invalid_admin_operator` / the panel's own 400s (components/parameters/PanelOperatorEmail)"},"401":{"description":"Missing or wrong admin key"},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"`payroll_lane_state_conflict` (details kind, network, status, version) (Shape B)"}}}},"/admin/tenants/{id}/payroll-lanes/{network}/pause":{"post":{"summary":"Pause a lane at once (one person; never fails on state; a lane with no row gets one in that state)","description":"Added 2026-10-21 (LOG:1342). Every lane write names its person by email in lower case (x-admin-operator with the admin key, x-panel-operator with the panel credential: an account id is refused here), is one compare-and-set with its audit row (payroll_lane.* on the tenant), raises the alert payroll.lane_changed, applies at the next request with no deploy, and is never replayed under an Idempotency-Key.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/PanelOperatorEmail"},{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, not the person's email in lower case, or undefined or null - 400 invalid_admin_operator), ignored with the panel credential: the person making the change.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"},"description":"A payroll network (each EVM chain on its own)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reason"],"properties":{"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The lane","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollLane"}}}},"400":{"description":"`unsupported_network` / `invalid_reason` / `invalid_request` (version) / `admin_operator_required` / `invalid_admin_operator` / the panel's own 400s (components/parameters/PanelOperatorEmail)"},"401":{"description":"Missing or wrong admin key"},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"`payroll_lane_state_conflict` (details kind, network, status, version) (Shape B)"}}}},"/admin/tenants/{id}/payroll-lanes/{network}/close":{"post":{"summary":"Close a lane at once (one person; never fails on state; a lane with no row gets one in that state)","description":"Added 2026-10-21 (LOG:1342). Every lane write names its person by email in lower case (x-admin-operator with the admin key, x-panel-operator with the panel credential: an account id is refused here), is one compare-and-set with its audit row (payroll_lane.* on the tenant), raises the alert payroll.lane_changed, applies at the next request with no deploy, and is never replayed under an Idempotency-Key.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/PanelOperatorEmail"},{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, not the person's email in lower case, or undefined or null - 400 invalid_admin_operator), ignored with the panel credential: the person making the change.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"},"description":"A payroll network (each EVM chain on its own)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reason"],"properties":{"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The lane","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollLane"}}}},"400":{"description":"`unsupported_network` / `invalid_reason` / `invalid_request` (version) / `admin_operator_required` / `invalid_admin_operator` / the panel's own 400s (components/parameters/PanelOperatorEmail)"},"401":{"description":"Missing or wrong admin key"},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"`payroll_lane_state_conflict` (details kind, network, status, version) (Shape B)"}}}},"/admin/payroll/network-holds":{"get":{"summary":"Every payroll network's hold (operator only)","description":"Added 2026-10-21. One object per payroll network, status `none` where no hold exists. Admin key only.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"}],"responses":{"200":{"description":"Every network's hold","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayrollNetworkHold"}},"has_more":{"type":"boolean"}}}}}},"401":{"description":"Missing or wrong admin key"}}}},"/admin/payroll/network-holds/{network}/hold":{"post":{"summary":"Hold a network for every tenant at once (one person; never fails on state)","description":"Added 2026-10-21 (OD-DC-7's rung \"that lane for every tenant\"). Admin key only; names its person by lower-case email; one compare-and-set with its audit row (payroll_network_hold.*, target payroll_network); alert payroll.network_hold_changed; never replayed.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, not the person's email in lower case, or undefined or null - 400 invalid_admin_operator): the person making the change.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reason"],"properties":{"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The network hold","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollNetworkHold"}}}},"400":{"description":"`unsupported_network` / `invalid_reason` / `invalid_request` / `admin_operator_required` / `invalid_admin_operator`"},"401":{"description":"Missing or wrong admin key"},"403":{"description":"The panel credential: `panel_route_not_allowed`"},"409":{"description":"`payroll_lane_state_conflict` (details kind network_hold) (Shape B)"}}}},"/admin/payroll/network-holds/{network}/request-release":{"post":{"summary":"Request the release of a held network (one person)","description":"Added 2026-10-21 (OD-DC-7's rung \"that lane for every tenant\"). Admin key only; names its person by lower-case email; one compare-and-set with its audit row (payroll_network_hold.*, target payroll_network); alert payroll.network_hold_changed; never replayed.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, not the person's email in lower case, or undefined or null - 400 invalid_admin_operator): the person making the change.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reason"],"properties":{"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The network hold","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollNetworkHold"}}}},"400":{"description":"`unsupported_network` / `invalid_reason` / `invalid_request` / `admin_operator_required` / `invalid_admin_operator`"},"401":{"description":"Missing or wrong admin key"},"403":{"description":"The panel credential: `panel_route_not_allowed`"},"409":{"description":"`payroll_lane_state_conflict` (details kind network_hold) (Shape B)"}}}},"/admin/payroll/network-holds/{network}/approve-release":{"post":{"summary":"Release a held network (a second person, never the requester)","description":"Added 2026-10-21 (OD-DC-7's rung \"that lane for every tenant\"). Admin key only; names its person by lower-case email; one compare-and-set with its audit row (payroll_network_hold.*, target payroll_network); alert payroll.network_hold_changed; never replayed.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, not the person's email in lower case, or undefined or null - 400 invalid_admin_operator): the person making the change.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["version"],"properties":{"version":{"type":"integer","minimum":1,"description":"The version the lane object shows: the compare-and-set's expected version."},"reason":{"type":"string","maxLength":500,"nullable":true}}}}}},"responses":{"200":{"description":"The network hold","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollNetworkHold"}}}},"400":{"description":"`unsupported_network` / `invalid_reason` / `invalid_request` / `admin_operator_required` / `invalid_admin_operator`"},"401":{"description":"Missing or wrong admin key"},"403":{"description":"`approval_requires_second_operator` (Shape B); the panel credential: `panel_route_not_allowed`"},"409":{"description":"`payroll_lane_state_conflict` (details kind network_hold) / `payroll_lane_request_expired` (Shape B)"}}}},"/admin/payroll/network-holds/{network}/reject-release":{"post":{"summary":"Keep a network held (reject its release request)","description":"Added 2026-10-21 (OD-DC-7's rung \"that lane for every tenant\"). Admin key only; names its person by lower-case email; one compare-and-set with its audit row (payroll_network_hold.*, target payroll_network); alert payroll.network_hold_changed; never replayed.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, not the person's email in lower case, or undefined or null - 400 invalid_admin_operator): the person making the change.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["version","reason"],"properties":{"version":{"type":"integer","minimum":1,"description":"The version the lane object shows: the compare-and-set's expected version."},"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The network hold","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayrollNetworkHold"}}}},"400":{"description":"`unsupported_network` / `invalid_reason` / `invalid_request` / `admin_operator_required` / `invalid_admin_operator`"},"401":{"description":"Missing or wrong admin key"},"403":{"description":"The panel credential: `panel_route_not_allowed`"},"409":{"description":"`payroll_lane_state_conflict` (details kind network_hold) (Shape B)"}}}},"/admin/payroll/bitcoin/status":{"get":{"summary":"Bitcoin payroll operator status (operator only)","description":"The trust tier, the read providers (never their secrets), the value caps and the global daily usage, the default fee mode and settlement provider, the fee-address pool against the acknowledged wallet range (null while no fee account is configured), and the count of open prepaid settlements. Operator-only: the pool figures are never served to a tenant.\n","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/AdminOperator"}],"security":[{"AdminApiKey":[]}],"responses":{"200":{"description":"The status","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["payroll_bitcoin_status"]},"enabled":{"type":"boolean"},"network":{"type":"string"},"trust_tier":{"type":"string","enum":["third_party","self_hosted"]},"quorum":{"type":"integer"},"providers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string"},"operator":{"type":"string"},"self_hosted":{"type":"boolean"},"auth":{"type":"string","nullable":true}}}},"caps":{"type":"object","description":"Sats as strings, null for no cap: intent, daily_tenant, daily_global, usage_daily_global."},"fee_mode_default":{"type":"string"},"fee_settlement_provider":{"type":"string"},"fee_address_pool":{"type":"object","nullable":true,"additionalProperties":true},"open_fee_settlements":{"type":"integer"}}}}}}}}},"/v1/instructions":{"post":{"deprecated":true,"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Submit a mass-payout batch that FastPay's relay signs with the tenant's session key (interim; see /v1/payroll-batches)","description":"Pays from the calling tenant's own onboarded smart account, but FastPay's relay starts and signs every payout with a session key it holds for the tenant, so this path is FastPay-controlled. Interim from 2026-10-05, it got fixes, not features; since 2026-10-18 it answers 410 custodial_path_closed (below) on every network and in both key modes, and nothing re-opens it. Client-signed payroll (/v1/payroll-batches) is the target, where the tenant's own wallet signs. An accepted job is audited as job.created (network, payment count, per-currency totals, status, whether the request asked for approval) with the member and API key behind the request (2026-10-16).\n","x-fastpay-interim":true,"tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["network","payments"],"properties":{"network":{"type":"string","enum":["ethereum","base","polygon","arbitrum","bnb","avalanche","robinhood","linea","optimism","monad","megaeth","hyperevm","tron","solana","bitcoin","litecoin","aptos","ton","sui"],"description":"litecoin added 2026-09-19 (CHAIN/CURRENCY LITECOIN/LTC, native coin only; shares Bitcoin's implementation - relay/utxoJobRunner.js - but signs raw rather than typed, so no enforceable Turnkey fee cap exists on it). bitcoin/aptos/ton/sui added 2026-09-10 - native coin only at the time (CHAIN/CURRENCY BITCOIN/BTC, APTOS/APT, TON/TON, SUI/SUI); sui also accepts USDC/FDUSD/AUSD since 2026-09-13, each dispatched to its own job runner; the tenant must be onboarded for that chain via POST /admin/tenants/{id}/<chain>/onboard first."},"payments":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/DisbursementPayment"},{"type":"object","description":"Relay only (2026-09-12): a payment may name a registered recipient instead of carrying WALLET - the relay fills WALLET with the recipient's address for `network` before the batch is accepted. If both are given they must agree (recipient_wallet_mismatch).","properties":{"RECIPIENT_ID":{"type":"string","example":"rcp_..."}}}]}},"callbackUrl":{"type":"string","format":"uri","description":"A public https URL (2026-10-16, as a payroll batch's callback_url; anything else is 400 invalid_callback_url). A job accepted earlier with an http URL keeps its deliveries there."},"approval":{"type":"string","enum":["required"],"description":"2026-09-12: make this job a draft (awaiting_approval) whatever the tenant's payout approval policy says. A policy that requires approval cannot be skipped from the request."},"gas_token":{"type":"string","example":"USDT","description":"2026-09-14: charge this job's network fee in a token instead of the chain's native coin, via FastPay's own ERC-4337 paymaster. EVM networks only (gas_token_unsupported_network otherwise) and only for a 'safe' account_type, since the paymaster implements v0.7's IPaymaster. The paymaster needs an allowance on the tenant's smart account: validation reads it before the operation's own callData runs, so an approve cannot be bundled into the same job. When the standing allowance is short, the job carries an EIP-2612 permit the contract applies inside validation instead - so an account that has never approved anything can pay. Verified against real mainnet contracts: USDC, EURC and PYUSD accept such a permit; DAI's permit is a non-standard form and Ethereum's USDT has none, so those two still need one manual approve per tenant. GET /v1/gas-tokens lists what each network will charge in; a currency it will not charge in is refused here (gas_token_unavailable) rather than accepted and failed later. Accepted in any letter case and stored canonical (usdt -> USDT, usde -> USDe)."}}}}}},"responses":{"202":{"description":"Job accepted, awaiting onboarding, or (2026-09-12) a draft awaiting approval","content":{"application/json":{"schema":{"type":"object","properties":{"jobId":{"type":"string"},"status":{"type":"string","enum":["accepted","awaiting_onboarding","awaiting_approval"]}}}}}},"400":{"description":"Validation error (Shape A envelope). `invalid_payment` also for `payments` missing, not an array or empty, and for a payment that fails a check against `network` - each one a details[] entry (index, code, type, slug, message): a WALLET that is not an address of the network, exactly as sent (201 wallet_address_invalid), an AMOUNT whose value has more decimals than its currency has on the network (105 amount_invalid; trailing zeros do not count), an ALLOCATION_ID starting with pr_ or cs_, checkout's own id prefixes (111 allocation_id_invalid); the whole request is refused. `unsupported_currency` for a currency the network cannot pay (Aptos, Sui, TON, Bitcoin and Litecoin; Tron and Solana only when the relay configures that chain); `chain_network_mismatch` for a request with any payment whose CHAIN is not the network's, answered before the per-payment network checks; `duplicate_allocation_id` for an ALLOCATION_ID already used by this tenant or repeated within the request (details[] gives index, allocation_id, first_index); `invalid_callback_url` for a callbackUrl that is not a public https URL. A refused request records no job and uses none of the daily cap, whatever refused it (an ALLOCATION_ID an earlier request used and a gas_token refusal included)."},"402":{"description":"daily_payment_cap_exceeded - the tenant's daily payment cap (mass-payout payments only). Not kept under an Idempotency-Key (2026-10-16): the same key runs again once the cap has room."},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}},"503":{"description":"The operator's kill switch is on: no new job is accepted (Shape A; error_slug added 2026-10-16, additive). The body is the ErrorEnvelope (error_message and request_id included) plus pausedAt and pausedReason. Not kept under an Idempotency-Key (2026-10-16): the same key runs again once the switch is off.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorEnvelope"},{"type":"object","properties":{"error_type":{"type":"string","enum":["service_paused"]},"error_slug":{"type":"string","enum":["service_paused"]},"error":{"type":"string"},"pausedAt":{"type":"string","format":"date-time","nullable":true},"pausedReason":{"type":"string","nullable":true}}}]}}}}}}},"/v1/jobs":{"get":{"summary":"List mass-payout jobs (added 2026-09-12)","description":"Cursor-paginated, newest first, this tenant only. Each item is exactly what GET /v1/jobs/{jobId} returns, payments included. Completed jobs past the retention window (RELAY_DATA_RETENTION_COMPLETED_JOBS_DAYS) are gone, as they are for the by-id route - this is recent history, not an archive.\n","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","schema":{"type":"string","enum":["awaiting_approval","accepted","awaiting_onboarding","processing","completed","cancelled"]}},{"name":"network","in":"query","schema":{"type":"string"},"description":"The same network names POST /v1/instructions accepts (ethereum","base":null,"...":null,"tron":null,"solana":null,"bitcoin":null,"ton":null,"aptos":null,"sui).":null}],"responses":{"200":{"description":"A page of jobs","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayoutJob"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"invalid_pagination_parameter (limit out of range, or both cursors given) | invalid_filter_parameter (unknown status/network, malformed created window)"},"401":{"description":"Missing/invalid API key"}}}},"/v1/jobs/{jobId}":{"get":{"summary":"Retrieve a mass-payout job's status","tags":["Relay Gateway"],"parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Job","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutJob"}}}},"404":{"description":"Job not found"}}}},"/v1/jobs/{jobId}/approve":{"post":{"deprecated":true,"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Approve a draft payout job (2026-09-12)","description":"One approval by the calling key's team member. Needs payouts:read only - approving is a capability of the member's role (owner, admin, approver), never of the key's scopes. Rules - not the member whose key created the job (self_approval_forbidden), once per member (already_approved), only while awaiting_approval (job_not_awaiting_approval). At quorum the job activates into accepted (dispatched at once) or awaiting_onboarding (a sandbox draft always becomes accepted). While the operator's kill switch is on, an accepted live job is not dispatched: like every accepted job that has not started, it waits until the switch is off; since 2026-10-18 the job sweep then cancels it (custodial_path_closed), never runs it.","tags":["Relay Gateway"],"parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The job after this decision","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutJob"}}}},"403":{"description":"`member_required` (a legacy key) / `approval_not_permitted` (the role cannot approve) / `self_approval_forbidden`"},"404":{"description":"`job_not_found`"},"409":{"description":"`already_approved` / `job_not_awaiting_approval`"},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}}}}},"/v1/jobs/{jobId}/reject":{"post":{"summary":"Reject a draft payout job (2026-09-12)","description":"Cancels the draft - status cancelled, approval.status rejected, every payment cancelled with the ERROR_MESSAGE \"Rejected: <reason>\" (\"Rejected by an approver.\" without a reason, or with an all-space one), allocation ids released for a resubmission; one payment.cancelled webhook per entry plus payout_job.rejected, recorded before the answer and delivered in the background (the answer does not wait for delivery). Same caller rules as approve, minus the self-approval rule: four eyes guards only the approve, so a creator whose role can approve (owner, admin) may reject its own draft, and a developer creator, whose role cannot, gets approval_not_permitted and withdraws the draft with cancel. The reject wins: from an approver who already approved the job (before its quorum), it replaces that approver's approve vote and rejects the job, so the job's approvals list one decision for them, reject. An all-space reason is stored as null.\n","tags":["Relay Gateway"],"parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","maxLength":500,"nullable":true}}}}}},"responses":{"200":{"description":"The cancelled job, its approvals listing this member's decision as reject","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutJob"}}}},"400":{"description":"`invalid_reason`"},"403":{"description":"`member_required` / `approval_not_permitted` (a suspended member's key is refused before, 401 `member_suspended`)"},"404":{"description":"`job_not_found`"},"409":{"description":"`job_not_awaiting_approval` (already rejected or cancelled, or after the quorum activated it: cancel it instead, with a key holding payouts:write (owner, admin or developer); an approver-role key cannot)"}}}},"/v1/jobs/{jobId}/cancel":{"post":{"summary":"Cancel a job (only while accepted/awaiting_onboarding/awaiting_approval)","description":"The job and every payment of it are cancelled together (one write), whatever the job's network. One payment.cancelled webhook per entry, recorded before the answer and delivered in the background - the answer does not wait for delivery. Audited as job.cancelled, naming the member and API key (2026-10-16).","tags":["Relay Gateway"],"parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Cancelled"},"404":{"description":"Job not found"},"409":{"description":"Job is no longer cancellable"}}}},"/v1/members/me":{"get":{"summary":"The team member the calling API key is issued to (2026-09-12)","tags":["Relay Gateway"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"404":{"description":"`resource_missing` - a legacy key, issued to no member"}}}},"/v1/members":{"post":{"summary":"Create a team member (2026-09-12)","description":"Needs the manage-team capability (owner or admin), or a legacy key while the tenant has no members yet (bootstrap). The owner role may only be granted by an owner. Email is unique per tenant.","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberInput"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"400":{"description":"`invalid_name` / `invalid_email` / `invalid_role` / `email_in_use` (Shape B)"},"403":{"description":"`member_required` / `capability_required` / `owner_role_required`"}}},"get":{"summary":"List the tenant's team members (paginated)","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["active","suspended"]}},{"name":"role","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MemberRole"}}],"responses":{"200":{"description":"Members, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Member"}},"has_more":{"type":"boolean"}}}}}}}}},"/v1/members/{id}":{"get":{"summary":"Retrieve a team member","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"404":{"description":"`resource_missing`"}}},"post":{"summary":"Update a member's name, role or status","description":"Manage-team capability. Changing an owner, or granting owner, needs an owner. The last active owner cannot be demoted or suspended (last_owner). A suspended member's keys are refused with 401 member_suspended until reactivated.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":200},"role":{"$ref":"#/components/schemas/MemberRole"},"status":{"type":"string","enum":["active","suspended"]}}}}}},"responses":{"200":{"description":"The updated member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"400":{"description":"Validation error (Shape B)"},"403":{"description":"`member_required` / `capability_required` / `owner_role_required`"},"404":{"description":"`resource_missing`"},"409":{"description":"`last_owner`"}}}},"/v1/members/{id}/api-keys":{"post":{"summary":"Mint an API key for a member (shown once)","description":"Manage-team capability. Same policy fields as POST /admin/tenants/{id}/api-keys; scopes default to the member's role ceiling and may never exceed it (scopes_exceed_role). The role narrows the key on every request, so a later demotion narrows this key too. The mode (since 2026-10-24) - `test` is a JSON boolean (400 `invalid_test` otherwise); left out or null, the key takes the calling key's mode; a sandbox (fp_test_) key never mints a live key (`test` false from it is 403 `live_key_required`, nothing minted), and a live key may mint a sandbox key. A live key needs the tenant approved by the operator (since 2026-10-16; 409 `tenant_not_approved` otherwise, nothing minted). An Idempotency-Key replay of a 2xx (the same calling key only) is audited as `api_key.replayed` (since 2026-10-24).","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiKeyPolicy"},{"type":"object","properties":{"test":{"type":"boolean","nullable":true,"description":"true mints an fp_test_ (sandbox) key, false an fp_live_ key; absent or null - the calling key's mode (since 2026-10-24; before, absent meant live). A JSON boolean only (400 invalid_test). false from an fp_test_ key - 403 live_key_required."},"expires_in_days":{"type":"number","nullable":true},"payroll_networks":{"type":"array","minItems":1,"items":{"type":"string"},"description":"2026-10-21: limit the key to these payroll networks, judged against the scopes it is minted with (the role's default when none are sent). Never widened later."}}}]}}}},"responses":{"201":{"description":"Created - the key object plus the one-time `apiKey`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithSecret"}}}},"400":{"description":"`invalid_test` (not a JSON boolean), `scopes_exceed_role`, `unsupported_network` / `invalid_request` (payroll_networks) and the API key policy errors (`invalid_ip_allowlist` among them)"},"403":{"description":"`member_required` / `capability_required` / `live_key_required` (`test` false from a sandbox key, since 2026-10-24; checked after the capability check and the member lookup, before the member's status and the body's other fields)"},"404":{"description":"`resource_missing`"},"409":{"description":"`member_suspended` / `tenant_not_approved` (a live key, and the tenant is not approved yet; nothing minted)"}}},"get":{"summary":"List a member's API keys (never the material)","description":"Paged like every list (audit O15, 2026-10-17; it answered every key), by `limit` and the two cursors only (no created window); without `limit` a page holds the largest page the relay serves (100).","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":100},"description":"Max items to return, 1-100 (default 100, the largest page)."},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"}],"responses":{"200":{"description":"Keys, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"`invalid_pagination_parameter`"},"404":{"description":"`resource_missing`"}}}},"/v1/payout-approval-policy":{"get":{"summary":"The tenant's payout approval policy (2026-09-12)","tags":["Relay Gateway"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutApprovalPolicy"}}}}}},"post":{"summary":"Set the payout approval policy (owner only)","description":"Decides at POST /v1/instructions time whether a job starts as a draft. approvals_required is snapshotted onto each draft at creation. Also settable by the operator via POST /admin/tenants/{id}/payout-approval-policy.","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutApprovalPolicyInput"}}}},"responses":{"200":{"description":"The stored policy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutApprovalPolicy"}}}},"400":{"description":"`invalid_policy_mode` / `invalid_approvals_required` / `invalid_thresholds`"},"403":{"description":"`member_required` / `capability_required` (only an owner)"}}}},"/v1/recipients":{"post":{"summary":"Create a recipient (a named payee with one payout address per network)","description":"Added 2026-09-12. Each address is validated for its network and stored in the chain's canonical form. Scope payouts:write. Recorded in the audit log as recipient.created.","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipientInput"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Recipient"}}}},"400":{"description":"`invalid_name` / `invalid_type` / `invalid_email` / `invalid_metadata` / `invalid_network` / `invalid_address` (details[] names the index and network) / `invalid_addresses` (Shape B)"}}},"get":{"summary":"List the calling tenant's recipients (paginated)","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["active","archived"]}}],"responses":{"200":{"description":"Recipients, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Recipient"}},"has_more":{"type":"boolean"}}}}}}}}},"/v1/recipients/{id}":{"get":{"summary":"Retrieve a recipient","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Recipient"}}}},"404":{"description":"`resource_missing` - no such recipient for this tenant"}}},"post":{"summary":"Update a recipient in place","description":"Only the fields given change; `email` set to null clears it; an `addresses` array replaces the whole set. Recorded as recipient.updated with the before/after addresses.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipientInput"}}}},"responses":{"200":{"description":"The updated recipient","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Recipient"}}}},"400":{"description":"Validation error (Shape B)"},"404":{"description":"`resource_missing`"}}}},"/v1/recipients/{id}/addresses/{network}":{"put":{"summary":"Set (or replace) one network's payout address","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"path","required":true,"schema":{"type":"string","enum":["ethereum","base","polygon","arbitrum","bnb","avalanche","robinhood","linea","optimism","monad","megaeth","hyperevm","tron","solana","bitcoin","litecoin","aptos","ton","sui"]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["address"],"properties":{"address":{"type":"string"},"label":{"type":"string","nullable":true,"maxLength":100}}}}}},"responses":{"200":{"description":"The updated recipient","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Recipient"}}}},"400":{"description":"`invalid_network` / `invalid_address` (Shape B)"},"404":{"description":"`resource_missing`"}}},"delete":{"summary":"Remove one network's payout address","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The updated recipient","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Recipient"}}}},"404":{"description":"`resource_missing` - no such recipient, or it has no address on that network"}}}},"/v1/recipients/{id}/archive":{"post":{"summary":"Archive a recipient (kept for history; can no longer be paid)","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The recipient, now archived","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Recipient"}}}},"404":{"description":"`resource_missing`"}}}},"/v1/recipients/{id}/activate":{"post":{"summary":"Re-activate an archived recipient","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The recipient, active again","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Recipient"}}}},"404":{"description":"`resource_missing`"}}}},"/v1/recipients/{id}/payouts":{"get":{"summary":"List every payout that named this recipient (paginated, newest first)","description":"The same payout shape as GET /v1/payouts/{id}, paginated on the payout's own id. Payouts older than the completed-job retention window are gone, so a cursor that names no payout of this recipient answers an empty page (has_more false) rather than the first page - a walk whose cursor row was deleted ends instead of restarting (List pagination in the API guide gives each list's rule). A payout is a mass-payout entry only; a client-signed payroll item that paid the recipient is read with GET /v1/payroll-batches/{id}/items.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"}],"responses":{"200":{"description":"Payouts, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Payout"}},"has_more":{"type":"boolean"}}}}}},"404":{"description":"`resource_missing`"}}}},"/v1/payouts/{id}":{"get":{"summary":"Retrieve a single mass-payout entry as its own resource (added 2026-09-03)","description":"Addressed by your own ALLOCATION_ID, not the job id. A thin, real view over the same job_payments row GET /v1/jobs/{jobId}'s own payments[] array already includes - not a second money-moving system (mass payout pays from the tenant's own funds but is signed by FastPay's relay - interim; see /v1/payroll-batches). An ALLOCATION_ID that a cancel or a rejection released and that was resubmitted names the resubmitted payout.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Payout","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Payout"}}}},"404":{"description":"No such payout for this tenant"}}}},"/v1/jobs/{jobId}/payouts":{"get":{"summary":"List every payout entry for one job (added 2026-09-03)","tags":["Relay Gateway"],"parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Payouts","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"has_more":{"type":"boolean","enum":[false],"description":"Not paged: always false (audit O15, 2026-10-17; additive)."},"data":{"type":"array","items":{"$ref":"#/components/schemas/Payout"}}}}}}},"404":{"description":"Job not found"}}}},"/v1/accounting/{provider}/connect":{"get":{"summary":"Start an OAuth connection to a cloud accounting provider (added 2026-09-09)","description":"Only xero/quickbooks/zoho/sage are connectable - see this file's own Accounting Integrations section in payment-gateway-api.md for why the rest of the internal provider list isn't exposed here. Returns a real, single-use state persisted server-side (10 min TTL) - the actual CSRF protection, not decorative. Live keys only (2026-10-24), as every /v1/accounting route.\n","tags":["Relay Gateway"],"parameters":[{"name":"provider","in":"path","required":true,"schema":{"type":"string","enum":["xero","quickbooks","zoho","sage"]}},{"name":"redirect_uri","in":"query","required":true,"description":"Required since 2026-10-24. Your own https page the provider sends the browser back to with code and state; it posts both to POST /v1/accounting/{provider}/callback with your API key (the relay serves no page for the redirect). Register the same URL with the provider: it is stored with the state and sent exactly as given. One https URL with a host, no user info, no fragment, printable ASCII, at most 2048 characters.","schema":{"type":"string","format":"uri","maxLength":2048}}],"responses":{"200":{"description":"Authorization URL to redirect the tenant to","content":{"application/json":{"schema":{"type":"object","properties":{"provider":{"type":"string"},"authorizationUrl":{"type":"string"},"state":{"type":"string"}}}}}},"400":{"description":"invalid_provider; parameter_missing (no redirect_uri, 2026-10-24); invalid_url (a redirect_uri that is not one https URL with a host, without user info or a fragment, in printable ASCII, at most 2048 characters, or a repeated parameter; 2026-10-24)"},"403":{"description":"live_key_required (2026-10-24) - a sandbox (fp_test_) key: the tenant's accounting connection, its OAuth states and mapping have no sandbox copy. Nothing is read or changed"},"501":{"description":"Provider not configured on this deployment (answered before redirect_uri is read)"}}}},"/v1/accounting/{provider}/callback":{"post":{"summary":"Complete an OAuth connection (added 2026-09-09)","tags":["Relay Gateway"],"parameters":[{"name":"provider","in":"path","required":true,"schema":{"type":"string","enum":["xero","quickbooks","zoho","sage"]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code","state"],"properties":{"code":{"type":"string"},"state":{"type":"string","description":"Must match the state issued to this exact tenant+provider by GET .../connect - unexpired, not already used"}}}}}},"responses":{"200":{"description":"Connected","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"provider":{"type":"string"},"connected":{"type":"boolean"},"expiresAt":{"type":"string","format":"date-time"}}}}}},"400":{"description":"missing_code, missing_state, or invalid_state (expired/already used/wrong tenant)"},"403":{"description":"live_key_required (2026-10-24) - a sandbox (fp_test_) key: the tenant's accounting connection, its OAuth states and mapping have no sandbox copy. Nothing is read or changed"},"501":{"description":"Provider not configured on this deployment"}}}},"/v1/accounting/{provider}/mappings":{"get":{"summary":"Get accounting connection status and General Ledger mappings (added 2026-09-09)","tags":["Relay Gateway"],"parameters":[{"name":"provider","in":"path","required":true,"schema":{"type":"string","enum":["xero","quickbooks","zoho","sage"]}}],"responses":{"200":{"description":"Status and mappings (never includes a token)","content":{"application/json":{"schema":{"type":"object","properties":{"provider":{"type":"string"},"connected":{"type":"boolean"},"expiresAt":{"type":"string","format":"date-time","nullable":true},"mapping":{"type":"object"}}}}}},"403":{"description":"live_key_required (2026-10-24) - a sandbox (fp_test_) key: the tenant's accounting connection, its OAuth states and mapping have no sandbox copy. Nothing is read or changed"}}},"post":{"summary":"Set General Ledger mappings (added 2026-09-09)","tags":["Relay Gateway"],"parameters":[{"name":"provider","in":"path","required":true,"schema":{"type":"string","enum":["xero","quickbooks","zoho","sage"]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["mapping"],"properties":{"mapping":{"type":"object","description":"See relay/accounting/configMapper.js for resolvable keys. A CURRENCY or CHAIN:CURRENCY key matches in any letter case (USDe, eth:usdc), and a CHAIN:CURRENCY entry wins over a CURRENCY entry whole (the keys it leaves unset take the defaults, not the CURRENCY entry's); for a key written twice in different cases, the upper-case one wins, else the one written first. A gateway entry may also name feeAccountCode (the gateway fee line's account, default 5030) and currencyCode (the ISO 4217 code a standalone payment request in that asset is booked under, e.g. USD for USDC; required to sync one). currencyCode is the one key read from the CHAIN:CURRENCY entry, else the CURRENCY entry (an asset's code is the same on every network). The mapping is stored and returned as sent."}}}}}},"responses":{"200":{"description":"Saved"},"400":{"description":"invalid_mapping"},"403":{"description":"live_key_required (2026-10-24) - a sandbox (fp_test_) key: the tenant's accounting connection, its OAuth states and mapping have no sandbox copy. Nothing is read or changed"}}}},"/v1/accounting/{provider}/sync":{"post":{"summary":"Manually sync a completed disbursement or paid checkout payment (added 2026-09-09)","description":"type disbursement is a live mass-payout payout (POST /v1/instructions), by its ALLOCATION_ID, pushed as a bill and a payment; a sandbox payout is never pushed, nor (since 2026-10-24) a sandbox payment request or checkout session, and a sandbox key is refused first (403 live_key_required). An ALLOCATION_ID released by a cancel or a rejection and resubmitted names the resubmitted payout. Client-signed payroll items are not synced to accounting (there is no sync type for them yet).","tags":["Relay Gateway"],"parameters":[{"name":"provider","in":"path","required":true,"schema":{"type":"string","enum":["xero","quickbooks","zoho","sage"]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["type","id"],"properties":{"type":{"type":"string","enum":["disbursement","gateway"]},"id":{"type":"string","description":"ALLOCATION_ID for type=disbursement; for type=gateway the payment request id (pr_..., or a checkout session cs_..., which is its payment request id too), never a pay_... payment id"}}}}}},"responses":{"200":{"description":"Sync receipt. A gateway sync of a checkout session books an invoice in the checkout's fiat currency - a sale line at the gross amount and a negative 'FastPay gateway fee' line on the mapping's feeAccountCode (default 5030) - and a payment of the net settlement; a standalone payment request books its own amount under the ISO 4217 currencyCode mapped for its asset. Every amount is a decimal string."},"400":{"description":"invalid_request, not_connected, invalid_type, payment_not_paid (a gateway payment request that is neither paid nor paid_after_expiry), or currency_not_mapped (a standalone payment request whose asset has no ISO 4217 currencyCode in the gateway mapping; nothing is pushed)"},"403":{"description":"live_key_required (2026-10-24) - a sandbox (fp_test_) key: the tenant's accounting connection, its OAuth states and mapping have no sandbox copy. Nothing is read or changed"},"404":{"description":"payout_not_found / payment_request_not_found - no such payout or payment request for this tenant (in the live mode)"},"409":{"description":"payout_not_completed (2026-10-16) - a disbursement whose payout is not completed with a transaction hash (queued, in process, failed or cancelled); sandbox_payout_not_synced (2026-10-16) - a sandbox payout (a test key's), which moved no money; sandbox_payment_not_synced (2026-10-24) - likewise a sandbox payment request or checkout session. Nothing is pushed. With the 403 first, a live key's sync meets neither sandbox refusal"},"501":{"description":"Provider not configured on this deployment"}}}},"/v1/ecommerce/{platform}/settings":{"post":{"summary":"Register a webhook secret and platform config (added 2026-09-09)","tags":["Relay Gateway"],"parameters":[{"name":"platform","in":"path","required":true,"schema":{"type":"string","enum":["whmcs","shopify","woocommerce","magento","prestashop","wix","squarespace","bigcommerce","opencart"]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"webhook_secret":{"type":"string"},"config":{"type":"object","description":"Platform-specific, e.g. a Shopify shop domain"}}}}}},"responses":{"200":{"description":"Saved","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"platform":{"type":"string"},"webhook_url":{"type":"string","description":"Register this exact URL on the platform's own side"}}}}}},"400":{"description":"invalid_platform, invalid_webhook_secret, or invalid_config"},"403":{"description":"live_key_required (2026-10-24) - a sandbox (fp_test_) key: the platform settings are one row for both modes. Nothing is read or changed"}}},"get":{"summary":"Get e-commerce connection status (added 2026-09-09)","tags":["Relay Gateway"],"parameters":[{"name":"platform","in":"path","required":true,"schema":{"type":"string","enum":["whmcs","shopify","woocommerce","magento","prestashop","wix","squarespace","bigcommerce","opencart"]}}],"responses":{"200":{"description":"Status (never includes the secret)","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string"},"connected":{"type":"boolean"},"config":{"type":"object"},"webhook_url":{"type":"string"}}}}}},"400":{"description":"invalid_platform"},"403":{"description":"live_key_required (2026-10-24) - a sandbox (fp_test_) key: the platform settings are one row for both modes. Nothing is read or changed"}}}},"/v1/ecommerce/{platform}/orders":{"post":{"summary":"Turn platform order data into a real checkout session (added 2026-09-09)","description":"Request body shape is platform-specific - see the adapter's own toCheckoutSessionPayload (relay/ecommerce/*.js), e.g. WHMCS expects invoiceId/amount/currency/returnUrl, plus an optional cancelUrl, network and settlementCurrency. An order that names no network settles on ethereum, one that names no settlementCurrency in USDC; with no cancel URL the session's cancel_url is its success URL. Customer details in the order are not read: the session carries no customer and no callback URL. A sandbox key may call it (its session is a sandbox one); the platform settings, which have no sandbox copy, are not read for it.\n","tags":["Relay Gateway"],"parameters":[{"name":"platform","in":"path","required":true,"schema":{"type":"string","enum":["whmcs","shopify","woocommerce","magento","prestashop","wix","squarespace","bigcommerce","opencart"]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"201":{"description":"Checkout session created - same shape as POST /v1/checkout/sessions","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"invalid_platform; parameter_missing (a field the platform's order needs - its id, amount, currency or return URL - the message names it); the checks of POST /v1/checkout/sessions on the session the order resolves to (invalid_amount - including more than 2 decimal places, trailing zeros counted - invalid_currency, invalid_url, invalid_network, invalid_settlement_currency); missing_recipient"}}}},"/v1/ecommerce/{platform}/webhook/{tenantId}":{"post":{"summary":"Inbound webhook receiver, called by the e-commerce platform itself (added 2026-09-09)","description":"Unauthenticated (no API key - the caller is the platform, not the tenant); tenantId in the path plus a valid HMAC signature over the real raw request bytes is what scopes and authorizes the request. Verified webhooks are parsed and durably logged, but do not automatically trigger a checkout session or mark a payment paid yet (see Known gaps in payment-gateway-api.md) - use POST /v1/ecommerce/{platform}/orders for that instead.\n","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"platform","in":"path","required":true,"schema":{"type":"string","enum":["whmcs","shopify","woocommerce","magento","prestashop","wix","squarespace","bigcommerce","opencart"]}},{"name":"tenantId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"Verified and processed - { received: true }"},"400":{"description":"invalid_json or invalid_payload"},"401":{"description":"invalid_signature (missing, wrong, or unparseable)"},"404":{"description":"invalid_platform, or this tenant has not configured that platform"}}}},"/v1/payment-requests":{"post":{"summary":"Create a checkout payment request (mature path, fires a `paid` webhook)","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentRequestCreate"}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Two layers guard the key. Inside the retention window (RELAY_IDEMPOTENCY_RETENTION_MS, default 24 hours) the API layer replays the first answer to a repeat with the same key and body from the same API key, answers a different body 409 idempotency_parameter_mismatch and another API key of the tenant 409 idempotency_key_in_use (both Shape B); the stored answer is kept encrypted with a key derived from that API key (since 2026-10-24), and one an earlier release stored unencrypted is never replayed (409 idempotency_key_in_use until it expires; the first request ran, so look up its result before sending it again under a new key). The payment request itself keeps the key for good, per tenant and mode. After the window a repeat runs the route again, its body checked first: the same body answers 200 with the existing request only while that request's expiresAt is still ahead (after it, 400 invalid_payment_request), and a different body 409 idempotency_key_conflict (Shape A). Without a recipient the body is compared on the tenant's payout wallet at the time, so after that wallet changes the same body is idempotency_key_conflict. Both layers' slugs are kept (docs/errors.md).","schema":{"type":"string","maxLength":200}}],"responses":{"200":{"description":"Idempotent replay of a prior creation"},"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentRequest"}}}},"400":{"description":"Validation error (Shape A envelope)"},"409":{"description":"The key was used with a different body: idempotency_parameter_mismatch inside the retention window (Shape B), idempotency_key_conflict after it (Shape A); idempotency_key_in_use when another API key of the tenant sent the key first, inside the window (Shape B), and for an answer an earlier release stored unencrypted, never replayed; idempotency_key_in_progress while the first is still running"}}},"get":{"summary":"List payment requests (added 2026-09-12)","description":"Cursor-paginated, newest first, this tenant only. Each item is exactly what GET /v1/payment-requests/{requestId} returns (checkoutUrl and qr included). A checkout session's own settlement row (id cs_...) is a payment request too and is listed here; tell them apart by the id prefix, or use GET /v1/checkout/sessions.\n","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","paid","paid_after_expiry"]}},{"name":"network","in":"query","schema":{"type":"string"},"description":"One of the checkout networks GET /v1/networks lists."},{"name":"payment_link_id","in":"query","schema":{"type":"string"},"description":"Only the requests a given payment link opened."}],"responses":{"200":{"description":"A page of payment requests","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PaymentRequest"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"invalid_pagination_parameter (limit out of range, or both cursors given) | invalid_filter_parameter (unknown status/network, malformed created window)"},"401":{"description":"Missing/invalid API key"}}}},"/v1/payment-requests/{requestId}":{"get":{"summary":"Retrieve a payment request (tenant-authenticated, full object)","tags":["Relay Gateway"],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Payment request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentRequest"}}}},"404":{"description":"Not found"}}}},"/v1/payment-requests/{requestId}/public":{"get":{"summary":"Retrieve the public (unauthenticated) view of a payment request","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public view","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentRequestPublic"}}}},"404":{"description":"Not found"},"429":{"description":"Rate limited"}}}},"/v1/payment-requests/{requestId}/intent":{"post":{"summary":"Build the unsigned transaction(s) for a connected wallet to sign","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["payerAddress"],"properties":{"payerAddress":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction intent (Ethereum or Tron shaped). On Tron, approval-only when a TRC20 allowance is needed first (approvalOnly true, transaction null, 2026-09-25) - broadcast the approval, wait for it on a full node, then call this route again for the disburse transaction.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentIntent"}}}},"400":{"description":"Invalid payerAddress","or network not yet live":null},"404":{"description":"Not found"},"409":{"description":"Already paid or expired (payment_request_not_payable; since 2026-10-18 also a request paid while this intent was being built - the authorization is never handed out), an x402 payment of the request being settled or confirmed and not yet recorded (x402_settlement_in_progress, 2026-10-18; no authorization was handed out, retry shortly), or the chain FastPayDisburser does not allowlist this token yet (currency_not_allowlisted, 2026-09-12 - its owner must call setTokenAllowed first), or this deployment has no token for the currency on that network (currency_not_allowlisted too; no setTokenAllowed call fixes it - the operator configures the token), or the request was opened from a payment link whose max_uses its other paid requests have used up (payment_link_unavailable, body carries reason: exhausted; nothing is signed) Also tenant_not_active (2026-10-24): the merchant's tenant is suspended; nothing is signed, live or sandbox, and confirm keeps working."},"429":{"description":"Rate limited"},"503":{"description":"Nothing was signed (error_type api_error). service_paused: the operator's kill switch is on - no new checkout intent is signed until it is off (confirm keeps working). A chain adapter's own typed refusal, under its slug: disburser_paused (the network's disburser is paused on-chain), payment_authorizer_mismatch (its on-chain payment authorizer is not the key FastPay signs with), and the deployment gates of FastPay's payroll authorizer, which checkout co-signs with - Aptos, Sui and TON payroll_network_not_configured (no audited deployment pinned for the live network; on TON also a jetton with no wallet-code pin there - USDe and FDUSD on mainnet, every jetton on testnet), payroll_deployment_not_audited and authorizer_network_mismatch (the authorizer key is not that network's); Solana authorizer_cluster_mismatch (a mainnet authorizer key never signs on another cluster); TON chain_unavailable (a jetton wallet that does not verify). Or signing_busy (2026-10-15): FastPay's signing service cannot co-sign within a few seconds, so the request is refused at once rather than kept waiting; a `Retry-After` header says when to retry."}}}},"/v1/payment-requests/{requestId}/confirm":{"post":{"summary":"Confirm a broadcast transaction hash against on-chain state","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"boc":{"type":"string","description":"TON only (2026-09-11): the base64 BOC a TonConnect wallet returned from sendTransaction. The relay derives the message hash from it and uses that as the reference; 400 invalid_boc if unparseable. Required for a TON jetton payment (USDT, USDe, FDUSD; 2026-10-19): the relay follows it to the merchant's credit, so a jetton confirm without it, or with one that is not a wallet's signed external message, is 400 invalid_boc. Ignored on other networks."},"txHash":{"type":"string","description":"32-byte hex, 0x-prefixed for Ethereum, unprefixed for Tron. On TON it confirms a native TON payment only (a jetton payment needs boc)."}}}}}},"responses":{"200":{"description":"Confirmation result","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["paid","paid_after_expiry","pending","failed"]},"txHash":{"type":"string"},"reason":{"type":"string","description":"Added 2026-09-03. Present only when status is \"failed\" - names why (tx_reverted/wrong_contract/no_disbursed_event/amount_mismatch/recipient_mismatch/token_mismatch). A classic underpayment/overpayment cannot reach amount_mismatch through the normal flow - the disburser contract enforces the exact quoted amount, so a wrong amount reverts outright (tx_reverted) rather than landing here.","enum":["tx_reverted","wrong_contract","no_disbursed_event","amount_mismatch","recipient_mismatch","token_mismatch","sandbox_forced_failure"]}}}}}},"400":{"description":"invalid_tx_hash (a txHash this network does not use), invalid_boc (TON: a boc that is not a parseable BOC; a jetton payment confirmed without a boc, or with one that is not a wallet's signed external message), or network not yet live"},"404":{"description":"Not found"},"409":{"description":"Already finalized"},"429":{"description":"Rate limited"},"503":{"description":"chain_unavailable (2026-10-19): a read failed while a TON jetton payment was followed to its delivery, or the TON disburser's is_allocation_used read failed (native TON too; never taken as unused, also on an expired request); nothing was recorded, retry. Or a chain adapter's own typed refusal, under its slug."}}}},"/v2/x402/supported":{"get":{"summary":"x402 facilitator - the kinds (scheme x CAIP-2 network) this relay settles and its signer addresses","tags":["Relay Gateway"],"security":[],"responses":{"200":{"description":"SupportedResponse (x402 v2)","content":{"application/json":{"schema":{"type":"object","properties":{"kinds":{"type":"array","items":{"type":"object","properties":{"x402Version":{"type":"integer","enum":[2]},"scheme":{"type":"string","enum":["exact"]},"network":{"type":"string","description":"CAIP-2 id, e.g. eip155:8453 or solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"},"extra":{"type":"object","description":"Scheme-specific; a Solana kind carries feePayer, which a client must copy into PaymentRequirements.extra"}}}},"extensions":{"type":"array","items":{"type":"string"}},"signers":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"CAIP-2 pattern -> the addresses settlements are broadcast from"}}}}}}}}},"/v2/x402/verify":{"post":{"summary":"x402 facilitator - verify a PaymentPayload against PaymentRequirements without settling","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/X402FacilitatorRequest"}}}},"responses":{"200":{"description":"VerifyResponse (x402 v2)","content":{"application/json":{"schema":{"type":"object","properties":{"isValid":{"type":"boolean"},"invalidReason":{"type":"string","description":"Present when isValid is false - the spec's / reference facilitator's reason vocabulary"},"payer":{"type":"string"},"invalidMessage":{"type":"string","description":"Optional human-readable detail some chains add to a refusal (which field or value was refused); branch on invalidReason, never on this"}}}}}},"400":{"description":"Malformed body (relay error envelope; code is the x402 reason","e.g. invalid_payment_requirements)":null},"401":{"description":"Missing or invalid API key"},"403":{"description":"The key lacks the checkout scope"},"409":{"description":"x402_sandbox_not_supported - a test key: x402 settles real transfers"},"429":{"description":"Per-tenant x402 limit (RELAY_X402_REQUESTS_PER_MINUTE)"}}}},"/v2/x402/settle":{"post":{"summary":"x402 facilitator - re-verify, broadcast and confirm a payment; the facilitator pays gas","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/X402FacilitatorRequest"}}}},"responses":{"200":{"description":"SettleResponse (x402 v2). success=false carries errorReason; transaction is empty only when nothing was broadcast. settlement_pending (with the transaction hash) means broadcast but not confirmed in the window - the relay keeps checking and never re-broadcasts; settling the same payment again waits for that outcome again. A payment's success is answered once: settling it again answers duplicate_settlement (EVM: invalid_exact_evm_nonce_already_used) with its transaction. An EVM authorization whose own transaction reverted is final (invalid_exact_evm_transaction_failed with the hash on every retry; the payer signs a new one). facilitator_busy (Solana, Algorand, Aptos, Hedera, Stellar) means the settlement waited too long for its network's queue and nothing was signed - retry. facilitator_paused means the operator's kill switch is on: nothing new is signed or broadcast until it is off (verify keeps answering); a payment already broadcast or settled still answers settlement_pending, its success or duplicate_settlement with its transaction.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"transaction":{"type":"string","description":"The transaction id; empty only when nothing was broadcast (settlement_pending, on-chain failures and a repeated settle of a paid authorization carry it)"},"network":{"type":"string"},"payer":{"type":"string"},"errorReason":{"type":"string"}}}}}},"400":{"description":"Malformed body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"The key lacks the checkout scope"},"409":{"description":"x402_sandbox_not_supported - a test key: x402 settles real transfers"},"429":{"description":"Per-tenant x402 limit (RELAY_X402_REQUESTS_PER_MINUTE)"}}}},"/v1/x402/pay/{requestId}":{"get":{"summary":"Pay a pending payment request over x402 (challenge)","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}},{"name":"PAYMENT-SIGNATURE","in":"header","required":false,"schema":{"type":"string"},"description":"base64-encoded x402 v2 PaymentPayload. Absent = the 402 challenge."}],"responses":{"200":{"description":"Paid: PAYMENT-RESPONSE header (base64 SettleResponse) and { id, status, txHash, payer, network }; or the already-paid record; with duplicateSettlement { transaction, payer } when the request was paid through another channel while this settlement landed (a second payment to refund)."},"400":{"description":"Malformed PAYMENT-SIGNATURE"},"402":{"description":"PAYMENT-REQUIRED header (base64 PaymentRequired; extra.paymentFlow = upfront); body = the same object plus the relay error triad. Also the answer to a payment that failed to verify or settle (reason in error / error_slug), and settlement_pending (PAYMENT-RESPONSE carries the transaction) while a settlement for the request is unresolved, or confirmed but not yet recorded on it."},"404":{"description":"Not found"},"409":{"description":"Not payable over x402 (x402_native_currency_not_supported, x402_sandbox_not_supported, x402_network_not_enabled, x402_currency_not_supported, x402_recipient_token_account_missing on Solana, x402_recipient_store_frozen / x402_recipient_invalid on Aptos), x402_settlement_in_progress, x402_checkout_intent_outstanding (2026-10-18: a checkout intent for the request was handed out within RELAY_CHECKOUT_AUTHORIZATION_VALIDITY_SECONDS and one minute more, and can still be paid; nothing is advertised or settled until the time the message names. After it, also when the relay could not check on chain whether that intent was paid - retry shortly; one that was is recorded and answered 200), payment_link_unavailable (a request opened from a payment link whose max_uses its other paid requests have used up; nothing is advertised or settled), or not pending tenant_not_active (2026-10-24): the merchant's tenant is suspended - nothing is advertised or settled, with or without PAYMENT-SIGNATURE; a settlement already in flight still answers 402 settlement_pending."},"410":{"description":"Expired, or fewer than RELAY_X402_SELLER_MIN_TIMEOUT_SECONDS (15) left"},"429":{"description":"Rate limited"},"500":{"description":"RELAY_PUBLIC_BASE_URL is not configured (resource.url is never built from the Host header; nothing is advertised or settled), or a confirmed settlement could not be recorded on the request - the relay's sweep records it and the request is paid then"}}},"post":{"summary":"Pay a pending payment request over x402 (same as GET; POST for clients that retry with the signature as a POST)","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}},{"name":"PAYMENT-SIGNATURE","in":"header","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"See GET"},"400":{"description":"Malformed PAYMENT-SIGNATURE"},"402":{"description":"See GET"},"404":{"description":"Not found"},"409":{"description":"Not payable over x402, x402_settlement_in_progress, x402_checkout_intent_outstanding, payment_link_unavailable, tenant_not_active (2026-10-24, a suspended merchant), or not pending (see GET)"},"410":{"description":"Expired"},"429":{"description":"Rate limited"}}}},"/v1/payment-requests/{requestId}/qr":{"get":{"summary":"The payment request as a QR code (SVG) - the hosted checkout URL, or a Solana Pay transaction request","description":"Added 2026-09-11. Unauthenticated like the other public checkout routes (the id is the secret). `type=checkout` (default) encodes the hosted checkout page URL - works for every network, since a mobile wallet's in-app browser injects window.ethereum into that page; `type=solana-pay` encodes the Solana Pay transaction-request URL (solana only, never a sandbox request). The same payloads are described on the public view / payment_request object's `qr` field, on the same origin (a sandbox request's on RELAY_SANDBOX_PUBLIC_BASE_URL when set). Deterministic per id/type - served with Cache-Control private, max-age=300.\n","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}},{"name":"type","in":"query","schema":{"type":"string","enum":["checkout","solana-pay"],"default":"checkout"}},{"name":"size","in":"query","description":"Rendered width/height in pixels, clamped to 128-1024 (default 256). The SVG scales crisply at any size.","schema":{"type":"integer","minimum":128,"maximum":1024,"default":256}}],"responses":{"200":{"description":"The QR code","content":{"image/svg+xml":{"schema":{"type":"string"}}}},"400":{"description":"invalid_qr_type or invalid_qr_size"},"404":{"description":"Not found"},"409":{"description":"qr_type_not_available - the type does not apply to this request (a solana-pay code for a non-Solana or sandbox request)"},"429":{"description":"Rate limited"}}}},"/v1/payment-requests/{requestId}/solana-pay":{"get":{"summary":"Solana Pay transaction request - what the wallet displays (label, icon)","description":"Added 2026-09-11. The endpoint a `solana:` QR points a Solana Pay wallet at (https://docs.solanapay.com/spec) - the WALLET calls this, not a merchant integration. CORS-open on purpose. Only for a `solana` payment request; 404 otherwise. Error bodies carry the spec's `message` field alongside the usual error_type/error_slug/error.\n","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"What the wallet shows before asking the customer to approve","content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string","description":"The merchant's tenant name (falls back to FastPay)."},"icon":{"type":"string","format":"uri","description":"Absolute URL of an SVG icon served by the relay."}}}}}},"404":{"description":"Not a Solana payment request"},"429":{"description":"Rate limited"}}},"post":{"summary":"Solana Pay transaction request - the unsigned transaction for the scanning wallet","description":"Returns the SAME unsigned transaction POST .../intent builds for the browser SDK (FastPay-signed authorization, fee contract and all), re-shaped to the Solana Pay spec's {transaction, message}. The wallet signs and broadcasts it itself and never calls .../confirm - the relay's Solana payment detection sweep finds the transaction by the reference key embedded in it and confirms it through the normal path.\n","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account"],"properties":{"account":{"type":"string","description":"The paying wallet's base58 public key."}}}}}},"responses":{"200":{"description":"Unsigned transaction + a message for the wallet to display","content":{"application/json":{"schema":{"type":"object","properties":{"transaction":{"type":"string","description":"Base64-serialized unsigned VersionedTransaction (legacy message)."},"message":{"type":"string","example":"Pay 1.5 SOL for order-4471"}}}}}},"400":{"description":"account missing or not a valid Solana public key"},"404":{"description":"Not a Solana payment request"},"409":{"description":"payment_request_not_payable (already paid, expired, or a sandbox request; also paid while the intent was being built, 2026-10-18) | x402_settlement_in_progress (2026-10-18: an x402 payment of the request is being settled; no authorization is handed out, retry shortly) | currency_not_allowlisted (accepted currency with no RELAY_<TOKEN>_ADDRESS_SOLANA on this deployment, or not allowlisted on the disburser) | payment_link_unavailable (a request opened from a payment link whose max_uses is used up; nothing is signed) | tenant_not_active (2026-10-24: the merchant's tenant is suspended; nothing is signed)"},"429":{"description":"Rate limited"},"503":{"description":"Nothing was signed. service_paused (the operator's kill switch is on: no new intent is signed), signing_busy (2026-10-15: FastPay's signing service cannot co-sign within a few seconds; a `Retry-After` header says when to retry), or the Solana adapter's typed refusals: payment_authorizer_mismatch, authorizer_cluster_mismatch (a mainnet authorizer key never signs on another cluster)."}}},"options":{"summary":"CORS preflight for the Solana Pay endpoint","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Allowed origins/methods/headers in the CORS response headers"}}}},"/v1/payment-links":{"post":{"summary":"Create a reusable payment link","description":"Added 2026-09-11. A shareable/printable link (fixed or customer-entered amount, any checkout network) that opens a fresh payment_request for every visitor - see the \"Payment links\" section of docs/payment-gateway-api.md. A link created with a test key is a sandbox link. Response objects are snake_case with object \"payment_link\".\n","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLinkCreate"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLink"}}}},"400":{"description":"error_slug: invalid_network | invalid_currency | invalid_amount | invalid_min_amount | invalid_max_amount | invalid_recipient | missing_recipient | invalid_reference_prefix | invalid_session_ttl | invalid_max_uses | invalid_expires_at | invalid_metadata | invalid_callback_url"},"401":{"description":"Missing/invalid API key"}}},"get":{"summary":"List payment links","description":"Cursor-paginated (limit/starting_after/has_more), newest first, this tenant only.","tags":["Relay Gateway"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"starting_after","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/ListEndingBefore"}],"responses":{"200":{"description":"A page of payment links","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PaymentLink"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"invalid_pagination_parameter"}}}},"/v1/payment-links/{linkId}":{"get":{"summary":"Retrieve a payment link","tags":["Relay Gateway"],"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The payment link","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLink"}}}},"404":{"description":"payment_link_not_found (including another tenant's link)"}}}},"/v1/payment-links/{linkId}/deactivate":{"post":{"summary":"Deactivate a payment link (stops new opens; requests already opened are untouched)","tags":["Relay Gateway"],"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The updated link (status inactive)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLink"}}}},"404":{"description":"payment_link_not_found"}}}},"/v1/payment-links/{linkId}/activate":{"post":{"summary":"Re-activate a payment link","tags":["Relay Gateway"],"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The updated link (status active)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLink"}}}},"404":{"description":"payment_link_not_found"}}}},"/v1/payment-links/{linkId}/public":{"get":{"summary":"Public view of a payment link - what its page renders","description":"Unauthenticated; the unguessable id is the credential. Never includes metadata, callback_url, the tenant id or the use count. Same per-IP limit as the other public checkout routes.","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public link data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLinkPublic"}}}},"404":{"description":"payment_link_not_found"},"429":{"description":"Rate limited"}}}},"/v1/payment-links/{linkId}/sessions":{"post":{"summary":"Open a payment link for one visitor - creates that visitor's own payment request","description":"Unauthenticated. `amount` only for a link without a fixed amount. The created request is an ordinary payment_request (paymentLinkId set, reference \"<prefix>-<8 hex>\", expiring session_ttl_seconds later) - pay it through the hosted checkout at checkout_url, or the intent/confirm routes.\n","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"amount":{"type":"string","description":"Decimal string; required for a customer-entered-amount link, must not differ from a fixed amount."}}}}}},"responses":{"201":{"description":"Opened","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentLinkSession"}}}},"400":{"description":"error_slug: invalid_amount | amount_below_minimum | amount_above_maximum | amount_not_customizable (a fixed-amount link given a different amount, or one with more decimals than the token has)"},"404":{"description":"payment_link_not_found"},"409":{"description":"tenant_not_active (2026-10-24) - the merchant's tenant is suspended, checked before the link's own state; no request is opened | payment_link_unavailable - body carries reason: inactive | expired | exhausted"},"429":{"description":"Rate limited"}}}},"/v1/payment-links/{linkId}/qr":{"get":{"summary":"The payment link as an SVG QR code","description":"type \"checkout\" (the link page URL, any network) or \"solana-pay\" (a live, fixed-amount Solana link only - 409 qr_type_not_available otherwise); size 128-1024. Same contract as GET /v1/payment-requests/{requestId}/qr.","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}},{"name":"type","in":"query","schema":{"type":"string","enum":["checkout","solana-pay"],"default":"checkout"}},{"name":"size","in":"query","schema":{"type":"integer","minimum":128,"maximum":1024,"default":256}}],"responses":{"200":{"description":"image/svg+xml","content":{"image/svg+xml":{"schema":{"type":"string"}}}},"400":{"description":"invalid_qr_type | invalid_qr_size"},"404":{"description":"payment_link_not_found"},"409":{"description":"qr_type_not_available"}}}},"/v1/payment-links/{linkId}/solana-pay":{"get":{"summary":"Solana Pay transaction request for a reusable link - what the wallet displays","description":"The endpoint a live, fixed-amount Solana link's `solana-pay` QR points a wallet at. CORS-open. 404 for a non-Solana link. Error bodies carry the spec's `message` field.","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"label + icon","content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string"},"icon":{"type":"string","format":"uri"}}}}}},"404":{"description":"Not a Solana payment link"}}},"post":{"summary":"Solana Pay transaction request for a reusable link - opens the link for this scan and returns the new request's unsigned transaction","description":"One printed code, any number of payers - each POST creates a fresh payment request for the scanning wallet; the relay's payment detection sweep confirms it. Only a live, fixed-amount Solana link.","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account"],"properties":{"account":{"type":"string","description":"The paying wallet's base58 public key."}}}}}},"responses":{"200":{"description":"Unsigned transaction + message","content":{"application/json":{"schema":{"type":"object","properties":{"transaction":{"type":"string"},"message":{"type":"string"}}}}}},"400":{"description":"account missing","or not a valid Solana public key":null},"404":{"description":"Not a Solana payment link"},"409":{"description":"payment_link_not_payable (sandbox, or customer-entered amount) | tenant_not_active (2026-10-24: the merchant's tenant is suspended; no request is opened) | payment_link_unavailable (inactive/expired/exhausted) | currency_not_allowlisted (no mint configured on this deployment for the link currency, or not allowlisted)"},"503":{"description":"Nothing was signed. service_paused (the operator's kill switch is on), signing_busy (with a `Retry-After` header), payment_authorizer_mismatch or authorizer_cluster_mismatch - as the payment-request Solana Pay POST."}}},"options":{"summary":"CORS preflight","tags":["Relay Gateway"],"security":[],"parameters":[{"name":"linkId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"CORS headers"}}}},"/v1/checkout/sessions":{"post":{"summary":"Create a fiat-quoted checkout session (newer path; fires a paid webhook if callback_url is set)","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutSessionCreate"}}}},"responses":{"201":{"description":"Created - the checkout_session object plus the quote's provenance (rate_source, rate_fetched_at), which is not stored and so only creation reports.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/CheckoutSession"},{"type":"object","properties":{"rate_source":{"type":"string","description":"Where the quote's rate came from: a live fetch, a still-recent cached value, or the static fallback table."},"rate_fetched_at":{"type":"string","format":"date-time","nullable":true}}}]}}}},"400":{"description":"Validation error (Shape B envelope): invalid_amount (not a positive decimal string, or more than 2 decimal places) | invalid_currency | invalid_url | invalid_network | invalid_settlement_currency | missing_recipient"},"503":{"description":"rate_unavailable - no exchange rate could be quoted for the pair"}}},"get":{"summary":"List checkout sessions (added 2026-09-12)","description":"Cursor-paginated, newest first, this tenant only. Each item is exactly what GET /v1/checkout/sessions/{id} returns, its payments array included (fetched in one batched query per page).","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","schema":{"type":"string","enum":["open","completed","expired"]}},{"name":"customer_id","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of checkout sessions","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/CheckoutSession"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"invalid_pagination_parameter (limit out of range, or both cursors given) | invalid_filter_parameter (unknown status/network, malformed created window)"},"401":{"description":"Missing/invalid API key"}}}},"/v1/checkout/sessions/{id}":{"get":{"summary":"Retrieve a checkout session, including its payments","description":"The checkout_session object - the create answer without rate_source and rate_fetched_at (audit O15, 2026-10-17; it lacked the quote, the network and payment_url, all additive).","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Checkout session","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutSession"}}}},"404":{"description":"`resource_missing` - no such session for this tenant in the key's mode"}}}},"/v1/checkout/sessions/{id}/expire":{"post":{"summary":"Manually expire a checkout session","description":"Answers the checkout_session object as it now stands (audit O15, 2026-10-17; it answered only id, object and status, additive).","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutSession"}}}},"404":{"description":"`resource_missing` - no such session for this tenant in the key's mode"},"409":{"description":"`checkout_session_not_expirable` - the session is no longer open"}}}},"/v1/payments":{"get":{"summary":"List payments (added 2026-09-12)","description":"Cursor-paginated, newest first, this tenant only. Each item is the FULL GET /v1/payments/{id} object (lifecycle, financial, blockchain, related) - \"list looks like retrieve\" - assembled from one batched query per related table, not one query per payment.\n","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","schema":{"type":"string","enum":["created","paid","expired"]}},{"name":"checkout_session_id","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of payments","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Payment"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"invalid_pagination_parameter (limit out of range, or both cursors given) | invalid_filter_parameter (unknown status/network, malformed created window)"},"401":{"description":"Missing/invalid API key"}}}},"/v1/payments/{id}":{"get":{"summary":"Retrieve full status history, financials, and blockchain data for a payment","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Payment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Payment"}}}},"404":{"description":"Not found"}}}},"/v1/payments/{id}/transactions":{"get":{"summary":"Every payment attempt's on-chain data (added 2026-09-03)","description":"Not just the most recent attempt Payment.blockchain already summarizes - a payer can retry with a different currency/network after an earlier attempt expired or was abandoned.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Payment attempts","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"has_more":{"type":"boolean","enum":[false],"description":"Not paged: always false (audit O15, 2026-10-17; additive)."},"data":{"type":"array","items":{"$ref":"#/components/schemas/BlockchainTransaction"}}}}}}},"404":{"description":"No such payment for this tenant"}}}},"/v1/payments/{id}/refunds":{"post":{"summary":"Create a refund - checkout_session payments only","description":"Non-custodial like the payment itself: returns an unsigned transaction for the merchant's own wallet to sign and broadcast (FastPay never holds the funds to refund them directly). Destination defaults to the payer the payment was verified from on chain (the payment request's settledPayer); a payment with none - TON, sandbox, or recorded before 2026-10-18 - needs an explicit destination (400 missing_destination). Every checkout network supports refunds (the 17 GET /v1/networks lists: ethereum/base/polygon/arbitrum/ bnb/avalanche/robinhood/linea/optimism/monad/megaeth/hyperevm/tron/solana/aptos/sui/ton).\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amount"],"properties":{"amount":{"type":"string","description":"Positive decimal string, in the payment's own paymentCurrency"},"reason":{"type":"string"},"destination":{"type":"string","description":"Optional override - defaults to the payer the payment was verified from on chain (settledPayer); required when it has none (TON, sandbox, or a payment recorded before 2026-10-18)"}}}}}},"responses":{"201":{"description":"Created - the refund object (GET /v1/refunds/{id}'s shape, tx_hash and failure_reason null) plus its unsigned transaction","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Refund"}}}},"400":{"description":"Validation error, e.g. amount_exceeds_refundable, settlement_amount_too_small (the refund's share of the payment's fiat net settlement, in whole cents rounded down, is zero), unsupported_network, missing_destination (no destination and no payer verified on chain, 2026-10-18; nothing is reserved)"},"404":{"description":"No such payment"},"409":{"description":"payment_not_refundable - the payment was not paid. A settlement recorded after the session's window closed (paid_after_expiry: the payment keeps its status - expired, or created when the window simply ran out - and its history carries paid_after_expiry) is paid and refundable. currency_not_configured - this deployment has no address for the refund's token; the message names the RELAY_<TOKEN>_ADDRESS_<NETWORK> variable and nothing is reserved."}}}},"/v1/refunds":{"get":{"summary":"List refunds (added 2026-09-12)","description":"Cursor-paginated, newest first, this tenant only. Each item is exactly what GET /v1/refunds/{id} returns.","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","confirmed","failed","expired"]}},{"name":"payment_id","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of refunds","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Refund"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"invalid_pagination_parameter (limit out of range, or both cursors given) | invalid_filter_parameter (unknown status/network, malformed created window)"},"401":{"description":"Missing/invalid API key"}}}},"/v1/refunds/{id}":{"get":{"summary":"Retrieve a refund","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Refund","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Refund"}}}},"404":{"description":"Not found"}}}},"/v1/refunds/{id}/confirm":{"post":{"summary":"Confirm a broadcast refund transaction against on-chain state","description":"Merchant-authenticated (unlike a payment's own confirm route) - only the merchant who requested and signed the refund should report it. Never trusts the hash alone. Only the refund's own transaction, reverted on chain, fails it (status failed, failure_reason tx_reverted, refund.failed sent, the amount refundable again). Any other transaction that does not match answers status failed with its failure_reason and writes nothing - the refund stays pending. A refund still pending RELAY_REFUND_PENDING_EXPIRY_HOURS (default 72) after creation expires. A refund that is no longer pending answers its stored state.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["txHash"],"properties":{"txHash":{"type":"string"}}}}}},"responses":{"200":{"description":"Confirmation result","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["refund"]},"status":{"type":"string","enum":["pending","confirmed","failed","expired"]},"tx_hash":{"type":"string","nullable":true},"failure_reason":{"type":"string","nullable":true,"enum":["tx_reverted","build_failed","expired","no_matching_transfer","sender_mismatch","reverted_not_this_refund","not_a_transaction_hash",null],"description":"With status failed: tx_reverted means the refund's own transaction reverted (terminal); any other value is a mismatch - nothing was written and the refund is still pending. On a refund that is no longer pending, its stored reason.\n"}}}}}},"400":{"description":"Invalid txHash"},"404":{"description":"Not found"},"409":{"description":"refund_tx_already_used - the transaction's matching transfers (same destination, currency and amount; for an EVM or Tron token, from the same merchant wallet) already confirmed other refunds (one transfer confirms one refund; sandbox refunds are not checked); nothing was written. Or currency_not_configured, as on create."}}}},"/v1/balance":{"get":{"summary":"List balances across all currencies for the calling tenant","tags":["Relay Gateway"],"responses":{"200":{"description":"Balances","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"has_more":{"type":"boolean","enum":[false],"description":"Not paged: always false (audit O15, 2026-10-17; additive)."},"data":{"type":"array","items":{"$ref":"#/components/schemas/Balance"}}}}}}}}}},"/v1/balance/{currency}":{"get":{"summary":"Retrieve balance for a single currency","description":"A currency with no ledger account answers zero balances (\"0.00\", the currency upper-cased) and creates no account, so it never appears in GET /v1/balance until something is posted in it. Every amount is exact cents; an amount posted with more decimals is rounded half-up (1.005 posts 1.01).","tags":["Relay Gateway"],"parameters":[{"name":"currency","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Balance","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Balance"}}}}}}},"/v1/balance/transactions":{"get":{"summary":"List every ledger entry for the calling tenant (paginated; format=csv is a full unpaginated export)","tags":["Relay Gateway"],"parameters":[{"name":"format","in":"query","required":false,"schema":{"type":"string","enum":["csv"]},"description":"Added 2026-09-03. Omit for the default paginated JSON response; \"csv\" returns the tenant's ENTIRE history (unaffected by limit/starting_after) as a downloadable text/csv file instead."},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"}],"responses":{"200":{"description":"Ledger entries (paginated JSON by default, or the full CSV file if format=csv)","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/LedgerTransaction"}},"has_more":{"type":"boolean","description":"Added 2026-09-04. Absent from the format=csv response (that one is never paginated)."}}}},"text/csv":{"schema":{"type":"string","description":"RFC 4180, CRLF line endings, newest first in the JSON list's order. A text cell a spreadsheet would open as a formula (leading =, +, -, @, tab or carriage return) is written with a leading apostrophe; a number, a negative balance such as -5.00 included, keeps its value. Written as fast as the client reads it: a download cut short (a dropped connection, or a server error after the first rows) ends without its final chunk, an incomplete transfer, never a shorter file that looks complete."}}}},"400":{"description":"limit out of range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/reconciliation":{"get":{"summary":"Cross-reference business/on-chain/ledger state for paid payments and report discrepancies","description":"Added 2026-09-03. Read-only. Cross-references payments.status='paid', payment_attempts (kept current for every checkout network by relay/confirmationSweep.js, reorgs included; each pass re-checks a capped page per network, see docs/confirmation-policy.md), and the double-entry ledger's own payable credit - none of which check themselves against the others anywhere else in this API.\n","tags":["Relay Gateway"],"parameters":[{"name":"from","in":"query","required":false,"schema":{"type":"string"},"description":"Lower bound on when each payment reached 'paid' - an ISO-8601 time or unix seconds, compared as UTC (period.from echoes it in UTC ISO-8601). Defaults to RELAY_RECONCILIATION_DEFAULT_WINDOW_DAYS (30) before 'to'."},{"name":"to","in":"query","required":false,"schema":{"type":"string"},"description":"Upper bound on when each payment reached 'paid' - an ISO-8601 time or unix seconds, compared as UTC; a date-only value (2026-09-30) runs through the end of that UTC day. Defaults to now. At most RELAY_RECONCILIATION_MAX_WINDOW_DAYS (92) after 'from'."}],"responses":{"200":{"description":"Reconciliation report","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReconciliationReport"}}}},"400":{"description":"invalid_date - from/to is not one ISO-8601 time or unix seconds (malformed, repeated or bracketed), or from is later than to; window_too_large - the period is longer than RELAY_RECONCILIATION_MAX_WINDOW_DAYS (92 days by default): ask for it in parts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/webhook-deliveries":{"get":{"summary":"List this tenant's own webhook delivery attempts (paginated)","description":"Added 2026-09-04; response_status/response_body added 2026-09-07. Closes the \"no webhook delivery dashboard visible to a merchant\" gap - exposes relay/webhooks/outbox.js's own webhook_deliveries table, previously internal only. See /docs/webhooks.md's own \"Delivery log\" section for exactly what's included, and POST /v1/webhook-deliveries/{id}/redeliver below for on-demand redelivery.\n","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"name":"allocation_id","in":"query","required":false,"schema":{"type":"string","maxLength":256},"description":"Filter to one payment/allocation's own delivery attempts. One string of at most 256 characters (the longest ALLOCATION_ID /v1/instructions accepts) - a repeated, bracketed or longer parameter answers 400 invalid_filter_parameter."}],"responses":{"200":{"description":"Webhook deliveries","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"invalid_pagination_parameter (limit out of range, or both cursors) | invalid_filter_parameter (allocation_id repeated, bracketed or longer than 256 characters)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/webhook-deliveries/{id}":{"get":{"summary":"Retrieve one webhook delivery, including the full payload sent","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Webhook delivery","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookDelivery"},{"type":"object","properties":{"payload":{"type":"object","description":"The exact JSON body sent (or queued to be sent) for this event."}}}]}}}},"404":{"description":"No such delivery for this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/webhook-deliveries/{id}/redeliver":{"post":{"summary":"Redeliver one webhook delivery on demand","description":"Added 2026-09-07. Runs the exact same send-and-record logic the periodic redelivery sweep uses, immediately rather than waiting for the sweep. Claimable even for an already-delivered row (re-sending one your receiver already acknowledged), and always sends: the sweep's rule that skips a superseded Mass Payout payment.* row does not apply. Returns 409 webhook_delivery_redelivery_in_progress while a delivery of the row is in flight - its first attempt (which holds the row for its own retry budget), this endpoint, or the periodic sweep.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The delivery's updated state after the redelivery attempt completed","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookDelivery"},{"type":"object","properties":{"payload":{"type":"object","description":"The exact JSON body sent for this event."}}}]}}}},"404":{"description":"No such delivery for this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"`webhook_delivery_redelivery_in_progress` - a delivery of this row is already in flight (its first attempt, a redelivery, or the periodic sweep); retry shortly (Shape B)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/sandbox":{"get":{"summary":"What this tenant's sandbox holds (added 2026-09-12)","description":"Row counts per object for the calling tenant's SANDBOX data - everything its fp_test_ keys created. Works with either key; `mode` reports which one asked. Sandbox isolation: every tenant-scoped object carries the mode of the key that created it, and a key only ever reads, changes and deletes its own mode's rows (a by-id fetch of the other mode's object is a plain 404). Needs the `sandbox:read` scope.\n","tags":["Relay Gateway"],"responses":{"200":{"description":"The sandbox's contents","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sandbox"}}}},"401":{"description":"Missing/invalid API key"},"403":{"description":"insufficient_scope"}}}},"/v1/sandbox/reset":{"post":{"summary":"Delete every sandbox row of this tenant (added 2026-09-12)","description":"Deletes the calling tenant's sandbox data in ONE transaction - all or nothing - and returns the fresh counts plus what was `deleted`. Requires an fp_test_ key (403 sandbox_key_required for a live key): a live key can never delete anything here, and a sandbox key can never touch live rows whatever it calls. Deliberately KEPT: API keys, team members, the payout approval policy, tenant wallets and webhook endpoints - a sandbox endpoint is configuration a merchant re-uses across runs, not data a run produced. Recorded in the audit log as `sandbox.reset`.\n","tags":["Relay Gateway"],"responses":{"200":{"description":"Reset - counts are all zero, `deleted` says what was removed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sandbox"}}}},"401":{"description":"Missing/invalid API key"},"403":{"description":"sandbox_key_required (a live key) | insufficient_scope"}}}},"/v1/sandbox/deposits":{"post":{"summary":"Simulate a deposit on a sandbox payment channel (added 2026-09-12)","description":"The sandbox's stand-in for the channel-deposit sweep, which never watches a sandbox channel (its addresses are synthetic and format-valid, but nothing holds a key for them and no Turnkey sub-organization is minted, so `monitored` is false). Goes through the real settle step, so the gateway fee, the sandbox ledger credit and the `payment_channel.deposit` webhook are the real ones. Requires an fp_test_ key and a sandbox channel.\n","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["payment_channel_id","network","currency","amount"],"properties":{"payment_channel_id":{"type":"string","example":"pc_..."},"network":{"type":"string","description":"A network this channel has an address on."},"currency":{"type":"string","description":"A currency that network accepts, in any letter case (usdc, usde). The deposit records it upper-cased: a USDe deposit, its balance and its webhook read USDE."},"amount":{"type":"string","description":"Positive decimal string, the GROSS amount received."},"from_address":{"type":"string","nullable":true}}}}}},"responses":{"201":{"description":"The recorded deposit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Deposit"}}}},"400":{"description":"parameter_missing | invalid_network | invalid_currency | invalid_amount"},"401":{"description":"Missing/invalid API key"},"403":{"description":"sandbox_key_required (a live key) | insufficient_scope"},"404":{"description":"resource_missing - no such SANDBOX payment channel (a live channel is not visible here)"},"409":{"description":"payment_channel_closed"}}}},"/admin/tenants/{id}/sandbox/reset":{"post":{"summary":"Delete every sandbox row of one tenant (operator only, added 2026-09-12)","description":"The operator equivalent of POST /v1/sandbox/reset - same one-transaction delete, same audit event, no tenant key needed.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Reset","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sandbox"}}}},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/v1/webhooks/event-types":{"get":{"summary":"Webhook event-type catalogue (added 2026-09-12)","description":"Every `type` value a Relay Gateway webhook can carry, for use in a webhook endpoint's enabled_events. Public, static.","tags":["Relay Gateway"],"security":[],"responses":{"200":{"description":"The catalogue","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"description":{"type":"string"}}}},"has_more":{"type":"boolean"}}}}}}}}},"/v1/webhooks/egress-ips":{"get":{"summary":"The relay's webhook egress IPs (added 2026-09-12)","description":"The fixed outbound addresses webhooks are sent from, for a receiver that allow-lists senders - read from RELAY_WEBHOOK_EGRESS_IPS. `configured` is false, and `data` empty, until the operator has set it; never a guessed address. Public.","tags":["Relay Gateway"],"security":[],"responses":{"200":{"description":"Egress IPs","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"type":"string"}},"configured":{"type":"boolean"},"has_more":{"type":"boolean"}}}}}}}}},"/v1/webhooks/secret/rotate":{"post":{"summary":"Rotate the tenant webhook secret (added 2026-09-12)","description":"Rotates the secret every per-object callback_url delivery is signed with (account-level endpoints have their own - see /v1/webhook-endpoints/{id}/rotate-secret). Returns the NEW secret. Until previous_secret_expires_at every delivery is signed with both secrets and x-webhook-signature carries two comma-separated values, current first. A live (fp_live_) key only (2026-10-24): the tenant has one secret for both modes, so it signs live callback_url deliveries too, and an fp_test_ key answers 403 live_key_required with nothing rotated.\n","tags":["Relay Gateway"],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotationRequest"}}}},"responses":{"200":{"description":"The new secret","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["webhook_secret"]},"secret":{"type":"string","description":"whsec_-prefixed; shown only here."},"previous_secret_expires_at":{"type":"string","nullable":true,"format":"date-time"}}}}}},"400":{"description":"invalid_expires_in_hours"},"401":{"description":"Missing/invalid API key"},"403":{"description":"live_key_required (an fp_test_ key; message: This action changes live data: use a live (fp_live_) API key.) | insufficient_scope"}}}},"/v1/webhook-endpoints":{"post":{"summary":"Register an account-level webhook endpoint (added 2026-09-12)","description":"Up to RELAY_MAX_WEBHOOK_ENDPOINTS (16) per account. Every event whose type the endpoint subscribes to is delivered to it - alongside the object's own callback_url, if any - as its own delivery-log row (webhook_endpoint_id set), signed with the endpoint's own secret in x-webhook-signature; since 2026-10-24 a delivery to an endpoint carries no x-webhook-secret header (only a live object's callback_url delivery does). An endpoint created with an fp_test_ key is a sandbox endpoint and receives only sandbox events. The secret is returned only here and on rotation.\n","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointCreate"}}}},"responses":{"201":{"description":"Created (secret included)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointWithSecret"}}}},"400":{"description":"error_slug: parameter_missing | invalid_url (not https, a username or password, over 2048 characters, or not a public address) | invalid_enabled_events | invalid_description | webhook_endpoint_limit_reached"},"401":{"description":"Missing/invalid API key"}}},"get":{"summary":"List webhook endpoints (added 2026-09-12)","description":"Cursor-paginated, newest first, this tenant only. Never includes secrets.","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","schema":{"type":"string","enum":["enabled","disabled"]}}],"responses":{"200":{"description":"A page of webhook endpoints","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"invalid_pagination_parameter | invalid_filter_parameter"}}}},"/v1/webhook-endpoints/{id}":{"get":{"summary":"Retrieve a webhook endpoint (added 2026-09-12)","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The endpoint (no secret)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"404":{"description":"resource_missing"}}},"post":{"summary":"Update a webhook endpoint (added 2026-09-12)","description":"Any of url, enabled_events, description, status (\"enabled\" | \"disabled\"); omitted fields keep their value. A url given must meet the create rule (https, no username or password, at most 2048 characters; 2026-10-24); an endpoint stored earlier with an http url keeps it through an update that names no url.\n","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointUpdate"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"error_slug: invalid_url (a url that is not https, has a username or password, is over 2048 characters, or does not resolve to a public address) | invalid_enabled_events | invalid_description | invalid_status"},"404":{"description":"resource_missing"}}},"delete":{"summary":"Delete a webhook endpoint (added 2026-09-12)","description":"Undelivered delivery-log rows for it are closed by the redelivery sweep (last_error explains) rather than retried.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["webhook_endpoint"]},"deleted":{"type":"boolean"}}}}}},"404":{"description":"resource_missing"}}}},"/v1/webhook-endpoints/{id}/rotate-secret":{"post":{"summary":"Rotate a webhook endpoint's secret (added 2026-09-12)","description":"Returns the endpoint with its NEW secret. The previous secret keeps signing (a second comma-separated value in x-webhook-signature) for expires_in_hours.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotationRequest"}}}},"responses":{"200":{"description":"Rotated (secret included)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointWithSecret"}}}},"400":{"description":"invalid_expires_in_hours"},"404":{"description":"resource_missing"}}}},"/v1/customers":{"post":{"summary":"Create a customer","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email","maxLength":254,"description":"A well-formed address (name@domain.tld), checked with the relay's one email rule (2026-10-17; it was any string containing @)."},"name":{"type":"string","nullable":true,"maxLength":200},"country":{"type":"string","nullable":true,"maxLength":64}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Customer"}}}},"400":{"description":"Shape B: `parameter_missing` (email absent or not a well-formed address), `invalid_name` / `invalid_country` (not a string within its bound, nor null; 2026-10-17 - an object answered 500 before)"}}},"get":{"summary":"List customers for the calling tenant (paginated)","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"}],"responses":{"200":{"description":"Customers","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Customer"}},"has_more":{"type":"boolean","description":"Added 2026-09-04."}}}}}},"400":{"description":"limit out of range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/customers/{id}":{"get":{"summary":"Retrieve a customer","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Customer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Customer"}}}},"404":{"description":"Not found"}}}},"/v1/payment-channels":{"post":{"deprecated":true,"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] [BLOCKED in live mode] Create a reusable deposit-address payment channel for a customer","description":"BLOCKED IN LIVE MODE SINCE 2026-09-15 - a live-key call mints one Turnkey sub-organization per requested network, which the relay's backend credential is no longer permitted to do since the custody rebuild removed its root privileges; it returns 500 from the Turnkey call. Sandbox (fp_test_) keys are unaffected - a sandbox channel gets a synthetic, format-valid address per network with no Turnkey call, and POST /v1/sandbox/deposits still simulates a deposit on it end to end.\n","tags":["Relay Gateway"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["customer_id","networks"],"properties":{"customer_id":{"type":"string"},"networks":{"type":"array","items":{"type":"string","enum":["ethereum","base","polygon","arbitrum","bnb","avalanche","robinhood","linea","optimism","monad","megaeth","hyperevm","tron","solana","aptos","sui","ton"]}},"callback_url":{"type":"string","format":"uri","nullable":true,"description":"Optional (2026-09-12). Receives this channel's payment_channel.deposit events, in addition to every subscribed account-level endpoint. http(s), public address (SSRF-checked) - 400 invalid_url otherwise."}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentChannel"}}}},"400":{"description":"Validation error (Shape B envelope)"},"404":{"description":"Customer not found"},"409":{"description":"no_tenant_root_key - live mode, and the tenant has no registered custody root key"},"410":{"description":"custodial_path_closed (Shape B; since 2026-10-18, BREAKING): creating a payment channel is closed with every relay-signed write (owner D1/D6) - each deposit address was a Turnkey wallet the relay could sign for. Either key mode; nothing is minted or written. Existing channels keep their reads, close and deposit detection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosedShapeB"}}}},"502":{"description":"custody_not_verified - a requested network's deposit address could not be minted (its custody attempt stopped short, or was refused part way); no channel was created"}}},"get":{"summary":"List payment channels for the calling tenant (paginated)","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"}],"responses":{"200":{"description":"Payment channels","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PaymentChannel"}},"has_more":{"type":"boolean","description":"Added 2026-09-04."}}}}}},"400":{"description":"limit out of range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/payment-channels/{id}/deposits":{"get":{"summary":"List the deposits detected on one payment channel (paginated)","description":"The same list as GET /v1/deposits fixed to one channel of the calling tenant. Scope customers:read. See payment-gateway-api.md#deposit-detection for what \"detected\" means per network.","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"customer_id","in":"query","required":false,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"currency","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deposits, newest first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DepositList"}}}},"400":{"description":"Invalid pagination or filter parameter (Shape B)"},"404":{"description":"No such payment channel for this tenant"}}}},"/v1/deposits":{"get":{"summary":"List every deposit detected on the calling tenant's payment channels (paginated)","description":"Added 2026-09-12. Deposits are recorded by the relay's channel-deposit sweep - on the EVM\ncheckout networks at confirmation depth (token deposits from a Transfer log filter, native\ncoins from the address's balance delta), on Solana at finalized commitment, on Tron (since\n2026-10-03) from solidified blocks (TRC20 deposits read from each transaction's receipt,\nplain TRX transfers) - exactly once each, credited to the ledger net of the gateway fee,\nand reported as payment_channel.deposit. Aptos/Sui/TON channel addresses are not watched\n(see `monitored` on the channel).\nScope customers:read.\n","tags":["Relay Gateway"],"parameters":[{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"payment_channel_id","in":"query","required":false,"schema":{"type":"string"}},{"name":"customer_id","in":"query","required":false,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"},"description":"One of the payment-channel networks; anything else is 400 invalid_filter_parameter."},{"name":"currency","in":"query","required":false,"schema":{"type":"string"},"description":"Case-insensitive, e.g. USDC, ETH, SOL."}],"responses":{"200":{"description":"Deposits, newest first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DepositList"}}}},"400":{"description":"Invalid pagination or filter parameter (Shape B)"}}}},"/v1/deposits/{id}":{"get":{"summary":"Retrieve one deposit","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Deposit"}}}},"404":{"description":"`resource_missing` - no such deposit for this tenant"}}}},"/v1/payment-channels/{id}":{"get":{"summary":"Retrieve a payment channel","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Payment channel","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentChannel"}}}},"404":{"description":"Not found"}}}},"/v1/payment-channels/{id}/close":{"post":{"summary":"Close a payment channel","tags":["Relay Gateway"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Closed"},"404":{"description":"Not found"}}}},"/admin/tenants/{id}/members":{"post":{"summary":"Create a team member for a tenant (operator; any role - how a tenant's first owner is created)","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/AdminIdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberInput"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"400":{"description":"Validation error (Shape B)"},"404":{"description":"No such tenant (`tenant_not_found`)"}}},"get":{"summary":"List a tenant's team members (operator; paginated like GET /v1/members)","description":"Newest first. Without `limit` a page holds the largest page the relay serves (PAGINATION_MAX_LIMIT, 100).","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":100},"description":"Max items to return, 1-100 (default 100 on this route, the largest page; ListLimit's 20 elsewhere)."},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["active","suspended"]}},{"name":"role","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MemberRole"}}],"responses":{"200":{"description":"Members","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Member"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"`invalid_pagination_parameter` / `invalid_filter_parameter`"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/tenants/{tenantId}/members/{memberId}":{"post":{"summary":"Update a tenant's member (operator; may assign owner; the last active owner is still protected)","description":"An Idempotency-Key is ignored and every call runs, since it sets a state and a stored answer would report one the member may no longer be in.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"tenantId","in":"path","required":true,"schema":{"type":"string"}},{"name":"memberId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":200},"role":{"$ref":"#/components/schemas/MemberRole"},"status":{"type":"string","enum":["active","suspended"]}}}}}},"responses":{"200":{"description":"The updated member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"404":{"description":"No such tenant (`tenant_not_found`, Shape A) / no such member (`resource_missing`, Shape B)"},"409":{"description":"`last_owner`"}}}},"/admin/tenants/{id}/payout-approval-policy":{"get":{"summary":"A tenant's payout approval policy (operator)","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutApprovalPolicy"}}}},"404":{"description":"No such tenant (`tenant_not_found`)"}}},"post":{"summary":"Set a tenant's payout approval policy (operator)","description":"With the panel credential (PanelApiKey) only `mode` \"always\" is accepted, with `approvals_required` at least the tenant's current one; anything else is 403 `panel_policy_not_allowed` and changes nothing. An Idempotency-Key is ignored and every call runs, since it sets a state and a stored answer would report one the tenant may no longer be in.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutApprovalPolicyInput"}}}},"responses":{"200":{"description":"The stored policy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayoutApprovalPolicy"}}}},"400":{"description":"`invalid_policy_mode` / `invalid_approvals_required` / `invalid_thresholds`"},"403":{"description":"`panel_policy_not_allowed`: the panel credential sent a policy weaker than mode always with the current approvals_required (Shape B)"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/audit-log":{"get":{"summary":"Immutable \"who did what, when\" trail for admin/tenant writes on this relay","description":"Never contains raw secrets (API keys, private keys). tenant_id matches actions the tenant itself performed, admin actions performed on it, and the rows about its custody attempts and its refunds (the relay's own refund.expired among them). Mass-payout jobs are audited at creation, approval, rejection and cancellation (job.created, job.approved, job.rejected, job.cancelled); a job the relay cancels itself since 2026-10-18, because relay-signed payouts are closed, writes no row (no actor; its reason is on each payment's ERROR_MESSAGE and payment.cancelled webhook); the onboarding finishes as tenant.onboarding_recorded (POST /admin/tenants/{id}/onboard and .../tron/onboard), custody.solana_vault_initialized and custody.onboarding_recorded. A list paged like every other (audit O15, 2026-10-17), newest first, within its own bounds, by `limit` and the two cursors (no created window). A cursor is looked up across the whole log, whatever the filters - an event id that exists nowhere answers the first page, and one outside the filters places the page at that event.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"name":"tenant_id","in":"query","schema":{"type":"string"}},{"name":"target_type","in":"query","schema":{"type":"string","example":"tenant"}},{"name":"target_id","in":"query","schema":{"type":"string"}},{"name":"member_id","in":"query","schema":{"type":"string"},"description":"2026-09-12 - one team member's own activity (actor_member_id)."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"description":"The page size: RELAY_AUDIT_LOG_DEFAULT_LIMIT (100) when absent, at most RELAY_AUDIT_LOG_MAX_LIMIT (500). Anything else - not an integer, 0, past the maximum - is 400 invalid_pagination_parameter (it was clamped silently before 2026-10-17)."}],"responses":{"200":{"description":"Audit events, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"has_more":{"type":"boolean","description":"More events exist beyond this page in the direction being paged."},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"aud_..."},"object":{"type":"string","enum":["audit_event"]},"actor_type":{"type":"string","enum":["admin","panel","tenant"],"description":"panel: an admin write made with the restricted panel credential (PanelApiKey)."},"actor_id":{"type":"string","nullable":true,"description":"The tenant id for actor_type=tenant; always null for actor_type=admin (one shared admin key, no per-user identity) and panel."},"actor_operator":{"type":"string","nullable":true,"description":"For actor_type=panel, the operator the call's x-panel-operator header named; for actor_type=admin, the person the call's x-admin-operator named (required on the tenant approval, tenant.approved), null when it named none or the row is one the payroll or custody modules write (components/parameters/AdminOperator); null for actor_type=tenant. Asserted by the caller, not authenticated."},"actor_member_id":{"type":"string","nullable":true,"description":"2026-09-12: the team member behind a tenant action; null for an admin action or a legacy key."},"actor_api_key_id":{"type":"string","nullable":true,"description":"2026-09-12: the API key behind a tenant action."},"action":{"type":"string","example":"tenant.limits_changed"},"target_type":{"type":"string","nullable":true},"target_id":{"type":"string","nullable":true},"details":{"type":"object","description":"Action-specific, e.g. { before, after } for status/limits changes."},"ip_address":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"}}}}}}}}},"400":{"description":"invalid_pagination_parameter - limit not an integer from 1 to 500, or both cursors; invalid_filter_parameter - tenant_id, target_type, target_id or member_id repeated, bracketed or longer than 200 characters"},"401":{"description":"Missing or invalid x-admin-api-key"}}}},"/admin/tenants":{"get":{"summary":"List tenants (operator only, paginated)","description":"Added 2026-10-15. Every tenant, newest first. Never a webhook secret or key column - the same fields as GET /admin/tenants/{id}.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["active","suspended"]}}],"responses":{"200":{"description":"Tenants, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/AdminTenant"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"`invalid_pagination_parameter` / `invalid_filter_parameter`"},"401":{"description":"Missing or wrong admin key"}}},"post":{"summary":"Create a tenant (operator only)","description":"The tenant starts unapproved (approval null): it can hold test (fp_test_) keys at once, and a live (fp_live_) key only after POST /admin/tenants/{id}/approve by a second person. The creator is recorded as created_by (admin or panel, and the operator the call named in x-panel-operator or x-admin-operator, null when an admin-key call named none), which the approval's four-eyes check reads. An admin-key Idempotency-Key replay of a 2xx, which hands the webhookSecret out again, is audited as `tenant.replayed` with the replaying operator.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}}}},"responses":{"201":{"description":"Created - the AdminTenant object (object, id, name, status, created_at, created_by, approval; object and created_at added for the admin key 2026-10-17, audit O15) plus, for the admin key only, webhookSecret. With the panel credential (PanelApiKey) the answer never carries webhookSecret.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AdminTenant"},{"type":"object","properties":{"webhookSecret":{"type":"string","description":"whsec_-prefixed, shown once. Admin key only: absent with the panel credential."}}}]}}}},"400":{"description":"`parameter_missing`: name is absent or blank"}}}},"/admin/tenants/{id}":{"get":{"summary":"Read one tenant (operator only)","description":"The same object as each item of GET /admin/tenants - never a webhook secret or key column. Lets an operator or the panel check that a typed tenant id is the tenant it means before writing to it.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTenant"}}}},"401":{"description":"Missing or wrong admin key"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/tenants/{id}/approve":{"post":{"summary":"Approve a tenant for live keys (operator only, a second person)","description":"Added 2026-10-16. A live (fp_live_) key is minted for a tenant only once a named person has\napproved it here, by any route (the admin and panel mint, POST /v1/members/{id}/api-keys, both\nrotations of a live key: otherwise 409 tenant_not_approved). Test (fp_test_) keys need no\napproval, and keys minted before it keep working (the gate is at mint time only), so a tenant\ncreated before 2026-10-16 starts unapproved and needs an approval before its next live key.\nWho approves: the admin key with x-admin-operator naming the person (required here), or the\npanel credential under its x-panel-operator. Four-eyes: a panel operator never approves a\ntenant that same operator created - the approving x-panel-operator is compared with the\ntenant's created_by.operator exactly as stored (byte for byte, case included), whichever\ncredential created it; a tenant with no recorded creator operator (created before 2026-10-16,\nor by the admin key without x-admin-operator, as FastPay's onboarding script does without\n--operator) can be approved by any panel operator: with no creator to compare, the\nsecond-person rule is the operators' to keep. The admin key may always approve. The first\napproval is the only one, and it has no undo. Checked in this order: four-eyes (403), already\napproved (409), suspended (409).\nRecorded on the tenant (approval) and as one audit row, tenant.approved (actor_type admin or\npanel, actor_operator the person, details.approved_by {type, operator, credential_fingerprint}),\nwritten together. credential_fingerprint is a one-way fingerprint (HKDF-SHA256, 64 hex\ncharacters) of the credential that made the call, never the key; a rotated admin or panel key\ngives a different one. The panel credential never reads the admin key's fingerprint: in an\nanswer to it, an approval the admin key gave has credential_fingerprint null (the admin key's\nreads and the audit log keep it). Every later api_key.created or api_key.rotated row of a live key carries\ndetails.tenant_approval {approved_at, approved_by_type, approved_by_operator,\napproved_by_credential}, copied from the tenant at that moment.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/PanelOperator"},{"name":"x-admin-operator","in":"header","required":false,"description":"Required with the admin key on this route (missing or empty - 400 admin_operator_required; not of the operator form, or undefined or null in any case - 400 invalid_admin_operator), ignored with the panel credential: the approving person, recorded as approved_by.operator. Same form as components/parameters/AdminOperator.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[A-Za-z0-9][A-Za-z0-9._%+@:-]*$","example":"owner@f-pay.com"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/AdminIdempotencyKey"}],"responses":{"200":{"description":"The approved tenant (approval set)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTenant"}}}},"400":{"description":"`admin_operator_required` / `invalid_admin_operator` (admin key; Shape A, before the Idempotency-Key layer: nothing stored); the panel's own 400s (components/parameters/PanelOperator)"},"401":{"description":"Missing or wrong admin key"},"403":{"description":"`approval_requires_second_operator` (Shape B): the panel operator created this tenant; another operator, or the admin key, approves it"},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"`tenant_already_approved` (the first approval stays the only one) / `tenant_not_active` (the tenant is suspended) (Shape B)"}}}},"/admin/tenants/{id}/webhook-endpoints":{"post":{"summary":"Create a webhook endpoint for a tenant (operator only, secret shown once)","description":"Added 2026-10-15. The body, cap and answer of POST /v1/webhook-endpoints, plus `test` (required JSON boolean): the endpoint's mode, which the tenant route takes from the calling key - a sandbox endpoint receives sandbox events only. Recorded in the audit log as `webhook_endpoint.created` (admin or panel actor, the tenant as target), never the secret; an Idempotency-Key replay of a 2xx (admin key or panel) as `webhook_endpoint.replayed`, with the replaying operator.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/AdminIdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEndpointCreate"},{"type":"object","required":["test"],"properties":{"test":{"type":"boolean","description":"true for a sandbox endpoint, false for a live one. Required."}}}]}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointWithSecret"}}}},"400":{"description":"`parameter_missing` (test or url) / `invalid_test` / `invalid_url` (the tenant route's url rule: https, no username or password, at most 2048 characters, a public address) / `invalid_enabled_events` / `invalid_description` / `webhook_endpoint_limit_reached` (Shape B)"},"401":{"description":"Missing or wrong admin key"},"404":{"description":"No such tenant (`tenant_not_found`)"}}},"get":{"summary":"List a tenant's webhook endpoints (operator only, paginated, never a secret)","description":"The tenant list's page and object (GET /v1/webhook-endpoints), across both modes unless `is_test` names one, so the panel can look an endpoint up before creating one. Never the signing secret. Not audited.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["enabled","disabled"]}},{"name":"is_test","in":"query","required":false,"schema":{"type":"string","enum":["true","false"]},"description":"Only sandbox (true) or live (false) endpoints; both when absent."}],"responses":{"200":{"description":"Webhook endpoints, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"`invalid_pagination_parameter` / `invalid_filter_parameter`"},"401":{"description":"Missing or wrong admin key"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/tenants/{id}/webhook-endpoints/{endpointId}":{"delete":{"summary":"Delete one of a tenant's webhook endpoints (admin key only, any tenant status)","description":"Added 2026-10-23. The delete of DELETE /v1/webhook-endpoints/{id}, in either mode (no mode is named: an endpoint id is unique) and whatever the tenant's status, so the endpoints of a suspended tenant, whose keys are all refused, can be removed. The endpoint receives no new delivery, and its undelivered delivery-log rows are closed by the redelivery sweep (last_error explains) rather than retried; a delivery already queued or under way when the endpoint is deleted is still sent, with its in-process retries (RELAY_WEBHOOK_MAX_ATTEMPTS, 5 by default), however long it waited for a delivery slot. Retries to a batch's own callback_url are not endpoint deliveries and are not touched. Recorded in the audit log as `webhook_endpoint.deleted` (the admin actor with its x-admin-operator, the tenant as target, details `{ webhookEndpointId, url, isTest }`), in one transaction with the delete. The admin key only: the panel credential gets 403 `panel_route_not_allowed`. An Idempotency-Key replay answers the first call's 200 and deletes or records nothing again; without one, a repeat is 404.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"endpointId","in":"path","required":true,"schema":{"type":"string"},"description":"The endpoint's `we_` id"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["webhook_endpoint"]},"deleted":{"type":"boolean"}}}}}},"401":{"description":"Missing or wrong admin key (`authentication_error` / `unauthorized`, Shape A)"},"403":{"description":"The panel credential: `panel_route_not_allowed`"},"404":{"description":"No such tenant (`tenant_not_found`, Shape A) / no such endpoint for this tenant, another tenant's included (`resource_missing`, Shape B)"}}}},"/admin/tenants/{id}/payroll-payer-wallets":{"get":{"summary":"List a tenant's payroll payer wallets (operator only, paginated)","description":"Added 2026-10-15. The tenant list's page and object (GET /v1/payroll-payer-wallets), across both modes unless `is_test` names one. No secrets.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["pending_verification","verified","revoked"]}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"is_test","in":"query","required":false,"schema":{"type":"string","enum":["true","false"]},"description":"Only sandbox (true) or live (false) wallets; both when absent."}],"responses":{"200":{"description":"Payer wallets, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayrollPayerWallet"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"`invalid_pagination_parameter` / `invalid_filter_parameter`"},"401":{"description":"Missing or wrong admin key"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/tenants/{id}/payroll-batches":{"get":{"summary":"List a tenant's payroll batches (operator only, paginated)","description":"Added 2026-10-15. The tenant list's page and object (GET /v1/payroll-batches), across both modes unless `is_test` names one. No secrets.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/ListLimit"},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"$ref":"#/components/parameters/ListCreatedGte"},{"$ref":"#/components/parameters/ListCreatedLte"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["awaiting_approval","open","closing","completed","closed","cancelled"]}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"is_test","in":"query","required":false,"schema":{"type":"string","enum":["true","false"]},"description":"Only sandbox (true) or live (false) batches; both when absent."}],"responses":{"200":{"description":"Batches, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PayrollBatch"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"`invalid_pagination_parameter` / `invalid_filter_parameter`"},"401":{"description":"Missing or wrong admin key"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/tenants/{id}/wallets":{"post":{"summary":"Register a tenant payout wallet for a network (operator only)","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["chain","address"],"properties":{"chain":{"type":"string","enum":["ethereum","base","polygon","arbitrum","bnb","avalanche","robinhood","linea","optimism","monad","megaeth","hyperevm","solana","tron","aptos","sui","ton"]},"address":{"type":"string"}}}}}},"responses":{"200":{"description":"Registered"},"400":{"description":"`parameter_missing` (chain or address absent), `invalid_chain` (not a checkout network), `invalid_address` (not an address of that network)"},"404":{"description":"No such tenant (`tenant_not_found`)"}}},"get":{"summary":"List a tenant's registered wallets (operator only)","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The tenant's wallets as a bare JSON array, not the list envelope (the one list without it)."},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/tenants/{id}/wallets/{chain}":{"delete":{"summary":"Remove a tenant's payout wallet for a network (operator only)","description":"Deletes the tenant's registered payout wallet for `chain` (any letter case), if there is one, and records a `wallet.removed` audit row; the answer is the same when none was registered. A checkout or payment request on that network then needs its own `recipient` (else 400 `missing_recipient`).","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"chain","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Removed - `{ success: true }`"},"401":{"description":"Missing or wrong admin key (`authentication_error` / `unauthorized`, Shape A)"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/tenants/{id}/api-keys":{"post":{"summary":"Mint an API key for a tenant (operator only, shown once)","description":"A key carries a policy enforced on every /v1 request - `scopes` (`[\"*\"]` or\n`<family>:read|write`; write implies read), an expiry, an optional `ip_allowlist` of\naddresses/CIDRs, and a `name`. Since 2026-10-15 nothing defaults on this route: `test`\n(a JSON boolean), `scopes` and `expires_in_days` are required, and a body without one is\nrefused with 400 `parameter_missing` (`invalid_test` for a `test` that is not a boolean).\nThe lifetime is `expires_in_days` alone: a body carrying `expires_at` is refused with 400\n`invalid_expires_at` (move an expiry later with the update route).\nThe raw key is returned once, never logged and never stored in clear (an Idempotency-Key\nanswer that carries it is kept encrypted with a key derived from the credential).\nA live key (`test: false`) needs an approved tenant (POST /admin/tenants/{id}/approve, since\n2026-10-16): otherwise 409 `tenant_not_approved` and nothing is minted. Its `api_key.created`\naudit row carries `details.tenant_approval`, the approval that allowed it.\nWith the panel credential (PanelApiKey) the key is one person's FastPay API key:\n`member_id` is required (400 `parameter_missing`); `scopes` may hold only the client-facing\nfamilies `payroll`, `checkout`, `refunds`, `customers` and `balance`, each `:read` or\n`:write`, in any mix (`*`, `team`, `webhooks`, `integrations`, `sandbox` or `payouts`: 403\n`panel_scope_not_allowed`; RELAY_PANEL_MINTABLE_FAMILIES may narrow the five, never widen\nthem, and production lists payroll, refunds and balance only); the member's role ceiling\nstill applies (an approver's or a viewer's write scope: 400 `scopes_exceed_role`); and\n`expires_in_days` is at most RELAY_PANEL_KEY_MAX_EXPIRES_IN_DAYS, default 90 (400\n`invalid_expires_at`). A `checkout:write` key the panel mints names its own `recipient` on\npayment requests and links (the tenant's registered checkout wallet fills in only when none\nis sent). Since 2026-10-25 (owner ruling LK-11) a live key (`test` false) the panel mints\nnames its payroll networks: without `payroll_networks` (absent or null) 400\n`payroll_networks_required`, so the panel mints no live key without a payroll scope (a live\nkey of refunds, balance, checkout or customers alone needs the admin key); and each network\nmust be open for the tenant as GET /admin/tenants/{id}/payroll-lanes reads it (`status`\n\"enabled\", `open` true), else 409 `payroll_lane_not_open` naming the first that is not, in\nthe order sent. Both come after the body's own checks and 409 `tenant_not_approved`, and\nmint nothing. An Idempotency-Key replay of a 2xx (admin key or panel) is audited as\n`api_key.replayed` with the replaying operator.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/AdminIdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["test","scopes","expires_in_days"],"description":"The policy fields of ApiKeyPolicy except `expires_at`, which this route refuses (400 invalid_expires_at).","properties":{"test":{"type":"boolean","description":"true mints an fp_test_ (sandbox) key, false an fp_live_ key. Required."},"scopes":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyScope"},"description":"Required."},"expires_in_days":{"type":"number","minimum":0,"exclusiveMinimum":true,"maximum":3650,"description":"Days until the key expires. Required; the key's only lifetime field on this route."},"name":{"type":"string","nullable":true,"maxLength":100},"ip_allowlist":{"type":"array","nullable":true,"maxItems":50,"items":{"type":"string","example":"203.0.113.0/24"},"description":"IPv4/IPv6 addresses or CIDR ranges. `null` or `[]` = no restriction. An entry is a bare address (`203.0.113.7`, `2001:db8::1`) or an address with a prefix of 1 to 3 digits, 1-32 for IPv4 or 1-128 for IPv6 (`203.0.113.0/24`); since 2026-10-24 an empty prefix (`203.0.113.7/`), a non-digit prefix (`0x10`, `1e1`, `+8`), `/0` and an IPv6 range that holds every IPv4 address (`::/64`, `::ffff:0:0/96`) are 400 `invalid_ip_allowlist`."},"member_id":{"type":"string","nullable":true,"description":"2026-09-12: issue the key to this team member - its scopes may not exceed the member's role ceiling (scopes_exceed_role); 404 for an unknown member, 409 member_suspended."},"payroll_networks":{"type":"array","minItems":1,"items":{"type":"string"},"description":"2026-10-21: limit the key to these payroll networks (each once; only on a key whose scopes reach payroll: 400 invalid_request; an unknown network: 400 unsupported_network). Never widened later; both rotations copy it. Absent: every network. Required on a live key minted with the panel credential, each network on a lane open for the tenant (since 2026-10-25: 400 payroll_networks_required, 409 payroll_lane_not_open)."}}}}}},"responses":{"201":{"description":"Created - the key object plus the one-time `apiKey`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithSecret"}}}},"400":{"description":"`parameter_missing` (test, scopes or expires_in_days absent or null; member_id with the panel credential) / `invalid_test` / `invalid_scopes` / `invalid_ip_allowlist` / `invalid_expires_at` (expires_in_days out of range, or an expires_at sent) / `invalid_name` / `scopes_exceed_role` (member_id's role does not allow a scope) / `unsupported_network` / `invalid_request` (payroll_networks) / `payroll_networks_required` (a live key minted with the panel credential without payroll_networks, since 2026-10-25) / `invalid_idempotency_key` (Shape B)"},"403":{"description":"`panel_scope_not_allowed`: the panel credential asked for a scope outside payroll, checkout, refunds, customers and balance (`*`, team, webhooks, integrations, sandbox or payouts), or outside the narrower list of RELAY_PANEL_MINTABLE_FAMILIES (Shape B)"},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"`tenant_not_approved` - a live key (test false) for a tenant not yet approved with POST /admin/tenants/{id}/approve (since 2026-10-16; nothing minted; a test key needs no approval) / `member_suspended` / `payroll_lane_not_open` - a live key minted with the panel credential names a network whose lane is not open for the tenant (since 2026-10-25; details network, status, reason; nothing minted; not kept under an Idempotency-Key) (Shape B)"}}},"get":{"summary":"List a tenant's API keys (operator only)","description":"Newest first, expired and revoked keys included (their rotation lineage matters). Never the key material or its hash. Paged like every list (audit O15, 2026-10-17; it answered every key), by `limit` and the two cursors only (no created window); without `limit` a page holds the largest page the relay serves (100).","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":100},"description":"Max items to return, 1-100 (default 100, the largest page)."},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}},"has_more":{"type":"boolean"}}}}}},"400":{"description":"`invalid_pagination_parameter`"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/tenants/{tenantId}/api-keys/{keyId}":{"post":{"summary":"Update a key's name, scopes, IP allow-list or expiry in place (operator only)","description":"Only the fields given change; pass `null` for `ip_allowlist` or `expires_at` to clear them. The key material itself never changes. Recorded as `api_key.updated`.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"tenantId","in":"path","required":true,"schema":{"type":"string"}},{"name":"keyId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiKeyPolicy"},{"type":"object","properties":{"expires_in_days":{"type":"number","nullable":true,"description":"Alternative to expires_at."}}}]}}}},"responses":{"200":{"description":"The updated key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKey"}}}},"400":{"description":"`invalid_scopes` / `invalid_ip_allowlist` / `invalid_expires_at` / `invalid_name` (Shape B)"},"404":{"description":"`resource_missing` - no such key for this tenant"}}}},"/admin/tenants/{tenantId}/api-keys/{keyId}/rotate":{"post":{"summary":"Rotate a key with a grace window (operator only)","description":"Mints a successor carrying the old key's prefix, name, scopes and IP allow-list, and gives\nthe old key `grace_hours` more of life (0-168, default 24; 0 revokes it immediately). Both\nkeys work during the window. A key already due to expire sooner keeps its earlier expiry.\nRefused (404) for a key that is already revoked or expired. Recorded as `api_key.rotated`;\nan Idempotency-Key replay of a 2xx as `api_key.replayed`, with the replaying operator.\nThe successor of a live key is a live key: it needs an approved tenant (409\n`tenant_not_approved` otherwise, nothing rotated; since 2026-10-16), and its audit row carries\n`details.tenant_approval`.\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"tenantId","in":"path","required":true,"schema":{"type":"string"}},{"name":"keyId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyRotationRequest"}}}},"responses":{"201":{"description":"The successor key (with its one-time `apiKey`) and the old key's fate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyRotated"}}}},"400":{"description":"`invalid_grace_hours` (Shape B)"},"404":{"description":"`resource_missing` - no such active key for this tenant"},"409":{"description":"`tenant_not_approved` - a live key's rotation for a tenant not yet approved (nothing rotated) / `member_suspended` (Shape B)"}}}},"/v1/api-keys/me":{"get":{"summary":"The calling API key's own policy","description":"Any key may introspect itself, regardless of scopes. Never returns the key material. Since 2026-09-12 the response also carries `member` ({ id, name, role, effective_scopes } - the key's scopes narrowed to the role) or null for a legacy key.","tags":["Relay Gateway"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKey"}}}},"401":{"description":"Missing/invalid API key, or `api_key_expired`"}}}},"/v1/api-keys/rotate":{"post":{"summary":"Rotate the calling API key (full-scope keys only)","description":"Same semantics as the admin rotate route, for the key making the call. A restricted key (any scopes other than `[\"*\"]`) gets `403 insufficient_scope` - a leaked restricted key can never mint anything; ask the operator to rotate it. A live key's rotation needs the tenant approved by the operator (409 `tenant_not_approved` otherwise, since 2026-10-16). The successor always has the calling key's own mode (the body names none). An Idempotency-Key replay of a 2xx (the same calling key only, within its grace window) is audited as `api_key.replayed` (since 2026-10-24). The answer kept for that replay (the successor's raw key) is encrypted under the calling key, so to replace a key you think leaked, send no Idempotency-Key, or have the operator revoke it and mint a new one.","tags":["Relay Gateway"],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyRotationRequest"}}}},"responses":{"201":{"description":"The successor key (with its one-time `apiKey`) and the old key's fate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyRotated"}}}},"400":{"description":"`invalid_grace_hours` (Shape B)"},"401":{"description":"Missing/invalid API key, or `api_key_expired`"},"403":{"description":"`permission_error` / `insufficient_scope` - not a full-scope key"},"409":{"description":"`api_key_not_rotatable` - the key is no longer active / `tenant_not_approved` - a live key, and the tenant is not approved yet (nothing rotated)"}}}},"/admin/tenants/{id}/custody/challenge":{"post":{"deprecated":true,"tags":["Relay Gateway"],"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Issue a single-use custody challenge for a tenant to sign (operator only)","description":"Step 1 of 3. The tenant signs these exact UTF-8 bytes with the PRIVATE half of the P-256 key they generated, proving they hold it before it is made the root of their sub-organizations. The challenge carries its own expiry inside the signed string and is consumed by the root-key call whether or not the signature verifies, so a captured one cannot be replayed against a second guess. Issuing a new challenge replaces any outstanding one.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"Challenge issued","content":{"application/json":{"schema":{"type":"object","properties":{"challenge":{"type":"string","example":"fastpay-custody:ten_123:9f3a...:2026-09-18T12:15:00.000Z"},"expires_at":{"type":"string","format":"date-time"},"note":{"type":"string"}}}}}},"404":{"description":"No such tenant (`tenant_not_found`)"},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}}}}},"/admin/tenants/{id}/custody/root-key":{"post":{"deprecated":true,"tags":["Relay Gateway"],"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Register the tenant's own P-256 key as their custody root (operator only)","description":"Step 2 of 3. Refused for the private half of a key pair, for a key that is not a valid point on P-256, for one of FastPay's own backend keys, for a key another tenant already registered, and for a missing, expired or unmatched challenge signature. FastPay holds no copy of the private half and cannot recover it: a tenant who loses it can no longer administer their own sub-organizations, though payouts keep working under the policies already written.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["public_key","signature"],"properties":{"public_key":{"type":"string","description":"Compressed P-256 public key, 66 hex characters starting 02 or 03."},"signature":{"type":"string","description":"ECDSA over SHA-256 of the challenge, DER or 64-byte compact, hex."},"email":{"type":"string","nullable":true,"description":"Optional; set on the tenant's root user at Turnkey. No recovery channel is enabled either way."}}}}}},"responses":{"201":{"description":"Key registered","content":{"application/json":{"schema":{"type":"object","properties":{"public_key":{"type":"string"},"email":{"type":"string","nullable":true},"note":{"type":"string"}}}}}},"400":{"description":"Refused. error_slug is one of parameter_missing (public_key or signature absent), invalid_root_public_key, root_key_not_on_curve, root_key_is_backend_key, root_key_claimed_by_another_tenant, no_custody_challenge, custody_challenge_expired, custody_challenge_signature_invalid.\n"},"404":{"description":"No such tenant (`tenant_not_found`)"},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}}}}},"/admin/tenants/{id}/custody":{"post":{"deprecated":true,"tags":["Relay Gateway"],"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Mint a tenant-rooted custody wallet for one chain (operator only)","description":"Step 3 of 3, and the replacement for every withdrawn mint route. Creates the sub-organization with the tenant's key as a root user and FastPay as a temporary second one, writes the DENY backstop and the chain's signing ALLOW, hands the root quorum to the tenant alone, then READS THAT BACK off Turnkey - and only then records the address as payable. Idempotent: a chain already verified, or an attempt already in flight, is returned rather than started again. On solana/bitcoin/ton/aptos/sui the wallet IS the payout wallet and the tenant is onboarded by this call. On ethereum and tron it only SIGNS: a Safe/SimpleAccount still has to be deployed (POST .../onboard) or a Tron permission slot granted by the tenant's own Owner key.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["chain"],"properties":{"chain":{"type":"string","enum":["ethereum","tron","solana","bitcoin","litecoin","ton","aptos","sui"]}}}}}},"responses":{"201":{"description":"Verified - the tenant is the sole root of the new sub-organization","content":{"application/json":{"schema":{"type":"object","properties":{"custody":{"$ref":"#/components/schemas/TenantCustody"},"onboarding":{"type":"object","nullable":true,"properties":{"chain":{"type":"string"},"address":{"type":"string"},"kind":{"type":"string","enum":["session_key","payout_wallet"]},"accountType":{"type":"string","nullable":true},"next":{"type":"string","description":"What the operator still has to do; empty when this chain is finished."},"guarded":{"type":"boolean","description":"Solana only, and always false here: the custody path records the session key as the payer holding funds directly. POST /admin/tenants/{tenantId}/solana/onboard upgrades to a guarded Vault. Absent for every other chain, which have no guarded mode to be missing."},"deposit":{"type":"string","description":"Present only for a payout wallet."}}},"note":{"type":"string"}}}}}},"202":{"description":"Started but not verified - nothing has been recorded as payable. The attempt is in the body and is resumable with POST /admin/custody/{custodyId}/advance.\n"},"400":{"description":"error_slug: parameter_missing (no chain), unsupported_custody_chain or no_tenant_root_key."},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"error_slug: solana_guarded_vault_would_be_downgraded - the tenant is already onboarded on Solana as a guarded vault, and recording this wallet as their payout wallet would set account_type back to \"solana_wallet\", routing payouts around the relay guard and away from the fund_authority PDA holding their funds. Nothing was changed.\n"},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}}}},"get":{"tags":["Relay Gateway"],"summary":"A page of a tenant's custody attempts, and their registered root key (operator only)","description":"Newest first, its payment channels' attempts among them, paged like every list (audit O15, 2026-10-17; it answered every attempt), by `limit`, the two cursors and `owner_type` (no created window). Without `limit` a page holds the largest page the relay serves (PAGINATION_MAX_LIMIT, 100).","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":100},"description":"Max items to return, 1-100 (default 100, the largest page)."},{"$ref":"#/components/parameters/ListStartingAfter"},{"$ref":"#/components/parameters/ListEndingBefore"},{"name":"owner_type","in":"query","required":false,"schema":{"type":"string","enum":["tenant","payment_channel"]},"description":"Only the tenant's own attempts, or only its payment channels' (anything else is 400 invalid_filter_parameter)."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"has_more":{"type":"boolean"},"root_key":{"type":"object","nullable":true,"properties":{"public_key":{"type":"string"},"email":{"type":"string","nullable":true}}},"data":{"type":"array","items":{"$ref":"#/components/schemas/TenantCustody"}}}}}}},"400":{"description":"`invalid_pagination_parameter` / `invalid_filter_parameter`"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/custody/{custodyId}/advance":{"post":{"deprecated":true,"tags":["Relay Gateway"],"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Resume a custody attempt that stopped part way (operator only)","description":"Runs the remaining steps. Safe to call repeatedly: each step is a compare-and-set on the attempt's own state, so two callers cannot both advance it, and a step already done is not redone. Records the onboarding if this run reaches verified AND the attempt is the tenant's own. An attempt created by POST /v1/payment-channels is a deposit address for one channel, not the tenant's payout identity, so it still advances but `onboarding` is null and `note` says what it is - copying it onto the tenant record would repoint their payouts at a customer's deposits.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"custodyId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"custody":{"$ref":"#/components/schemas/TenantCustody"},"onboarding":{"type":"object","nullable":true},"note":{"type":"string","description":"Present when a verified attempt was NOT applied to the tenant record."}}}}}},"404":{"description":"No such custody attempt (`custody_not_found`)"},"409":{"description":"error_slug: custody_leased (another process is advancing it), or solana_guarded_vault_would_be_downgraded (the tenant is already onboarded on Solana as a guarded vault; recording this wallet would route payouts around the guard).\n"},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}}}}},"/admin/custody/{custodyId}/abandon":{"post":{"tags":["Relay Gateway"],"summary":"Give up on a custody attempt (operator only)","description":"What this does depends on whether FastPay still roots the sub-organization. Before the handoff it is really DELETED (state 'abandoned'); after it, FastPay cannot delete or re-police it and the attempt is marked 'relinquished' - the sub-organization still exists and the tenant is its root. An attempt that never reached Turnkey is simply 'failed'.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"custodyId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"custody":{"$ref":"#/components/schemas/TenantCustody"},"note":{"type":"string"}}}}}},"404":{"description":"No such custody attempt (`custody_not_found`)"},"409":{"description":"error_slug: custody_not_abandonable - the attempt already finished; or custody_leased - another process is advancing it (an advance in this process finishes first, and the abandon then judges the row it left)."}}}},"/admin/tenants/{id}/session-key":{"post":{"tags":["Relay Gateway"],"summary":"[WITHDRAWN] Superseded by POST /admin/tenants/{id}/custody","description":"WITHDRAWN 2026-09-18. This operation minted a Turnkey sub-organization with FastPay's own backend key as its SOLE ROOT USER. A sub-organization's root quorum bypasses Turnkey's policy engine entirely, so no policy could constrain that key - it held unconstrained authority over the tenant's wallet and the tenant held none over their own. It had also been unable to run at all since 2026-09-15 (the relay credential is non-root, with no CREATE_SUB_ORGANIZATION grant), so nothing that worked is being removed. Use POST /admin/tenants/{id}/custody with chain \"ethereum\" instead: it mints the wallet inside a sub-organization rooted by the TENANT'S own P-256 key, writes the policies that bind FastPay, hands over the root quorum, and verifies all of that before the address is recorded as payable. Register the tenant's key first via .../custody/challenge and .../custody/root-key.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"410":{"description":"Gone - use POST /admin/tenants/{id}/custody","content":{"application/json":{"schema":{"type":"object","properties":{"error_type":{"type":"string","enum":["request_error"],"description":"Added 2026-10-17 (it was null)."},"error_slug":{"type":"string","enum":["route_withdrawn_use_custody"]},"error":{"type":"string"},"replacement":{"type":"string","description":"The exact call to make instead."}}}}}}}}},"/admin/tenants/{id}/onboard":{"post":{"deprecated":true,"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Finalize a tenant's EVM onboarding (operator only, step 2)","description":"Records the smart account the session key controls. Runs a live bundler-simulation proof that the (smart account, session key, account type) triple genuinely authorizes UserOperations before accepting it - `network` only picks which chain's bundler that proof runs against (default ethereum); a tenant's onboarding record itself has no per-chain concept.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["smartAccountAddress"],"properties":{"smartAccountAddress":{"type":"string"},"accountType":{"type":"string","enum":["simple_account","safe"]},"network":{"type":"string","description":"One of the relay's EVM networks (default ethereum) - selects the bundler for the binding proof only."}}}}}},"responses":{"200":{"description":"`{ success: true, sessionKeyBindingVerified }`"},"400":{"description":"`session_key_missing` (no session key on file yet), `parameter_missing` (no smartAccountAddress), `invalid_account_type`, `invalid_network`, or `session_key_binding_failed` (the binding proof failed; the message says why)"},"404":{"description":"No such tenant (`tenant_not_found`)"},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}}}}},"/admin/tenants/{id}/tron/session-key":{"post":{"tags":["Relay Gateway"],"summary":"[WITHDRAWN] Superseded by POST /admin/tenants/{id}/custody","description":"WITHDRAWN 2026-09-18. This operation minted a Turnkey sub-organization with FastPay's own backend key as its SOLE ROOT USER. A sub-organization's root quorum bypasses Turnkey's policy engine entirely, so no policy could constrain that key - it held unconstrained authority over the tenant's wallet and the tenant held none over their own. It had also been unable to run at all since 2026-09-15 (the relay credential is non-root, with no CREATE_SUB_ORGANIZATION grant), so nothing that worked is being removed. Use POST /admin/tenants/{id}/custody with chain \"tron\" instead: it mints the wallet inside a sub-organization rooted by the TENANT'S own P-256 key, writes the policies that bind FastPay, hands over the root quorum, and verifies all of that before the address is recorded as payable. Register the tenant's key first via .../custody/challenge and .../custody/root-key.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"410":{"description":"Gone - use POST /admin/tenants/{id}/custody","content":{"application/json":{"schema":{"type":"object","properties":{"error_type":{"type":"string","enum":["request_error"],"description":"Added 2026-10-17 (it was null)."},"error_slug":{"type":"string","enum":["route_withdrawn_use_custody"]},"error":{"type":"string"},"replacement":{"type":"string","description":"The exact call to make instead."}}}}}}}}},"/admin/tenants/{id}/tron/permission-grant-transaction":{"post":{"deprecated":true,"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Build the unsigned Tron Active-Permission grant for the tenant to sign (step 2)","description":"Tron has no smart-account deploy step - the tenant's own account grants the session key a scoped Active-Permission slot on itself, which only the tenant's Owner key can sign (e.g. via TronLink). Returns the exact unsigned AccountPermissionUpdateContract transaction plus the operations mask it grants.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tronAddress"],"properties":{"tronAddress":{"type":"string","description":"The tenant's own base58 Tron account"}}}}}},"responses":{"200":{"description":"`{ transaction, operations, note }`"},"400":{"description":"No Tron session key on file yet (`session_key_missing`), or invalid tronAddress (`invalid_tron_address`); tron_relay_unscoped_session_key (until 2026-10-18 Tron relay onboarding was refused unless RELAY_TRON_UNSCOPED_KEY_RISK_ACCEPTED; the 410 above now answers first, whatever the variable holds, so no call reaches it) or tron_account_not_found (the account is not activated on-chain), both Shape B"},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"tron_permission_slots_full - all 8 active-permission slots are in use and none is FastPay's (Shape B)"},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}},"500":{"description":"internal_error - Tron RPC not configured or unreachable (masked, Shape B)"}}}},"/admin/tenants/{id}/tron/onboard":{"post":{"deprecated":true,"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Verify the Tron grant landed on-chain and finalize onboarding (step 3)","description":"Reads the tenant's account back from live chain state and confirms the FastPay Active-Permission slot granting the session key actually exists - never trusts the request body's claim alone.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tronAddress"],"properties":{"tronAddress":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ success: true, permissionId }`"},"400":{"description":"No Tron session key on file (`session_key_missing`), invalid tronAddress (`invalid_tron_address`), or no matching permission found on-chain yet (`tron_permission_not_found`)"},"404":{"description":"No such tenant (`tenant_not_found`)"},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}},"500":{"description":"internal_error - Tron RPC not configured or unreachable (masked, Shape B)"}}}},"/admin/tenants/{id}/solana/onboard":{"post":{"deprecated":true,"summary":"[CLOSED 2026-10-18: 410 custodial_path_closed] Initialize a tenant's guarded Solana Vault (operator only)","description":"Mints nothing (since 2026-09-18): the Solana key comes from POST /admin/tenants/{id}/custody with chain \"solana\", which already records the tenant as payable on the unguarded path (the session key is the payer). This call does the one Solana-only step custody cannot: it initializes the tenant's fastpay_solana_relay_guard Vault with that key as authority, and the returned fundAuthorityAddress becomes the deposit address. It needs a verified Solana custody wallet and a configured guard (RELAY_SOLANA_GUARD_PROGRAM_ID plus an admin signer).\n","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"`{ fundAuthorityAddress, guarded: true, note }`"},"404":{"description":"No such tenant (`tenant_not_found`)"},"409":{"description":"Shape A, error_type request_error (added 2026-10-17; it was null): no_verified_custody (with `replacement`, the custody call to make first) or solana_guard_not_configured (with `solanaAddress` and `guarded: false` - the tenant stays on the unguarded path).\n","content":{"application/json":{"schema":{"type":"object","properties":{"error_type":{"type":"string","enum":["request_error"]},"error_slug":{"type":"string","enum":["no_verified_custody","solana_guard_not_configured"]},"error":{"type":"string"},"replacement":{"type":"string"},"solanaAddress":{"type":"string"},"guarded":{"type":"boolean","enum":[false]}}}}}},"410":{"description":"custodial_path_closed (Shape A; since 2026-10-18, BREAKING): every relay-signed write is closed (owner D1/D6) - answered right after authentication and the scope check, before any other check, for either key mode, any network and any tenant id; nothing is recorded or reserved, and no setting re-opens it. Pay out with client-signed payroll (/v1/payroll-batches).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustodialPathClosed"}}}},"500":{"description":"internal_error - the on-chain Vault initialization failed (masked)"}}}},"/admin/tenants/{id}/bitcoin/onboard":{"post":{"tags":["Relay Gateway"],"summary":"[WITHDRAWN] Superseded by POST /admin/tenants/{id}/custody","description":"WITHDRAWN 2026-09-18. This operation minted a Turnkey sub-organization with FastPay's own backend key as its SOLE ROOT USER. A sub-organization's root quorum bypasses Turnkey's policy engine entirely, so no policy could constrain that key - it held unconstrained authority over the tenant's wallet and the tenant held none over their own. It had also been unable to run at all since 2026-09-15 (the relay credential is non-root, with no CREATE_SUB_ORGANIZATION grant), so nothing that worked is being removed. Use POST /admin/tenants/{id}/custody with chain \"bitcoin\" instead: it mints the wallet inside a sub-organization rooted by the TENANT'S own P-256 key, writes the policies that bind FastPay, hands over the root quorum, and verifies all of that before the address is recorded as payable. Register the tenant's key first via .../custody/challenge and .../custody/root-key.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"410":{"description":"Gone - use POST /admin/tenants/{id}/custody","content":{"application/json":{"schema":{"type":"object","properties":{"error_type":{"type":"string","enum":["request_error"],"description":"Added 2026-10-17 (it was null)."},"error_slug":{"type":"string","enum":["route_withdrawn_use_custody"]},"error":{"type":"string"},"replacement":{"type":"string","description":"The exact call to make instead."}}}}}}}}},"/admin/tenants/{id}/ton/onboard":{"post":{"tags":["Relay Gateway"],"summary":"[WITHDRAWN] Superseded by POST /admin/tenants/{id}/custody","description":"WITHDRAWN 2026-09-18. This operation minted a Turnkey sub-organization with FastPay's own backend key as its SOLE ROOT USER. A sub-organization's root quorum bypasses Turnkey's policy engine entirely, so no policy could constrain that key - it held unconstrained authority over the tenant's wallet and the tenant held none over their own. It had also been unable to run at all since 2026-09-15 (the relay credential is non-root, with no CREATE_SUB_ORGANIZATION grant), so nothing that worked is being removed. Use POST /admin/tenants/{id}/custody with chain \"ton\" instead: it mints the wallet inside a sub-organization rooted by the TENANT'S own P-256 key, writes the policies that bind FastPay, hands over the root quorum, and verifies all of that before the address is recorded as payable. Register the tenant's key first via .../custody/challenge and .../custody/root-key.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"410":{"description":"Gone - use POST /admin/tenants/{id}/custody","content":{"application/json":{"schema":{"type":"object","properties":{"error_type":{"type":"string","enum":["request_error"],"description":"Added 2026-10-17 (it was null)."},"error_slug":{"type":"string","enum":["route_withdrawn_use_custody"]},"error":{"type":"string"},"replacement":{"type":"string","description":"The exact call to make instead."}}}}}}}}},"/admin/tenants/{id}/aptos/onboard":{"post":{"tags":["Relay Gateway"],"summary":"[WITHDRAWN] Superseded by POST /admin/tenants/{id}/custody","description":"WITHDRAWN 2026-09-18. This operation minted a Turnkey sub-organization with FastPay's own backend key as its SOLE ROOT USER. A sub-organization's root quorum bypasses Turnkey's policy engine entirely, so no policy could constrain that key - it held unconstrained authority over the tenant's wallet and the tenant held none over their own. It had also been unable to run at all since 2026-09-15 (the relay credential is non-root, with no CREATE_SUB_ORGANIZATION grant), so nothing that worked is being removed. Use POST /admin/tenants/{id}/custody with chain \"aptos\" instead: it mints the wallet inside a sub-organization rooted by the TENANT'S own P-256 key, writes the policies that bind FastPay, hands over the root quorum, and verifies all of that before the address is recorded as payable. Register the tenant's key first via .../custody/challenge and .../custody/root-key.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"410":{"description":"Gone - use POST /admin/tenants/{id}/custody","content":{"application/json":{"schema":{"type":"object","properties":{"error_type":{"type":"string","enum":["request_error"],"description":"Added 2026-10-17 (it was null)."},"error_slug":{"type":"string","enum":["route_withdrawn_use_custody"]},"error":{"type":"string"},"replacement":{"type":"string","description":"The exact call to make instead."}}}}}}}}},"/admin/tenants/{id}/sui/onboard":{"post":{"tags":["Relay Gateway"],"summary":"[WITHDRAWN] Superseded by POST /admin/tenants/{id}/custody","description":"WITHDRAWN 2026-09-18. This operation minted a Turnkey sub-organization with FastPay's own backend key as its SOLE ROOT USER. A sub-organization's root quorum bypasses Turnkey's policy engine entirely, so no policy could constrain that key - it held unconstrained authority over the tenant's wallet and the tenant held none over their own. It had also been unable to run at all since 2026-09-15 (the relay credential is non-root, with no CREATE_SUB_ORGANIZATION grant), so nothing that worked is being removed. Use POST /admin/tenants/{id}/custody with chain \"sui\" instead: it mints the wallet inside a sub-organization rooted by the TENANT'S own P-256 key, writes the policies that bind FastPay, hands over the root quorum, and verifies all of that before the address is recorded as payable. Register the tenant's key first via .../custody/challenge and .../custody/root-key.\n","security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"410":{"description":"Gone - use POST /admin/tenants/{id}/custody","content":{"application/json":{"schema":{"type":"object","properties":{"error_type":{"type":"string","enum":["request_error"],"description":"Added 2026-10-17 (it was null)."},"error_slug":{"type":"string","enum":["route_withdrawn_use_custody"]},"error":{"type":"string"},"replacement":{"type":"string","description":"The exact call to make instead."}}}}}}}}},"/admin/tenants/{tenantId}/api-keys/{keyId}/revoke":{"post":{"summary":"Revoke one of a tenant's API keys (operator only)","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"tenantId","in":"path","required":true,"schema":{"type":"string"}},{"name":"keyId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Revoked - `{ success: true }`. Recorded in the admin audit log as `api_key.revoked`."},"404":{"description":"No such active API key for this tenant"}}}},"/admin/tenants/{id}/limits":{"post":{"summary":"Set a tenant's per-minute request cap and daily payment-count cap (operator only)","description":"Each field is a positive integer, or omitted or null to clear that cap back to the relay-wide default. Anything else (0, a negative or fractional number, a string) is `400 invalid_limits` and nothing is written. The before/after values are recorded in the admin audit log as `tenant.limits_changed`. An Idempotency-Key is ignored and every call runs, since it sets a state and a stored answer would report one the tenant may no longer be in.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"requestsPerMinute":{"type":"integer","minimum":1,"nullable":true},"dailyPaymentCountCap":{"type":"integer","minimum":1,"nullable":true,"description":"Counts mass-payout (POST /v1/instructions) payments only, per rolling UTC day: client-signed payroll, whose payer signs every payment, never draws on it."}}}}}},"responses":{"200":{"description":"Updated - `{ success: true }`"},"400":{"description":"error_slug invalid_limits (Shape A) - a field is not a positive integer or null; nothing was written"},"404":{"description":"No such tenant (`tenant_not_found`)"}}}},"/admin/tenants/{id}/webhooks/test":{"post":{"summary":"Send a mock webhook event to a callback URL (operator only)","description":"Delivers a synthetic `event`-shaped payload (a mock payment object) to `callback_url`, signed with the tenant's own webhook secret, so an integrator's receiver can be exercised before any real payment exists. `callback_url` goes through the same SSRF check every real callback URL does. The panel credential (added 2026-10-16) may call it with `webhook_endpoint_id` only - a registered endpoint, signed with that endpoint's own secret; a panel call with a free `callback_url` answers `403 panel_endpoint_required`. Since 2026-10-24 the event carries no `x-webhook-secret` header, to an endpoint or a free `callback_url`, only `x-webhook-timestamp` and `x-webhook-signature`.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]},{"PanelApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"$ref":"#/components/parameters/PanelOperator"},{"$ref":"#/components/parameters/AdminIdempotencyKey"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["event_type","callback_url"],"properties":{"event_type":{"type":"string","description":"One of the types GET /v1/webhooks/event-types lists, e.g. payment_request.paid; any other value answers 400 invalid_event_type and nothing is sent."},"webhook_endpoint_id":{"type":"string","description":"Added 2026-09-12. Target an account-level endpoint (its URL, signed with its own secret) instead of callback_url."},"callback_url":{"type":"string","format":"uri"}}}}}},"responses":{"200":{"description":"Delivered - `{ success: true, message, payload }`"},"400":{"description":"`parameter_missing`; `invalid_event_type` (event_type is not a relay webhook event type, nothing sent); or `invalid_url` (callback_url failed the SSRF check) (Shape B)"},"403":{"description":"`panel_endpoint_required`: a panel-credential call without `webhook_endpoint_id` (Shape B, type invalid_request_error)"},"404":{"description":"No such tenant (`tenant_not_found`, Shape A; it was Shape B `resource_missing` until 2026-10-17), or no such webhook endpoint for this tenant (`resource_missing`, Shape B)"},"502":{"description":"The receiver returned a non-2xx status or timed out"}}}},"/admin/tenants/{id}/status":{"post":{"summary":"Suspend or reactivate a tenant (operator only)","description":"An Idempotency-Key is ignored and every call runs, since it sets a state and a stored answer would report one the tenant may no longer be in. A suspended tenant's API keys are refused, its unstarted jobs are not run, and (since 2026-10-24) its anonymous checkout starts no new payment: POST /v1/payment-requests/{requestId}/intent, both Solana Pay POSTs, POST /v1/payment-links/{linkId}/sessions and /v1/x402/pay/{requestId} answer 409 tenant_not_active; confirm and the detection and x402 settlement sweeps still record a payment already sent.","tags":["Relay Gateway"],"security":[{"AdminApiKey":[]}],"parameters":[{"$ref":"#/components/parameters/AdminOperator"},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["active","suspended"]}}}}}},"responses":{"200":{"description":"Updated"},"400":{"description":"`invalid_tenant_status`: status is not active or suspended"},"404":{"description":"No such tenant (`tenant_not_found`)"},"500":{"description":"internal_error - a database failure (masked, Shape B)"}}}}},"components":{"securitySchemes":{"BearerApiKey":{"type":"http","scheme":"bearer","description":"The FastPay API key, e.g. \"Authorization: Bearer fp_live_...\" (Relay Gateway): one kind of key whatever the tenant uses FastPay for, its scopes (components/schemas/ApiKeyScope) deciding what it reaches. Issue one per person, bound to that member with member_id (required with the panel credential; a key without one is a legacy key). payouts (the relay mass payout, interim: FastPay's relay signs) and payroll (client-signed) are separate scope families; one key holds both only through the admin key or a tenant owner or admin."},"AdminApiKey":{"type":"apiKey","in":"header","name":"x-admin-api-key","description":"Shared admin key (Disbursement Engine: ADMIN_API_KEY; Relay Gateway: RELAY_ADMIN_API_KEY) - same header name, two independently configured values per process. Relay admin writes accept an Idempotency-Key (components/parameters/AdminIdempotencyKey), and every relay admin call may name the person making it in x-admin-operator (components/parameters/AdminOperator; required on POST /admin/tenants/{id}/approve). On the Relay Gateway a missing or wrong key answers 401 authentication_error / unauthorized (Shape A, since 2026-10-17), plain HTTP from the network 400 https_required, and an unknown tenant id 404 tenant_not_found; every relay admin refusal carries an error_type and an error_slug."},"PanelApiKey":{"type":"apiKey","in":"header","name":"x-panel-api-key","description":"The Relay Gateway's restricted panel credential (RELAY_PANEL_API_KEY), for the admin panel that onboards clients: accepted only on the operations that list it (the onboarding routes), HTTPS or loopback only like the admin key, and every call names its operator (components/parameters/PanelOperator). Any other /admin route answers 403 panel_route_not_allowed (Shape A; the key update and rotate among them); a wrong key, or any key while RELAY_PANEL_API_KEY is unset, 401 like the admin key; sending x-admin-api-key as well, 400 ambiguous_admin_credential. Its admin writes are audited as actor_type panel with actor_operator, and its Idempotency-Key answers are kept apart from the admin key's."}},"parameters":{"PanelOperator":{"name":"x-panel-operator","in":"header","required":false,"description":"Required with the panel credential (x-panel-api-key), ignored with the admin key: the operator behind the call, an email or an account id, stored as sent in the audit log's actor_operator. Missing or empty - 400 panel_operator_required; not of this form, or the text undefined or null in any case (what a client sends for a missing value) - 400 invalid_panel_operator (a doubled header fails too). Checked after the key and before the route's allow-list (403 panel_route_not_allowed).","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[A-Za-z0-9][A-Za-z0-9._%+@:-]*$","example":"ops@f-pay.com"}},"PanelOperatorEmail":{"name":"x-panel-operator","in":"header","required":false,"description":"x-panel-operator on the chain-lane writes (added 2026-10-21, LOG:1342): required with the panel credential, ignored with the admin key, and on these routes the person's email in lower case only (one '@', a dotted domain), so one person compares equal across the admin key and the panel in the four-eyes rule. An account id, or capital letters, are refused here - 400 invalid_panel_operator; missing or empty - 400 panel_operator_required.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$","example":"ops@f-pay.com"}},"AdminOperator":{"name":"x-admin-operator","in":"header","required":false,"description":"Added 2026-10-16. With the admin key (x-admin-api-key): the person making the call, an email or an account id of the x-panel-operator form (undefined and null, in any case, are refused), recorded as the audit row's actor_operator on every admin write the relay's routes audit themselves (null when none is named), and as the tenant's created_by.operator on POST /admin/tenants. Not recorded on the rows the payroll and custody modules write: the flagged-payroll resolution, the payroll fee mode and fee shortfall (each names its person in a body field: resolved_by, set_by, settled_by) and a custody attempt's own step rows (custody.attempt_created through custody.verified, custody.abandoned, custody.relinquished, custody.failed). Optional, except on POST /admin/tenants/{id}/approve (missing or empty - 400 admin_operator_required). On any relay admin route a value not of this form is 400 invalid_admin_operator (a doubled header fails too), checked after the admin key and before the Idempotency-Key layer. Ignored with the panel credential. Send a stable value: it is stored and compared as sent.","schema":{"type":"string","minLength":1,"maxLength":254,"pattern":"^[A-Za-z0-9][A-Za-z0-9._%+@:-]*$","example":"owner@f-pay.com"}},"AdminIdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"description":"Added 2026-10-15. Accepted on every Relay Gateway admin write (POST/PUT/PATCH/DELETE; not the Disbursement Engine's) but the ones that set a state, which ignore it and run on every call: POST /admin/turnkey-gate, /admin/pause, /admin/resume, /admin/tenants/{id}/status, /admin/tenants/{id}/limits, /admin/tenants/{id}/payout-approval-policy and /admin/tenants/{tenantId}/members/{memberId} (since 2026-10-17), and the payroll lane and network-hold writes, POST /admin/tenants/{id}/payroll-lanes/{network}/* and POST /admin/payroll/network-holds/{network}/* (since 2026-10-21). The first request with a key runs; a repeat with the same key and body (same method and path) replays its answer for RELAY_IDEMPOTENCY_RETENTION_MS (default 24 hours), so a retried POST never makes a second tenant or key. Every answer is kept, 4xx and 5xx included, but a 429, 409 tenant_not_approved and 403 approval_requires_second_operator (each wrote nothing: the same key runs again once the tenant is approved, or for another operator): after correcting a refused body, or after a 5xx, send a new key. Nor, since 2026-10-25, 409 payroll_lane_not_open (the panel credential's live key mint: the same key mints once the lane is open). Same key, different body - 409 idempotency_parameter_mismatch; the first still running - 409 idempotency_key_in_progress; a key that is empty or over 200 characters - 400 invalid_idempotency_key. Scoped to the admin key in use and checked only after it matched. The panel credential (PanelApiKey) has its own scope: its answers never replay to the admin key, nor the reverse, and the operator is not part of it. A replay of a 2xx that carries a secret (a tenant's webhookSecret, a minted or rotated key, an endpoint's signing secret) is audited with the replaying operator, for either credential.","schema":{"type":"string","minLength":1,"maxLength":200}},"ListLimit":{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20},"description":"Added 2026-09-04. Max items to return, 1-100 (default 20)."},"ListStartingAfter":{"name":"starting_after","in":"query","required":false,"schema":{"type":"string"},"description":"Added 2026-09-04. A previous page's last item id - returns the next page after it, most-recently-created first."},"ListEndingBefore":{"name":"ending_before","in":"query","required":false,"schema":{"type":"string"},"description":"Added 2026-09-12. A previous page's FIRST item id - returns the page immediately before it (newer items), still most-recently-created first, so a client can page backwards. Mutually exclusive with starting_after (400 invalid_pagination_parameter if both are given). has_more then means \"more items exist beyond this page in the direction being paged\"."},"ListCreatedGte":{"name":"created[gte]","in":"query","required":false,"schema":{"type":"string"},"description":"Added 2026-09-12. Only items created at or after this instant - an ISO-8601 timestamp (a date-only value is midnight UTC) or unix seconds. The flat alias created_gte is accepted too. 400 invalid_filter_parameter on an unparseable value."},"ListCreatedLte":{"name":"created[lte]","in":"query","required":false,"schema":{"type":"string"},"description":"Added 2026-09-12. Only items created at or before this instant; same formats as created[gte] (alias created_lte). 400 invalid_filter_parameter if it precedes created[gte]."}},"schemas":{"X402FacilitatorRequest":{"type":"object","required":["x402Version","paymentPayload","paymentRequirements"],"properties":{"x402Version":{"type":"integer","enum":[2]},"paymentPayload":{"type":"object","description":"x402 v2 PaymentPayload - { x402Version, resource?, accepted (the PaymentRequirements chosen), payload (scheme-specific; exact/EVM = { signature, authorization { from, to, value, validAfter, validBefore, nonce } }) }"},"paymentRequirements":{"type":"object","description":"x402 v2 PaymentRequirements - { scheme, network (CAIP-2), amount (atomic units, string), asset, payTo, maxTimeoutSeconds, extra { name, version, assetTransferMethod? } }"}}},"TenantCustody":{"type":"object","description":"One attempt to create a tenant-rooted Turnkey sub-organization. One row per attempt, so a half-done onboarding is a visible state rather than an absent record.\n","properties":{"id":{"type":"string","example":"cust_3cdb94098a85ae25e62d206f82b3eaa2"},"object":{"type":"string","enum":["tenant_custody"]},"tenant_id":{"type":"string"},"owner_type":{"type":"string","enum":["tenant","payment_channel"],"description":"A tenant's own payout/session wallet","or one payment channel's deposit address.":null},"owner_id":{"type":"string"},"chain":{"type":"string","enum":["ethereum","tron","solana","bitcoin","litecoin","ton","aptos","sui"]},"state":{"type":"string","enum":["requested","created","policied","delegated","verified","verification_failed","failed","abandoned","relinquished","dead_root"],"description":"requested - nothing exists at Turnkey yet. created - the sub-organization and wallet exist; FastPay is STILL a root user. policied - the DENY backstop and the chain's signing ALLOW are written; still root. delegated - the root quorum was handed to the tenant; FastPay is no longer root. verified - that handoff was read back off Turnkey. Only now is the address payable. verification_failed - the read-back disagreed; resumable. failed - a step threw and nothing was left at Turnkey. abandoned - rolled back and the sub-organization DELETED (only possible while root). relinquished - given up on after the handoff; the sub-organization still exists and the tenant roots it, so FastPay cannot delete it. dead_root - a pre-2026-09-15 sub-organization whose only root key was deleted; nobody can administer it, and a tenant on one is refused for payouts.\n"},"relay_is_root":{"type":"boolean","description":"Whether FastPay is still a root user - true only in 'created' and 'policied'."},"sub_org_id":{"type":"string","nullable":true},"sub_org_name":{"type":"string","description":"Unique per attempt","which is what lets a retry find an orphan rather than mint a second organization.":null},"wallet_id":{"type":"string","nullable":true},"address":{"type":"string","nullable":true},"relay_user_id":{"type":"string","nullable":true,"description":"FastPay's user id INSIDE that sub-organization - what every policy's consensus pins."},"tenant_root_user_id":{"type":"string","nullable":true},"policy_ids":{"type":"array","items":{"type":"string"}},"last_error":{"type":"string","nullable":true},"verified_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"DisbursementHealth":{"type":"object","description":"Disbursement Engine's own /health response shape.","properties":{"status":{"type":"string","enum":["ok","error"]},"ledger":{"type":"string","enum":["ok","unreachable"]},"paused":{"type":"boolean"}}},"DisbursementPayment":{"type":"object","description":"One payment inside a Disbursement Engine batch (POST /disburse) or a Relay mass-payout batch (POST /v1/instructions) - the same shape serves both. Additional properties beyond those listed here are permitted and preserved (e.g. your own SUBACCOUNT/PAYEE business metadata, echoed back unmodified in the CSV report) - EXCEPT STATUS/TX_HASH/ ALL_TX_HASHES/JOB_ID/FEE, which the Disbursement Engine owns exclusively and strips from every incoming payment object before validation ever runs (utils/ paymentRequestValidator.js#stripInternalPaymentFields), regardless of what value you send. Sending a payment object straight out of a previously-downloaded CSV report - which carries those same field names from that report's own snapshot - is safe for this reason.\n","required":["ALLOCATION_ID","CHAIN","WALLET","CURRENCY","AMOUNT"],"properties":{"ALLOCATION_ID":{"type":"string","maxLength":256,"description":"Caller-supplied idempotency key for this payment."},"CHAIN":{"type":"string","description":"One of utils/chainRegistry.js's VALID_CHAIN_KEYS. A valid value does not guarantee a live, configured adapter in this deployment - an unconfigured chain fails cleanly per-payment via a `failed` webhook.\n","example":"ETH"},"WALLET":{"type":"string","description":"Recipient address, validated per-chain downstream by the Disbursement Engine. POST /v1/instructions validates it against the request's network at submission (400 invalid_payment, wallet_address_invalid)."},"CURRENCY":{"type":"string","example":"USDC","description":"The chain's native asset (ETH/SOL/TRX/...) or a token configured for that chain in config/tokenConfig.js: the eleven stablecoins of config/stablecoinContracts.js (USDC, USDT, DAI, USDe, USDS, USD1, FDUSD, EURC, PYUSD, USDG, AUSD) wherever they genuinely exist, plus USDCE, Tempo's bridged USDC.e. DAI is on Ethereum, Base, Polygon, Arbitrum and BNB Chain, at 18 decimals on all five. Availability varies per chain and decimals resolve per chain rather than per ticker. In contract mode the token must also be in that chain's FastPayDisburser allowedTokens mapping - the engine pre-flights it and fails the payment with the exact setTokenAllowed call needed rather than paying for an approve that would revert. On POST /v1/instructions it is accepted in any letter case and stored canonical (usdc -> USDC, usde -> USDe).\n"},"AMOUNT":{"oneOf":[{"type":"number"},{"type":"string"}],"description":"Must be > 0 and <= MAX_AMOUNT_PER_PAYMENT (default 1,000,000)."}}},"DisburseRequest":{"type":"object","required":["payments","callbackUrl"],"properties":{"payments":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/DisbursementPayment"}},"callbackUrl":{"type":"string","format":"uri","description":"Public http(s) URL - loopback/private/link-local targets are rejected."},"resetDeduplication":{"type":"boolean","default":false,"description":"Clears ALL deduplication state before processing this batch. Use with care."},"verifyRecipients":{"type":"boolean","default":false,"description":"Optional recipient pre-flight. When true, each destination is checked against its chain's own rules before the job is grouped into batches; a payment that provably cannot succeed there is failed on its own with recipient_unavailable (204, pre_broadcast_error) and never joins a batch. The check fails open: an unreadable answer never blocks a payment. Must be a JSON boolean.\n"}}},"DisburseAccepted":{"type":"object","properties":{"jobId":{"type":"string","format":"uuid"},"message":{"type":"string"},"payments_accepted":{"type":"integer"},"duplicates_skipped":{"type":"integer"},"skipped_allocations":{"type":"array","items":{"type":"string"}},"verify_recipients":{"type":"boolean","description":"Present (true) only when the request set verifyRecipients."}}},"RecipientVerdict":{"type":"object","required":["ALLOCATION_ID","CHAIN","WALLET","CURRENCY","AMOUNT","status"],"properties":{"ALLOCATION_ID":{"type":"string"},"CHAIN":{"type":"string"},"WALLET":{"type":"string"},"CURRENCY":{"type":"string"},"AMOUNT":{"oneOf":[{"type":"number"},{"type":"string"}]},"status":{"type":"string","enum":["ok","rejected","unchecked"]},"reason":{"type":"string","description":"Why it was rejected, why nothing was learned, or a note on an ok. Always present on rejected."}}},"DisbursementErrorEnvelope":{"type":"object","description":"The Disbursement Engine's own error shape - genuinely different from the Relay Gateway's ErrorEnvelope below (a numeric error_code, no request_id) - kept separate rather than folded together.","properties":{"error_code":{"type":"integer"},"error_type":{"type":"string"},"error_slug":{"type":"string"},"error":{"type":"string"},"details":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer"},"code":{"type":"integer"},"type":{"type":"string"},"slug":{"type":"string"},"message":{"type":"string"}}}}}},"CustodialPathClosed":{"description":"The answer of every relay-signed (custodial) write since 2026-10-18 (relay/custodialGate.js; owner D1 and D6): 410, Shape A. Its message points to client-signed payroll.","allOf":[{"$ref":"#/components/schemas/ErrorEnvelope"},{"type":"object","properties":{"error_type":{"type":"string","enum":["request_error"]},"error_slug":{"type":"string","enum":["custodial_path_closed"]},"error":{"type":"string"}}}]},"CustodialPathClosedShapeB":{"description":"POST /v1/payment-channels' form of CustodialPathClosed: 410, Shape B (error.code custodial_path_closed), as the route's other refusals.","allOf":[{"$ref":"#/components/schemas/ErrorEnvelope"},{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["invalid_request_error"]},"code":{"type":"string","enum":["custodial_path_closed"]},"message":{"type":"string"}}}}}]},"ErrorEnvelope":{"type":"object","description":"The Relay Gateway's own error shape for every non-2xx response. Converged 2026-09-03 (relay/errors/envelope.js) - see /docs/errors.md for the full history. error_type/ error_slug/error_message are always present and identically named regardless of which route produced the error; branch new code on those three. The legacy `error` field is also always present, kept only for backward compatibility - it is a plain string on older routes (/v1/instructions, /v1/payment-requests, /v1/jobs/*, auth, quota, and the admin refusals that were a bare {error} until 2026-10-17: the admin key 401, HTTPS, the tenant and custody lookups, and the validation refusals of tenant create, wallets, custody, onboarding and tenant status) and a nested {type, code, message} object on newer ones (checkout sessions, /v1/payments/*, refunds, customers, payment channels, payroll, and the admin refusals that were already Shape B - the Turnkey gate, tenant approval, members, approval policy, API keys, webhook endpoints and the webhook test, list pagination, the Idempotency-Key layer) - do not rely on its type being consistent across routes. Since 2026-10-17 every /admin refusal carries an error_type and an error_slug: the bare {error} bodies gained both.\n","properties":{"error_type":{"type":"string","nullable":true,"description":"Stable category, e.g. request_error, authentication_error, quota_error, api_error."},"error_slug":{"type":"string","nullable":true,"description":"Stable, specific machine-readable reason within error_type."},"error_message":{"type":"string","nullable":true,"description":"Human-readable description. Free text - do not branch program logic on its wording."},"request_id":{"type":"string","nullable":true,"description":"Added 2026-09-05. Matches this same response's own X-Request-ID header - use to correlate a specific response with a server-side log line."},"error":{"description":"Legacy field, kept for backward compatibility only - see this schema's own top-level description.","oneOf":[{"type":"string"},{"type":"object","properties":{"type":{"type":"string"},"code":{"type":"string"},"message":{"type":"string"},"param":{"type":"string","nullable":true},"request_id":{"type":"string","nullable":true},"details":{"description":"Structured detail of a typed error, when it carries any (payroll errors, multi-field validation)."}}}]},"details":{"type":"array","nullable":true,"description":"Present on some request-validation failures (e.g. multi-field payment validation) - one entry per invalid field.","items":{"type":"object"}}}},"WebhookDelivery":{"type":"object","description":"Added 2026-09-04 (relay/webhooks/outbox.js). One attempted (or still-pending) webhook delivery.","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["webhook_delivery"]},"event_id":{"type":"string","description":"Matches the payload's own event_id field. Identifies this delivery (this row): it stays the same across the row's retries and redeliveries, but one occurrence fanned out to several targets has one row, and one event_id, per target. Dedupe one occurrence on the payload's id (evt_...)."},"allocation_id":{"type":"string"},"job_id":{"type":"string","nullable":true},"action":{"type":"string"},"status":{"type":"string"},"callback_url":{"type":"string"},"attempts":{"type":"integer"},"last_error":{"type":"string","nullable":true,"description":"Free-text failure summary."},"response_status":{"type":"integer","nullable":true,"description":"The receiver's HTTP status code from the most recent delivery attempt. Null if no HTTP response was ever received (a network-level failure - timeout, DNS, connection refused)."},"response_body":{"type":"string","nullable":true,"description":"The receiver's response body from the most recent delivery attempt, truncated to 4096 characters. Null under the same conditions as response_status."},"delivered":{"type":"boolean","description":"True once delivered - or once closed unsent, with last_error saying why: its account-level endpoint was deleted or disabled, or (Mass Payout payment.* events only) a later payment.* event for the same allocation already reached the same target."},"delivered_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"ReconciliationReport":{"type":"object","description":"Added 2026-09-03 (relay/reconciliation/report.js). Cross-references business/on-chain/ledger state for every payment that reached 'paid' within the requested period.","properties":{"object":{"type":"string","enum":["reconciliation_report"]},"tenant_id":{"type":"string"},"period":{"type":"object","properties":{"from":{"type":"string","format":"date-time"},"to":{"type":"string","format":"date-time"}}},"summary":{"type":"object","properties":{"payments_paid":{"type":"integer"},"total_settlement_amount_by_currency":{"type":"object","additionalProperties":{"type":"string"},"description":"Keyed by payments.settlement_currency (the fiat side for a checkout_session payment). An exact sum in cents, two decimals (\"4180.00\")."},"payments_with_discrepancy":{"type":"integer"}}},"discrepancies":{"type":"array","items":{"type":"object","properties":{"payment_id":{"type":"string"},"type":{"type":"string","enum":["chain_unconfirmed","chain_reorged","ledger_missing","ledger_amount_mismatch"]},"detail":{"type":"string"}}}}}},"PayrollPayerWallet":{"type":"object","description":"Client-signed payroll (2026-10-05). A wallet the tenant pays payroll FROM; FastPay holds no key for it.","properties":{"id":{"type":"string","description":"pyw_..."},"object":{"type":"string","enum":["payroll_payer_wallet"]},"network":{"type":"string"},"chain_ref":{"type":"string","description":"FastPay-local chain id, e.g. eip155:8453, aptos:1, sui:<id>, ton:-239; mock:... for sandbox."},"address":{"type":"string","nullable":true,"description":"null on TON until the proof names it."},"address_kind":{"type":"string","nullable":true},"proof_timing":{"type":"string","enum":["challenge_first","at_connect"]},"status":{"type":"string","enum":["pending_verification","verified","revoked"]},"verification_method":{"type":"string","nullable":true,"enum":["eip191","eip1271","tip191_v2","solana_sign_message","aptos_sign_message","sui_sign_personal_message","ton_proof","stx_sign_structured_message","stacks_control_tx","sandbox",null]},"verified_at":{"type":"string","format":"date-time","nullable":true},"revoked_at":{"type":"string","format":"date-time","nullable":true},"reverification_reason":{"type":"string","nullable":true,"description":"Why an intent sent the wallet back to verification."},"label":{"type":"string","nullable":true},"challenge":{"type":"string","nullable":true,"description":"Shown only while pending verification. Signing it authorizes no payment."},"challenge_expires_at":{"type":"string","format":"date-time","nullable":true},"challenge_format":{"type":"string","enum":["personal_sign","tip191_v2","solana_sign_message","aptos_sign_message","sui_personal_message","ton_proof","stx_sign_structured_message","bitcoin_message"],"description":"On register/challenge responses: how the network's wallet signs the challenge (the sandbox answers the same format as live)."},"challenge_extra":{"type":"object","description":"On register/challenge responses: the rest of the chain's signing envelope."},"nonce":{"type":"string","description":"TON only: the ton_proof payload."},"ton_proof_payload":{"type":"string","description":"TON only: same as nonce."},"accepted_domains":{"type":"array","items":{"type":"string"},"description":"TON only: domains a ton_proof may name."},"created_by_member_id":{"type":"string","nullable":true,"description":"2026-10-21: the member who registered it (several makers per chain); null for a legacy key."},"verified_by_member_id":{"type":"string","nullable":true,"description":"2026-10-21: the member who proved it."},"is_test":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"PayrollBatchCreate":{"type":"object","required":["network","currency","payer_wallet_id","items"],"properties":{"network":{"type":"string"},"currency":{"type":"string","description":"A token symbol (tokens only; the native coin is refused), in any letter case; stored canonical (usde -> USDe)."},"payer_wallet_id":{"type":"string","description":"A verified payer wallet on the same network."},"reference":{"type":"string","maxLength":256},"callback_url":{"type":"string","description":"A public https URL."},"approval":{"type":"string","enum":["required"]},"items":{"type":"array","minItems":1,"description":"At most RELAY_PAYROLL_MAX_ITEMS_PER_BATCH (default 500).","items":{"type":"object","required":["reference","amount"],"properties":{"reference":{"type":"string","maxLength":256,"description":"Unique per tenant and mode among items that are not failed or cancelled; a completed item keeps its reference (409 duplicate_item_reference)."},"recipient_address":{"type":"string"},"recipient_id":{"type":"string","description":"rcp_... - resolved to its address on the network. One of recipient_address / recipient_id is required."},"amount":{"type":"string","description":"A positive decimal string in token units, at most RELAY_PAYROLL_MAX_AMOUNT_PER_ITEM whole units (default 1000000, MAX_AMOUNT_PER_PAYMENT; 2026-10-16): above it, an invalid_items problem."}}}}}},"PayrollBatch":{"type":"object","properties":{"id":{"type":"string","description":"pyb_..."},"object":{"type":"string","enum":["payroll_batch"]},"status":{"type":"string","enum":["awaiting_approval","open","closing","completed","closed","cancelled"]},"network":{"type":"string"},"chain_ref":{"type":"string"},"currency":{"type":"string"},"token_address":{"type":"string"},"payer_wallet_id":{"type":"string"},"payer_address":{"type":"string"},"item_count":{"type":"integer"},"total_amount":{"type":"string"},"approvals_required":{"type":"integer"},"approvals":{"type":"array","description":"Each approve/reject decision, oldest first, in a mass-payout job's decision shape (member_name added 2026-10-17, audit O15).","items":{"$ref":"#/components/schemas/JobApproval"}},"expires_at":{"type":"string","format":"date-time","description":"End of the batch's lifetime (RELAY_PAYROLL_BATCH_LIFETIME_SECONDS)."},"cancel_requested_at":{"type":"string","format":"date-time","nullable":true},"closed_at":{"type":"string","format":"date-time","nullable":true},"counts":{"type":"object","description":"Item counts per status.","additionalProperties":{"type":"integer"}},"reference":{"type":"string","nullable":true},"callback_url":{"type":"string","nullable":true},"is_test":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"PayrollItem":{"type":"object","properties":{"id":{"type":"string","description":"pyi_..."},"object":{"type":"string","enum":["payroll_item"]},"batch_id":{"type":"string"},"position":{"type":"integer"},"reference":{"type":"string"},"recipient":{"type":"string"},"recipient_id":{"type":"string","nullable":true},"amount":{"type":"string"},"fee":{"type":"string","nullable":true,"description":"Charged on top, from the payer; set once quoted."},"status":{"type":"string","enum":["pending","authorized","completed","failed","cancelled"]},"allocation_id":{"type":"string","description":"The item's on-chain replay-guard id (0x + 64 hex)."},"authorization_id":{"type":"string","nullable":true},"attempt_count":{"type":"integer"},"last_failure_reason":{"type":"string","nullable":true},"tx_hash":{"type":"string","nullable":true},"chain_position":{"type":"string","nullable":true},"completed_at":{"type":"string","format":"date-time","nullable":true},"is_test":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"PayrollAuthorizationView":{"type":"object","description":"One co-signed chunk of an intent (served as transactions[]).","properties":{"authorization_id":{"type":"string","description":"pya_..."},"chunk_index":{"type":"integer"},"status":{"type":"string","enum":["issuing","live","flagged","settled","expired","void","deployment_retired"]},"auth_scheme":{"type":"string","enum":["batch","per_entry","none"],"description":"none: Bitcoin - FastPay signed nothing."},"entry_match":{"type":"string","enum":["all_entries","independent"]},"expires_at":{"type":"string","format":"date-time","description":"Executable by the payer wallet until then; cannot be revoked."},"flag_kind":{"type":"string","enum":["suspect","trust_mismatch","incomplete_group","deployment_mismatch"]},"flagged_reason":{"type":"string","nullable":true},"groups":{"type":"array","items":{"type":"object","properties":{"group_index":{"type":"integer"},"item_ids":{"type":"array","items":{"type":"string"}},"digest":{"type":"string","nullable":true},"entry_digests":{"type":"array","nullable":true,"items":{"type":"string"}}}}},"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"position":{"type":"integer","nullable":true},"reference":{"type":"string","nullable":true},"recipient":{"type":"string"},"amount":{"type":"string"},"fee":{"type":"string"},"status":{"type":"string","description":"The item's status, or released when this authorization no longer holds it."}}}},"totals":{"$ref":"#/components/schemas/PayrollTotals"},"payloads":{"type":"array","description":"Unsigned transactions for the payer wallet, one per group still holding an unpaid item. Omitted on the intents list. format evm_tx (transaction {walletChain, from, to, data, value, gas_limit}, digest), tron_tx (wallet_chain, purpose, tx_ref, transaction {visible, txID, raw_data, raw_data_hex}, fee_limit_sun, expiration, permission_id), solana_tx (wallet_chain, transaction - a base64 legacy transaction the payer signs as fee payer, blockhash, last_valid_block_height, size_bytes, envelope_valid_for_ms - the envelope's remaining life on the relay's clock when built), aptos_entry_function (wallet_chain, sender, data, options), sui_tx (wallet_chain, transport, transaction, coin_type, config_object_id, package_id, total, execution, tx_ref for bcs), ton_connect (request {validUntil, network, from, messages}), stacks_contract_call (wallet_chain, transaction - the stx_callContract request {contract, functionName, functionArgs, postConditionMode deny, postConditions, network, sponsored} - cap, fee_payment), Bitcoin psbt_v0_base64 (kind bitcoin_psbt: version, psbt_base64, unsigned_txid, inputs, outputs, fee_output {address, value, attestation} or null when prepaid, miner_fee_sats, fee_rate_sat_vb, verify; a multisig vault adds multisig). Plus group_index, item_ids, envelope_expires_at where the envelope expires, and warnings.\n","items":{"type":"object","additionalProperties":true}},"envelope_expires_at":{"type":"string","format":"date-time","nullable":true},"cancel_payload":{"type":"object","description":"Bitcoin only: an unsigned PSBT spending the reserved inputs back to the payer. Signed, broadcast and confirmed, it makes every served version unexecutable.","properties":{"version":{"type":"integer"},"psbt_base64":{"type":"string"},"unsigned_txid":{"type":"string"},"fee_rate_sat_vb":{"type":"string"}}},"from":{"type":"string","description":"Single evm_tx payload only."},"to":{"type":"string","description":"Single evm_tx payload only: the disburser."},"data":{"type":"string","description":"Single evm_tx payload only."},"value":{"type":"string","description":"Single evm_tx payload only."},"gas_limit":{"type":"integer","description":"Single evm_tx payload only - send with exactly this gas limit."},"digest":{"type":"string","description":"Single evm_tx payload only."}}},"PayrollTotals":{"type":"object","properties":{"amount":{"type":"string"},"fee":{"type":"string"},"total":{"type":"string"}}},"PayrollIntent":{"type":"object","description":"Client-signed payroll (2026-10-05). The authorizations FastPay co-signed and the unsigned transactions the payer wallet signs and sends.","properties":{"id":{"type":"string","description":"pyx_..."},"object":{"type":"string","enum":["payroll_intent"]},"batch_id":{"type":"string"},"network":{"type":"string"},"chain_ref":{"type":"string"},"chain_id":{"type":"integer","description":"EVM only."},"payer":{"type":"string"},"token":{"type":"string"},"currency":{"type":"string"},"fee_bps":{"type":"integer","description":"The fee is charged on top of each amount, pulled from the payer."},"expires_at":{"type":"string","format":"date-time"},"flagged":{"type":"boolean","description":"On the intents list only."},"deployment":{"type":"object","properties":{"key":{"type":"string"},"disburser":{"type":"string"}}},"pre_steps":{"type":"array","nullable":true,"description":"EVM and Tron: the exact approve(s) to send first and wait for (reset_allowance then set_allowance for a token that requires it; Tron serves them as tron_tx payloads). Empty elsewhere. null when it could not be computed at POST - GET the intent before sending.","items":{"type":"object","additionalProperties":true}},"allowance":{"type":"object","description":"Exact-approve chains only.","properties":{"current":{"type":"string","nullable":true},"required":{"type":"string","description":"Base units: amount + fee of every still-unpaid item of the live authorizations."},"calls":{"type":"array","items":{"type":"object","properties":{"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"},"purpose":{"type":"string","enum":["reset_allowance","set_allowance"]}}}}}},"transactions":{"type":"array","items":{"$ref":"#/components/schemas/PayrollAuthorizationView"}},"totals":{"$ref":"#/components/schemas/PayrollTotals"},"balances":{"type":"array","description":"POST only.","items":{"type":"object","properties":{"asset":{"type":"string"},"required":{"type":"string"},"available":{"type":"string"},"blocking":{"type":"boolean"}}}},"payer_balance":{"type":"string","nullable":true,"description":"POST only: the payer's token balance in base units."},"fee_mode":{"type":"string","enum":["in_tx_debt","stacks_prepaid"],"description":"Bitcoin only (absent elsewhere): how this intent's FastPay fee is paid, fixed when it was issued."},"fee_settlement":{"type":"object","nullable":true,"description":"Bitcoin only: the prepaid fee quote a stacks_prepaid intent consumed; null for in_tx_debt.","properties":{"fee_quote_id":{"type":"string"},"reference":{"type":"string","description":"0x + 64 hex: the reference the Stacks payment carried."},"stacks_txids":{"type":"array","items":{"type":"string"}}}},"warnings":{"type":"array","items":{"type":"string"}},"note":{"type":"string"},"is_test":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}}},"PayrollConfirmation":{"type":"object","properties":{"object":{"type":"string","enum":["payroll_confirmation"]},"batch_id":{"type":"string"},"report_kind":{"type":"string","enum":["tx_hash","boc"]},"tx_hash":{"type":"string","nullable":true},"status":{"type":"string","enum":["applied","flagged","confirming","deferred","reorged","pending","reverted","unrelated"]},"confirmations":{"type":"integer","nullable":true,"description":"EVM depth count while confirming."},"required_confirmations":{"type":"integer","nullable":true},"authorization_ids":{"type":"array","items":{"type":"string"}},"transactions":{"type":"array","items":{"type":"object","properties":{"tx_ref":{"type":"string"},"status":{"type":"string"}}}},"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","nullable":true}}}},"is_test":{"type":"boolean"}}},"PayrollRestoreFence":{"type":"object","description":"The payroll restore fence (2026-10-18). Only `object` and `set` while no fence is set.","required":["object","set"],"properties":{"object":{"type":"string","enum":["payroll_restore_fence"]},"set":{"type":"boolean"},"restore_point":{"type":"string","format":"date-time"},"data_loss_at":{"type":"string","format":"date-time"},"set_by":{"type":"string"},"set_at":{"type":"string","format":"date-time"},"reason":{"type":"string","nullable":true},"families":{"type":"array","items":{"type":"object","properties":{"family":{"type":"string","enum":["evm","tron","solana","aptos","sui","ton","stacks","bitcoin"]},"clears":{"type":"string","enum":["family","per_payer"]},"cleared":{"type":"boolean","description":"clears family."},"clearable_after":{"type":"string","format":"date-time","description":"clears family: the earliest time it may be cleared."},"cleared_at":{"type":"string","format":"date-time","nullable":true},"cleared_by":{"type":"string","nullable":true},"cleared_payers":{"type":"array","description":"clears per_payer (Bitcoin).","items":{"type":"object","properties":{"payer":{"type":"string"},"cleared_at":{"type":"string","format":"date-time"},"cleared_by":{"type":"string"},"basis":{"type":"string","enum":["self_spend","attestation"]}}}}}}}}},"PayrollFeeQuote":{"type":"object","description":"Bitcoin stacks_prepaid (2026-10-06). The FastPay fee of a batch's next intent, prepaid on Stacks before the PSBT is served.","properties":{"id":{"type":"string","description":"pfs_... - the fee_quote_id POST .../intents takes."},"object":{"type":"string","enum":["payroll_fee_quote"]},"batch_id":{"type":"string"},"intent_id":{"type":"string","description":"pyx_... - the id the intent will take; reference is derived from it."},"network":{"type":"string"},"status":{"type":"string","enum":["quoted","paid","issuing","consumed","expired","abandoned"]},"mode":{"type":"string","enum":["stacks_prepaid"]},"item_ids":{"type":"array","items":{"type":"string"},"description":"Exactly the items the intent will pay."},"amount":{"type":"string","description":"The fee in the settlement token's base units (8 decimals)."},"token":{"type":"string","description":"The settlement token contract on Stacks."},"settlement_network":{"type":"string"},"settlement_chain_id":{"type":"string"},"reference":{"type":"string","description":"0x + 64 hex the payment must carry."},"expires_at":{"type":"string","format":"date-time"},"call":{"type":"object","additionalProperties":true,"description":"The pay-service-fee contract call to sign on Stacks (functionName, arguments, ...), and `payer`: the one Stacks address the authorization lets pay it (the quote's stacks_payer, canonical). Sent from any other address, the call aborts."},"paid_amount":{"type":"string","nullable":true},"stacks_txids":{"type":"array","items":{"type":"string"}},"is_test":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}}},"Payout":{"type":"object","description":"Added 2026-09-03. Deliberately carries no fee/conversion breakdown - that fee is paid on-chain from the tenant's own smart account to FastPayDisburser (in a transaction FastPay's relay signs - interim), not tracked in this off-chain row.","properties":{"id":{"type":"string","description":"The tenant's own ALLOCATION_ID from POST /v1/instructions."},"object":{"type":"string","enum":["payout"]},"job_id":{"type":"string"},"network":{"type":"string","enum":["ethereum","base","polygon","arbitrum","bnb","avalanche","robinhood","linea","optimism","monad","megaeth","hyperevm","tron","solana","bitcoin","litecoin","aptos","ton","sui"]},"destination":{"type":"string"},"recipient_id":{"type":"string","nullable":true,"description":"The recipient this payment named (2026-09-12), null for an inline WALLET."},"currency":{"type":"string"},"amount":{"type":"string"},"status":{"type":"string"},"tx_hash":{"type":"string","nullable":true},"error_message":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"RecipientAddress":{"type":"object","properties":{"network":{"type":"string"},"address":{"type":"string","description":"Stored in the network's canonical form (EIP-55 on EVM, base58 on Solana/Tron, 0x+64 hex on Aptos/Sui, user-friendly on TON, bech32/base58 on Bitcoin)."},"label":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"RecipientInput":{"type":"object","properties":{"name":{"type":"string","maxLength":200,"description":"Required on create."},"type":{"type":"string","enum":["individual","business"],"default":"individual"},"email":{"type":"string","format":"email","nullable":true},"metadata":{"type":"object","additionalProperties":true,"description":"At most 50 keys, keys <= 40 chars, scalar values, strings <= 500 chars."},"addresses":{"type":"array","description":"One entry per network. On update, replaces the whole set.","items":{"type":"object","required":["network","address"],"properties":{"network":{"type":"string","enum":["ethereum","base","polygon","arbitrum","bnb","avalanche","robinhood","linea","optimism","monad","megaeth","hyperevm","tron","solana","bitcoin","litecoin","aptos","ton","sui"]},"address":{"type":"string"},"label":{"type":"string","nullable":true,"maxLength":100}}}}}},"Recipient":{"type":"object","description":"A named payee (2026-09-12) with one payout address per network, referenced from POST /v1/instructions by RECIPIENT_ID.","properties":{"id":{"type":"string","example":"rcp_..."},"object":{"type":"string","enum":["recipient"]},"name":{"type":"string"},"type":{"type":"string","enum":["individual","business"]},"email":{"type":"string","nullable":true},"status":{"type":"string","enum":["active","archived"]},"metadata":{"type":"object","additionalProperties":true},"addresses":{"type":"array","items":{"$ref":"#/components/schemas/RecipientAddress"}},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"BlockchainTransaction":{"type":"object","description":"Added 2026-09-03. One payment attempt - a payer can retry with a different currency/network, so a payment may have more than one of these.","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["blockchain_transaction"]},"payment_id":{"type":"string"},"network":{"type":"string"},"currency":{"type":"string"},"amount":{"type":"string"},"address":{"type":"string"},"status":{"type":"string"},"tx_hash":{"type":"string","nullable":true},"confirmations":{"type":"integer","nullable":true,"description":"Real, live-updated confirmation count on every checkout network (relay/confirmationSweep.js, polling at RELAY_CONFIRMATION_SWEEP_INTERVAL_MS). On the EVM networks (ethereum/base/polygon/arbitrum/bnb/avalanche/robinhood/linea/optimism/monad/megaeth/hyperevm) it grows with real block depth; on Tron, Solana, Aptos, TON and Sui (no depth concept) it is binary - 1 once independently re-verified landed, never a fabricated intermediate value (docs/confirmation-policy.md). Null until the sweep has counted the attempt."},"required_confirmations":{"type":"integer","nullable":true,"description":"The threshold confirmations must reach before this transaction is considered final: RELAY_REQUIRED_CONFIRMATIONS_<NETWORK> (default 12) on every configured EVM network; a fixed 1 on Tron, Solana, Aptos, TON and Sui (no depth to tune - see the confirmations field's own description). Null only for a network the confirmation sweep does not cover."},"detected_at":{"type":"string","format":"date-time","nullable":true},"confirmed_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"Payment":{"type":"object","description":"Relay Gateway's own Payment resource (GET /v1/payments/{id}) - distinct from DisbursementPayment above, which is a mass-payout batch entry, not a retrievable resource.","properties":{"object":{"type":"string","enum":["payment"]},"id":{"type":"string"},"status":{"type":"string"},"lifecycle":{"type":"array","items":{"type":"object","properties":{"status":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}}},"financial":{"type":"object","properties":{"originalAmount":{"type":"string"},"originalCurrency":{"type":"string"},"paymentAmount":{"type":"string"},"paymentCurrency":{"type":"string"},"exchangeRate":{"type":"string"},"gatewayFee":{"type":"string","nullable":true},"networkFee":{"type":"string","nullable":true},"conversionFee":{"type":"string","nullable":true},"netSettlement":{"type":"string","nullable":true},"settlementCurrency":{"type":"string","nullable":true},"settlementType":{"type":"string","enum":["ledger_credit"],"description":"The only settlement model this API implements today (utils/settlementTypes.js): crypto moves directly payer -> merchant's own wallet, non-custodial, and this is a bookkeeping record of that fact - not a real balance FastPay holds or could withdraw on your behalf. Always 'ledger_credit'."},"refundedAmount":{"type":"string","description":"Sum of confirmed + pending refunds, in paymentCurrency."},"refundableAmount":{"type":"string","description":"paymentAmount minus refundedAmount."}}},"blockchain":{"type":"object","nullable":true,"properties":{"network":{"type":"string"},"asset":{"type":"string"},"address":{"type":"string"},"txHash":{"type":"string","nullable":true},"confirmations":{"type":"integer","nullable":true,"description":"Real, live-updated confirmation count on every checkout network (relay/confirmationSweep.js): a growing block depth on the EVM chains, 1 once landed on Tron, Solana, Aptos, TON and Sui (docs/confirmation-policy.md). Null until the sweep has counted the attempt. See GET /v1/payments/:id/transactions for the per-attempt breakdown plus each attempt's required_confirmations threshold."}}},"related":{"type":"object","properties":{"checkoutSessionId":{"type":"string"},"customerId":{"type":"string","nullable":true},"refundIds":{"type":"array","items":{"type":"string"}},"ledgerTransactionIds":{"type":"array","items":{"type":"string"}}}}}},"Refund":{"type":"object","properties":{"id":{"type":"string","example":"re_..."},"object":{"type":"string","enum":["refund"]},"payment_id":{"type":"string"},"status":{"type":"string","enum":["pending","confirmed","failed","expired"],"description":"failed - its own transaction reverted on chain, or its transaction could not be built; expired - not confirmed within RELAY_REFUND_PENDING_EXPIRY_HOURS (default 72) of its creation. Both release the reserved amount.\n"},"tx_hash":{"type":"string","nullable":true,"description":"The transaction that confirmed or failed it; null on a new refund (the creation answer carries it, null, since 2026-10-17)."},"failure_reason":{"type":"string","nullable":true,"enum":["tx_reverted","build_failed","expired",null],"description":"Why it ended without confirming; null on a new refund (the creation answer carries it, null, since 2026-10-17)."},"amount":{"type":"string"},"currency":{"type":"string"},"destination":{"type":"string"},"reason":{"type":"string","nullable":true},"transaction":{"nullable":true,"description":"Present only on creation - the unsigned transaction for the merchant's own wallet to sign. Shape varies by network, mirroring PaymentIntent's own transaction field: every EVM network (ethereum/base/polygon/arbitrum/bnb/avalanche/robinhood/linea/optimism/monad/ megaeth/hyperevm) returns {chainId, from, to, data, value}; Tron returns a full unsigned transaction object under this same \"transaction\" field name; Solana returns a base64-serialized unsigned VersionedTransaction as a plain string. Aptos/Sui/TON (2026-09-10) return a plain native-coin transfer in the same wallet-intent shape their PaymentIntent uses (an AIP-62 payload plus `sender`, a Transaction.toJSON() string, a TonConnect request). A TON refund is confirmed against the MERCHANT wallet's own transaction history, so the txHash reported to /v1/refunds/{id}/confirm must be that wallet's own transaction hash (64 hex chars or base64/base64url).\n","oneOf":[{"type":"object","properties":{"chainId":{"type":"integer"},"from":{"type":"string"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string","nullable":true}}},{"type":"object","description":"Tron - a full unsigned transaction object."},{"type":"string","description":"Solana - base64-serialized unsigned VersionedTransaction."}]},"created_at":{"type":"string","format":"date-time"},"is_test":{"type":"boolean","description":"Inherited from the underlying payment's own is_test - see the Sandbox / test mode section in payment-gateway-api.md."}}},"PaymentRequestCreate":{"type":"object","required":["reference","network","currency","amount","expiresAt"],"properties":{"reference":{"type":"string","maxLength":200},"network":{"type":"string","enum":["ethereum","base","polygon","arbitrum","bnb","avalanche","robinhood","linea","optimism","monad","megaeth","hyperevm","solana","tron","aptos","sui","ton"]},"currency":{"type":"string","description":"Accepted in any letter case and stored canonical (usde/USDE -> USDe, usdc -> USDC, sol -> SOL); the response and webhooks carry that spelling. Widened 2026-09-12 to the eleven supported stablecoins wherever they exist on the chain (config/stablecoinContracts.js). ethereum: ETH/USDC/USDT/DAI/USDe/USDS/USD1/FDUSD/EURC/PYUSD/USDG/AUSD; base: ETH/USDC/DAI/USDe/USDS/EURC/AUSD; polygon: POL/USDC/DAI/AUSD; arbitrum: ETH/USDC/DAI/USDe/USDS/PYUSD/AUSD; bnb: BNB/USDC/USDT/DAI/USDe/USD1/FDUSD/AUSD; avalanche: AVAX/USDC/USDT/USDe/EURC/AUSD; robinhood: ETH/USDe/USDG; linea: ETH/USDC/USDe; optimism: ETH/USDC/USDT/USDe; monad: MON/USDC/USDe/AUSD; megaeth: ETH/USDe (no USDC at all); hyperevm: HYPE/USDC/USDe (all four added 2026-09-14); solana: SOL plus all ten; tron: TRX/USDC/USDT/USD1 (Tron's USDC is a Circle-discontinued token - genuine contract, no issuer support since February 2024 and no redemption with Circle; offered deliberately for anyone who wants it, and not allowlisted on the Tron disburser today, so it returns 409 currency_not_allowlisted); aptos: APT/USDC/USDT/USDe (fungible assets, added 2026-09-13 - entries are metadata object addresses, and all three are FA-only with no coin type at all; the allowlist is set by set_fa_allowed(module, metadata, true) after a one-time initialize_fa); sui: SUI/USDC/FDUSD/AUSD (added 2026-09-13 - Sui entries are Move coin types, not addresses, and Sui's USDT is a bridged asset, deliberately absent); ton: TON/USDT/USDe/FDUSD (jettons, added 2026-09-13 - entries are jetton master addresses, and there is deliberately no USDC because Circle issues none on TON; on TON the allowlist is keyed on the disburser's own jetton wallet and set by set_jetton_wallet_allowed(wallet, true)). Still no USDT on base/polygon/arbitrum/linea (the address commonly called USDT there is a different asset, USDT0). Decimals resolve per chain, not per ticker (BNB USDC/USDT are 18; Solana USDe is 9, USDS/USD1/FDUSD 6). A currency accepted here is not automatically payable: the chain FastPayDisburser must also allowlist the token on-chain - POST .../intent returns 409 currency_not_allowlisted otherwise (on Sui the allowlist is keyed on the coin type and set by set_coin_type_allowed<T>(true))."},"amount":{"type":"string","description":"Positive decimal string"},"recipient":{"type":"string","description":"Optional if a wallet is pre-registered for this network via /admin/tenants/:id/wallets"},"expiresAt":{"type":"string","format":"date-time","description":"Future, at most 7 days out"},"callbackUrl":{"type":"string","format":"uri"},"metadata":{"type":"object"}}},"PaymentRequest":{"allOf":[{"$ref":"#/components/schemas/PaymentRequestCreate"},{"type":"object","properties":{"id":{"type":"string","example":"pr_..."},"status":{"type":"string","enum":["pending","paid","paid_after_expiry","expired"]},"tenantId":{"type":"string"},"txHash":{"type":"string","nullable":true},"payer":{"type":"string","nullable":true,"description":"The wallet the latest checkout intent was built for. Never verified, and never a refund destination."},"settledPayer":{"type":"string","nullable":true,"description":"Added 2026-10-18. The payer the settlement was verified from on chain (the disburser's event, or the x402 facilitator) - the default refund destination and the payer a payment_request.paid webhook names. Null until paid, and on a TON or sandbox payment or one recorded before 2026-10-18."},"isTest":{"type":"boolean","description":"true if created with an fp_test_-prefixed API key - see Sandbox / test mode."},"chainId":{"type":"integer","description":"Added 2026-09-23. EVM networks only, absent otherwise. The numeric chain id, so a checkout client can request the right chain when opening a WalletConnect session (which must name its chain up front)."},"checkoutUrl":{"type":"string","format":"uri","description":"Added 2026-09-11. The hosted checkout page URL for this request."},"qr":{"$ref":"#/components/schemas/PaymentRequestQr"},"paymentLinkId":{"type":"string","nullable":true,"description":"Added 2026-09-11. The reusable payment link this request was opened from (plink_...), null for a directly-created request."}}}]},"PaymentLinkCreate":{"type":"object","required":["network","currency"],"properties":{"network":{"type":"string","description":"Any checkout network POST /v1/payment-requests accepts."},"currency":{"type":"string","description":"Any currency POST /v1/payment-requests accepts on that network, in any letter case; stored canonical (usde -> USDe), and every request the link opens carries that spelling."},"amount":{"type":"string","nullable":true,"description":"Fixed amount (decimal string). Omit for a customer-entered amount."},"min_amount":{"type":"string","nullable":true,"description":"Lower bound for a customer-entered amount (not with amount)."},"max_amount":{"type":"string","nullable":true,"description":"Upper bound for a customer-entered amount (not with amount)."},"recipient":{"type":"string","description":"Optional when a payout wallet is registered for the network."},"reference_prefix":{"type":"string","description":"1-100 chars of [A-Za-z0-9._-]; each opened request's reference is <prefix>-<8 hex>. Default: the link id."},"session_ttl_seconds":{"type":"integer","minimum":60,"maximum":604800,"description":"How long a visitor has to pay once they open the link. Default RELAY_PAYMENT_LINK_SESSION_TTL_SECONDS (1800)."},"max_uses":{"type":"integer","minimum":1,"nullable":true,"description":"Counts PAID requests; an abandoned open never consumes a use. Checked when the link is opened and again when an intent is served for one of its requests (409 payment_link_unavailable); an intent already served can still be paid until its authorization expires."},"expires_at":{"type":"string","format":"date-time","nullable":true},"metadata":{"type":"object","additionalProperties":true,"description":"Copied onto every request the link opens."},"callback_url":{"type":"string","format":"uri","nullable":true,"description":"Copied onto every request the link opens (SSRF-checked)."}}},"PaymentLinkQr":{"type":"object","description":"The link's QR codes - checkout (its page URL, any network) always; solanaPay only for a live, fixed-amount Solana link (a reusable Solana Pay transaction request: each scan opens the link).","properties":{"checkout":{"type":"object","properties":{"payload":{"type":"string","format":"uri"},"svgUrl":{"type":"string","format":"uri"}}},"solanaPay":{"type":"object","properties":{"payload":{"type":"string"},"svgUrl":{"type":"string","format":"uri"}}}}},"PaymentLink":{"type":"object","description":"The tenant-facing payment link (added 2026-09-11). snake_case, object payment_link.","properties":{"id":{"type":"string","example":"plink_..."},"object":{"type":"string","enum":["payment_link"]},"network":{"type":"string"},"currency":{"type":"string"},"amount":{"type":"string","nullable":true},"min_amount":{"type":"string","nullable":true},"max_amount":{"type":"string","nullable":true},"recipient":{"type":"string"},"reference_prefix":{"type":"string"},"session_ttl_seconds":{"type":"integer"},"max_uses":{"type":"integer","nullable":true},"uses":{"type":"integer","description":"Paid requests opened from this link."},"status":{"type":"string","enum":["active","inactive"]},"available":{"type":"boolean","description":"Whether the link opens right now."},"unavailable_reason":{"type":"string","nullable":true,"enum":["inactive","expired","exhausted",null]},"expires_at":{"type":"string","format":"date-time","nullable":true},"metadata":{"type":"object","additionalProperties":true},"callback_url":{"type":"string","nullable":true},"is_test":{"type":"boolean"},"url":{"type":"string","format":"uri","description":"The link's page - /checkout/link.html?id=plink_..."},"qr":{"$ref":"#/components/schemas/PaymentLinkQr"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"PaymentLinkPublic":{"type":"object","description":"What the link's page renders - never metadata, callback_url, the tenant id or the use count.","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["payment_link"]},"merchant_name":{"type":"string","nullable":true},"network":{"type":"string"},"currency":{"type":"string"},"amount":{"type":"string","nullable":true},"min_amount":{"type":"string","nullable":true},"max_amount":{"type":"string","nullable":true},"recipient":{"type":"string"},"status":{"type":"string"},"available":{"type":"boolean"},"unavailable_reason":{"type":"string","nullable":true},"expires_at":{"type":"string","format":"date-time","nullable":true},"is_test":{"type":"boolean"},"url":{"type":"string","format":"uri"},"qr":{"$ref":"#/components/schemas/PaymentLinkQr"}}},"PaymentLinkSession":{"type":"object","properties":{"payment_request_id":{"type":"string","example":"pr_..."},"payment_link_id":{"type":"string","example":"plink_..."},"checkout_url":{"type":"string","format":"uri","description":"The hosted checkout page for the created request."},"expires_at":{"type":"string","format":"date-time"}}},"PaymentRequestQr":{"type":"object","description":"Added 2026-09-11. The QR code(s) that put this payment request in front of a customer - each as the text the code encodes (`payload`, render it with any QR library) and a ready-made image (`svgUrl`, GET .../qr). `checkout` is always present; `solanaPay` only for a live (non-sandbox) Solana request. Every link is built from RELAY_PUBLIC_BASE_URL, else the calling request's own origin.\n","properties":{"checkout":{"type":"object","properties":{"payload":{"type":"string","format":"uri","description":"The hosted checkout page URL."},"svgUrl":{"type":"string","format":"uri"}}},"solanaPay":{"type":"object","properties":{"payload":{"type":"string","description":"A Solana Pay transaction-request URL, `solana:https://.../solana-pay`."},"svgUrl":{"type":"string","format":"uri"}}}}},"PaymentRequestPublic":{"type":"object","properties":{"id":{"type":"string"},"reference":{"type":"string"},"network":{"type":"string"},"currency":{"type":"string"},"amount":{"type":"string"},"recipient":{"type":"string"},"status":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"},"txHash":{"type":"string","nullable":true},"redirectUrl":{"type":"string","nullable":true},"isTest":{"type":"boolean"},"chainId":{"type":"integer","description":"Added 2026-09-23. EVM networks only, absent otherwise. The numeric chain id, so a checkout client can request the right chain when opening a WalletConnect session (which must name its chain up front)."},"checkoutUrl":{"type":"string","format":"uri","description":"Added 2026-09-11. The hosted checkout page URL for this request."},"qr":{"$ref":"#/components/schemas/PaymentRequestQr"}}},"PaymentIntent":{"type":"object","description":"Shape varies by network - every EVM network (ethereum/base/polygon/arbitrum/bnb/ avalanche/robinhood/linea/optimism/monad/megaeth/hyperevm) and Tron return raw calldata/a full unsigned transaction object respectively; Solana returns a base64-serialized unsigned transaction as a plain string instead (no chainId/to/data/ value/approval fields at all - see the transaction field's own description). Aptos, Sui and TON (2026-09-10) each return their own wallet-standard intent shape under the same `transaction` field - see that field's description. For a sandbox (is_test) payment request, this is instead a simulated descriptor (simulated: true, sandboxTxHashes: { failed, pending }, ...) with no real chain call - see Sandbox / test mode.\n","properties":{"paymentRequestId":{"type":"string"},"network":{"type":"string","description":"The request's network, on every intent (EVM networks included)."},"reference":{"type":"string","description":"Solana only, added 2026-09-11: the request's Solana Pay reference public key (sha256(\"fastpay:solana-pay-reference:\" + id)), embedded in `transaction` as a read-only non-signer account on the disburse instruction. The relay's own detection sweep confirms a QR payment by getSignaturesForAddress(reference); a merchant can watch it the same way.\n"},"chainId":{"type":"integer","description":"EVM networks, Tron, and Aptos"},"to":{"type":"string","description":"EVM networks only"},"data":{"type":"string","nullable":true,"description":"EVM networks only"},"value":{"type":"string","nullable":true,"description":"EVM networks only, hex-encoded wei for native-currency payments"},"transaction":{"nullable":true,"description":"Tron: a full unsigned transaction object, null on an approval-only intent (approvalOnly true, 2026-09-25); broadcastable only until transactionExpiresAt. Solana: a base64-serialized unsigned VersionedTransaction, as a plain string - deserialize with VersionedTransaction.deserialize(Buffer.from(transaction, 'base64')), sign with the connected wallet-adapter, then send. Aptos: an AIP-62 entry-function payload `{ function, typeArguments, functionArguments }` (u64s as decimal strings, vector<u8> as 0x-hex) for the wallet to build/sign/submit itself. Sui: a Transaction.toJSON() string - `Transaction.from(transaction)`, then the wallet resolves gas/objects and signs (the payment coin is split from the gas coin, so one coin object suffices). TON: a TonConnect sendTransaction request `{ validUntil, messages: [{ address, amount, payload }] }` - `amount` there is `total` plus the disburser's gas reserve (see attachedValue); the wallet builds its own outer transaction.\n","oneOf":[{"type":"object"},{"type":"string"}]},"attachedValue":{"type":"string","description":"TON only - the native TON the wallet is asked to attach, always in TON's own 9 decimals. For a native payment that is total plus the disburser's forwarding-gas reserve - a TON gas surcharge, not refunded: what the disburser does not spend stays in it, withdrawable above its reserve floor to FastPay's treasury; for a jetton payment it is purely gas, since the payment itself rides in the message body, so it is not comparable to total."},"approval":{"type":"object","nullable":true,"description":"Present only when an ERC20/TRC20 allowance approval is needed first. EVM: { to, data } calldata. Tron (2026-09-25): a full unsigned Tron transaction object, and the only transaction on that intent (approvalOnly true) - see approvalOnly.","properties":{"to":{"type":"string"},"data":{"type":"string"}}},"approvalOnly":{"type":"boolean","description":"Tron only, on EVERY Tron intent (2026-09-25): true when this intent carries only the TRC20 approval and transaction is null; false on native TRX and on an allowance-sufficient TRC20 intent, where approval is null. Broadcast the approval, wait for it on a full node (wallet/gettransactioninfobyid - the solidity endpoint trails by ~60 s), then call POST .../intent again for the disburse transaction. It is built only then because a Tron transaction expires 60 s after the head block it was built against, so one built beside the approval is dead by the time the approval is visible. Never broadcast a second approval; if the relay keeps answering approval-only, retry from the top."},"transactionExpiresAt":{"type":"string","format":"date-time","description":"Tron only, on every Tron intent (2026-09-25): the real broadcast window of the transaction the payer signs next (the approval when approvalOnly is true, otherwise the disburse) - its raw_data.expiration, extended by the relay to ~10 minutes from the intent call (RELAY_TRON_CHECKOUT_TX_VALIDITY_SECONDS), capped at expiresAt. The relay only lengthens a transaction, never shortens one: when expiresAt is already within ~60 s of the intent call the node's own ~60 s stamp is kept as built, so transactionExpiresAt can then sit up to ~60 s past expiresAt - the node accepts such a broadcast after expiresAt, the contract reverts it (AuthorizationExpired). A Tron transaction is NOT broadcastable for the whole expiresAt window, which is the authorization's."},"currency":{"type":"string"},"amount":{"type":"string"},"fee":{"type":"string","description":"FastPay's protocol fee, as a decimal string with every decimal the currency has on this network (USDC: 6, so '0.625000'; native ETH: 18) - never trimmed, never in exponent form, on every network."},"feeCurrency":{"type":"string","description":"What fee is paid in: on a checkout intent, always the payment's own currency."},"total":{"type":"string","description":"amount + fee, in fee's format."},"expiresAt":{"type":"string","format":"date-time","description":"When the co-signed authorization stops being accepted on-chain: the sooner of the request's expiresAt and the checkout authorization window (RELAY_CHECKOUT_AUTHORIZATION_VALIDITY_SECONDS, 15 minutes by default), in whole seconds (Sui keeps milliseconds)."},"simulated":{"type":"boolean","description":"Sandbox mode only - true when this intent was built by the simulated adapter, not a real chain."}}},"CheckoutSessionCreate":{"type":"object","required":["amount","currency","success_url","cancel_url","network","settlement_currency"],"properties":{"amount":{"type":"string","pattern":"^[0-9]+(\\.[0-9]{1,2})?$","description":"Positive decimal string, fiat, with at most 2 decimal places (cents): '10.005' answers 400 invalid_amount."},"currency":{"type":"string","enum":["EUR","USD"]},"success_url":{"type":"string","format":"uri"},"cancel_url":{"type":"string","format":"uri"},"network":{"type":"string","enum":["ethereum","base","polygon","arbitrum","bnb","avalanche","robinhood","linea","optimism","monad","megaeth","hyperevm","solana","tron","aptos","sui","ton"],"description":"aptos/sui/ton accepted since 2026-09-10. Every network's native coin (APT/SUI/TON, SOL/TRX, AVAX/POL/BNB included) has been quotable since 2026-09-18; GET /v1/currencies lists them."},"settlement_currency":{"type":"string","description":"Must be valid for the network AND quotable - today every currency the network takes, its native coin included (GET /v1/currencies); anything else is 400 invalid_settlement_currency. Accepted in any letter case and stored canonical: usde and USDE settle in USDe, usdc in USDC, eth in ETH; payment_currency, the stored payment and every webhook carry that spelling. The chain FastPayDisburser must also allowlist the token on-chain before the resulting request can be paid."},"merchant_reference":{"type":"string"},"callback_url":{"type":"string","format":"uri","description":"Optional - receives a paid webhook on completion (SSRF-validated, public http(s) only)."},"customer":{"type":"object","properties":{"id":{"type":"string"}}}}},"CheckoutSession":{"type":"object","description":"One shape for create (plus rate_source and rate_fetched_at), retrieve, list and expire (audit O15, 2026-10-17).","properties":{"id":{"type":"string","example":"cs_..."},"object":{"type":"string","enum":["checkout_session"]},"status":{"type":"string","enum":["open","completed","expired"]},"payment_status":{"type":"string","enum":["created","paid","expired"],"description":"The session's payment's status."},"amount":{"type":"string"},"currency":{"type":"string"},"network":{"type":"string"},"payment_amount":{"type":"string"},"payment_currency":{"type":"string"},"exchange_rate":{"type":"string"},"success_url":{"type":"string","format":"uri"},"cancel_url":{"type":"string","format":"uri"},"payment_url":{"type":"string","format":"uri","description":"The hosted checkout page for this session, on RELAY_PUBLIC_BASE_URL (RELAY_SANDBOX_PUBLIC_BASE_URL for a sandbox session); read back, on the request's own origin when neither is set."},"expires_at":{"type":"string","format":"date-time","description":"The session's deadline, or the moment of POST /v1/checkout/sessions/{id}/expire when it was expired early - the public view's expiresAt either way."},"is_test":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"payments":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"pay_..."},"status":{"type":"string"},"merchant_reference":{"type":"string","nullable":true},"expires_at":{"type":"string","format":"date-time","description":"The session's expires_at."}}}}}},"Balance":{"type":"object","properties":{"currency":{"type":"string"},"available":{"type":"string"},"pending":{"type":"string"},"total":{"type":"string"}}},"LedgerTransaction":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["ledger_transaction"]},"transaction_id":{"type":"string"},"direction":{"type":"string","enum":["credit","debit"]},"amount":{"type":"string"},"currency":{"type":"string"},"balance_before":{"type":"string"},"balance_after":{"type":"string"},"reference_type":{"type":"string"},"reference_id":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}},"RotationRequest":{"type":"object","properties":{"expires_in_hours":{"type":"number","minimum":0,"maximum":72,"default":24,"description":"How long the previous secret keeps signing. 0 invalidates it immediately."}}},"WebhookEndpointCreate":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^[Hh][Tt][Tt][Pp][Ss]://","description":"An https URL with no username or password, at most 2048 characters (2026-10-24; payroll's callback_url rule), that resolves to a public address - checked like every callback_url. Else 400 invalid_url. An endpoint stored earlier with an http URL keeps it."},"enabled_events":{"type":"array","items":{"type":"string"},"description":"Event types from GET /v1/webhooks/event-types, or [\"*\"] (default)."},"description":{"type":"string","maxLength":500,"nullable":true}}},"WebhookEndpointUpdate":{"type":"object","properties":{"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^[Hh][Tt][Tt][Pp][Ss]://","description":"The create rule (https, no username or password, at most 2048 characters); else 400 invalid_url."},"enabled_events":{"type":"array","items":{"type":"string"}},"description":{"type":"string","maxLength":500,"nullable":true},"status":{"type":"string","enum":["enabled","disabled"]}}},"WebhookEndpoint":{"type":"object","properties":{"id":{"type":"string","description":"we_..."},"object":{"type":"string","enum":["webhook_endpoint"]},"url":{"type":"string"},"description":{"type":"string","nullable":true},"enabled_events":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["enabled","disabled"]},"is_test":{"type":"boolean","description":"Registered with an fp_test_ key; receives sandbox events only."},"previous_secret_expires_at":{"type":"string","nullable":true,"format":"date-time","description":"Non-null only while a rotation overlap window is open."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"WebhookEndpointWithSecret":{"allOf":[{"$ref":"#/components/schemas/WebhookEndpoint"},{"type":"object","properties":{"secret":{"type":"string","description":"whsec_-prefixed signing secret - returned only on create and rotate."}}}]},"ApiKeyScope":{"type":"string","description":"`*` (everything) or `<family>:read` / `<family>:write`; write implies read. The family a request needs is derived from its method and path - see payment-gateway-api.md#api-keys.","enum":["*","checkout:read","checkout:write","payouts:read","payouts:write","refunds:read","refunds:write","customers:read","customers:write","balance:read","balance:write","webhooks:read","webhooks:write","integrations:read","integrations:write","team:read","team:write","sandbox:read","sandbox:write","payroll:read","payroll:write"]},"AdminTenant":{"type":"object","description":"A tenant as the admin reads show it (2026-10-15) - never its webhook secrets or key columns.","properties":{"object":{"type":"string","enum":["tenant"]},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"status":{"type":"string","enum":["active","suspended"]},"created_at":{"type":"string","format":"date-time"},"created_by":{"$ref":"#/components/schemas/TenantCreatedBy"},"approval":{"$ref":"#/components/schemas/TenantApproval"}}},"TenantCreatedBy":{"type":"object","nullable":true,"description":"Added 2026-10-16. Who created the tenant through POST /admin/tenants; null for a tenant created before 2026-10-16.","properties":{"type":{"type":"string","enum":["admin","panel"]},"operator":{"type":"string","nullable":true,"description":"The x-panel-operator, or the x-admin-operator of an admin-key call (null when it named none)."}}},"TenantApproval":{"type":"object","nullable":true,"description":"Added 2026-10-16. The approval a live key mint needs (POST /admin/tenants/{id}/approve); null until a person approves the tenant (a tenant created before 2026-10-16 starts null: no backfill).","properties":{"approved_at":{"type":"string","format":"date-time"},"approved_by":{"type":"object","properties":{"type":{"type":"string","enum":["admin","panel"],"description":"The credential that approved."},"operator":{"type":"string","description":"The approving person (x-admin-operator or x-panel-operator)."},"credential_fingerprint":{"type":"string","nullable":true,"pattern":"^[0-9a-f]{64}$","description":"A one-way fingerprint of that credential (HKDF-SHA256), never the key; a rotated key gives another. null in an answer to the panel credential for an approval the admin key gave: the panel never reads the admin key's fingerprint."}}}}},"ApiKeyPolicy":{"type":"object","description":"The policy fields of an API key, as accepted by create and update.","properties":{"name":{"type":"string","nullable":true,"maxLength":100},"scopes":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyScope"},"description":"Omitted on a tenant-side create (POST /v1/members/{id}/api-keys) = the member's role ceiling; required on the admin mint since 2026-10-15."},"expires_at":{"type":"string","format":"date-time","nullable":true,"description":"Must be in the future. `null` clears it (update). Not accepted by the admin mint, which takes expires_in_days."},"ip_allowlist":{"type":"array","nullable":true,"maxItems":50,"items":{"type":"string","example":"203.0.113.0/24"},"description":"IPv4/IPv6 addresses or CIDR ranges. `null` or `[]` = no restriction. Accepted forms - a bare address (`203.0.113.7`, `2001:db8::1`), or an address with a prefix of 1 to 3 digits, 1-32 for IPv4 or 1-128 for IPv6 (`203.0.113.0/24`, `2001:db8::/32`; /32 and /128 are stored as the bare address). Anything else is 400 `invalid_ip_allowlist`, since 2026-10-24 also an empty prefix (`203.0.113.7/`, which was stored as /0), a prefix that is not plain digits (`0x10`, `1e1`, `+8`), `/0`, which would allow every address of the family, and an IPv6 range that holds every IPv4 address (`::/64`, `::ffff:0:0/96`: an IPv4 client is matched against an IPv6 range as `::ffff:a.b.c.d`). On every mint and update (operator, panel, POST /v1/members/{id}/api-keys); an update judges the whole new list."}}},"PayrollLane":{"type":"object","description":"A tenant's chain lane on one payroll network (2026-10-21, LOG:1342).","properties":{"object":{"type":"string","enum":["payroll_lane"]},"id":{"type":"string","nullable":true,"description":"pln_...; null where no lane exists (status none)."},"tenant_id":{"type":"string"},"network":{"type":"string"},"family":{"type":"string"},"status":{"type":"string","enum":["none","requested","enabled","paused","closed"]},"version":{"type":"integer","description":"0 for none; +1 on every write."},"basis":{"type":"string","nullable":true,"enum":["live_payroll_key","live_full_key","tron_tenant_ids",null],"description":"Why migration 3 made the lane; null for an operator's."},"live_open":{"type":"boolean","description":"The master and family switches."},"within_ceiling":{"type":"boolean"},"network_held":{"type":"boolean"},"open":{"type":"boolean","description":"live_open, within_ceiling, not held, and status enabled."},"unchanged":{"type":"boolean","description":"On pause and close only: true when nothing was changed."},"requested":{"type":"object","nullable":true,"properties":{"by":{"$ref":"#/components/schemas/OperatorRef"},"at":{"type":"string","format":"date-time"},"reason":{"type":"string","nullable":true},"expires_at":{"type":"string","format":"date-time"},"expired":{"type":"boolean"}}},"approved":{"type":"object","nullable":true,"properties":{"by":{"$ref":"#/components/schemas/OperatorRef"},"at":{"type":"string","format":"date-time"}}},"last_change":{"type":"object","nullable":true,"properties":{"action":{"type":"string"},"by":{"$ref":"#/components/schemas/OperatorRef"},"at":{"type":"string","format":"date-time"},"reason":{"type":"string","nullable":true}}}}},"PayrollNetworkHold":{"type":"object","description":"A payroll network held for every tenant (2026-10-21).","properties":{"object":{"type":"string","enum":["payroll_network_hold"]},"network":{"type":"string"},"family":{"type":"string"},"status":{"type":"string","enum":["none","held","release_requested","released"]},"held":{"type":"boolean","description":"true while held or release_requested."},"version":{"type":"integer"},"unchanged":{"type":"boolean"},"hold":{"type":"object","nullable":true,"properties":{"by":{"$ref":"#/components/schemas/OperatorRef"},"at":{"type":"string","format":"date-time"},"reason":{"type":"string","nullable":true}}},"release_requested":{"type":"object","nullable":true,"properties":{"by":{"$ref":"#/components/schemas/OperatorRef"},"at":{"type":"string","format":"date-time"},"reason":{"type":"string","nullable":true},"expires_at":{"type":"string","format":"date-time"},"expired":{"type":"boolean"}}},"last_change":{"type":"object","nullable":true,"properties":{"action":{"type":"string"},"by":{"$ref":"#/components/schemas/OperatorRef"},"at":{"type":"string","format":"date-time"},"reason":{"type":"string","nullable":true}}}}},"OperatorRef":{"type":"object","description":"Who made an admin step - the shape of approved_by on a tenant approval.","properties":{"type":{"type":"string","enum":["admin","panel","migration"]},"operator":{"type":"string","nullable":true},"credential_fingerprint":{"type":"string","nullable":true,"description":"A one-way fingerprint of the credential; null for the panel reading another credential's step."}}},"ApiKey":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["api_key"]},"prefix":{"type":"string","enum":["fp_live_","fp_test_"]},"name":{"type":"string","nullable":true},"scopes":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyScope"}},"status":{"type":"string","enum":["active","expired","revoked"]},"is_test":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time","nullable":true},"ip_allowlist":{"type":"array","nullable":true,"items":{"type":"string"},"description":"The allow-list as stored, enforced as stored; a rotation copies it. An entry an earlier release stored may be a /0 (every address of its family; an IPv6 /0, like an IPv6 range holding ::ffff:0:0/96, every IPv4 address too), which a mint or update now refuses: replace it with POST /admin/tenants/{tenantId}/api-keys/{keyId}."},"rotated_from_key_id":{"type":"string","nullable":true,"description":"The key this one succeeded, when minted by a rotation."},"member_id":{"type":"string","nullable":true,"description":"2026-09-12: the team member this key is issued to; null for a legacy key."},"payroll_networks":{"type":"array","nullable":true,"items":{"type":"string"},"description":"2026-10-21: the payroll networks this key may act on (set at mint, never widened); null = every network."},"created_at":{"type":"string","format":"date-time"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"last_used_at":{"type":"string","format":"date-time","nullable":true}}},"MemberRole":{"type":"string","enum":["owner","admin","developer","approver","viewer"],"description":"owner - everything, the only role that grants/revokes owner; admin - everything but the approval policy and the owner role; developer - full API access, no approvals, no team management; approver - read-only keys plus approving/rejecting payout jobs; viewer - read-only."},"MemberInput":{"type":"object","required":["name","email","role"],"properties":{"name":{"type":"string","maxLength":200},"email":{"type":"string","format":"email","description":"Unique per tenant; stored lower-cased."},"role":{"$ref":"#/components/schemas/MemberRole"}}},"Member":{"type":"object","description":"A team member (2026-09-12) - the object API keys are issued to.","properties":{"id":{"type":"string","example":"mem_..."},"object":{"type":"string","enum":["member"]},"name":{"type":"string"},"email":{"type":"string"},"role":{"$ref":"#/components/schemas/MemberRole"},"status":{"type":"string","enum":["active","suspended"]},"capabilities":{"type":"object","properties":{"can_approve":{"type":"boolean"},"can_manage_team":{"type":"boolean"},"can_set_approval_policy":{"type":"boolean"}}},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"PayoutApprovalPolicyInput":{"type":"object","required":["mode"],"properties":{"mode":{"type":"string","enum":["never","always","above"],"description":"never (default) accepts jobs at once; always makes every job a draft; above makes a job a draft when any currency's summed AMOUNT reaches its threshold (a currency with no threshold always needs approval)."},"approvals_required":{"type":"integer","minimum":1,"maximum":3,"default":1},"thresholds":{"type":"object","additionalProperties":{"type":"string"},"description":"CURRENCY -> decimal amount string; required for mode above."}}},"PayoutApprovalPolicy":{"allOf":[{"type":"object","properties":{"object":{"type":"string","enum":["payout_approval_policy"]}}},{"$ref":"#/components/schemas/PayoutApprovalPolicyInput"}]},"JobApproval":{"type":"object","properties":{"member_id":{"type":"string"},"member_name":{"type":"string","nullable":true},"decision":{"type":"string","enum":["approve","reject"]},"reason":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"PayoutJob":{"type":"object","description":"A mass-payout job as GET /v1/jobs/{jobId}, GET /v1/jobs and the approve/reject routes return it.","properties":{"jobId":{"type":"string"},"status":{"type":"string","enum":["awaiting_approval","accepted","awaiting_onboarding","processing","completed","cancelled"]},"network":{"type":"string"},"payments":{"type":"array","items":{"type":"object"}},"approval":{"type":"object","description":"2026-09-12. { required: false } for a job created under no approval requirement.","properties":{"required":{"type":"boolean"},"status":{"type":"string","enum":["pending","approved","rejected"]},"approvals_required":{"type":"integer"},"approvals":{"type":"array","items":{"$ref":"#/components/schemas/JobApproval"}}}},"created_by":{"type":"object","properties":{"member_id":{"type":"string","nullable":true},"api_key_id":{"type":"string","nullable":true}}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"ApiKeyWithSecret":{"allOf":[{"$ref":"#/components/schemas/ApiKey"},{"type":"object","properties":{"apiKey":{"type":"string","description":"The raw key - shown once, never retrievable again."},"warning":{"type":"string"}}}]},"ApiKeyRotationRequest":{"type":"object","properties":{"grace_hours":{"type":"number","minimum":0,"maximum":168,"default":24,"description":"How long the previous key keeps working. 0 revokes it immediately."}}},"ApiKeyRotated":{"allOf":[{"$ref":"#/components/schemas/ApiKeyWithSecret"},{"type":"object","properties":{"previous_key_id":{"type":"string"},"previous_key_expires_at":{"type":"string","format":"date-time","nullable":true,"description":"`null` when grace_hours was 0 (revoked immediately)."}}}]},"Customer":{"type":"object","properties":{"id":{"type":"string","example":"cus_..."},"object":{"type":"string","enum":["customer"]},"email":{"type":"string","format":"email"},"name":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"PaymentChannel":{"type":"object","properties":{"id":{"type":"string","example":"pc_..."},"object":{"type":"string","enum":["payment_channel"]},"customer_id":{"type":"string"},"status":{"type":"string","enum":["active","closed"]},"callback_url":{"type":"string","nullable":true,"description":"Receiver for this channel's payment_channel.deposit events (2026-09-12)."},"addresses":{"type":"array","items":{"type":"object","properties":{"currency":{"type":"string"},"network":{"type":"string"},"address":{"type":"string"},"monitored":{"type":"boolean","description":"Whether the channel-deposit sweep watches this address (2026-09-12): true on the EVM checkout networks, Solana and (since 2026-10-04) Tron, false on Aptos/Sui/TON, whose addresses are minted but not watched - and false on a watched network this deployment is not set up to watch (RPC not configured, a Tron host that is not TronGrid-compatible, or the sweep switched off). true does not mean every read is succeeding right now: an RPC that is down or rate-limiting leaves it true while the sweep retries."}}}},"created_at":{"type":"string","format":"date-time"}}},"Sandbox":{"type":"object","description":"A view over everything the tenant's fp_test_ keys created (2026-09-12) - not a stored object. `deleted` is present only on a reset response.\n","properties":{"object":{"type":"string","enum":["sandbox"]},"tenant_id":{"type":"string"},"mode":{"type":"string","enum":["test","live"],"description":"The mode of the key that asked (GET only)."},"counts":{"type":"object","description":"Sandbox row count per object name, after the call.","additionalProperties":{"type":"integer"}},"deleted":{"type":"object","description":"Rows removed by this reset, per object name (reset responses only).","additionalProperties":{"type":"integer"}}}},"Deposit":{"type":"object","description":"A deposit detected on a payment-channel address (2026-09-12). Amounts are exact decimal strings in the deposit's own currency.","properties":{"id":{"type":"string","example":"dep_..."},"object":{"type":"string","enum":["deposit"]},"payment_channel_id":{"type":"string"},"customer_id":{"type":"string"},"network":{"type":"string"},"currency":{"type":"string","description":"Upper-cased as recorded (USDC, USDT; a USDe deposit reads USDE, as its ledger balance does)."},"address":{"type":"string"},"amount":{"type":"string","description":"Gross amount received."},"fee":{"type":"string","description":"The gateway fee (RELAY_GATEWAY_FEE_PERCENT of amount)."},"net_amount":{"type":"string","description":"amount minus fee - what the ledger was credited."},"tx_hash":{"type":"string","nullable":true,"description":"null for an EVM native-coin deposit that arrived by internal transfer (see block_ref)."},"from_address":{"type":"string","nullable":true},"block_ref":{"type":"string","nullable":true,"description":"Block number (EVM, Tron) or slot (Solana)."},"status":{"type":"string","enum":["confirmed"]},"ledger_transaction_id":{"type":"string","nullable":true,"description":"null when net_amount rounds to 0.00 at the ledger's two-decimal precision (recorded, not credited)."},"is_test":{"type":"boolean","description":"A simulated deposit (POST /v1/sandbox/deposits), never one the sweep found."},"detected_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"DepositList":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Deposit"}},"has_more":{"type":"boolean"}}}}}}