@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/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).