@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/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** (`ak_test_…`) from Dashboard → Developers, and your
7
- organization id. Set both:
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=ak_test_
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. If you use Node, `npx -y @avvio/payments doctor` does step 1 and tells
18
- you which credential is wrong.
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 *names* depend on how your organization is routed, and we may re-route
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": "5.00", "max": "5000.00" }
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`. `rate` on a payout always
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
- 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.
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. 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.
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
- { "payoutId": "…", "status": "pending", "reference": "ZZ-2026-0042", … }
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`. This endpoint is always live and is
116
- authoritative more so than a webhook you may have missed.
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/balance/history" \
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
- `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.
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 `reversal` row,
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
- { "type": "FORBIDDEN", "detail": "This API key cannot access that organization" }
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": "012345678901230003"
269
+ "clabeNumber": "012180000000070003"
172
270
  ```
173
271
 
174
- Pay it, then keep polling past `completed`. It flips:
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 | 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 |
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 "idempotency-key: $(uuidgen)" \
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 | 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 |
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":"http://localhost:4000/hooks"}'
343
+ -d '{"url":"https://example.ngrok-free.app/hooks"}'
243
344
  ```
244
345
 
245
346
  ```jsonc
246
- { "id": "…", "url": "…", "secret": "whsec_…",
247
- "warning": "Store this secret now — it is not retrievable." }
347
+ {
348
+ "id": "",
349
+ "url": "…",
350
+ "secret": "whsec_…",
351
+ "warning": "Store this secret now — it is not retrievable.",
352
+ }
248
353
  ```
249
354
 
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
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](../PARTNER_PAYOUTS_API.md#webhooks) — verification and delivery
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 *same* `Idempotency-Key` — a replay returns 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=ak_test_ # server-side only
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 instead? The credential goes in an `x-api-key`
40
- header, and every mutation needs an `Idempotency-Key`:
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
- > **Never ship the key to a browser or a mobile app.** It is a bearer
48
- > credential for your money. Our CORS policy does not allow the header, so a
49
- > browser cannot send one even by accident.
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=012345678901234567
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 a `ak_test_` key and none of this touches a payment network. The **last four
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. Anything not recognisably `ak_test_` reports 'live'.
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: '012345678901234567' },
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 http://localhost:4000/hooks
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
- `http://localhost` is accepted **in sandbox only**, so your first receiver can be
233
- a script on your laptop rather than a tunnel you have to stand up first. Live
234
- endpoints are https-only and are created from the dashboard by a human — a
235
- credential that could repoint its own webhook URL could quietly redirect every
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> # what we sent, what came back
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": "ak_test_…",
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
- Fifteen tools: `list_corridors`, `get_requirements`, `quote`,
353
- `create_beneficiary`, `list_beneficiaries`, `send_payout`, `get_payout`,
354
- `list_payouts`, `funding_accounts`, `list_events`, `get_balance`,
355
- `get_funding`, `create_payout_link`, `confirm_funding`, `cancel_payout`. Ten
356
- reads are marked read-only so a host can auto-approve them.
357
-
358
- > **Three tools move money or end a payment**, and each requires an explicit
359
- > `confirm: true`: `send_payout`, `confirm_funding` and `cancel_payout`.
360
- > `send_payout` additionally requires an `idempotencyKey`, so a half-parsed
361
- > instruction cannot become a payment and a reflexive retry cannot become two.
362
- > Start with a test key.
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`.