@avvio/payments 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -33,16 +33,201 @@ payment days later, producing `failed` with `returned_by_bank`. This is not a
33
33
  breaking change we will version away — it is how payments work. Do not write a
34
34
  ledger that treats `completed` as immutable.
35
35
 
36
- **You may not see every state.** We publish a payout's state on a short cycle,
37
- so a fast payout can go straight to `completed` with no `pending` in between.
38
- Build on the state you are given, not on having observed a sequence.
36
+ **Every transition is an event, at least once, in order.** Each state change
37
+ is written to the event feed in the same transaction that caused it and
38
+ delivered in `sequence` order per endpoint. You may see an event twice; dedupe
39
+ on its `id`, an opaque string with no prefix.
39
40
 
40
41
  ---
41
42
 
43
+ ## Unreleased
44
+
45
+ One removal, agreed before any partner integrated: `balanceHistory()` and
46
+ `GET /balance/history` are gone. `listBalanceTransactions()` is the one money
47
+ feed. Everything else is additive.
48
+
49
+ - **`getPolicy()`** (`GET .../policy`, CLI `policy`, MCP `get_policy`): what
50
+ your organization is bound by, read live: payout caps, the approval threshold
51
+ and M, effective features, rate limits per minute, idempotency windows and
52
+ the currencies that need a `purposeOfPayment`. Read it before the first send
53
+ instead of learning a cap from a `422` or an approval from a `202`.
54
+ - **`balanceHistory()` removed** (and the CLI's `balance --history`). Same
55
+ rows, one feed: `listBalanceTransactions()` / `eachBalanceTransaction()`,
56
+ newest first, `id` as the cursor, holds and fees included.
57
+ - **`purposeOfPayment` is required for INR, GHS, CNY and BRL payouts** (`400
58
+ VALIDATION_ERROR` naming the field, before anything is priced). Validated
59
+ against the corridor catalogue whenever sent; never defaulted on your behalf.
60
+ - **`balance()` always carries `ledger`** (per currency: `available`, `held`,
61
+ `total`, from our own record) beside the payment network's figure. The two
62
+ are never summed.
63
+ - **`balance()` counts your wallet.** `amount` and `balances` are now
64
+ everything a payout can draw on: what the payment network holds for you
65
+ (`provider`, new) plus the USD stablecoins in your own wallet (`wallet`,
66
+ new, per chain, USDC and USDT at face value). A wallet-funded routing holds
67
+ none of your money at the network, so an organization with $3,000 of USDC
68
+ in its wallet used to read `0.00`. `ledger` is unchanged and describes the
69
+ `provider` part only.
70
+
71
+ - **Every mutation carries an `Idempotency-Key`.** The API now honours the
72
+ header on every `POST`, `PATCH` and `DELETE`, so the client mints one per
73
+ call for the methods that did not already (`updateBeneficiary`,
74
+ `deleteBeneficiary`, `deleteBeneficiaryMethod`, `quote`, `pricePayout`,
75
+ `cancelPayout`) and each accepts `{ idempotencyKey }` to supply your own.
76
+ Retries reuse that key, never a fresh one per attempt; a mutation is still
77
+ only ever retried under the key it first went out with. The one exception
78
+ is `createWebhookEndpoint()`, which sends no key and is not retried: the
79
+ server ignores the header on routes that return a secret once, because a
80
+ stored replay would keep it for seven days.
81
+
82
+ - **`fee` on the payout.** `payout()`, `getPayout()` and `listPayouts()` rows
83
+ carry `fee` (`Money | null`), the same value the event `data` and the
84
+ `payout` row on `listBalanceTransactions()` already reported. It is inside
85
+ `sourceAmount`, not on top of it.
86
+ - **The feed row is the webhook body.** `listEvents()` rows carry `data` — the
87
+ same object a webhook delivers for the same event, with amounts, `fee`,
88
+ `rate`, `reference` and `endUserId` — plus `apiVersion` and `batchId`. Batch,
89
+ approval and endpoint events now appear in the feed (`payoutId` is `null` on
90
+ them); `type` (string or array) narrows it. Rows become readable about two
91
+ seconds after they happen.
92
+ - **Webhook envelope.** Deliveries carry `{ id, sequence, type, createdAt,
93
+ apiVersion, livemode, data }`; `id` equals `svix-id` and the feed row's
94
+ `id`. New header `Avvio-Webhook-Version`. Retries now run nine rungs over
95
+ roughly 70 hours with jitter; endpoints that stay dead are auto-disabled and
96
+ `webhookEndpoints()` carries `disabledReason`, `consecutiveFailures`,
97
+ `lastSuccessAt`, `lastFailureAt`.
98
+ - **Approvals.** `payout()` and `confirmPayoutBatch()` may answer 202 with a
99
+ `PendingApproval` when the organization requires human approval;
100
+ `listApprovals()` / `getApproval()` read the queue (also `approvals` /
101
+ `approval` on the CLI and `list_approvals` / `get_approval` MCP tools).
102
+ Approve and reject are deliberately absent: a key cannot.
103
+ - **Audit trail.** `listAuditEvents()` and `eachAuditEvent()` page who did
104
+ what with which credential, newest first (`audit-events` on the CLI,
105
+ `list_audit_events` MCP tool).
106
+ - New error types `PAYOUT_LIMIT_EXCEEDED` (422) and `PAYOUT_REFUSED` (422) on
107
+ `payout()`; `PAYOUT_BATCH_AWAITING_APPROVAL` (409) on `confirmPayoutBatch()`;
108
+ `USE_POST_PAYOUTS` (403) on `pricePayout()`, `acceptQuote()` and payout links
109
+ when the organization has an approval threshold or a velocity cap.
110
+ - **Balance transactions.** `listBalanceTransactions()` pages every change to
111
+ what you can spend, newest first, each row with `balanceAfter`: funding,
112
+ payouts, returns, holds and their release, operator adjustments. `id` is the
113
+ cursor and the dedupe key, so a reconciler can check its ledger row by row
114
+ instead of against one number. `eachBalanceTransaction()` pages for you.
115
+ Also a CLI command (`balance-transactions`) and a read-only MCP tool
116
+ (`list_balance_transactions`).
117
+ - `balance()` may now carry `balances` (per currency, from the payment
118
+ network).
119
+ - `listBalanceTransactions()` is served on every environment; an empty page
120
+ means no rows yet. (`LEDGER_NOT_AVAILABLE`, briefly documented in a
121
+ pre-release draft, was never shipped.)
122
+ - `PAYOUT_UNDER_REVIEW` is removed from the error type union; the review state
123
+ it described cannot occur. `PAYOUT_REFUSED` stays.
124
+
125
+ ---
126
+
127
+ ## 0.5.0
128
+
129
+ Additive. Nothing that worked at 0.4.0 changes.
130
+
131
+ - **Mass payouts.** `createPayoutBatch()` submits up to 1,000 payout
132
+ instructions as one run — each line exactly a `POST /payouts` body — with
133
+ the idempotency key covering the RUN, so a resubmitted file is the same
134
+ batch, never a second payroll. The batch validates every line before
135
+ anything is priced or debited; `autoCommit: true` (the default) sends a
136
+ clean run straight to creation, and any validation errors hold it at
137
+ `awaiting_confirmation` for `confirmPayoutBatch()` or `cancelPayoutBatch()`.
138
+ `externalReferenceId` (your run id) is required and unique per organization;
139
+ a repeat under a fresh key is `PAYOUT_BATCH_DUPLICATE_REFERENCE` naming the
140
+ original batch. Batch submission is gated per organization
141
+ (`MASS_PAYOUTS_DISABLED`) and limited to 30 submits a minute.
142
+ - `getPayoutBatch()`, `listPayoutBatches()` and `listPayoutBatchItems()` track
143
+ a run. Items echo your instruction back verbatim, so errors join to your
144
+ file by content; `?status=created` joins the run to the payout ledger, where
145
+ each line lives the ordinary payout lifecycle.
146
+ - `requires_review` is the one item status to read about before you need it:
147
+ the outcome is unknown, it is never retried automatically, and support
148
+ resolves it — re-running a line that may already have paid is how a crash
149
+ becomes a double payment.
150
+ - Four new webhook events — `payout_batch.awaiting_confirmation`,
151
+ `payout_batch.completed`, `payout_batch.canceled`, `payout_batch.failed` —
152
+ on the same signing and retry ladder as `payout.*`. There is deliberately no
153
+ `payout_batch.creating`.
154
+ - The batch methods are Node client surface only — deliberately not MCP
155
+ tools, because one confirm committing up to 1,000 payments does not belong
156
+ behind a single agent tool call, and not CLI commands yet: a file-shaped run
157
+ wants a file-shaped input, which the CLI does not have a good spelling for.
158
+
159
+ ---
160
+
161
+ ## 0.4.0
162
+
163
+ The partner surface this release covers grew after the authentication change
164
+ below; both land in the same unpublished version.
165
+
166
+ New in the client, CLI and MCP server:
167
+
168
+ - Read a beneficiary back by our id or by your own `externalId`, and fetch the
169
+ full account behind one payment method — lists carry only `last4`, so the
170
+ details are a deliberate second call rather than a field on every bulk read.
171
+ - Correct a beneficiary's contact details, and delete a beneficiary or one of
172
+ its payment methods. The registered account is not editable: the rail
173
+ validated it, so a wrong account is a new method, not an edit.
174
+ - `paymentReason` values, read from the API rather than guessed.
175
+ - List your webhook endpoints and their recent delivery attempts, including
176
+ what your server answered and when we will retry.
177
+
178
+ Breaking authentication simplification:
179
+
180
+ - The complete `avvio_live_*` or `avvio_test_*` API key is the only request
181
+ credential. The client, CLI, and MCP server no longer require
182
+ `AVVIO_PRIVATE_KEY` or add request-signing headers.
183
+ - Historical public `akid_*` signing identifiers and retired `ak_live_*` /
184
+ `ak_test_*` credentials are rejected locally rather than being treated as
185
+ bearer secrets.
186
+ - Test keys work in ReadMe's API explorer, including the operation that creates
187
+ a hosted payout link. Live keys remain server-side only.
188
+
189
+ ---
190
+
191
+ ## 0.3.0
192
+
193
+ Breaking authentication hardening:
194
+
195
+ - The client, CLI, and MCP server now accept only signed `akid_live_*` and
196
+ `akid_test_*` identifiers.
197
+ - `AVVIO_PRIVATE_KEY` (or `privateKeyPem`) is required for every accepted API
198
+ credential. Retired `ak_live_*` and `ak_test_*` bearer credentials fail at
199
+ client construction instead of reaching the network.
200
+
201
+ ## 0.2.0
202
+
203
+ Additive. Nothing that worked at 0.1.0 changes.
204
+
205
+ - **Send someone a figure named in YOUR currency, fees on top.**
206
+ `payout({amountLeg: 'source_net'})` reads `amount` as what the recipient is to
207
+ receive, converted at the market rate `quote()` publishes, with the fees added
208
+ to your debit. "Send them $200 worth." On a live corridor, 200 USD paid out
209
+ 3426.81 MXN and debited 203.447236 — the fees, plus the difference between the
210
+ market rate quoted and the rate the network executed at. `--worth` on the CLI.
211
+ - **Pay an exact amount, with the fees on top.** `payout({amountLeg:
212
+ 'destination'})` makes `amount` the figure the beneficiary RECEIVES, in their
213
+ currency, and the fees are added to your debit instead of taken out of it. On
214
+ a live corridor, naming 3400 MXN debited 201.879397 USDC and paid out
215
+ 3400.00. `--exact` on the CLI, `amountLeg` on the `send_payout` MCP tool.
216
+ Available where `capabilities.exactOutput` is true on the corridors call;
217
+ refused with `EXACT_OUTPUT_UNSUPPORTED` elsewhere, rather than quietly
218
+ pricing the other side.
219
+ - **`quote()` now answers on every routing.** It previously refused with
220
+ `INDICATIVE_PRICING_UNAVAILABLE` for some organizations, on the endpoint the
221
+ quickstart calls step 3.
222
+ - `limits` is omitted from a quote when the routing publishes no corridor floor
223
+ or ceiling, instead of being reported as `{min: "0", max: "0"}` — a zero
224
+ minimum reads as a promise that any amount is sendable.
225
+ - `endUser.id`, still optional, is now indexed: "everything I have paid this
226
+ person" is a fast lookup rather than a scan.
227
+
42
228
  ## 0.1.0
43
229
 
44
- First release. Not yet published to npm — install from a tarball, see the
45
- README.
230
+ First release.
46
231
 
47
232
  - Node client, CLI, and MCP server, sharing one core. Zero dependencies.
48
233
  - Sandbox with deterministic outcomes chosen by the last four digits of the
package/ERRORS.md CHANGED
@@ -9,7 +9,7 @@ versions, where the HTTP status and the prose are not.
9
9
  "status": 400,
10
10
  "detail": "Refusing to send: quoted 3384.65 but you expected ~9999 (6615 bps of drift, limit 200). Nothing was sent.",
11
11
  "resolution": "Nothing was sent. Re-quote, show the payer the new amount, and send again.",
12
- "requestId": "req-1c"
12
+ "requestId": "req-1c",
13
13
  }
14
14
  ```
15
15
 
@@ -31,28 +31,34 @@ stays a string in every case, so `detail.toLowerCase()` is always safe.
31
31
 
32
32
  ## Request errors
33
33
 
34
- | `type` | Status | What happened | What to do |
35
- |---|---|---|---|
36
- | `VALIDATION_ERROR` | 400 | A field is missing or malformed | Fix the fields in `errors` and retry |
37
- | `UNAUTHORIZED` | 401 | Key missing, wrong, revoked, or used on an endpoint keys cannot reach | Check it was copied whole and is not revoked |
38
- | `FORBIDDEN` | 403 | Valid key, wrong organization | Check `AVVIO_ORG_ID` |
39
- | `NOT_FOUND` | 404 | No such payout or beneficiary | Check the id came from us |
40
- | `RATE_LIMITED` | 429 | Too many requests | Back off, then retry |
41
- | `BAD_REQUEST` | 400 | A request we understood but cannot carry out — an expired quote, an amount above the corridor maximum, a non-positive amount | Read `detail`; it names the specific condition. Never retryable unchanged |
42
- | `PROVIDER_REJECTED` | 400 | The payout network refused the requestmost often an amount below that corridor's minimum. **Nothing was submitted** | Read `detail`; it carries the network's own wording, e.g. `the minimum amount for this payment is $10 USD`. Change the request. Retrying it unchanged fails identically |
43
- | `FUNDING_TRANSACTION_INVALID` | 400 | We read the chain and the transaction does not fund this payout reverted, wrong token, wrong address, short, or from a wallet other than the registered one. **Nothing was recorded** | Read `detail`; it names which. The payout is still fundable, so send the correct transaction and confirm that |
44
- | `FUNDING_NOT_YET_VERIFIABLE` | 409 | We could not read the transaction yetnot mined, or we could not reach the chain. **Nothing was recorded** | Retry the same request once it is mined. If you already paid, your funds are unaffected |
45
- | `FUNDING_TRANSACTION_ALREADY_USED` | 409 | That transaction already funded a different payout. The deposit address is shared between payouts, so one transfer funds exactly one | Send a separate transfer for this payout. `detail` names the payout it already funded |
46
- | `PAYOUT_NOT_FUNDABLE` | 400 | The payout is cancelled or already finished, so it cannot be funded. **Nothing was recorded** | Do not retry. Read the payout; if you still owe the recipient, create a new one |
47
- | `BENEFICIARY_EXTERNAL_ID_CONFLICT` | 409 | That `externalId` already identifies a beneficiary with **different** account details. **Nothing was changed** | Use a new `externalId` for a different account. If this was a retry, read the existing beneficiary an `externalId` identifies one account, and a second account is a second `externalId` |
48
- | `CONFLICT` | 409 | The payout's funding state changed under you it is already funded with a different transaction, or a concurrent request won. **Nothing was recorded** | Re-read the payout before retrying. If it is already funded, you are done |
49
- | `ACCOUNT_BLOCKED` | 403 | This organization is suspended | Contact us; retrying will not help |
50
- | `INSUFFICIENT_BALANCE` | 400 | Your balance will not cover this payout. **Nothing was sent** | Top up, then retry. Branch on this type rather than parsing the message — it is the one condition a payroll run must handle |
51
- | `ORDERS_TEMPORARILY_UNAVAILABLE` | 503 | We could not read the full payout list, so we will not report a partial page as complete | Retry. If you passed a `cursor` we did not issue, that is the likeliest cause |
52
- | `CORRIDOR_UNAVAILABLE` | | Raised by the **Node client**, not the API: the corridor you asked about is not offered on your routing | Read the corridors call and pick one it lists |
53
- | `TIMEOUT` | 504 | Raised by the **Node client**, not the API: no response within the client's timeout. **The outcome is unknown** — if this was a send, the payout may exist | Retry with the **same** `Idempotency-Key`; a replay returns the original. Never start over with a new key |
54
- | `NETWORK_ERROR` | 502 | Raised by the **Node client**, not the API: the request never got a response DNS, TLS, a dropped connection. **The outcome is unknown** unless you know it never left | Retry with the **same** `Idempotency-Key`, then read the payout back |
55
- | `INTERNAL` | 500 | Ours | Retry with the same `Idempotency-Key`. Send us the `requestId` if it persists |
34
+ | `type` | Status | What happened | What to do |
35
+ | ---------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
36
+ | `VALIDATION_ERROR` | 400 | A field is missing or malformed | Fix the fields in `errors` and retry |
37
+ | `UNAUTHORIZED` | 401 | Key missing, wrong, revoked, or used on an endpoint keys cannot reach | Check it was copied whole and is not revoked |
38
+ | `FORBIDDEN` | 403 | Valid key, wrong organization | Check `AVVIO_ORG_ID` |
39
+ | `NOT_FOUND` | 404 | No such payout or beneficiary | Check the id came from us |
40
+ | `ROUTE_NOT_FOUND` | 404 | No such endpoint the method and path matched no route. Distinct from `NOT_FOUND`, which means the route was right and the id was not | Check the path against the API reference. Payouts are SENT to `/payouts` and READ from `/orders` |
41
+ | `RATE_LIMITED` | 429 | Too many requests | Back off, then retry |
42
+ | `BAD_REQUEST` | 400 | A request we understood but cannot carry out an expired quote, an amount above the corridor maximum, a non-positive amount | Read `detail`; it names the specific condition. Never retryable unchanged |
43
+ | `PROVIDER_REJECTED` | 400 | The payout network refused the requestmost often an amount below that corridor's minimum. **Nothing was submitted** | Read `detail`; it carries the network's own wording, e.g. `the minimum amount for this payment is $10 USD`. Change the request. Retrying it unchanged fails identically |
44
+ | `FUNDING_TRANSACTION_INVALID` | 400 | We read the chain and the transaction does not fund this payout reverted, wrong token, wrong address, short, or from a wallet other than the registered one. **Nothing was recorded** | Read `detail`; it names which. The payout is still fundable, so send the correct transaction and confirm that |
45
+ | `FUNDING_NOT_YET_VERIFIABLE` | 409 | We could not read the transaction yet not mined, or we could not reach the chain. **Nothing was recorded** | Retry the same request once it is mined. If you already paid, your funds are unaffected |
46
+ | `FUNDING_TRANSACTION_ALREADY_USED` | 409 | That transaction already funded a different payout. The deposit address is shared between payouts, so one transfer funds exactly one | Send a separate transfer for this payout. `detail` names the payout it already funded |
47
+ | `PAYOUT_NOT_FUNDABLE` | 400 | The payout is cancelled or already finished, so it cannot be funded. **Nothing was recorded** | Do not retry. Read the payout; if you still owe the recipient, create a new one |
48
+ | `BENEFICIARY_EXTERNAL_ID_CONFLICT` | 409 | That `externalId` already identifies a beneficiary with **different** account details. **Nothing was changed** | Use a new `externalId` for a different account. If this was a retry, read the existing beneficiary an `externalId` identifies one account, and a second account is a second `externalId` |
49
+ | `CONFLICT` | 409 | The payout's funding state changed under you — it is already funded with a different transaction, or a concurrent request won. **Nothing was recorded** | Re-read the payout before retrying. If it is already funded, you are done |
50
+ | `ACCOUNT_BLOCKED` | 403 | This organization is suspended | Contact us; retrying will not help |
51
+ | `INSUFFICIENT_BALANCE` | 400 | Your balance will not cover this payout. **Nothing was sent** | Top up, then retry. Branch on this type rather than parsing the message it is the one condition a payroll run must handle |
52
+ | `CRYPTO_PAYOUTS_DISABLED` | 400 | This API key was not granted the `crypto_payouts` scope, so it cannot pay a wallet beneficiary from your organization wallet. **Nothing was sent** | Create a key with scopes `["write", "crypto_payouts"]` (or pay a bank beneficiary instead) |
53
+ | `WALLET_NOT_PROVISIONED` | 400 | Your organization has no EVM wallet to pay a wallet beneficiary from. **Nothing was sent** | Finish wallet setup in the dashboard, then retry |
54
+ | `ORDERS_TEMPORARILY_UNAVAILABLE` | 503 | We could not read the full payout list, so we will not report a partial page as complete | Retry. If you passed a `cursor` we did not issue, that is the likeliest cause |
55
+ | `PAYOUT_ACCOUNT_PROVIDER_UNAVAILABLE` | 503 | The selected provider could not register the beneficiary account, and no local beneficiary account was saved | Retry the same request and `Idempotency-Key` |
56
+ | `CORRIDOR_UNAVAILABLE` | — | Raised by the **Node client**, not the API: the corridor you asked about is not offered on your routing | Read the corridors call and pick one it lists |
57
+ | `TIMEOUT` | 504 | Raised by the **Node client**, not the API: no response within the client's timeout. **The outcome is unknown** — if this was a send, the payout may exist | Retry with the **same** `Idempotency-Key`; a replay returns the original. Never start over with a new key |
58
+ | `NETWORK_ERROR` | 502 | Raised by the **Node client**, not the API: the request never got a response — DNS, TLS, a dropped connection. **The outcome is unknown** unless you know it never left | Retry with the **same** `Idempotency-Key`, then read the payout back |
59
+ | `INTERNAL` | 500 | Ours. **Nothing was recorded under your `Idempotency-Key`** — the key was released, so a retry runs as a fresh request | Retry with the same `Idempotency-Key`. Send us the `requestId` if it persists |
60
+ | `PAYOUT_OUTCOME_UNKNOWN` | 500 | A money operation failed without proving the payout network did not execute it — a timeout, a network 5xx, a crash after the accept left. **The payout may exist.** The key is kept, not released | **Do not send a new `Idempotency-Key`.** Poll `GET /orders?reference=<your reference>`, or replay the same key: it answers `409 PAYOUT_OUTCOME_UNKNOWN` until we resolve it. Contact support with the `requestId` |
61
+ | `PAYOUT_LINKS_UNAVAILABLE` | 503 | Hosted payout links are not configured on this environment (the receipt provider contacts are missing). Nothing was sent. Contact support. **Nothing was changed** |
56
62
 
57
63
  `BAD_REQUEST` is the catch-all for a 400 that is not a field-validation failure.
58
64
  Because it covers several conditions, it is the one type where you should read
@@ -65,21 +71,22 @@ you to branch on one; the Node client derives it for you, but over raw HTTP the
65
71
 
66
72
  ## Idempotency
67
73
 
68
- | `type` | Status | Meaning | What to do |
69
- |---|---|---|---|
70
- | `IDEMPOTENCY_KEY_REQUIRED` | 400 | No header on a mutation | Add one, unique per operation |
71
- | `IDEMPOTENCY_KEY_INVALID` | 400 | Malformed | 1–255 chars of `A-Z a-z 0-9 _ . : -`. A UUID works |
72
- | `IDEMPOTENCY_KEY_CONFLICT` | 409 | Same key, **different body** | **Do not retry.** This is a bug on your side — a different request needs a different key |
73
- | `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS` | 409 | An identical request is still running | Back off, retry the **same** key |
74
- | `IDEMPOTENCY_UNAVAILABLE` | 503 | We could not record it. **Nothing executed** | Retry the same key |
75
- | `DUPLICATE_REQUEST_DETECTED` | 409 | An identical request arrived under a **different** key seconds ago. **Nothing executed** | See below |
74
+ | `type` | Status | Meaning | What to do |
75
+ | ------------------------------------- | ------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
76
+ | `IDEMPOTENCY_KEY_REQUIRED` | 400 | No header on a mutation | Add one, unique per operation |
77
+ | `IDEMPOTENCY_KEY_INVALID` | 400 | Malformed | 1–255 chars of `A-Z a-z 0-9 _ . : -`. A UUID works |
78
+ | `IDEMPOTENCY_KEY_CONFLICT` | 409 | Same key, **different body** | **Do not retry.** This is a bug on your side — a different request needs a different key |
79
+ | `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS` | 409 | An identical request is still running | Back off, retry the **same** key |
80
+ | `PAYOUT_OUTCOME_UNKNOWN` | 409 | The first request under this key ended with an unknown outcome (see the 500 above). **The payout may exist.** The key stays reserved until support resolves it; `originalRequestId` names that first call | **Never mint a new key for it.** Poll `GET /orders?reference=`, keep replaying the same key (it turns into the real receipt once resolved), and contact support with `originalRequestId` |
81
+ | `IDEMPOTENCY_UNAVAILABLE` | 503 | We could not record it. **Nothing executed** | Retry the same key |
82
+ | `DUPLICATE_REQUEST_DETECTED` | 409 | An identical request arrived under a **different** key seconds ago. **Nothing executed** | See below |
76
83
 
77
84
  A `4xx` **releases** the key — a request that failed validation committed
78
85
  nothing, so you may correct the body and reuse it.
79
86
 
80
87
  ### `DUPLICATE_REQUEST_DETECTED`
81
88
 
82
- The key protects you only if your retry sends the *same* key. Some HTTP clients
89
+ The key protects you only if your retry sends the _same_ key. Some HTTP clients
83
90
  generate one per attempt, which defeats it silently: every retry looks like a new
84
91
  request, and every retry pays. So we watch a second signal — same body, different
85
92
  key, within 15 minutes — and refuse.
@@ -89,7 +96,7 @@ key, within 15 minutes — and refuse.
89
96
  "type": "DUPLICATE_REQUEST_DETECTED",
90
97
  "originalIdempotencyKey": "zz_advance_88213",
91
98
  "originalPayoutId": "pay_01J…",
92
- "detail": "An identical request was received in the last 15 minutes under a different Idempotency-Key…"
99
+ "detail": "An identical request was received in the last 15 minutes under a different Idempotency-Key…",
93
100
  }
94
101
  ```
95
102
 
@@ -133,7 +140,7 @@ at any window length** — a different reference is a different body. That, plus
133
140
  persisting your own idempotency key, is the durable protection. This guard is a
134
141
  net for the accidental case, not a substitute for either.
135
142
 
136
- We refuse rather than silently returning the first payout, because *both*
143
+ We refuse rather than silently returning the first payout, because _both_
137
144
  readings are common. Two advances of the same amount to the same worker in one
138
145
  week is ordinary payroll; replaying there would mean the second one never goes
139
146
  out while your ledger records that it did. A 409 you have to answer is recoverable.
@@ -150,23 +157,62 @@ Past seven days the record is deleted and the key is genuinely unknown to us —
150
157
  treat seven days as the outer bound on retrying, not on caring. If you are
151
158
  reconciling something older, read the payout by id.
152
159
 
160
+ Two exceptions to the seven days. A key whose outcome is unknown
161
+ (`PAYOUT_OUTCOME_UNKNOWN`) is **never** swept; it is released or linked to the
162
+ real payout by support, so replaying it can never re-execute. And a request that
163
+ dies mid-flight (the process is killed after the network call left) holds its
164
+ key for about two minutes; past that, a money key becomes `PAYOUT_OUTCOME_UNKNOWN`
165
+ rather than re-running, and a non-money key (a beneficiary create) is released
166
+ and your retry runs.
167
+
168
+ ## API key authentication
169
+
170
+ The complete `avvio_live_*` or `avvio_test_*` value is a bearer credential.
171
+ Send it as `x-api-key`; no signing, nonce, timestamp, or challenge headers are
172
+ required.
173
+
174
+ | `type` | Status | Meaning |
175
+ | -------------------- | ------ | -------------------------------------------------------------------------- |
176
+ | `UNAUTHORIZED` | 401 | The key is missing, malformed, unknown, revoked, or was copied incompletely |
177
+ | `KEY_EXPIRED` | 401 | Rotate the key; the successor overlaps the predecessor |
178
+ | `KEY_IP_NOT_ALLOWED` | 401 | The key is pinned and this request came from another source address |
179
+ | `FORBIDDEN` | 403 | The key is valid but cannot access that organization or operation |
180
+
153
181
  ## Sending
154
182
 
155
- | `type` | Status | Meaning |
156
- |---|---|---|
157
- | `DESTINATION_ACCOUNT_NOT_FOUND` | 404 | No payout account with that id belongs to your organization. **Nothing was sent** |
158
- | `RATE_DRIFT_EXCEEDED` | 400 | The quote moved further from `expectDestination` than you allowed. **Nothing was sent** |
159
- | `QUOTE_UNVERIFIABLE` | 400 | We could not compare the quote to your expectation. **Nothing was sent** |
160
- | `EXACT_OUTPUT_UNSUPPORTED` | 400 | This routing cannot lock the receiving amount. Check `capabilities.exactOutput` on the corridors call |
161
- | `PAYOUT_NOT_CANCELABLE` | 400 | Only a payout still awaiting your funds can be cancelled. **Do not retry** |
162
- | `INSUFFICIENT_SCOPE` | 403 | This key is read-only. Issue one with the `write` scope to move money |
163
- | `INDICATIVE_PRICING_UNAVAILABLE` | 400 | This routing publishes no price without a beneficiary. **Do not retry** check `capabilities.indicativePricing` and price against a real beneficiary |
183
+ | `type` | Status | Meaning |
184
+ | -------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
185
+ | `DESTINATION_ACCOUNT_NOT_FOUND` | 404 | No payout account with that id belongs to your organization. **Nothing was sent** |
186
+ | `RATE_DRIFT_EXCEEDED` | 400 | The quote moved further from `expectDestination` than you allowed. **Nothing was sent** |
187
+ | `QUOTE_UNVERIFIABLE` | 400 | We could not compare the quote to your expectation. **Nothing was sent** |
188
+ | `QUOTE_NOT_POSITIVE` | 400 | The quote would deliver zero or less after fees. **Nothing was sent.** Increase the amount and request a new quote |
189
+ | `EXACT_OUTPUT_UNSUPPORTED` | 400 | This routing cannot lock the receiving amount. Check `capabilities.exactOutput` on the corridors call |
190
+ | `PAYOUT_NOT_CANCELABLE` | 400 | Only a payout still awaiting your funds can be cancelled. **Do not retry** |
191
+ | `INSUFFICIENT_SCOPE` | 403 | This key is read-only. Issue one with the `write` scope to move money |
192
+ | `INDICATIVE_PRICING_UNAVAILABLE` | 400 | This routing publishes no price without a beneficiary. **Do not retry** — check `capabilities.indicativePricing` and price against a real beneficiary |
193
+ | `FUNDING_NOT_APPLICABLE` | 501 | This payout settles from your balance; there is nothing for you to fund. **Do not retry** |
194
+ | `PAYOUT_LIMIT_EXCEEDED` | 422 | The amount would take your organization over a payout limit (single, daily, or daily per end user); the message names which. **Nothing was sent.** Split it, or ask us to raise the limit |
195
+ | `PAYOUT_REFUSED` | 422 | This payout cannot be sent to this beneficiary. **Nothing was sent. Do not retry** — contact us with the `requestId` |
196
+ | `USE_POST_PAYOUTS` | 403 | Your organization has an approval threshold or a velocity cap, and only `POST /payouts` enforces them, so quote-accept and payout links refuse. **Nothing was sent.** Send it through `POST /payouts`, which may answer `202 pending_approval` |
164
197
 
165
198
  `DESTINATION_ACCOUNT_NOT_FOUND` is the guard against paying an id you did not
166
199
  get from us. A stale, typo'd, or copied-from-elsewhere account id is refused
167
200
  before anything is priced — rather than being sent, settling, and reporting
168
201
  `completed` to a payroll run where nobody received the money.
169
202
 
203
+ ### `FUNDING_NOT_APPLICABLE`
204
+
205
+ `GET /payouts/{payoutId}/funding` answers "where do I send the USDC" — which
206
+ only has an answer on a routing that waits for you to fund each payout. On a
207
+ routing that settles from a balance you already hold, the payout is on its way
208
+ the moment it is accepted and there is nothing to send.
209
+
210
+ Like the pricing case above, this is a property of how your organization is
211
+ routed rather than an outage: retrying will never succeed. The signal to branch
212
+ on is `requiresFunding` on the payout itself — call funding only when it is
213
+ `true`, and treat this error as a bug in that branch rather than as a step to
214
+ retry.
215
+
170
216
  ### `INDICATIVE_PRICING_UNAVAILABLE`
171
217
 
172
218
  `GET /rates` shows a price before a beneficiary exists — "you send $200, they get
@@ -180,11 +226,28 @@ and pricing against it.
180
226
  It used to surface as a `501` typed `INTERNAL` advising "retry with the same
181
227
  Idempotency-Key" — retry advice for a `GET`, on a condition that never changes.
182
228
 
229
+ ## Batch payouts
230
+
231
+ | `type` | Status | Meaning |
232
+ | ------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
233
+ | `BATCH_NOT_FOUND` | 404 | No batch with that id in your organization. **Nothing was changed** |
234
+ | `PAYOUT_BATCH_NOT_CONFIRMABLE` | 409 | The batch is not `awaiting_confirmation` — it already proceeded, was canceled, or failed. `detail` names where it actually is. **Nothing was changed** |
235
+ | `PAYOUT_BATCH_NOT_CANCELABLE` | 409 | Creation already started, so the run is committed. Cancel individual payouts while they are still `pending` via `POST .../payouts/{payoutId}/cancel`. **Nothing was changed** |
236
+ | `PAYOUT_BATCH_AWAITING_APPROVAL` | 409 | The run needs the organization's approvers before it can be confirmed, or an approver rejected it. `approvalId` names the request; approvers act on it in the dashboard. **Nothing was created** |
237
+ | `PAYOUT_BATCH_DUPLICATE_REFERENCE` | 409 | A batch with this `externalReferenceId` already exists — the run id is unique per organization, and this is the guard against a submit job that crashed and re-ran with a fresh `Idempotency-Key`. `originalBatchId` names the existing run. **Nothing was submitted** |
238
+ | `MASS_PAYOUTS_DISABLED` | 403 | Your organization has opted out of batch submission (it is on by default). Single payouts are unaffected; contact support to turn it back on. **Nothing was submitted** |
239
+
240
+ A line that failed **inside** an accepted batch is not an error response either
241
+ — it is an item whose `status` is `invalid` or `create_failed`, carrying
242
+ `errors[]`. Read the items with `?status=invalid` after
243
+ `payout_batch.awaiting_confirmation`, exactly as a failed payout is a payout
244
+ whose `status` is `failed`.
245
+
183
246
  ## Payout links
184
247
 
185
- | `type` | Status | Meaning | What to do |
186
- |---|---|---|---|
187
- | `PAYOUT_LINK_UNUSABLE` | 400 | The link is expired, already spent, or failed at execution | Mint a new one. Links are single-use by design |
248
+ | `type` | Status | Meaning | What to do |
249
+ | ---------------------- | ------ | ---------------------------------------------------------- | ---------------------------------------------- |
250
+ | `PAYOUT_LINK_UNUSABLE` | 400 | The link is expired, already spent, or failed at execution | Mint a new one. Links are single-use by design |
188
251
 
189
252
  A link that was already spent successfully is **not** an error: a repeat submit
190
253
  returns the original payout with `status: "already_submitted"`, so a recipient
@@ -200,17 +263,17 @@ a stranger probing links learns nothing from the difference.
200
263
  A payout that was accepted and later failed is **not** an error response — it is
201
264
  a payout whose `status` is `failed`, carrying a `failureCode`.
202
265
 
203
- | `failureCode` | What happened | Is the money back? | What to do |
204
- |---|---|---|---|
205
- | `returned_by_bank` | It settled, then the receiving bank returned it | Yes | Tell your user. Reverse whatever you credited |
206
- | `account_invalid` | The account details are wrong | Yes | Ask for correct details, create a new beneficiary |
207
- | `account_cannot_receive` | The account cannot accept this payment | Yes | Try another account or corridor |
208
- | `compliance_rejected` | Refused by compliance screening | **Not automatically** | Contact us with the `payoutId`. Do not retry |
209
- | `limit_exceeded` | Above a corridor or account limit | Yes | Split it, or check `limits` on the corridors call |
210
- | `quote_expired` | Too long between quoting and sending | Yes | Re-quote and send again |
211
- | `authorization_not_completed` | An authorisation step was not finished | Yes | Start again |
212
- | `execution_failed` | It did not go through, cause not established | Check `fundsReturned` | Safe to retry with a **new** idempotency key |
213
- | `unknown` | We do not have a specific cause | Check `fundsReturned` | Contact us with the `payoutId` |
266
+ | `failureCode` | What happened | Is the money back? | What to do |
267
+ | ----------------------------- | ----------------------------------------------- | --------------------- | ------------------------------------------------- |
268
+ | `returned_by_bank` | It settled, then the receiving bank returned it | Yes | Tell your user. Reverse whatever you credited |
269
+ | `account_invalid` | The account details are wrong | Yes | Ask for correct details, create a new beneficiary |
270
+ | `account_cannot_receive` | The account cannot accept this payment | Yes | Try another account or corridor |
271
+ | `compliance_rejected` | Refused by compliance screening | **Not automatically** | Contact us with the `payoutId`. Do not retry |
272
+ | `limit_exceeded` | Above a corridor or account limit | Yes | Split it, or check `limits` on the corridors call |
273
+ | `quote_expired` | Too long between quoting and sending | Yes | Re-quote and send again |
274
+ | `authorization_not_completed` | An authorisation step was not finished | Yes | Start again |
275
+ | `execution_failed` | It did not go through, cause not established | Check `fundsReturned` | Safe to retry with a **new** idempotency key |
276
+ | `unknown` | We do not have a specific cause | Check `fundsReturned` | Contact us with the `payoutId` |
214
277
 
215
278
  **Read `fundsReturned`, not the code.** It is the only field that answers "is
216
279
  the money back in my balance?", and it is absent when we do not yet know — which
@@ -226,7 +289,6 @@ the positive statement that the money did not come back.
226
289
  New failure codes are added without a major version. Treat an unrecognised one
227
290
  as `execution_failed`.
228
291
 
229
-
230
292
  ### What the sandbox can and cannot produce
231
293
 
232
294
  Only three failure codes are reachable in sandbox — `account_invalid` (`0001`),