@avvio/payments 0.1.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 ADDED
@@ -0,0 +1,66 @@
1
+ # Changelog and versioning
2
+
3
+ ## What we may change without warning
4
+
5
+ These are safe for us to change, so **do not build on them**:
6
+
7
+ - **Adding fields to a response.** Ignore what you do not recognise.
8
+ - **Adding a new `failureCode`.** Treat an unrecognised one as
9
+ `execution_failed`.
10
+ - **Adding a new `stage`.** Treat an unrecognised one as the status alone.
11
+ - **Adding a new event type.** Return `2xx` for events you do not handle.
12
+ - **Prose in `detail` and `message`.** Branch on `type`, never on the wording.
13
+ - **Which payment network we route an organization to.** Corridor field names
14
+ and sets can change with it, which is why you must read
15
+ `GET /recipients/{orgId}/corridors` rather than hardcoding a form.
16
+ - **Exact rates and fees**, obviously.
17
+
18
+ ## What we will not change without notice
19
+
20
+ - Removing or renaming an endpoint.
21
+ - Removing or renaming a field you receive today.
22
+ - Changing the meaning of a `status` or an existing `failureCode`.
23
+ - Making an optional request field required.
24
+ - Narrowing a validation rule so a request that works today stops working.
25
+
26
+ Anything in that second list gets a new API version and a deprecation period.
27
+ The version is in the URL: `/api/v1`.
28
+
29
+ ## The two guarantees worth designing around
30
+
31
+ **`completed` is not always final.** A receiving bank can return a settled
32
+ payment days later, producing `failed` with `returned_by_bank`. This is not a
33
+ breaking change we will version away — it is how payments work. Do not write a
34
+ ledger that treats `completed` as immutable.
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.
39
+
40
+ ---
41
+
42
+ ## 0.1.0
43
+
44
+ First release. Not yet published to npm — install from a tarball, see the
45
+ README.
46
+
47
+ - Node client, CLI, and MCP server, sharing one core. Zero dependencies.
48
+ - Sandbox with deterministic outcomes chosen by the last four digits of the
49
+ beneficiary's account number, including `0003`, which completes and is then
50
+ returned by the bank.
51
+ - `payout()` and `POST /payouts` price and send in one call, refusing to send
52
+ when the quote has drifted from what you promised the payer.
53
+ - Idempotency on every mutation, generated for you if you do not supply one.
54
+ - Webhook verification built in — no third-party library needed.
55
+
56
+ ### Known limits at 0.1.0
57
+
58
+ Stated here rather than discovered by you:
59
+
60
+ - **Cancellation does not exist.** `canceled` is in the status vocabulary
61
+ because we expect to need it; there is no endpoint today.
62
+ - **Exact-output depends on your routing.** Check `capabilities.exactOutput` on
63
+ the corridors call before offering it in your UI.
64
+ - **`GET /payouts` filters** are available; a cursor is returned but the
65
+ aggregate page size is capped.
66
+ - **Sandbox rates are fixed** and settlement takes seconds, not days.
package/ERRORS.md ADDED
@@ -0,0 +1,252 @@
1
+ # Errors
2
+
3
+ Every failure has the same shape. **Branch on `type`** — it is stable across
4
+ versions, where the HTTP status and the prose are not.
5
+
6
+ ```jsonc
7
+ {
8
+ "type": "RATE_DRIFT_EXCEEDED",
9
+ "status": 400,
10
+ "detail": "Refusing to send: quoted 3384.65 but you expected ~9999 (6615 bps of drift, limit 200). Nothing was sent.",
11
+ "resolution": "Nothing was sent. Re-quote, show the payer the new amount, and send again.",
12
+ "requestId": "req-1c"
13
+ }
14
+ ```
15
+
16
+ `resolution` says what to do, when there is a specific answer. **It is not on
17
+ every error** — treat it as optional and fall back to `detail`, which is always
18
+ present. (An earlier version of this page listed exactly which types omit it;
19
+ the list was incomplete, and an incomplete enumeration is worse than saying
20
+ "optional".)
21
+
22
+ `requestId` is in the **body of every error**, and on a SUCCESSFUL response it
23
+ is the `x-request-id` **header** rather than a body field. Log the header and
24
+ you have it for every request either way. (This previously claimed the body
25
+ carried it on success; it does not.)
26
+
27
+ Validation failures add `errors`, a list of the fields that failed. `detail`
28
+ stays a string in every case, so `detail.toLowerCase()` is always safe.
29
+
30
+ ---
31
+
32
+ ## Request errors
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 request — most 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 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 |
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 |
56
+
57
+ `BAD_REQUEST` is the catch-all for a 400 that is not a field-validation failure.
58
+ Because it covers several conditions, it is the one type where you should read
59
+ `detail` — it names the specific condition. **None of them is retryable
60
+ unchanged.**
61
+
62
+ There is no `retryable` field on the wire. An earlier version of this page told
63
+ you to branch on one; the Node client derives it for you, but over raw HTTP the
64
+ `type` is what you branch on.
65
+
66
+ ## Idempotency
67
+
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 |
76
+
77
+ A `4xx` **releases** the key — a request that failed validation committed
78
+ nothing, so you may correct the body and reuse it.
79
+
80
+ ### `DUPLICATE_REQUEST_DETECTED`
81
+
82
+ The key protects you only if your retry sends the *same* key. Some HTTP clients
83
+ generate one per attempt, which defeats it silently: every retry looks like a new
84
+ request, and every retry pays. So we watch a second signal — same body, different
85
+ key, within 15 minutes — and refuse.
86
+
87
+ ```jsonc
88
+ {
89
+ "type": "DUPLICATE_REQUEST_DETECTED",
90
+ "originalIdempotencyKey": "zz_advance_88213",
91
+ "originalPayoutId": "pay_01J…",
92
+ "detail": "An identical request was received in the last 15 minutes under a different Idempotency-Key…"
93
+ }
94
+ ```
95
+
96
+ **This is not "already paid".** Nothing was executed. Reading it as a success and
97
+ marking the wage settled is the one wrong move, and it leaves a worker unpaid
98
+ with your ledger saying otherwise.
99
+
100
+ Two ways forward, and you have to pick one — we will not guess:
101
+
102
+ - **It was a retry.** Send it again with the **value** of
103
+ `originalIdempotencyKey` as your `Idempotency-Key` header. That is the key the
104
+ first attempt used, so this replays it: you get the original payout back and
105
+ nothing is sent twice.
106
+
107
+ ```bash
108
+ # the 409 gave you: "originalIdempotencyKey": "zz_advance_88213"
109
+ curl -s -X POST ".../payouts" \
110
+ -H "idempotency-key: zz_advance_88213" \ # <- that value, as the header
111
+ -H "content-type: application/json" \
112
+ -d '{ ...the same body... }'
113
+ ```
114
+
115
+ `originalIdempotencyKey` is a field we send **to** you, not one you send back.
116
+ Putting it in the request body is rejected — request bodies reject unknown
117
+ properties.
118
+
119
+ - **You meant two payments.** Add `X-Allow-Duplicate: true` and send again.
120
+ **This sends a second real payment.** Only take this branch if you are certain
121
+ the first one was intended too.
122
+
123
+ **The window is 15 minutes, and that is a real boundary.** It covers a crashed
124
+ job that requeues on a backoff — the realistic incident. It is deliberately not
125
+ the full 7-day retention: content matching cannot tell a retry from a genuine
126
+ repeat, and two advances of the same amount to the same worker in one week are
127
+ ordinary payroll. At 7 days every routine repeat would be refused and you would
128
+ end up sending `X-Allow-Duplicate` unconditionally, which removes the protection
129
+ while appearing to strengthen it.
130
+
131
+ **Send a unique `reference` per logical payment and this can never false-positive
132
+ at any window length** — a different reference is a different body. That, plus
133
+ persisting your own idempotency key, is the durable protection. This guard is a
134
+ net for the accidental case, not a substitute for either.
135
+
136
+ We refuse rather than silently returning the first payout, because *both*
137
+ readings are common. Two advances of the same amount to the same worker in one
138
+ week is ordinary payroll; replaying there would mean the second one never goes
139
+ out while your ledger records that it did. A 409 you have to answer is recoverable.
140
+ A payment that quietly evaporates is not.
141
+
142
+ ### How long a key is remembered
143
+
144
+ Seven days, and the window is about **storage, not correctness**. There is no
145
+ "the key expired, so we ran it again" path: while we hold the record it is
146
+ authoritative, and an old key retried against it replays rather than re-executes.
147
+ A TTL that quietly re-arms a key is a double payment on a timer.
148
+
149
+ Past seven days the record is deleted and the key is genuinely unknown to us — so
150
+ treat seven days as the outer bound on retrying, not on caring. If you are
151
+ reconciling something older, read the payout by id.
152
+
153
+ ## Sending
154
+
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 |
164
+
165
+ `DESTINATION_ACCOUNT_NOT_FOUND` is the guard against paying an id you did not
166
+ get from us. A stale, typo'd, or copied-from-elsewhere account id is refused
167
+ before anything is priced — rather than being sent, settling, and reporting
168
+ `completed` to a payroll run where nobody received the money.
169
+
170
+ ### `INDICATIVE_PRICING_UNAVAILABLE`
171
+
172
+ `GET /rates` shows a price before a beneficiary exists — "you send $200, they get
173
+ 3,410 MXN" while your user is still typing. Not every routing publishes one.
174
+
175
+ This is a permanent property of how your organization is routed, not an outage,
176
+ so retrying will never succeed. Read `capabilities.indicativePricing` on the
177
+ corridors call and, when it is `false`, skip straight to creating the beneficiary
178
+ and pricing against it.
179
+
180
+ It used to surface as a `501` typed `INTERNAL` advising "retry with the same
181
+ Idempotency-Key" — retry advice for a `GET`, on a condition that never changes.
182
+
183
+ ## Payout links
184
+
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 |
188
+
189
+ A link that was already spent successfully is **not** an error: a repeat submit
190
+ returns the original payout with `status: "already_submitted"`, so a recipient
191
+ who double-taps gets what they already have.
192
+
193
+ A `404` on a link route covers expired, spent, forged and never-existed alike —
194
+ a stranger probing links learns nothing from the difference.
195
+
196
+ ---
197
+
198
+ ## When a payout fails
199
+
200
+ A payout that was accepted and later failed is **not** an error response — it is
201
+ a payout whose `status` is `failed`, carrying a `failureCode`.
202
+
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` |
214
+
215
+ **Read `fundsReturned`, not the code.** It is the only field that answers "is
216
+ the money back in my balance?", and it is absent when we do not yet know — which
217
+ is deliberately not the same as `false`. Do not re-credit a user on a code alone.
218
+
219
+ **The one case where it stays absent: `compliance_rejected`.** Those funds are
220
+ held pending a human review, so there is no automatic answer to give and we will
221
+ not invent one. Absent here means "ask us", not "not yet" — contact us with the
222
+ `payoutId`. Your ledger can still settle the question without waiting: the
223
+ balance history shows the debit with **no** matching `reversal` entry, which is
224
+ the positive statement that the money did not come back.
225
+
226
+ New failure codes are added without a major version. Treat an unrecognised one
227
+ as `execution_failed`.
228
+
229
+
230
+ ### What the sandbox can and cannot produce
231
+
232
+ Only three failure codes are reachable in sandbox — `account_invalid` (`0001`),
233
+ `compliance_rejected` (`0004`) and `returned_by_bank` (`0003`). The rest
234
+ (`limit_exceeded`, `account_cannot_receive`, `authorization_not_completed`,
235
+ `execution_failed`, `quote_expired`, `unknown`) come from live rails only.
236
+
237
+ `stage` is likewise live-only and never appears on a sandbox payout.
238
+
239
+ **So do not treat a sandbox run as proof your failure handling is complete.**
240
+ Write the switch for every code in the table, and make the default branch behave
241
+ like `execution_failed` — you cannot test the others before go-live.
242
+
243
+ ---
244
+
245
+ ## The one that surprises people
246
+
247
+ `completed → failed` with `returned_by_bank` happens **after** you were told the
248
+ payout succeeded, sometimes days later.
249
+
250
+ Keep processing webhooks for a payout after it completes, and do not write a
251
+ ledger that treats `completed` as immutable. Trigger it on demand in sandbox
252
+ with an account number ending `0003`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Avvio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/QUICKSTART.md ADDED
@@ -0,0 +1,317 @@
1
+ # Quickstart
2
+
3
+ From an API key to a completed payout, in one page. Nothing here touches a real
4
+ payment network, and no real money can move.
5
+
6
+ You need a **test key** (`ak_test_…`) from Dashboard → Developers, and your
7
+ organization id. Set both:
8
+
9
+ ```bash
10
+ export AVVIO_API_KEY=ak_test_…
11
+ export AVVIO_ORG_ID=cmsx… # a cuid, not an org_ prefix
12
+ export AVVIO_BASE_URL=https://api.avvio.xyz/business/api/v1
13
+ ```
14
+
15
+ Every step below is shown as a CLI command, as curl, and as Node — pick a tab
16
+ and it stays picked for the rest of the site. Nothing here depends on a
17
+ language. If you use Node, `npx -y @avvio/payments doctor` does step 1 and tells
18
+ you which credential is wrong.
19
+
20
+ ---
21
+
22
+ ## 1. Check the key works
23
+
24
+ {{sample:listCorridors}}
25
+
26
+ You get the currencies you can pay out to and the fields each one needs.
27
+
28
+ **Read this rather than hardcoding a form.** Both the corridor list and the
29
+ field *names* depend on how your organization is routed, and we may re-route
30
+ you. Mexico is one field; India is two.
31
+
32
+ ---
33
+
34
+ ## 2. Give yourself a balance
35
+
36
+ Sandbox only. Real balances are funded by wire.
37
+
38
+ {{sample:fundSandbox}}
39
+
40
+ It is an explicit call rather than a balance we hand you, so you can also test
41
+ the underfunded path deliberately — `400` at quote time is a case your
42
+ integration has to handle.
43
+
44
+ ---
45
+
46
+ ## 3. Show a price before anyone commits
47
+
48
+ {{sample:getIndicativeQuote}}
49
+
50
+ ```jsonc
51
+ {
52
+ "indicative": true,
53
+ "sourceAmount": { "currency": "USD", "amount": "200.00" },
54
+ "destinationAmount": { "currency": "MXN", "amount": "3384.65" },
55
+ "fee": { "currency": "USD", "amount": "1.02" },
56
+ "totalDebit": { "currency": "USD", "amount": "200.00" },
57
+ "rate": "17.010001005126146", # full precision — round it yourself for display
58
+ "limits": { "min": "5.00", "max": "5000.00" }
59
+ }
60
+ ```
61
+
62
+ No beneficiary needed — this is what you show while someone is still typing an
63
+ amount. The fee comes **out of** the send, so
64
+ `destinationAmount = (sourceAmount − fee) × rate`.
65
+
66
+ On a **payout** the fee is already inside the two amounts, so the identity there
67
+ is simply `destinationAmount = sourceAmount × rate`. `rate` on a payout always
68
+ means destination units per one source unit, whichever network carried it — it is
69
+ derived from the payout's own amounts rather than passed through, because the
70
+ networks state it in different directions and on different bases.
71
+
72
+ ---
73
+
74
+ ## 4. Create the beneficiary
75
+
76
+ {{sample:createBeneficiary}}
77
+
78
+ Two ids, two jobs. `endUserId` is **your** id for the person *sending* — it
79
+ scopes the beneficiary so one of your users never sees another's saved
80
+ accounts. `externalId` is your id for the beneficiary, and makes a repeat
81
+ create return the existing one instead of registering a second bank account.
82
+
83
+ **An `externalId` identifies one account.** Re-sending it with the SAME account
84
+ replays and returns the existing beneficiary. Re-sending it with a DIFFERENT
85
+ account is refused with `BENEFICIARY_EXTERNAL_ID_CONFLICT` — a second account
86
+ needs a second `externalId`. Nothing is silently substituted.
87
+
88
+ `type`, `name`, `email` and `method` are always required, whatever the corridor.
89
+ The corridors call describes the fields inside `recipientDetails`; these four sit
90
+ outside it and apply everywhere.
91
+
92
+ Keep `paymentMethods[0].destinationAccountId` from the response.
93
+
94
+ ---
95
+
96
+ ## 5. Send it
97
+
98
+ {{sample:createPayout}}
99
+
100
+ ```jsonc
101
+ { "payoutId": "…", "status": "pending", "reference": "ZZ-2026-0042", … }
102
+ ```
103
+
104
+ **`expectDestination` is the one field not to skip.** Between pricing and
105
+ sending, a rate can move. Pass the number you showed your user and we refuse to
106
+ send if it has drifted more than 2% — nothing goes, and you re-quote. Without
107
+ it, you send at whatever the quote says.
108
+
109
+ ---
110
+
111
+ ## 6. Watch it settle
112
+
113
+ {{sample:getPayout}}
114
+
115
+ `pending → processing → completed`. This endpoint is always live and is
116
+ authoritative — more so than a webhook you may have missed.
117
+
118
+ ---
119
+
120
+ ## 6b. Money in, money out, and stopping one
121
+
122
+ Three endpoints you will need on day one.
123
+
124
+ **Your balance, and why it is that number:**
125
+
126
+ ```bash
127
+ curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/balance" \
128
+ -H "x-api-key: $AVVIO_API_KEY"
129
+
130
+ curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/balance/history" \
131
+ -H "x-api-key: $AVVIO_API_KEY"
132
+ ```
133
+
134
+ `balance/history` is the ledger behind the number: every funding, every payout
135
+ debit, and every `reversal` when money comes back. Read it whenever the balance
136
+ is not what you expect — it answers the question directly rather than making you
137
+ infer it from payouts.
138
+
139
+ **Stopping a payout that has not been funded yet:**
140
+
141
+ {{sample:cancelPayout}}
142
+
143
+ It reports `canceled`, the money returns to your balance with a `reversal` row,
144
+ and the payout can no longer be funded. **Once a payout is funded it cannot be
145
+ cancelled** — you get `PAYOUT_NOT_CANCELABLE`, and that is the honest answer
146
+ rather than a cancellation that does not happen. So cancel is for "created by
147
+ mistake", not for "stop one already in flight".
148
+
149
+ ---
150
+
151
+ ## A trap worth knowing now: there are two organization ids
152
+
153
+ The id you authenticate with — the one in every URL — is **not** the
154
+ `organizationId` that comes back inside response bodies. That second id is an
155
+ internal one, and using it in a URL gives:
156
+
157
+ ```jsonc
158
+ { "type": "FORBIDDEN", "detail": "This API key cannot access that organization" }
159
+ ```
160
+
161
+ which reads as a credentials problem when it is not. **Keep using the org id you
162
+ were issued.** Ignore `organizationId` in response bodies.
163
+
164
+ ---
165
+
166
+ ## 7. The one to run before you go live
167
+
168
+ Create a second beneficiary whose account number ends **`0003`**:
169
+
170
+ ```
171
+ "clabeNumber": "012345678901230003"
172
+ ```
173
+
174
+ Pay it, then keep polling past `completed`. It flips:
175
+
176
+ ```jsonc
177
+ { "status": "failed", "failureCode": "returned_by_bank", "fundsReturned": true }
178
+ ```
179
+
180
+ **A completed payout is not always final.** A receiving bank can return one days
181
+ later. If your ledger treats `completed` as immutable, this is the case that
182
+ breaks it — and it is the reason this trigger exists rather than being described
183
+ in a paragraph you would skim.
184
+
185
+ Other triggers, and exactly what each does:
186
+
187
+ | account ends | what happens |
188
+ |---|---|
189
+ | `0001` | fails at the rail with `failureCode: account_invalid`, funds returned |
190
+ | `0002` | settles slowly — useful for testing a poll loop |
191
+ | `0003` | completes, then flips to `failed` / `returned_by_bank` (above) |
192
+ | `0004` | `compliance_rejected`, and the money does **not** come back |
193
+ | `0005` | the quote expires — the payout is refused at **create** with `400 BAD_REQUEST`, so no payout exists to poll |
194
+ | `0006` | waits for you to fund it from your own wallet |
195
+ | anything else | completes |
196
+
197
+ `0006` is worth running too. Some routings do not settle on acceptance — they
198
+ price the payout and wait for your funds. It returns `requiresFunding: true`
199
+ and does not move until you confirm.
200
+
201
+ Read the deposit instructions from `GET /payments/organizations/{orgId}/payouts/{payoutId}/funding`
202
+ — the address, the amount, the network and an expiry — then send the funds and
203
+ report the transaction:
204
+
205
+ ```bash
206
+ curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/payouts/$PAYOUT_ID/funding/confirm" \
207
+ -H "x-api-key: $AVVIO_API_KEY" \
208
+ -H "idempotency-key: $(uuidgen)" \
209
+ -H "content-type: application/json" \
210
+ -d '{"transactionHash":"0x…"}'
211
+ ```
212
+
213
+ **We read the chain before recording it.** In production a hash matching no
214
+ transfer, the wrong token, the wrong address, the wrong amount or a wallet other
215
+ than the one registered is refused — nothing is recorded, and the payout stays
216
+ fundable. One transfer funds exactly one payout.
217
+
218
+ The sandbox has no chain to read, so the **last four digits of the hash** choose
219
+ the outcome, the same way the account number does:
220
+
221
+ | hash ends | you get |
222
+ |---|---|
223
+ | `0001` | `FUNDING_TRANSACTION_INVALID` — 400, we read the chain and it does not fund this payout |
224
+ | `0002` | `FUNDING_NOT_YET_VERIFIABLE` — 409, not mined yet; retry the same request |
225
+ | anything else | accepted, and the payout settles |
226
+ | a hash already used | `FUNDING_TRANSACTION_ALREADY_USED` — 409, naming the payout it funded |
227
+
228
+ Testing that branch here is the alternative to discovering it in production.
229
+
230
+ ---
231
+
232
+ ## 8. Webhooks, before you need them
233
+
234
+ Poll-only integrations survive the sandbox and struggle in production. Issue a
235
+ sandbox secret and prove your handler works now:
236
+
237
+ ```bash
238
+ curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/webhook-endpoints" \
239
+ -H "x-api-key: $AVVIO_API_KEY" \
240
+ -H "idempotency-key: $(uuidgen)" \
241
+ -H "content-type: application/json" \
242
+ -d '{"url":"http://localhost:4000/hooks"}'
243
+ ```
244
+
245
+ ```jsonc
246
+ { "id": "…", "url": "…", "secret": "whsec_…",
247
+ "warning": "Store this secret now — it is not retrievable." }
248
+ ```
249
+
250
+ `http://localhost` works **in sandbox only**, so your first receiver can be a
251
+ script rather than a tunnel. Live endpoints are https and are created from the
252
+ dashboard by a human, because a credential able to repoint its own webhook URL
253
+ could redirect every payout notification.
254
+
255
+ Then check what we actually sent:
256
+
257
+ ```bash
258
+ curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/webhook-endpoints/$ID/deliveries" \
259
+ -H "x-api-key: $AVVIO_API_KEY"
260
+ ```
261
+
262
+ ---
263
+
264
+ ## Not on Node?
265
+
266
+ Every example here is curl, and the OpenAPI spec generates a working client. This
267
+ is tested, not asserted — a generated Python client runs the full flow above in
268
+ our CI, which is how we found (and fixed) four places where the spec disagreed
269
+ with the server.
270
+
271
+ ```bash
272
+ npx @openapitools/openapi-generator-cli generate \
273
+ -i partner-payouts.openapi.yaml -g python -o ./avvio --package-name avvio_payouts
274
+ pip install -e ./avvio
275
+ ```
276
+
277
+ ```python
278
+ import avvio_payouts
279
+ from avvio_payouts.api.payouts_api import PayoutsApi
280
+
281
+ cfg = avvio_payouts.Configuration(host=BASE_URL)
282
+ cfg.api_key["ApiKeyAuth"] = API_KEY
283
+
284
+ with avvio_payouts.ApiClient(cfg) as api:
285
+ payout = PayoutsApi(api).create_payout(
286
+ ORG_ID, str(uuid.uuid4()), # the Idempotency-Key
287
+ create_payout_request={
288
+ "amount": "200.00",
289
+ "destinationAccountId": acct,
290
+ "expectDestination": quote.destination_amount.amount,
291
+ })
292
+ ```
293
+
294
+ Swap `-g python` for `java`, `go`, `ruby`, `csharp`, `php` — the generator
295
+ supports all of them off the same file.
296
+
297
+ **One shape to watch:** on the REST surface `sourceAmount` is a `Money` object
298
+ (`{currency, amount}`); inside a **webhook** it is a flat decimal string beside a
299
+ separate `sourceCurrency`. They are two schemas in the spec — `Payout` and
300
+ `WebhookPayout` — because they are genuinely different.
301
+
302
+ ---
303
+
304
+ ## Then
305
+
306
+ - [Errors](./ERRORS.md) — every code, and what to do about each
307
+ - [Webhooks](../PARTNER_PAYOUTS_API.md#webhooks) — verification and delivery
308
+ - [Going live](./GOING_LIVE.md) — the checklist
309
+
310
+ ## Two rules worth internalising now
311
+
312
+ **A timeout is an unknown outcome, not a failure.** If a send times out, the
313
+ payout may exist. Retry with the *same* `Idempotency-Key` — a replay returns the
314
+ original. Calling again without it is a second payment.
315
+
316
+ **Webhooks are the fast path, not the guarantee.** Reconcile against the payout
317
+ read; treat a webhook as the nudge to look.