@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 +66 -0
- package/ERRORS.md +252 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +317 -0
- package/README.md +411 -0
- package/index.d.ts +680 -0
- package/package.json +55 -0
- package/src/cli.js +635 -0
- package/src/client.js +799 -0
- package/src/mcp.js +434 -0
- package/src/webhooks.js +191 -0
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.
|