@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/README.md
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# @avvio/payments
|
|
2
|
+
|
|
3
|
+
Pay out to your own customers, from your balance, over one API.
|
|
4
|
+
|
|
5
|
+
One package, three ways to use it: a **Node client**, a **CLI**, and an **MCP
|
|
6
|
+
server** so an agent can drive payouts directly. All three share the same core,
|
|
7
|
+
so they cannot drift apart.
|
|
8
|
+
|
|
9
|
+
## Zero dependencies
|
|
10
|
+
|
|
11
|
+
Not a boast — a deliberate answer to a question your security review will ask.
|
|
12
|
+
|
|
13
|
+
This package sits inside your infrastructure holding a credential that moves
|
|
14
|
+
money. Transitive npm compromise is the most realistic path to that credential,
|
|
15
|
+
and the shortest answer to "what is in this dependency tree" is *nothing*. It
|
|
16
|
+
also means no peer conflicts inside your app and nothing to compile.
|
|
17
|
+
|
|
18
|
+
Node 18 or newer.
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm i @avvio/payments
|
|
24
|
+
```
|
|
25
|
+
|
|
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
|
+
```bash
|
|
34
|
+
export AVVIO_API_KEY=ak_test_… # server-side only
|
|
35
|
+
export AVVIO_ORG_ID=cmsx… # a cuid, not an org_ prefix
|
|
36
|
+
export AVVIO_BASE_URL=https://api.avvio.xyz/api/v1 # optional
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Calling the API directly instead? The credential goes in an `x-api-key`
|
|
40
|
+
header, and every mutation needs an `Idempotency-Key`:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
curl "$AVVIO_BASE_URL/recipients/$AVVIO_ORG_ID/corridors" \
|
|
44
|
+
-H "x-api-key: $AVVIO_API_KEY"
|
|
45
|
+
```
|
|
46
|
+
|
|
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.
|
|
50
|
+
|
|
51
|
+
Start here:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx -y @avvio/payments doctor
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
It checks the key, the organization, connectivity and your balance, and names
|
|
58
|
+
which one is wrong. "It doesn't work" is otherwise four indistinguishable
|
|
59
|
+
problems.
|
|
60
|
+
|
|
61
|
+
## Sixty seconds to a payout
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx -y @avvio/payments guide # the whole flow, as commands
|
|
65
|
+
npx -y @avvio/payments fund --amount 5000 # sandbox balance
|
|
66
|
+
npx -y @avvio/payments corridors # what you can pay
|
|
67
|
+
npx -y @avvio/payments requirements MXN # what Mexico needs
|
|
68
|
+
npx -y @avvio/payments quote --amount 200 --to MXN
|
|
69
|
+
|
|
70
|
+
npx -y @avvio/payments beneficiary create \
|
|
71
|
+
--name "Maria Gonzalez" --email maria@example.com \
|
|
72
|
+
--currency MXN --end-user employee_42 \
|
|
73
|
+
--field clabeNumber=012345678901234567
|
|
74
|
+
|
|
75
|
+
npx -y @avvio/payments pay --amount 200 --to <destinationAccountId> \
|
|
76
|
+
--end-user employee_42 --expect 3410.00
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Use a `ak_test_` key and none of this touches a payment network. The **last four
|
|
80
|
+
digits** of the account number choose what happens — `0003` completes and is
|
|
81
|
+
then returned by the bank, which is the case worth testing before you go live.
|
|
82
|
+
|
|
83
|
+
## Node
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
const { PayoutsClient } = require('@avvio/payments');
|
|
87
|
+
// or, from ESM — the package declares an `exports` map, so named imports work:
|
|
88
|
+
// import { PayoutsClient, stableKey } from '@avvio/payments';
|
|
89
|
+
// import { verifyWebhook } from '@avvio/payments/webhooks';
|
|
90
|
+
|
|
91
|
+
const avvio = new PayoutsClient();
|
|
92
|
+
|
|
93
|
+
// Which world this key pays into, from its prefix. Worth asserting in your own
|
|
94
|
+
// test suite before anything sends: a live key in a payroll fixture pays real
|
|
95
|
+
// people. Anything not recognisably `ak_test_` reports 'live'.
|
|
96
|
+
if (avvio.mode !== 'test') throw new Error('refusing to run tests against live');
|
|
97
|
+
|
|
98
|
+
// Show the price while your user is still typing. No beneficiary needed.
|
|
99
|
+
const quote = await avvio.quote({ amount: '200.00', to: 'MXN' });
|
|
100
|
+
// → { sourceAmount, destinationAmount, fee, rate, limits, indicative: true }
|
|
101
|
+
|
|
102
|
+
const beneficiary = await avvio.createBeneficiary({
|
|
103
|
+
name: 'Maria Gonzalez',
|
|
104
|
+
email: 'maria@example.com',
|
|
105
|
+
currency: 'MXN',
|
|
106
|
+
endUserId: 'employee_42', // scopes it to ONE of your users
|
|
107
|
+
externalId: 'emp42_maria', // makes a repeat create safe
|
|
108
|
+
details: { clabeNumber: '012345678901234567' },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const payout = await avvio.payout({
|
|
112
|
+
amount: '200.00',
|
|
113
|
+
destinationAccountId: beneficiary.paymentMethods[0].destinationAccountId,
|
|
114
|
+
expectDestination: quote.destinationAmount.amount, // read this next
|
|
115
|
+
endUser: { id: 'employee_42', name: 'Ana Lopez' },
|
|
116
|
+
reference: 'ZZ-2026-0042',
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**`expectDestination` is the one option not to skip.** (The CLI spells it `--expect`; the wire field is
|
|
121
|
+
`expectDestination`. This client also accepts `expectDestinationAmount` and
|
|
122
|
+
translates it — but that is a courtesy of the Node client only, so if you are
|
|
123
|
+
generating a client from the OpenAPI spec, `expectDestination` is the only name
|
|
124
|
+
that exists.) Between the quote
|
|
125
|
+
you showed your user and the send, the rate can move. Pass the number you
|
|
126
|
+
promised and the send is refused if it has drifted more than 2% — nothing is
|
|
127
|
+
sent, and you re-quote. Without it, you ship whatever the market did in between.
|
|
128
|
+
|
|
129
|
+
## Retrying safely
|
|
130
|
+
|
|
131
|
+
**Persist your own `idempotencyKey` before you send, and reuse it on every
|
|
132
|
+
retry.** That single habit is what prevents a double payment; everything else on
|
|
133
|
+
this page is a safety net under it.
|
|
134
|
+
|
|
135
|
+
We generate a key when you omit one, which makes a single call safe — and means
|
|
136
|
+
**calling `payout()` again is not a retry**, it is a second payment with a second
|
|
137
|
+
key. A job that crashes and requeues without having persisted its key will
|
|
138
|
+
eventually pay a wage twice. Our server-side duplicate check covers 15 minutes of
|
|
139
|
+
that gap; a backoff longer than 15 minutes is outside it, by design and by
|
|
140
|
+
arithmetic.
|
|
141
|
+
|
|
142
|
+
Retry with the *same* key and you get the original result, never a second
|
|
143
|
+
payment.
|
|
144
|
+
|
|
145
|
+
**If you would rather not persist one, derive it.** `stableKey()` hashes the
|
|
146
|
+
things that make the payment unique into a UUID, so the same payroll row
|
|
147
|
+
produces the same key on every attempt — including the attempt after the crash,
|
|
148
|
+
where there is nothing left in memory to reuse and nothing was written down:
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
const { stableKey } = require('@avvio/payments');
|
|
152
|
+
|
|
153
|
+
await avvio.payout({
|
|
154
|
+
...args,
|
|
155
|
+
idempotencyKey: stableKey(orgId, payrollRunId, employeeId),
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Pass whatever identifies *this* payment and nothing that changes between
|
|
160
|
+
attempts — a timestamp or a retry counter in there defeats the whole thing.
|
|
161
|
+
Parts are NUL-separated, so `('a','bc')` and `('ab','c')` are different keys,
|
|
162
|
+
and an empty part throws rather than quietly collapsing two people's wages onto
|
|
163
|
+
one key.
|
|
164
|
+
|
|
165
|
+
**A timeout is an unknown outcome, not a failure.** If a send times out the
|
|
166
|
+
payout may exist. The key is on the error:
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
try {
|
|
170
|
+
await avvio.payout({ ...args });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err.type === 'TIMEOUT') {
|
|
173
|
+
// Same key. A replay returns the original payout; a new one pays twice.
|
|
174
|
+
await avvio.payout({ ...args, idempotencyKey: err.idempotencyKey });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The drift guard throws the same `RATE_DRIFT_EXCEEDED` you would get over
|
|
180
|
+
HTTP, so one branch handles both surfaces.
|
|
181
|
+
|
|
182
|
+
`err.retryable` tells you whether retrying unchanged is worth it. A `409`
|
|
183
|
+
conflict is **not** retryable — it means the same key was used with a different
|
|
184
|
+
body, which is a bug on your side.
|
|
185
|
+
|
|
186
|
+
### The retry that quietly defeats all of this
|
|
187
|
+
|
|
188
|
+
`payout()` generates a key when you do not pass one. That makes the safe thing
|
|
189
|
+
the default for a single call — and it means **calling `payout()` again is not a
|
|
190
|
+
retry**, it is a second payment with a second key. An HTTP client that mints a
|
|
191
|
+
key per attempt has the same shape, and it is the most common way a retry turns
|
|
192
|
+
into a double charge: key-based replay never fires, because every attempt looks
|
|
193
|
+
like a new request.
|
|
194
|
+
|
|
195
|
+
So we watch a second signal server-side: same body, different key, inside 15
|
|
196
|
+
minutes. You get a `409` instead of a payment. It applies only to routes that
|
|
197
|
+
move money — registering the same beneficiary twice is not a duplicate payment
|
|
198
|
+
and is not refused.
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
try {
|
|
202
|
+
await avvio.payout({ ...args });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (err.type === 'DUPLICATE_REQUEST_DETECTED') {
|
|
205
|
+
// Nothing was sent. Two ways forward — pick one, we will not guess.
|
|
206
|
+
await avvio.payout({ ...args, idempotencyKey: err.originalIdempotencyKey });
|
|
207
|
+
// …or, if you really do mean to send it twice:
|
|
208
|
+
// await avvio.payout({ ...args, allowDuplicate: true });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
It refuses rather than returning the first payout, because both readings happen.
|
|
214
|
+
Two advances of the same amount to the same worker in one week is ordinary
|
|
215
|
+
payroll — replaying there would mean the second never goes out while your ledger
|
|
216
|
+
says it did. **The best outcome for a genuinely ambiguous request is a loud one.**
|
|
217
|
+
|
|
218
|
+
Retention is seven days, and it is about storage, not correctness: there is no
|
|
219
|
+
"the key expired, so we ran it again" path. A key we still hold replays; past
|
|
220
|
+
seven days the record is gone and the key is unknown to us.
|
|
221
|
+
|
|
222
|
+
## Webhooks
|
|
223
|
+
|
|
224
|
+
First, get a signing secret. In sandbox you can issue one yourself:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
npx -y @avvio/payments webhook create --url http://localhost:4000/hooks
|
|
228
|
+
# Signing secret (shown once — store it now):
|
|
229
|
+
# whsec_…
|
|
230
|
+
```
|
|
231
|
+
|
|
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
|
|
236
|
+
payout notification, so that one stays off the API.
|
|
237
|
+
|
|
238
|
+
The secret is shown **once** and is not retrievable. Store it before you close
|
|
239
|
+
the terminal.
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
npx -y @avvio/payments webhook deliveries <endpointId> # what we sent, what came back
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
const { verifyWebhook } = require('@avvio/payments');
|
|
247
|
+
|
|
248
|
+
app.post('/hooks/avvio', express.raw({ type: '*/*' }), (req, res) => {
|
|
249
|
+
let event;
|
|
250
|
+
try {
|
|
251
|
+
event = verifyWebhook({
|
|
252
|
+
body: req.body, // the RAW bytes, not a parsed object
|
|
253
|
+
headers: req.headers,
|
|
254
|
+
secret: process.env.AVVIO_WEBHOOK_SECRET,
|
|
255
|
+
});
|
|
256
|
+
} catch {
|
|
257
|
+
return res.sendStatus(400);
|
|
258
|
+
}
|
|
259
|
+
res.sendStatus(200); // ack fast, process after
|
|
260
|
+
handle(event);
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Verify over the **raw** body. Re-serializing a parsed object does not reproduce
|
|
265
|
+
the same bytes, and one reordered key fails every signature.
|
|
266
|
+
|
|
267
|
+
Worth doing once before you go live: verify a real event with the **wrong**
|
|
268
|
+
secret and confirm your handler rejects it. A verification step that silently
|
|
269
|
+
passes is indistinguishable from no verification at all until someone posts you
|
|
270
|
+
a forged payout.
|
|
271
|
+
|
|
272
|
+
Two things to build for:
|
|
273
|
+
|
|
274
|
+
- **Webhooks are the fast path, not the guarantee.** `getPayout()` is
|
|
275
|
+
authoritative. Treat a webhook as the nudge to look.
|
|
276
|
+
- **`completed` is not always final.** A bank can return a settled payment days
|
|
277
|
+
later, giving `failed` with `returned_by_bank` and `fundsReturned: true`. Do
|
|
278
|
+
not write a ledger that treats `completed` as immutable.
|
|
279
|
+
|
|
280
|
+
## Reconciling
|
|
281
|
+
|
|
282
|
+
The event feed is the durable half of the pair above: every transition, in
|
|
283
|
+
order, each with a `sequence` you carry forward. A webhook you missed is gone;
|
|
284
|
+
an event is still there.
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
avvio-payments events --since 4102 # a page, plus the nextSince to keep
|
|
288
|
+
avvio-payments events --payout-id pay_01J… # everything that happened to one
|
|
289
|
+
avvio-payments events --follow # tail it, one JSON row per line
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`--follow` polls and prints new rows as they land, which is what a terminal is
|
|
293
|
+
for. **It holds the watermark in memory only** — it is a tail, not a
|
|
294
|
+
reconciler. When it stops, the gap is read again only if you persisted a
|
|
295
|
+
`nextSince` and pass it back as `--since`. In Node, `eachEvent()` pages for you:
|
|
296
|
+
|
|
297
|
+
```js
|
|
298
|
+
for await (const event of avvio.eachEvent({ since: savedWatermark })) {
|
|
299
|
+
await apply(event); // dedupe on event.id — the feed is at-least-once
|
|
300
|
+
savedWatermark = event.sequence;
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
To watch one payout instead of the whole feed:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
avvio-payments status pay_01J… --watch
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
It polls until the payout stops moving and prints the final payout to stdout,
|
|
311
|
+
with progress on stderr so `--json` still pipes. It stops on `completed`
|
|
312
|
+
because nothing further happens *on this payout* — a bank return arrives days
|
|
313
|
+
later as a new event, which is why it tells you to keep reading the feed.
|
|
314
|
+
|
|
315
|
+
## Payouts you fund yourself
|
|
316
|
+
|
|
317
|
+
Some routings hand you the payout with `requiresFunding: true` instead of
|
|
318
|
+
debiting a balance. The money stays in your wallet until you move it; we hold no
|
|
319
|
+
key and cannot move it.
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
avvio-payments funding pay_01J… # address, amount, network, expiry
|
|
323
|
+
avvio-payments funding confirm pay_01J… --tx 0xabc… # after you have broadcast it
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`confirm` reports a transfer that has **already left your wallet**, so it is
|
|
327
|
+
strict about the hash: a truncated paste is refused here, before anything is
|
|
328
|
+
reported, rather than coming back as a rejection that reads like your transfer
|
|
329
|
+
failed. Pass `--idempotency-key` if you want a timed-out confirm to be safely
|
|
330
|
+
repeatable. Bare `funding`, with no payout id, is still the other question —
|
|
331
|
+
where to wire a top-up for your balance.
|
|
332
|
+
|
|
333
|
+
## MCP
|
|
334
|
+
|
|
335
|
+
For an agent that pays people.
|
|
336
|
+
|
|
337
|
+
```json
|
|
338
|
+
{
|
|
339
|
+
"mcpServers": {
|
|
340
|
+
"avvio-payments": {
|
|
341
|
+
"command": "npx",
|
|
342
|
+
"args": ["-y", "@avvio/payments", "mcp"],
|
|
343
|
+
"env": {
|
|
344
|
+
"AVVIO_API_KEY": "ak_test_…",
|
|
345
|
+
"AVVIO_ORG_ID": "cmsx…"
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
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.
|
|
363
|
+
|
|
364
|
+
## CLI reference
|
|
365
|
+
|
|
366
|
+
| Command | |
|
|
367
|
+
|---|---|
|
|
368
|
+
| `guide` | The whole flow, as commands you can paste |
|
|
369
|
+
| `doctor` | Check credentials, connectivity, balance |
|
|
370
|
+
| `fund [--amount]` | Credit your sandbox balance |
|
|
371
|
+
| `balance` | What you can currently send |
|
|
372
|
+
| `corridors` | Currencies you can pay out to |
|
|
373
|
+
| `requirements <CCY>` | Fields that corridor needs |
|
|
374
|
+
| `quote --amount --to` | Price with no beneficiary |
|
|
375
|
+
| `beneficiary create` | Register who is paid. `--external-id` makes a repeat create safe |
|
|
376
|
+
| `beneficiary list [--end-user]` | Saved beneficiaries |
|
|
377
|
+
| `pay --amount --to [--expect]` | Send. `--expect` refuses the send if the rate moved |
|
|
378
|
+
| `status <payoutId> [--watch]` | One payout, live. `--watch` polls until it stops moving |
|
|
379
|
+
| `payouts` | Recent payouts |
|
|
380
|
+
| `events [--since] [--follow]` | The change feed. `--follow` tails it as JSON lines |
|
|
381
|
+
| `funding` | Where to wire a top-up |
|
|
382
|
+
| `funding <payoutId>` | Deposit instructions for a payout you fund yourself |
|
|
383
|
+
| `funding confirm <payoutId> --tx` | Report the transfer you already sent |
|
|
384
|
+
| `mcp` | Run as an MCP server |
|
|
385
|
+
|
|
386
|
+
Every command takes `--json`.
|
|
387
|
+
|
|
388
|
+
## What can change under you
|
|
389
|
+
|
|
390
|
+
`CHANGELOG.md` says exactly which parts of this API we may change without
|
|
391
|
+
warning and which we will not. The short version: **branch on `type` and on
|
|
392
|
+
`status`, ignore fields you do not recognise, and never treat `completed` as
|
|
393
|
+
final** — a bank can return a settled payment days later.
|
|
394
|
+
|
|
395
|
+
## Errors
|
|
396
|
+
|
|
397
|
+
`PayoutsError` carries `type` (stable — branch on this, not the message),
|
|
398
|
+
`status`, `requestId`, `idempotencyKey`, and `retryable`.
|
|
399
|
+
|
|
400
|
+
In TypeScript, `type` is the `PayoutsErrorType` union of every documented code,
|
|
401
|
+
so a `switch` over it is checked rather than a set of string literals nobody
|
|
402
|
+
verifies. It keeps `(string & {})` in the union deliberately: new types ship
|
|
403
|
+
without a major version, and an unrecognised one must still compile rather than
|
|
404
|
+
break your build against a live API. Write the `default` branch.
|
|
405
|
+
|
|
406
|
+
Quote the `requestId`
|
|
407
|
+
when you contact us; it is in the body of every ERROR, and on every response as the `x-request-id` header.
|
|
408
|
+
|
|
409
|
+
## License
|
|
410
|
+
|
|
411
|
+
MIT. See [LICENSE](./LICENSE).
|