@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 +190 -5
- package/ERRORS.md +120 -58
- package/QUICKSTART.md +162 -54
- package/README.md +136 -37
- package/index.d.ts +801 -82
- package/package.json +11 -5
- package/src/cli.js +204 -20
- package/src/client.js +576 -58
- package/src/mcp.js +279 -29
package/QUICKSTART.md
CHANGED
|
@@ -3,30 +3,65 @@
|
|
|
3
3
|
From an API key to a completed payout, in one page. Nothing here touches a real
|
|
4
4
|
payment network, and no real money can move.
|
|
5
5
|
|
|
6
|
-
You need a **test key**
|
|
7
|
-
|
|
6
|
+
You need a **test API key** from Dashboard → Developers and your organization
|
|
7
|
+
id. The complete key is shown once; copy it then because only its hash is
|
|
8
|
+
stored. Set these values:
|
|
8
9
|
|
|
9
10
|
```bash
|
|
10
|
-
export AVVIO_API_KEY=
|
|
11
|
+
export AVVIO_API_KEY=avvio_test_…
|
|
11
12
|
export AVVIO_ORG_ID=cmsx… # a cuid, not an org_ prefix
|
|
12
13
|
export AVVIO_BASE_URL=https://api.avvio.xyz/business/api/v1
|
|
13
14
|
```
|
|
14
15
|
|
|
16
|
+
If your Avvio contact gave you a different base URL (a dedicated or sandbox
|
|
17
|
+
host), use that one; the paths are identical.
|
|
18
|
+
|
|
19
|
+
### Use the API key directly
|
|
20
|
+
|
|
21
|
+
The complete `avvio_test_*` value is a bearer credential. Send it as
|
|
22
|
+
`x-api-key`; no private key, signature, nonce, timestamp, or challenge is
|
|
23
|
+
required:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/balance" \
|
|
27
|
+
-H "x-api-key: $AVVIO_API_KEY"
|
|
28
|
+
```
|
|
29
|
+
|
|
15
30
|
Every step below is shown as a CLI command, as curl, and as Node — pick a tab
|
|
16
31
|
and it stays picked for the rest of the site. Nothing here depends on a
|
|
17
|
-
language.
|
|
18
|
-
|
|
32
|
+
language. Generated curl samples include `x-api-key`; idempotent operations also
|
|
33
|
+
include `Idempotency-Key`, which must stay identical on every retry. If you use
|
|
34
|
+
Node, `npx -y @avvio/payments doctor` checks the credential and tells you what is
|
|
35
|
+
wrong.
|
|
36
|
+
|
|
37
|
+
In ReadMe, paste the complete `avvio_test_*` key once under Authorization. Enter
|
|
38
|
+
your ordinary organization id as `orgId`, then use **Try It** on any protected
|
|
39
|
+
endpoint. Never paste a live key into the documentation console.
|
|
19
40
|
|
|
20
41
|
---
|
|
21
42
|
|
|
22
|
-
## 1. Check the key works
|
|
43
|
+
## 1. Check the key works, and read your policy
|
|
44
|
+
|
|
45
|
+
{{sample:getPolicy}}
|
|
46
|
+
|
|
47
|
+
One call tells you what your organization is bound by before you send anything:
|
|
48
|
+
`limits` (USD caps, `null` for none), `approvals.thresholdUsd` and how many
|
|
49
|
+
approvers a held payout needs, which `features` are on, `rateLimits` per
|
|
50
|
+
minute, the `idempotency` windows, the currencies that need a
|
|
51
|
+
`purposeOfPayment`, and `fees.payout`: the fee schedule your routing can state
|
|
52
|
+
before a quote (`bps`, `fixedUsd`, per-currency overrides), with `null` where
|
|
53
|
+
it's only priced inside a quote. Everything in it is read from your
|
|
54
|
+
organization and your routing's configuration at request time, so plan against
|
|
55
|
+
it rather than discovering a cap from a `422` later.
|
|
56
|
+
|
|
57
|
+
Then the corridors:
|
|
23
58
|
|
|
24
59
|
{{sample:listCorridors}}
|
|
25
60
|
|
|
26
61
|
You get the currencies you can pay out to and the fields each one needs.
|
|
27
62
|
|
|
28
63
|
**Read this rather than hardcoding a form.** Both the corridor list and the
|
|
29
|
-
field
|
|
64
|
+
field _names_ depend on how your organization is routed, and we may re-route
|
|
30
65
|
you. Mexico is one field; India is two.
|
|
31
66
|
|
|
32
67
|
---
|
|
@@ -55,7 +90,7 @@ integration has to handle.
|
|
|
55
90
|
"fee": { "currency": "USD", "amount": "1.02" },
|
|
56
91
|
"totalDebit": { "currency": "USD", "amount": "200.00" },
|
|
57
92
|
"rate": "17.010001005126146", # full precision — round it yourself for display
|
|
58
|
-
"limits": { "min": "
|
|
93
|
+
"limits": { "min": "1.00", "max": "5000.00" }
|
|
59
94
|
}
|
|
60
95
|
```
|
|
61
96
|
|
|
@@ -63,8 +98,24 @@ No beneficiary needed — this is what you show while someone is still typing an
|
|
|
63
98
|
amount. The fee comes **out of** the send, so
|
|
64
99
|
`destinationAmount = (sourceAmount − fee) × rate`.
|
|
65
100
|
|
|
101
|
+
**Three names, two things.** `quote()` in the SDK and `quote` in the CLI call
|
|
102
|
+
`GET /rates`: this indicative price, no beneficiary. `POST /quotes/offramp`
|
|
103
|
+
(`pricePayout()` in the SDK) is the two-step flow's binding quote against a
|
|
104
|
+
real `destinationAccountId`, the one `POST /quotes/accept` executes. The one-call `POST /payouts` in §5 quotes
|
|
105
|
+
and accepts for you.
|
|
106
|
+
|
|
107
|
+
**Two rates, two names.** The `rate` here is the **indicative mid-rate before
|
|
108
|
+
the fee**: what one unit converts at. The `rate` on a payout is the **effective
|
|
109
|
+
rate**, destination over source with the fee inside, so for the same conversion
|
|
110
|
+
it reads lower by exactly the fee share (`17.0100` here, `16.9233` on the
|
|
111
|
+
payout, say). That's not the rate moving between the two calls; net the fee out
|
|
112
|
+
of the payout's `sourceAmount` and the two agree.
|
|
113
|
+
|
|
66
114
|
On a **payout** the fee is already inside the two amounts, so the identity there
|
|
67
|
-
is simply `destinationAmount = sourceAmount × rate`.
|
|
115
|
+
is simply `destinationAmount = sourceAmount × rate`. The payout object still
|
|
116
|
+
reports it as `fee` (`{currency, amount}`, or `null` when the network has not
|
|
117
|
+
disclosed one), the same value its events and its `/balance_transactions` row
|
|
118
|
+
carry. `rate` on a payout always
|
|
68
119
|
means destination units per one source unit, whichever network carried it — it is
|
|
69
120
|
derived from the payout's own amounts rather than passed through, because the
|
|
70
121
|
networks state it in different directions and on different bases.
|
|
@@ -75,19 +126,37 @@ networks state it in different directions and on different bases.
|
|
|
75
126
|
|
|
76
127
|
{{sample:createBeneficiary}}
|
|
77
128
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
129
|
+
**The one decision first.** Paying your own workers or vendors: omit `endUser`
|
|
130
|
+
and `endUserId`. Paying on behalf of your customers: `endUser` is the customer
|
|
131
|
+
who is sending, never the beneficiary.
|
|
132
|
+
|
|
133
|
+
Two ids, two jobs. `endUserId` is **your** id for the party _sending_ through
|
|
134
|
+
your platform, your own customer, never the beneficiary. It scopes the
|
|
135
|
+
beneficiary so one of your customers never sees another's saved accounts, and
|
|
136
|
+
the per-end-user daily cap counts against it. `externalId` is your id for the
|
|
137
|
+
beneficiary, and makes a repeat create return the existing one instead of
|
|
138
|
+
registering a second bank account.
|
|
139
|
+
|
|
140
|
+
Three parties, then. **You** are the partner and the sender of record.
|
|
141
|
+
**`endUser`** is your customer the money is sent for. The **recipient** is the
|
|
142
|
+
account being paid. Payroll makes the roles concrete: an earned-wage-access
|
|
143
|
+
platform paying a worker on behalf of an employer sends
|
|
144
|
+
`endUserId: "employer_acme"` (the company drawing down its payroll) and
|
|
145
|
+
`externalId: "hris_emp_4471"` (the worker's HRIS id, one per bank account),
|
|
146
|
+
with `reference: "PAYROLL-2026-09-01"` on the payout naming the run. If you
|
|
147
|
+
are the sender yourself, with no customer between you and the beneficiary,
|
|
148
|
+
omit `endUser` entirely.
|
|
82
149
|
|
|
83
150
|
**An `externalId` identifies one account.** Re-sending it with the SAME account
|
|
84
|
-
replays and returns the existing beneficiary
|
|
85
|
-
|
|
86
|
-
|
|
151
|
+
replays and returns the existing beneficiary, still with **`201`**: the status
|
|
152
|
+
does not tell a repeat from a fresh create, the unchanged `id` does. Re-sending
|
|
153
|
+
it with a DIFFERENT account is refused with `BENEFICIARY_EXTERNAL_ID_CONFLICT` —
|
|
154
|
+
a second account needs a second `externalId`. Nothing is silently substituted.
|
|
87
155
|
|
|
88
156
|
`type`, `name`, `email` and `method` are always required, whatever the corridor.
|
|
89
157
|
The corridors call describes the fields inside `recipientDetails`; these four sit
|
|
90
|
-
outside it and apply everywhere.
|
|
158
|
+
outside it and apply everywhere. `method.rail` is optional; omit it and the
|
|
159
|
+
corridor's default rail is used.
|
|
91
160
|
|
|
92
161
|
Keep `paymentMethods[0].destinationAccountId` from the response.
|
|
93
162
|
|
|
@@ -98,9 +167,13 @@ Keep `paymentMethods[0].destinationAccountId` from the response.
|
|
|
98
167
|
{{sample:createPayout}}
|
|
99
168
|
|
|
100
169
|
```jsonc
|
|
101
|
-
|
|
170
|
+
// 200
|
|
171
|
+
{ "payoutId": "…", "status": "pending", "fee": { "currency": "USD", "amount": "1.02" }, "reference": "ZZ-2026-0042", … }
|
|
102
172
|
```
|
|
103
173
|
|
|
174
|
+
An accepted payout answers `200` with the payout body. `202` means your
|
|
175
|
+
organization requires approval and no payout exists yet.
|
|
176
|
+
|
|
104
177
|
**`expectDestination` is the one field not to skip.** Between pricing and
|
|
105
178
|
sending, a rate can move. Pass the number you showed your user and we refuse to
|
|
106
179
|
send if it has drifted more than 2% — nothing goes, and you re-quote. Without
|
|
@@ -112,8 +185,18 @@ it, you send at whatever the quote says.
|
|
|
112
185
|
|
|
113
186
|
{{sample:getPayout}}
|
|
114
187
|
|
|
115
|
-
`pending → processing → completed
|
|
116
|
-
|
|
188
|
+
`pending → processing → completed`, though not every payout is seen in every
|
|
189
|
+
state: a rail that settles on acceptance can go straight from `pending` to
|
|
190
|
+
`completed`, and in sandbox `processing` lasts eight seconds on most timelines,
|
|
191
|
+
so a poll or a webhook may never catch it (`0002` holds it for fifty). This
|
|
192
|
+
endpoint is always live and is authoritative — more so than a webhook you may
|
|
193
|
+
have missed.
|
|
194
|
+
|
|
195
|
+
Reading one is `GET /payments/organizations/{orgId}/orders/{payoutId}`, and the
|
|
196
|
+
list is `GET /payments/organizations/{orgId}/orders`. Note the asymmetry, because
|
|
197
|
+
guessing costs you an afternoon: you **send** to `/payouts` and **read** from
|
|
198
|
+
`/orders`. Only the sub-resources of a payout you already have — `/cancel`,
|
|
199
|
+
`/funding`, `/funding/confirm` — hang off `/payouts/{payoutId}`.
|
|
117
200
|
|
|
118
201
|
---
|
|
119
202
|
|
|
@@ -127,21 +210,33 @@ Three endpoints you will need on day one.
|
|
|
127
210
|
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/balance" \
|
|
128
211
|
-H "x-api-key: $AVVIO_API_KEY"
|
|
129
212
|
|
|
130
|
-
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/
|
|
213
|
+
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/balance_transactions" \
|
|
131
214
|
-H "x-api-key: $AVVIO_API_KEY"
|
|
132
215
|
```
|
|
133
216
|
|
|
134
|
-
`
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
217
|
+
`balance_transactions` is the ledger behind the number, one row per change to
|
|
218
|
+
what you can spend, each with the balance after it. Six row `type`s:
|
|
219
|
+
|
|
220
|
+
- `funding`: money in (a sandbox `fund`, a settled wire or crypto deposit).
|
|
221
|
+
- `hold`: reserved when a payout is accepted, before the network has answered.
|
|
222
|
+
Negative, `orderId: null`; it's joined to its payout by `snapshotId`.
|
|
223
|
+
- `hold_release`: the reservation given back, whether the payout went through
|
|
224
|
+
(paired with a `payout` row, net zero) or was refused (`reason` says why).
|
|
225
|
+
- `payout`: the debit for a payout the network accepted; `fee` is inside it.
|
|
226
|
+
- `payout_return`: money back on a canceled or returned payout.
|
|
227
|
+
- `adjustment`: an operator correction, with a `reason`.
|
|
228
|
+
|
|
229
|
+
Read it whenever the balance is not what you expect — it answers the question
|
|
230
|
+
directly rather than making you infer it from payouts. The recipe for keeping
|
|
231
|
+
your own ledger in step with it is in
|
|
232
|
+
[RECONCILIATION.md](RECONCILIATION.md#reconciling-your-balance-get-balance_transactions).
|
|
138
233
|
|
|
139
234
|
**Stopping a payout that has not been funded yet:**
|
|
140
235
|
|
|
141
236
|
{{sample:cancelPayout}}
|
|
142
237
|
|
|
143
|
-
It reports `canceled`, the money returns to your balance with a `
|
|
144
|
-
and the payout can no longer be funded. **Once a payout is funded it cannot be
|
|
238
|
+
It reports `canceled`, the money returns to your balance with a `payout_return`
|
|
239
|
+
row, and the payout can no longer be funded. **Once a payout is funded it cannot be
|
|
145
240
|
cancelled** — you get `PAYOUT_NOT_CANCELABLE`, and that is the honest answer
|
|
146
241
|
rather than a cancellation that does not happen. So cancel is for "created by
|
|
147
242
|
mistake", not for "stop one already in flight".
|
|
@@ -155,7 +250,10 @@ The id you authenticate with — the one in every URL — is **not** the
|
|
|
155
250
|
internal one, and using it in a URL gives:
|
|
156
251
|
|
|
157
252
|
```jsonc
|
|
158
|
-
{
|
|
253
|
+
{
|
|
254
|
+
"type": "FORBIDDEN",
|
|
255
|
+
"detail": "This API key cannot access that organization",
|
|
256
|
+
}
|
|
159
257
|
```
|
|
160
258
|
|
|
161
259
|
which reads as a credentials problem when it is not. **Keep using the org id you
|
|
@@ -168,10 +266,12 @@ were issued.** Ignore `organizationId` in response bodies.
|
|
|
168
266
|
Create a second beneficiary whose account number ends **`0003`**:
|
|
169
267
|
|
|
170
268
|
```
|
|
171
|
-
"clabeNumber": "
|
|
269
|
+
"clabeNumber": "012180000000070003"
|
|
172
270
|
```
|
|
173
271
|
|
|
174
|
-
Pay it, then keep polling past `completed`.
|
|
272
|
+
Pay it, then keep polling past `completed`. In sandbox the return lands 30
|
|
273
|
+
seconds after `completed` (40 seconds after create; give it a minute), so it
|
|
274
|
+
flips:
|
|
175
275
|
|
|
176
276
|
```jsonc
|
|
177
277
|
{ "status": "failed", "failureCode": "returned_by_bank", "fundsReturned": true }
|
|
@@ -184,15 +284,15 @@ in a paragraph you would skim.
|
|
|
184
284
|
|
|
185
285
|
Other triggers, and exactly what each does:
|
|
186
286
|
|
|
187
|
-
| account ends
|
|
188
|
-
|
|
189
|
-
| `0001`
|
|
190
|
-
| `0002`
|
|
191
|
-
| `0003`
|
|
192
|
-
| `0004`
|
|
193
|
-
| `0005`
|
|
194
|
-
| `0006`
|
|
195
|
-
| anything else | completes
|
|
287
|
+
| account ends | what happens |
|
|
288
|
+
| ------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
289
|
+
| `0001` | fails at the rail with `failureCode: account_invalid`, funds returned |
|
|
290
|
+
| `0002` | settles slowly — useful for testing a poll loop |
|
|
291
|
+
| `0003` | completes, then flips to `failed` / `returned_by_bank` (above) |
|
|
292
|
+
| `0004` | `compliance_rejected`, and the money does **not** come back |
|
|
293
|
+
| `0005` | the quote expires — the payout is refused at **create** with `400 BAD_REQUEST`, so no payout exists to poll |
|
|
294
|
+
| `0006` | waits for you to fund it from your own wallet |
|
|
295
|
+
| anything else | completes |
|
|
196
296
|
|
|
197
297
|
`0006` is worth running too. Some routings do not settle on acceptance — they
|
|
198
298
|
price the payout and wait for your funds. It returns `requiresFunding: true`
|
|
@@ -203,9 +303,11 @@ Read the deposit instructions from `GET /payments/organizations/{orgId}/payouts/
|
|
|
203
303
|
report the transaction:
|
|
204
304
|
|
|
205
305
|
```bash
|
|
306
|
+
# Generate once for this logical operation; reuse for every retry.
|
|
307
|
+
export AVVIO_IDEMPOTENCY_KEY=$(uuidgen)
|
|
206
308
|
curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/payouts/$PAYOUT_ID/funding/confirm" \
|
|
207
309
|
-H "x-api-key: $AVVIO_API_KEY" \
|
|
208
|
-
-H "
|
|
310
|
+
-H "Idempotency-Key: $AVVIO_IDEMPOTENCY_KEY" \
|
|
209
311
|
-H "content-type: application/json" \
|
|
210
312
|
-d '{"transactionHash":"0x…"}'
|
|
211
313
|
```
|
|
@@ -218,12 +320,12 @@ fundable. One transfer funds exactly one payout.
|
|
|
218
320
|
The sandbox has no chain to read, so the **last four digits of the hash** choose
|
|
219
321
|
the outcome, the same way the account number does:
|
|
220
322
|
|
|
221
|
-
| hash ends
|
|
222
|
-
|
|
223
|
-
| `0001`
|
|
224
|
-
| `0002`
|
|
225
|
-
| anything else
|
|
226
|
-
| a hash already used | `FUNDING_TRANSACTION_ALREADY_USED` — 409, naming the payout it funded
|
|
323
|
+
| hash ends | you get |
|
|
324
|
+
| ------------------- | --------------------------------------------------------------------------------------- |
|
|
325
|
+
| `0001` | `FUNDING_TRANSACTION_INVALID` — 400, we read the chain and it does not fund this payout |
|
|
326
|
+
| `0002` | `FUNDING_NOT_YET_VERIFIABLE` — 409, not mined yet; retry the same request |
|
|
327
|
+
| anything else | accepted, and the payout settles |
|
|
328
|
+
| a hash already used | `FUNDING_TRANSACTION_ALREADY_USED` — 409, naming the payout it funded |
|
|
227
329
|
|
|
228
330
|
Testing that branch here is the alternative to discovering it in production.
|
|
229
331
|
|
|
@@ -237,18 +339,23 @@ sandbox secret and prove your handler works now:
|
|
|
237
339
|
```bash
|
|
238
340
|
curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/webhook-endpoints" \
|
|
239
341
|
-H "x-api-key: $AVVIO_API_KEY" \
|
|
240
|
-
-H "idempotency-key: $(uuidgen)" \
|
|
241
342
|
-H "content-type: application/json" \
|
|
242
|
-
-d '{"url":"
|
|
343
|
+
-d '{"url":"https://example.ngrok-free.app/hooks"}'
|
|
243
344
|
```
|
|
244
345
|
|
|
245
346
|
```jsonc
|
|
246
|
-
{
|
|
247
|
-
"
|
|
347
|
+
{
|
|
348
|
+
"id": "…",
|
|
349
|
+
"url": "…",
|
|
350
|
+
"secret": "whsec_…",
|
|
351
|
+
"warning": "Store this secret now — it is not retrievable.",
|
|
352
|
+
}
|
|
248
353
|
```
|
|
249
354
|
|
|
250
|
-
|
|
251
|
-
|
|
355
|
+
When testing against the hosted API, use a publicly reachable HTTPS receiver;
|
|
356
|
+
a development tunnel is fine. `http://localhost` is accepted by the sandbox
|
|
357
|
+
route only when the Avvio backend itself runs locally. A hosted backend resolves
|
|
358
|
+
`localhost` to itself, not to your laptop. Live endpoints are created from the
|
|
252
359
|
dashboard by a human, because a credential able to repoint its own webhook URL
|
|
253
360
|
could redirect every payout notification.
|
|
254
361
|
|
|
@@ -303,14 +410,15 @@ separate `sourceCurrency`. They are two schemas in the spec — `Payout` and
|
|
|
303
410
|
|
|
304
411
|
## Then
|
|
305
412
|
|
|
413
|
+
- [Agent quickstart](./AGENT_QUICKSTART.md) — the one prompt to hand an agent with a test key
|
|
306
414
|
- [Errors](./ERRORS.md) — every code, and what to do about each
|
|
307
|
-
- [Webhooks](
|
|
415
|
+
- [Webhooks](./WEBHOOKS.md) — verification and delivery
|
|
308
416
|
- [Going live](./GOING_LIVE.md) — the checklist
|
|
309
417
|
|
|
310
418
|
## Two rules worth internalising now
|
|
311
419
|
|
|
312
420
|
**A timeout is an unknown outcome, not a failure.** If a send times out, the
|
|
313
|
-
payout may exist. Retry with the
|
|
421
|
+
payout may exist. Retry with the _same_ `Idempotency-Key` — a replay returns the
|
|
314
422
|
original. Calling again without it is a second payment.
|
|
315
423
|
|
|
316
424
|
**Webhooks are the fast path, not the guarantee.** Reconcile against the payout
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Pay out to your own customers, from your balance, over one API.
|
|
4
4
|
|
|
5
|
+
**Docs:** [docs.avvio.xyz](https://docs.avvio.xyz) · **Product:** [avvio.xyz](https://avvio.xyz) · **Partner program:** [avvio.xyz/partners](https://avvio.xyz/partners)
|
|
6
|
+
|
|
5
7
|
One package, three ways to use it: a **Node client**, a **CLI**, and an **MCP
|
|
6
8
|
server** so an agent can drive payouts directly. All three share the same core,
|
|
7
9
|
so they cannot drift apart.
|
|
@@ -23,30 +25,28 @@ Node 18 or newer.
|
|
|
23
25
|
npm i @avvio/payments
|
|
24
26
|
```
|
|
25
27
|
|
|
26
|
-
Not published yet. Until it is, install the tarball from the repo:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
cd packages/avvio-payments && npm pack
|
|
30
|
-
npm i /path/to/avvio-payments-0.1.0.tgz
|
|
31
|
-
```
|
|
32
|
-
|
|
33
28
|
```bash
|
|
34
|
-
export AVVIO_API_KEY=
|
|
29
|
+
export AVVIO_API_KEY=avvio_test_… # complete bearer key; shown once
|
|
35
30
|
export AVVIO_ORG_ID=cmsx… # a cuid, not an org_ prefix
|
|
36
|
-
export AVVIO_BASE_URL=https://api.avvio.xyz/api/v1 # optional
|
|
31
|
+
export AVVIO_BASE_URL=https://api.avvio.xyz/business/api/v1 # optional
|
|
37
32
|
```
|
|
38
33
|
|
|
39
|
-
Calling the API directly
|
|
40
|
-
|
|
34
|
+
The API key is the complete credential. Calling the API directly? Send it in
|
|
35
|
+
`x-api-key`; there is no private key, request signature, nonce, timestamp, or
|
|
36
|
+
authentication challenge:
|
|
41
37
|
|
|
42
38
|
```bash
|
|
43
39
|
curl "$AVVIO_BASE_URL/recipients/$AVVIO_ORG_ID/corridors" \
|
|
44
40
|
-H "x-api-key: $AVVIO_API_KEY"
|
|
45
41
|
```
|
|
46
42
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
Only operations marked idempotent in the API reference take an
|
|
44
|
+
`Idempotency-Key`; generate it once and reuse it with every retry of the same
|
|
45
|
+
logical request.
|
|
46
|
+
|
|
47
|
+
> **Treat the complete API key like a database password.** Keep it in
|
|
48
|
+
> server-side secret storage. Use only an `avvio_test_*` key in ReadMe's Try It
|
|
49
|
+
> console and never embed a key in your own browser or mobile application.
|
|
50
50
|
|
|
51
51
|
Start here:
|
|
52
52
|
|
|
@@ -70,13 +70,13 @@ npx -y @avvio/payments quote --amount 200 --to MXN
|
|
|
70
70
|
npx -y @avvio/payments beneficiary create \
|
|
71
71
|
--name "Maria Gonzalez" --email maria@example.com \
|
|
72
72
|
--currency MXN --end-user employee_42 \
|
|
73
|
-
--field clabeNumber=
|
|
73
|
+
--field clabeNumber=012180000080004471
|
|
74
74
|
|
|
75
75
|
npx -y @avvio/payments pay --amount 200 --to <destinationAccountId> \
|
|
76
76
|
--end-user employee_42 --expect 3410.00
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
Use
|
|
79
|
+
Use an `avvio_test_` key and none of this touches a payment network. The **last four
|
|
80
80
|
digits** of the account number choose what happens — `0003` completes and is
|
|
81
81
|
then returned by the bank, which is the case worth testing before you go live.
|
|
82
82
|
|
|
@@ -92,12 +92,14 @@ const avvio = new PayoutsClient();
|
|
|
92
92
|
|
|
93
93
|
// Which world this key pays into, from its prefix. Worth asserting in your own
|
|
94
94
|
// test suite before anything sends: a live key in a payroll fixture pays real
|
|
95
|
-
// people.
|
|
95
|
+
// people. The client rejects anything that is not a complete `avvio_*` credential.
|
|
96
96
|
if (avvio.mode !== 'test') throw new Error('refusing to run tests against live');
|
|
97
97
|
|
|
98
98
|
// Show the price while your user is still typing. No beneficiary needed.
|
|
99
99
|
const quote = await avvio.quote({ amount: '200.00', to: 'MXN' });
|
|
100
100
|
// → { sourceAmount, destinationAmount, fee, rate, limits, indicative: true }
|
|
101
|
+
// quote() calls GET /rates: an indicative price. The two-step flow's binding
|
|
102
|
+
// quote is POST /quotes/offramp (`pricePayout()` here), which needs a beneficiary.
|
|
101
103
|
|
|
102
104
|
const beneficiary = await avvio.createBeneficiary({
|
|
103
105
|
name: 'Maria Gonzalez',
|
|
@@ -105,7 +107,7 @@ const beneficiary = await avvio.createBeneficiary({
|
|
|
105
107
|
currency: 'MXN',
|
|
106
108
|
endUserId: 'employee_42', // scopes it to ONE of your users
|
|
107
109
|
externalId: 'emp42_maria', // makes a repeat create safe
|
|
108
|
-
details: { clabeNumber: '
|
|
110
|
+
details: { clabeNumber: '012180000080004471' },
|
|
109
111
|
});
|
|
110
112
|
|
|
111
113
|
const payout = await avvio.payout({
|
|
@@ -224,24 +226,34 @@ seven days the record is gone and the key is unknown to us.
|
|
|
224
226
|
First, get a signing secret. In sandbox you can issue one yourself:
|
|
225
227
|
|
|
226
228
|
```bash
|
|
227
|
-
npx -y @avvio/payments webhook create --url
|
|
229
|
+
npx -y @avvio/payments webhook create --url https://example.ngrok-free.app/hooks
|
|
228
230
|
# Signing secret (shown once — store it now):
|
|
229
231
|
# whsec_…
|
|
230
232
|
```
|
|
231
233
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
When calling the hosted API, use a publicly reachable HTTPS receiver; a
|
|
235
|
+
development tunnel is fine. `http://localhost` works only when the Avvio backend
|
|
236
|
+
itself is running locally, because a hosted backend resolves localhost to itself,
|
|
237
|
+
not to your laptop. Live endpoints are created from the dashboard by a human —
|
|
238
|
+
a credential that could repoint its own webhook URL could quietly redirect every
|
|
236
239
|
payout notification, so that one stays off the API.
|
|
237
240
|
|
|
238
241
|
The secret is shown **once** and is not retrievable. Store it before you close
|
|
239
242
|
the terminal.
|
|
240
243
|
|
|
241
244
|
```bash
|
|
242
|
-
npx -y @avvio/payments webhook deliveries <endpointId> #
|
|
245
|
+
npx -y @avvio/payments webhook deliveries <endpointId> # a sandbox endpoint you registered
|
|
246
|
+
npx -y @avvio/payments webhook endpoints # what is registered on your org
|
|
247
|
+
npx -y @avvio/payments webhook attempts <endpointId> # the last 50 attempts, newest first
|
|
243
248
|
```
|
|
244
249
|
|
|
250
|
+
`attempts` answers "did you send me that event" without opening a dashboard:
|
|
251
|
+
each row carries `eventId` — the `svix-id` we sent, so it is what your dedupe
|
|
252
|
+
keys on — plus `attempts`, `lastError` (your server's own response) and
|
|
253
|
+
`nextAttemptAt`. Retries back off over roughly 70 hours; after that only the
|
|
254
|
+
event feed still has it. Payloads are not returned, and a dashboard replay
|
|
255
|
+
re-fires under the same `eventId`, so your deduplication still holds.
|
|
256
|
+
|
|
245
257
|
```js
|
|
246
258
|
const { verifyWebhook } = require('@avvio/payments');
|
|
247
259
|
|
|
@@ -323,6 +335,10 @@ avvio-payments funding pay_01J… # address, amount, netwo
|
|
|
323
335
|
avvio-payments funding confirm pay_01J… --tx 0xabc… # after you have broadcast it
|
|
324
336
|
```
|
|
325
337
|
|
|
338
|
+
`funding` is a pure read — poll it as often as you like. `expiresAt` comes back
|
|
339
|
+
`null`: there is no countdown on the deposit address, so ask `status <payoutId>`
|
|
340
|
+
whether an old set of instructions is still good rather than running a timer.
|
|
341
|
+
|
|
326
342
|
`confirm` reports a transfer that has **already left your wallet**, so it is
|
|
327
343
|
strict about the hash: a truncated paste is refused here, before anything is
|
|
328
344
|
reported, rather than coming back as a rejection that reads like your transfer
|
|
@@ -330,6 +346,60 @@ failed. Pass `--idempotency-key` if you want a timed-out confirm to be safely
|
|
|
330
346
|
repeatable. Bare `funding`, with no payout id, is still the other question —
|
|
331
347
|
where to wire a top-up for your balance.
|
|
332
348
|
|
|
349
|
+
## Mass payouts
|
|
350
|
+
|
|
351
|
+
Up to 1,000 payouts in one request. Each line of `items` is exactly a
|
|
352
|
+
`POST /payouts` wire body, and the **idempotency key covers the run** — a
|
|
353
|
+
submit loop that dies and resubmits the same file under the same key gets the
|
|
354
|
+
same batch back, never a second payroll. `externalReferenceId` is **required
|
|
355
|
+
and unique per organization**: the same run id under a fresh key is refused
|
|
356
|
+
with `PAYOUT_BATCH_DUPLICATE_REFERENCE` naming the original batch, which is
|
|
357
|
+
what stops a crashed submit job from paying a payroll twice. A corrected
|
|
358
|
+
resubmission is a new run and needs its own id. Batches are on by default
|
|
359
|
+
(`MASS_PAYOUTS_DISABLED` only if your organization opted out) and submission is
|
|
360
|
+
limited to 30 requests a minute.
|
|
361
|
+
|
|
362
|
+
```js
|
|
363
|
+
const batch = await avvio.createPayoutBatch({
|
|
364
|
+
externalReferenceId: 'payroll-2026-09-01', // REQUIRED: your run id, unique per org
|
|
365
|
+
idempotencyKey: 'payroll-2026-09-01-run1', // persist this BEFORE you send
|
|
366
|
+
items: [
|
|
367
|
+
{ amount: '200.00', destinationAccountId: 'acct_…', reference: 'PR-0042' },
|
|
368
|
+
{ amount: '150.00', destinationAccountId: 'acct_…', reference: 'PR-0043' },
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The `202` means **received, not paid**: every line is validated first (nothing
|
|
374
|
+
priced, nothing debited). With `autoCommit: true` — the default — a clean run
|
|
375
|
+
goes straight to creation; any validation errors and it holds at
|
|
376
|
+
`awaiting_confirmation` for your call:
|
|
377
|
+
|
|
378
|
+
```js
|
|
379
|
+
let run = await avvio.getPayoutBatch(batch.batchId);
|
|
380
|
+
if (run.status === 'awaiting_confirmation') {
|
|
381
|
+
const bad = await avvio.listPayoutBatchItems(batch.batchId, { status: 'invalid' });
|
|
382
|
+
// each item echoes your instruction back verbatim — join on content
|
|
383
|
+
await avvio.confirmPayoutBatch(batch.batchId); // proceed with the valid lines
|
|
384
|
+
// …or avvio.cancelPayoutBatch(batch.batchId) to stop the whole run
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
A batch tracks **creation, not settlement**: `completed` means every line
|
|
389
|
+
either became a payout or was refused — read `run.counts`. Join the run to your
|
|
390
|
+
ledger with `listPayoutBatchItems(batchId, { status: 'created' })`; each
|
|
391
|
+
created line carries a `payoutId` that lives the ordinary payout lifecycle —
|
|
392
|
+
`payout.*` webhooks, the event feed, `getPayout()`.
|
|
393
|
+
|
|
394
|
+
One item status is special: **`requires_review` means the outcome is unknown**
|
|
395
|
+
(the process died mid-create). It is never retried automatically — re-running a
|
|
396
|
+
line that may already have paid is how a crash becomes a double payment —
|
|
397
|
+
and it is never folded into `create_failed`. Contact support with the
|
|
398
|
+
`batchId`. `listPayoutBatches({ externalReferenceId })` finds your runs, and
|
|
399
|
+
the `payout_batch.*` webhooks (`awaiting_confirmation`, `completed`,
|
|
400
|
+
`canceled`, `failed`) arrive on the same signing and retry ladder as
|
|
401
|
+
`payout.*`.
|
|
402
|
+
|
|
333
403
|
## MCP
|
|
334
404
|
|
|
335
405
|
For an agent that pays people.
|
|
@@ -341,7 +411,7 @@ For an agent that pays people.
|
|
|
341
411
|
"command": "npx",
|
|
342
412
|
"args": ["-y", "@avvio/payments", "mcp"],
|
|
343
413
|
"env": {
|
|
344
|
-
"AVVIO_API_KEY": "
|
|
414
|
+
"AVVIO_API_KEY": "avvio_test_…",
|
|
345
415
|
"AVVIO_ORG_ID": "cmsx…"
|
|
346
416
|
}
|
|
347
417
|
}
|
|
@@ -349,17 +419,33 @@ For an agent that pays people.
|
|
|
349
419
|
}
|
|
350
420
|
```
|
|
351
421
|
|
|
352
|
-
|
|
353
|
-
`create_beneficiary`, `list_beneficiaries`, `
|
|
354
|
-
`
|
|
355
|
-
`
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
422
|
+
Twenty-five tools: `get_policy`, `list_corridors`, `get_requirements`, `quote`,
|
|
423
|
+
`create_beneficiary`, `list_beneficiaries`, `get_beneficiary`,
|
|
424
|
+
`get_beneficiary_by_external_id`, `update_beneficiary`,
|
|
425
|
+
`delete_beneficiary_method`, `list_payment_reasons`, `send_payout`,
|
|
426
|
+
`get_payout`, `list_payouts`, `funding_accounts`, `list_events`, `get_balance`,
|
|
427
|
+
`list_balance_transactions`, `list_approvals`, `get_approval`,
|
|
428
|
+
`list_audit_events`, `get_funding`, `create_payout_link`,
|
|
429
|
+
`confirm_funding`, `cancel_payout`.
|
|
430
|
+
Eighteen reads are marked read-only so a host can auto-approve them.
|
|
431
|
+
Approving a held payout has no tool, by design: a key cannot approve, and
|
|
432
|
+
neither can an agent holding one.
|
|
433
|
+
|
|
434
|
+
> **Four tools move money or destroy something**, and each requires an explicit
|
|
435
|
+
> `confirm: true`: `send_payout`, `confirm_funding`, `cancel_payout` and
|
|
436
|
+
> `delete_beneficiary_method`. `send_payout` additionally requires an
|
|
437
|
+
> `idempotencyKey`, so a half-parsed instruction cannot become a payment and a
|
|
438
|
+
> reflexive retry cannot become two. Start with a test key.
|
|
439
|
+
|
|
440
|
+
Some client methods are deliberately NOT agent tools: `deleteBeneficiary`
|
|
441
|
+
removes a whole record and every method on it, `getBeneficiaryMethodDetails`
|
|
442
|
+
returns full account numbers that do not belong in a model's context when
|
|
443
|
+
`last4` answers the question, and
|
|
444
|
+
quote behind it, and the webhook reads are ops work rather than payout work.
|
|
445
|
+
The batch methods (`createPayoutBatch` and its five companions) are excluded
|
|
446
|
+
too: one confirm committing up to 1,000 payments does not belong behind a
|
|
447
|
+
single tool call — an agent pays one person at a time via `send_payout`.
|
|
448
|
+
Reach for those from Node or the CLI.
|
|
363
449
|
|
|
364
450
|
## CLI reference
|
|
365
451
|
|
|
@@ -369,18 +455,31 @@ reads are marked read-only so a host can auto-approve them.
|
|
|
369
455
|
| `doctor` | Check credentials, connectivity, balance |
|
|
370
456
|
| `fund [--amount]` | Credit your sandbox balance |
|
|
371
457
|
| `balance` | What you can currently send |
|
|
458
|
+
| `balance-transactions [--cursor] [--type]` | Every change to your balance, newest first, with `balanceAfter` |
|
|
459
|
+
| `policy` | Your caps, approval threshold, features and rate limits. Read it first |
|
|
372
460
|
| `corridors` | Currencies you can pay out to |
|
|
373
461
|
| `requirements <CCY>` | Fields that corridor needs |
|
|
374
462
|
| `quote --amount --to` | Price with no beneficiary |
|
|
375
463
|
| `beneficiary create` | Register who is paid. `--external-id` makes a repeat create safe |
|
|
376
464
|
| `beneficiary list [--end-user]` | Saved beneficiaries |
|
|
465
|
+
| `beneficiary get <id>` | One beneficiary. `--external-id` looks it up by YOUR id |
|
|
466
|
+
| `beneficiary update <id>` | Contact details only — bank details are not editable |
|
|
467
|
+
| `beneficiary delete <id>` | Removes them and every payment method on them |
|
|
468
|
+
| `beneficiary method delete <id> <methodId>` | Removes one account; the rest stay payable |
|
|
469
|
+
| `beneficiary method details <id> <methodId>` | The full account on file, not just `last4` |
|
|
470
|
+
| `payment-reasons` | Reasons a payout may state, where a corridor asks for one |
|
|
377
471
|
| `pay --amount --to [--expect]` | Send. `--expect` refuses the send if the rate moved |
|
|
378
472
|
| `status <payoutId> [--watch]` | One payout, live. `--watch` polls until it stops moving |
|
|
379
473
|
| `payouts` | Recent payouts |
|
|
380
|
-
| `events [--since] [--follow]` | The change feed. `--follow` tails it as JSON lines |
|
|
474
|
+
| `events [--since] [--type] [--follow]` | The change feed. `--type` narrows it; `--follow` tails it as JSON lines |
|
|
475
|
+
| `approvals [--status]` | Payouts and runs waiting on your approvers (a 202 from `pay` or a batch confirm) |
|
|
476
|
+
| `approval <approvalId>` | One approval; `executed` ones name the `payoutId` |
|
|
477
|
+
| `audit-events [--action] [--cursor]` | Who did what, with which credential, newest first |
|
|
381
478
|
| `funding` | Where to wire a top-up |
|
|
382
479
|
| `funding <payoutId>` | Deposit instructions for a payout you fund yourself |
|
|
383
480
|
| `funding confirm <payoutId> --tx` | Report the transfer you already sent |
|
|
481
|
+
| `webhook endpoints` | Endpoints registered for your org (read-only) |
|
|
482
|
+
| `webhook attempts <endpointId>` | Last 50 delivery attempts: `eventId`, `attempts`, `lastError` |
|
|
384
483
|
| `mcp` | Run as an MCP server |
|
|
385
484
|
|
|
386
485
|
Every command takes `--json`.
|