@mesub/node 0.0.0-stage → 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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +835 -2
  3. package/dist/chunk-43Q4PNF7.cjs +480 -0
  4. package/dist/chunk-43Q4PNF7.cjs.map +1 -0
  5. package/dist/chunk-ELK7IBLO.js +340 -0
  6. package/dist/chunk-ELK7IBLO.js.map +1 -0
  7. package/dist/chunk-JJWHMB2X.js +480 -0
  8. package/dist/chunk-JJWHMB2X.js.map +1 -0
  9. package/dist/chunk-KMPBFNJE.js +2038 -0
  10. package/dist/chunk-KMPBFNJE.js.map +1 -0
  11. package/dist/chunk-PCDZI7B4.cjs +340 -0
  12. package/dist/chunk-PCDZI7B4.cjs.map +1 -0
  13. package/dist/chunk-SGSYQJYT.cjs +2038 -0
  14. package/dist/chunk-SGSYQJYT.cjs.map +1 -0
  15. package/dist/chunk-VMKI4VVA.cjs +120 -0
  16. package/dist/chunk-VMKI4VVA.cjs.map +1 -0
  17. package/dist/chunk-XDLLXY7Y.js +120 -0
  18. package/dist/chunk-XDLLXY7Y.js.map +1 -0
  19. package/dist/client-BQuRYSdB.d.ts +759 -0
  20. package/dist/client-Dm1jamju.d.cts +759 -0
  21. package/dist/errors-CtPucyOh.d.cts +112 -0
  22. package/dist/errors-DqW3DMBS.d.ts +112 -0
  23. package/dist/express.cjs +129 -0
  24. package/dist/express.cjs.map +1 -0
  25. package/dist/express.d.cts +57 -0
  26. package/dist/express.d.ts +57 -0
  27. package/dist/express.js +129 -0
  28. package/dist/express.js.map +1 -0
  29. package/dist/guard-DKb6iU1z.d.ts +51 -0
  30. package/dist/guard-Dhy4fI6n.d.cts +51 -0
  31. package/dist/index.cjs +39 -0
  32. package/dist/index.cjs.map +1 -0
  33. package/dist/index.d.cts +44 -0
  34. package/dist/index.d.ts +44 -0
  35. package/dist/index.js +39 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/nest.cjs +62 -0
  38. package/dist/nest.cjs.map +1 -0
  39. package/dist/nest.d.cts +57 -0
  40. package/dist/nest.d.ts +57 -0
  41. package/dist/nest.js +62 -0
  42. package/dist/nest.js.map +1 -0
  43. package/dist/next.cjs +113 -0
  44. package/dist/next.cjs.map +1 -0
  45. package/dist/next.d.cts +63 -0
  46. package/dist/next.d.ts +63 -0
  47. package/dist/next.js +113 -0
  48. package/dist/next.js.map +1 -0
  49. package/dist/routes-CLtZnUzl.d.ts +130 -0
  50. package/dist/routes-DWkRFOP0.d.cts +130 -0
  51. package/dist/situations.cjs +15 -0
  52. package/dist/situations.cjs.map +1 -0
  53. package/dist/situations.d.cts +607 -0
  54. package/dist/situations.d.ts +607 -0
  55. package/dist/situations.js +15 -0
  56. package/dist/situations.js.map +1 -0
  57. package/dist/subscriptions-D1hE7MUc.d.cts +768 -0
  58. package/dist/subscriptions-D1hE7MUc.d.ts +768 -0
  59. package/dist/testing.cjs +1486 -0
  60. package/dist/testing.cjs.map +1 -0
  61. package/dist/testing.d.cts +331 -0
  62. package/dist/testing.d.ts +331 -0
  63. package/dist/testing.js +1486 -0
  64. package/dist/testing.js.map +1 -0
  65. package/package.json +158 -4
package/README.md CHANGED
@@ -1,3 +1,836 @@
1
- # Temporary Holding Version
1
+ # @mesub/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Server-side SDK for [Mesub](https://mesub.io), recurring payments on Solana.
4
+
5
+ Mesub runs the billing: it holds no funds, pulls each period on the merchant's
6
+ behalf, retries, and keeps the record of every attempt. This package is the
7
+ part that lives on your servers. It answers **does this customer have access
8
+ to this plan?** on every request, and opens subscriptions for your front to
9
+ have the wallet sign.
10
+
11
+ **[Read the docs](https://docs.mesub.io)** for the walkthroughs. This page is
12
+ the short version and the reference of the options.
13
+
14
+ > **Status: early, 0.x, not on npm yet.** The API may still change between
15
+ > minor versions. See the [board](https://github.com/orgs/Mesub-io/projects/4)
16
+ > for what is next.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm install @mesub/node
22
+ ```
23
+
24
+ Node 22 or later. No runtime dependency.
25
+
26
+ ## How it fits
27
+
28
+ Your subscribers get no Mesub account. You keep your own login and your own
29
+ UI; your server talks to Mesub with the API key, through this package, and
30
+ your pages only have the wallet sign. See
31
+ [How it works](https://docs.mesub.io/docs/how-it-works).
32
+
33
+ | Where | What |
34
+ | -------------------- | -------------------------------------------------------------------------------- |
35
+ | Your server's `.env` | `MESUB_API_KEY=SUB_...`, the API key, from your dashboard. Read by this package. |
36
+ | Your server's `.env` | With webhooks: `MESUB_WEBHOOK_SECRET=whsec_...`, the endpoint's signing secret. |
37
+ | Your pages | Nothing. No key ever goes to the browser. |
38
+
39
+ ## Gate a route
40
+
41
+ Your own login says who the user is; Mesub says whether they have access.
42
+
43
+ ```ts
44
+ import { Mesub } from '@mesub/node';
45
+
46
+ const mesub = new Mesub(); // reads MESUB_API_KEY
47
+
48
+ // Your login sets req.user: declare it once. With Passport, add these fields to Express.User.
49
+ declare global {
50
+ namespace Express {
51
+ interface Request {
52
+ user?: { id: string; email?: string };
53
+ }
54
+ }
55
+ }
56
+
57
+ app.get('/api/analytics', yourLogin, async (req, res) => {
58
+ const user = req.user!; // yourLogin answers 401 when nobody is signed in
59
+ if (!(await mesub.hasAccess({ external_id: user.id }, 'pro'))) {
60
+ res.status(402).json({ error: 'The Pro plan is needed.' });
61
+ return;
62
+ }
63
+ res.json(buildAnalytics(user));
64
+ });
65
+ ```
66
+
67
+ Called like this, `hasAccess` has no time budget: it waits out the client's
68
+ timeout and retries, and a 429's `Retry-After` twice, so a rate limit can hold
69
+ the route for about two minutes. The guards do the same in one line, within
70
+ `guardTimeout` (2 s by default), and answer the refusals themselves (401
71
+ nobody signed in, 402 no access, 503 Mesub could not answer about a customer it
72
+ never saw):
73
+
74
+ ```ts
75
+ // Express
76
+ import type { Request } from 'express';
77
+ import { requirePlan } from '@mesub/node/express';
78
+
79
+ const customer = (req: Request) => (req.user ? { external_id: req.user.id } : null);
80
+ app.get('/api/analytics', yourLogin, requirePlan('pro', { customer }), handler);
81
+ ```
82
+
83
+ ```ts
84
+ // Next, app/api/analytics/route.ts
85
+ import { withMesub } from '@mesub/node/next';
86
+
87
+ export const GET = withMesub(async (request, mesub) => Response.json(await analytics(mesub)), {
88
+ plan: 'pro',
89
+ customer: async (request) => {
90
+ const session = await yourSession(request);
91
+ return session ? { external_id: session.userId } : null;
92
+ },
93
+ });
94
+ ```
95
+
96
+ ```ts
97
+ // Nest
98
+ import { Controller, Get, UseGuards } from '@nestjs/common';
99
+ import { MesubAccess, type MesubRequest, RequirePlan } from '@mesub/node/nest';
100
+
101
+ interface AuthedRequest extends MesubRequest {
102
+ user?: { id: string };
103
+ }
104
+
105
+ @Controller('analytics')
106
+ @UseGuards(
107
+ YourAuthGuard,
108
+ RequirePlan<AuthedRequest>('pro', {
109
+ customer: (req) => (req.user ? { external_id: req.user.id } : null),
110
+ }),
111
+ )
112
+ export class AnalyticsController {
113
+ @Get()
114
+ list(@MesubAccess() mesub: MesubAccess) {
115
+ return buildAnalytics(mesub.customer);
116
+ }
117
+ }
118
+ ```
119
+
120
+ > **`customer` must come from a session you verified**, never from the request
121
+ > itself. A guard reading `req.query.wallet` lets in anyone who types a
122
+ > subscriber's address.
123
+
124
+ A customer is named by exactly one of `external_id` (your own id for them),
125
+ `wallet` or `email`. A guard takes one plan, a list of up to three, or a
126
+ function of the request. `access` returns the whole answer for a screen, and
127
+ `accessList` every plan a customer has anything on.
128
+
129
+ A guard reads `access`, never `status`. Access runs past `access_until`
130
+ while the renewal goes through, a couple of minutes when nothing is in its
131
+ way; never past it on a blocked subscription, nor past a plan's end. On a plan
132
+ with an end date (`ends_at` on the plan) `access_until` is never later than
133
+ that end: nobody has access past it, the last period being charged in full
134
+ all the same. In that last period the
135
+ answer has no `next_charge_at`, and a late one no `next_retry_at`, since no
136
+ pull is left to run. For a few minutes past the end, `status` can still read
137
+ `active` or `unpaid` with `access: false`, until Mesub ends the subscription
138
+ (`ended`, `end_reason: 'plan_ended'`).
139
+
140
+ A charge can also fail for a reason that is not your customer's. The answer
141
+ then carries `blocked_since` and `blocked_reason`, both null otherwise:
142
+ `mesub` for a cause on Mesub's side, or your plan's own setup by name,
143
+ `puller_not_allowed`, `receiver_not_allowed`, `receiver_account_missing`,
144
+ `receiver_account_frozen` or `mint_gone`. `status` and `payment_status` do not
145
+ move for it and Mesub keeps trying; `access` ends at `access_until` if nothing
146
+ is collected by then, and comes back with the first charge that goes through,
147
+ nothing to sign. On `mint_gone` no charge can go through again, so access does
148
+ not come back.
149
+
150
+ Everything else, the answer's fields, the errors, who to ask about:
151
+ [Check access](https://docs.mesub.io/docs/access). What each status means:
152
+ [Lifecycle](https://docs.mesub.io/docs/lifecycle).
153
+
154
+ ## When Mesub does not answer
155
+
156
+ `hasAccess` and the guards do not lock out a paying subscriber for an outage,
157
+ nor let a stranger in: they serve the last answer they knew for that customer
158
+ and plan, for up to 24 hours (`maxStaleMs`), and `false` or a 503 for one they
159
+ never saw. An answer with no charge or retry ahead (cancelled, parked, or in
160
+ a plan's last period) stops at its `access_until`; one with a renewal ahead
161
+ keeps the fallback, as it was likely paid while Mesub was down. A blocked one
162
+ (`blocked_since`) stops at its `access_until` whatever is ahead, as Mesub
163
+ does: its retries say nothing of a payment.
164
+ `access` throws instead. A guard never holds a request longer than
165
+ `guardTimeout`, 2 s by default, a cache store that hangs included: a read not
166
+ answered by then is a miss, and no answer waits for a write. Its 503 carries
167
+ the `Retry-After` Mesub sent, 30 s when it sent none. A bad key or an unknown
168
+ plan always throws: a broken integration is never read as "not subscribed".
169
+
170
+ ## Show your plans
171
+
172
+ ```ts
173
+ const plans = await mesub.plans.list(); // sorted by slug
174
+ const pro = await mesub.plans.retrieve('pro');
175
+ // pro.amount_display "9.99", pro.symbol "USDC", pro.period_hours 720, pro.available
176
+ // pro.token { symbol: "USDC", name: "USD Coin", logo_url, verified: true }, pro.amount_usd "9.99"
177
+ ```
178
+
179
+ `available` is false on a plan that is ending and when your project is full:
180
+ show "Subscribe" only when it is true. `ends_at` is when a plan ends, null
181
+ for one with no end. Nothing is cached.
182
+
183
+ `symbol` is set only for a mint Mesub vouches for (USDC, USDT, PYUSD).
184
+ `token` is what Mesub resolved for any mint: its symbol, name and logo, and
185
+ `verified`, true for a vouched mint or one Jupiter marks verified. A symbol is
186
+ free text on chain and a fake "USDC" is a known scam: show an unverified token
187
+ as unverified and keep `mint` visible. `amount_usd` is the plan's amount in
188
+ dollars at the latest price Mesub knows, a decimal string to show beside the
189
+ amount, null when it knows none. An attempt's `amount_usd` is priced when it
190
+ was attempted, null until then.
191
+
192
+ ## Subscribe from your server
193
+
194
+ Three steps, with the wallet in the middle. The API key never leaves your
195
+ server.
196
+
197
+ ```ts
198
+ // 1. On your server: Mesub reserves it and builds what the wallet signs.
199
+ const { subscription, transaction, terms, costs } = await mesub.subscriptions.create({
200
+ plan: 'pro',
201
+ wallet, // the wallet that signs and pays
202
+ external_id: user.id, // optional: your own id, what you gate routes by
203
+ email, // optional: where their notices go
204
+ });
205
+
206
+ // 2. In your pages: the wallet signs terms.message, then the transaction,
207
+ // without sending it, before terms.expires_at.
208
+
209
+ // 3. On your server: Mesub co-signs, sends, and waits for the chain.
210
+ const { subscription: now, reason } = await mesub.subscriptions.submit(subscription.id, {
211
+ transaction: signedTransaction,
212
+ terms_signature,
213
+ });
214
+ if (now.access) {
215
+ // it landed: grant the plan
216
+ }
217
+ ```
218
+
219
+ `submit` waits for the chain: up to 130 s by default, which `{ timeout, budget }`
220
+ lower for a serverless function. When no answer says what became of it, it
221
+ throws a `MesubSubmitError`: read the subscription back with `retrieve` before
222
+ creating a new one, since the wallet may have paid. A 503 `unavailable` (too
223
+ many submits waiting on your project) says nothing was signed or sent: `submit`
224
+ sends the same request again after its `Retry-After`, and past its replays
225
+ throws it as is, `retryable`, with `retryAfter`.
226
+
227
+ A transaction lives about half a minute, the terms five:
228
+ `transaction_expires_at` says when it should run out, an estimate that errs
229
+ early. Have the terms signed first, then ask for the transaction when the
230
+ wallet is ready, and after a submit refused with `transaction_expired`:
231
+
232
+ ```ts
233
+ const fresh = await mesub.subscriptions.transaction(subscription.id);
234
+ // fresh.terms.message is the text the wallet signed: its signature is still good.
235
+ // Another text is new terms: have the wallet sign them too.
236
+ ```
237
+
238
+ It answers what `create` answers for the same subscription, reserves nothing,
239
+ and is refused with `not_awaiting_signature` once the subscription is signed
240
+ or expired. Always sign the transaction of the last call.
241
+
242
+ A `submit` or a confirm that settled nothing answers the subscription with a
243
+ `reason`, a sentence for a person, and a `reason_code` for your code
244
+ (`transaction_expired`, `nothing_landed`, ...): branch on `reason_code`, and
245
+ keep a default branch, since Mesub adds codes.
246
+
247
+ The signing code, what `create` answers, the refusals:
248
+ [Subscribe from your server](https://docs.mesub.io/docs/subscribe).
249
+
250
+ ## Cancel, resume, close, pay now, and payments
251
+
252
+ Each action is two calls from your server with the wallet in between: your
253
+ server builds a transaction, the subscription's own wallet signs and sends it,
254
+ your server confirms with the signature.
255
+
256
+ | To | Build | Confirm |
257
+ | --------------------------------------------- | ------------ | ---------------------------------- |
258
+ | stop the renewals of a running one | `cancel(id)` | `confirmCancel(id, { signature })` |
259
+ | take a cancellation back before its end | `resume(id)` | `confirmResume(id, { signature })` |
260
+ | close one that is over, and get its rent back | `close(id)` | `confirmClose(id, { signature })` |
261
+
262
+ Whether `close` can work now is on every subscription and access answer:
263
+ `closable`, and `closable_from`, the date it can when not yet (null when no
264
+ date is known: a running one is cancelled first). A subscription stopped
265
+ because the wallet closed its approval for good (`stop_reason`
266
+ `authority_closed`) can be closed at once, and cannot come back. An access
267
+ answer names its subscription in `subscription_id`, so you can act on it
268
+ without listing first.
269
+
270
+ A late payment is paid now in one call, with nothing to sign: `retry(id)`,
271
+ shown to your customer as "Pay now". Mesub answers the subscription, still
272
+ `unpaid`: the outcome comes with the pull, as the `subscription.renewed` or
273
+ `subscription.payment_failed` webhook. It refuses with an `apiCode` when paying
274
+ cannot succeed, so no retry is spent for nothing: `insufficient_balance` (top
275
+ up first), `retry_cannot_succeed` (the wallet no longer approves it, its token
276
+ account is frozen, or its authority is closed), `retry_too_soon` (`retryAfter`
277
+ says when). Two refusals are `retryable`: `subscription_changed` (two retries
278
+ met, or a pull moved it on) and, on Free, `pull_running` (a pull runs now).
279
+ Read the subscription back before you ask again. A 503 (`unavailable`, the
280
+ charge could not be queued, or `network_unavailable`) changed nothing, and
281
+ `retryAfter` says when to ask again. Sent once, never retried.
282
+
283
+ ```ts
284
+ await mesub.subscriptions.retrieve(id);
285
+ await mesub.subscriptions.list({ external_id: user.id }); // { data, has_more }
286
+ const { data, paid } = await mesub.subscriptions.attempts(id); // its charges, and the total paid
287
+ ```
288
+
289
+ `listAll` and `allAttempts` walk every page. The steps, the refusals and what
290
+ an attempt carries: [Manage from your server](https://docs.mesub.io/docs/manage).
291
+
292
+ ## Show a subscription's situation
293
+
294
+ `explain` turns an access answer or a subscription into the words Mesub uses
295
+ everywhere: one sentence for the subscriber, one for you, the access line,
296
+ and what each of you can do now. Show these rather than your own: a late
297
+ payment the wallet cannot fix by adding funds reads differently from one it
298
+ can.
299
+
300
+ ```ts
301
+ import { explain } from '@mesub/node'; // or '@mesub/node/situations' in a page
302
+
303
+ const answer = await mesub.access({ external_id: user.id }, 'pro');
304
+ const { key, subscriber, access, actions } = explain(answer, {
305
+ names: { plan: 'Pro', merchant: 'Acme', amount: '9.99 USDC', token: 'USDC' },
306
+ });
307
+ // key "unpaid_approval_revoked"
308
+ // subscriber "Mesub could not take your payment of 9.99 USDC: your wallet no longer approves it (...)"
309
+ // access "No access"
310
+ // actions [{ by: 'subscriber', action: 'cancel', sdk: 'subscriptions.cancel', confirm: 'Cancel Pro? ...' }]
311
+ ```
312
+
313
+ `names` fills what neither answer carries; a placeholder left without a value
314
+ stays as `{amount}` and is listed in `missing`. Dates are written as Mesub
315
+ serves them, in ISO 8601, unless you pass `formatDate`. A status or a reason
316
+ newer than this release gives `key: 'unknown'` and the access line, never an
317
+ error. A blocked subscription has its own keys, `blocked_mesub` and one per
318
+ cause of your setup (`blocked_puller_not_allowed`, ...): the subscriber reads
319
+ that nothing is theirs to do, you read what to fix.
320
+
321
+ `close` is offered only while Mesub says the subscription is `closable`, and
322
+ the sentence names `closable_from` when it can be closed later. A
323
+ subscription stopped for good (`stop_reason` `authority_closed`) reads
324
+ `stopped_authority_closed`: no comeback, only the close.
325
+ `@mesub/node/situations` imports nothing of Node, so your pages can use
326
+ it too; `SITUATIONS` is the whole table.
327
+
328
+ ## Routes for the React widget
329
+
330
+ [`@mesub/react`](https://github.com/Mesub-io/react-sdk) is optional. It never
331
+ talks to Mesub: it calls your server, on routes this package mounts in one
332
+ line.
333
+
334
+ `@mesub/react` 0.1.x works with the routes of `@mesub/node` 0.1.x: upgrade
335
+ both to the same minor together.
336
+
337
+ ```ts
338
+ // Express, after your own login. Under Nest: the same app.use in main.ts.
339
+ import { mesubRoutes } from '@mesub/node/express';
340
+
341
+ app.use(
342
+ '/api/mesub',
343
+ yourLogin,
344
+ mesubRoutes({
345
+ customer: (req) => (req.user ? { external_id: req.user.id } : null),
346
+ email: (req) => req.user?.email,
347
+ }),
348
+ );
349
+ ```
350
+
351
+ ```ts
352
+ // Next, app/api/mesub/[...mesub]/route.ts
353
+ import { mesubRouteHandlers } from '@mesub/node/next';
354
+
355
+ export const { GET, POST } = mesubRouteHandlers({
356
+ customer: async (request) => {
357
+ const session = await yourSession(request);
358
+ return session ? { external_id: session.userId } : null;
359
+ },
360
+ });
361
+ ```
362
+
363
+ Every subscription created is tied to the customer your function returns,
364
+ whatever the browser sends; one that is not theirs answers 404. `plans:
365
+ ['pro', 'team']` keeps the widget to those plans. The browser never gets your
366
+ API key, nor the email and the id you gave Mesub.
367
+
368
+ | Route | What it does |
369
+ | ------------------------------------------------------------ | ------------------------------------------- |
370
+ | `GET /plans/:slug` | The plan to show. Public. |
371
+ | `GET /subscriptions` | The customer's subscriptions, 500 at most. |
372
+ | `GET /subscriptions/:id` | One of them, with its payments. |
373
+ | `POST /subscriptions` | Prepares one: terms and a transaction. |
374
+ | `POST /subscriptions/:id/transaction` | A fresh transaction for it, and its terms. |
375
+ | `POST /subscriptions/:id/submit` | Sends what the wallet signed. |
376
+ | `POST /subscriptions/:id/retry` | Pay now: queues the missed charge. |
377
+ | `POST /subscriptions/:id/cancel`, `/resume`, `/close` | The transaction the wallet signs and sends. |
378
+ | `POST /subscriptions/:id/cancel/confirm`, and the two others | Confirms it with its signature. |
379
+
380
+ `GET /plans/:slug` is public, so the routes answer it from your project's
381
+ plan list, kept in memory for 60 seconds: one call to Mesub a minute at most,
382
+ whatever the slugs asked, and a slug you do not have costs none. A change to a
383
+ plan shows there within a minute. `mesub.plans.list` and `mesub.plans.retrieve`
384
+ themselves are never cached.
385
+
386
+ A refusal is `{ error: { code, message } }`. A 401 always means nobody is
387
+ signed in on your site. Mesub refusing your API key (missing, wrong or
388
+ rotated) is thrown to your framework, which logs it and answers 500, never
389
+ handed to the browser as a 401. The widget's side:
390
+ [React widget](https://docs.mesub.io/docs/react).
391
+
392
+ Under Express, `requirePlan` and `mesubRoutes` hand such an error to
393
+ `next(err)`, and Express's own handler answers an error's `status`. So one
394
+ Mesub answered with a 4xx (a refused key, an unknown plan) reaches your error
395
+ handler as a `MesubError` with the same `code` and `apiCode` and a `status`
396
+ of 500, the original, with Mesub's status, as its `cause`: with no error
397
+ handler of yours, Express answers 500.
398
+
399
+ ## Webhooks
400
+
401
+ Mesub posts an event to your endpoint when a subscription changes, signed the
402
+ [Standard Webhooks](https://www.standardwebhooks.com) way. Verify the raw body,
403
+ never one a JSON parser read and wrote again:
404
+
405
+ ```ts
406
+ import { MesubError } from '@mesub/node';
407
+
408
+ app.post('/webhooks/mesub', express.raw({ type: 'application/json' }), async (req, res) => {
409
+ let event;
410
+ try {
411
+ event = await mesub.webhooks.verify(req.body, req.headers);
412
+ } catch (error) {
413
+ if (!(error instanceof MesubError)) throw error;
414
+ // Not Mesub's, or not now: refuse it.
415
+ if (error.code === 'invalid_webhook') return res.status(400).end();
416
+ // Mesub signed it, but this release cannot read it: update @mesub/node.
417
+ console.error(error);
418
+ return res.status(500).end();
419
+ }
420
+
421
+ if (event.test) return res.status(200).end(); // sent from the dashboard
422
+ if (await alreadyHandled(event.delivery_id)) return res.status(200).end();
423
+ // event.type, event.data (the subscription), event.detail
424
+ res.status(200).end();
425
+ });
426
+ ```
427
+
428
+ In a Next route handler: `await verifyWebhook(await request.text(),
429
+ request.headers)`.
430
+
431
+ Answer 400 only for `invalid_webhook`. An `unexpected` error is a body Mesub
432
+ signed that this release cannot read: log it, and answer a 5xx. Mesub then
433
+ retries the same body for 3 days, so the event still gets through once you
434
+ update `@mesub/node`; a 2xx would lose it. After 3 days of failures, Mesub
435
+ disables the endpoint.
436
+
437
+ To rotate the signing secret, regenerate it on the endpoint's dashboard page
438
+ with an overlap (24 hours, rather than stopping it now): the old secret keeps signing beside the new
439
+ one until the overlap ends. Every delivery in that window carries two
440
+ signatures in `webhook-signature`, space separated, the new secret's first,
441
+ and `verify` accepts it when any one matches. `verify` takes one secret, so
442
+ there is nothing to hold twice: the old secret keeps verifying until you
443
+ deploy the new one as `MESUB_WEBHOOK_SECRET` (or `webhookSecret`), and the new
444
+ one verifies from then on. Deploy it before the overlap ends and no delivery
445
+ fails. Regenerated with no overlap, the old secret stops at once: deliveries
446
+ fail verification until the new secret is deployed, and Mesub retries each
447
+ for about 3 days.
448
+
449
+ `event.delivery_id` is the `webhook-id` header: one per event and per
450
+ endpoint, the same on every retry and resend. Drop a delivery you already
451
+ handled by it. `event.id` is the event's own, the same for every endpoint:
452
+ deduplicate on it across endpoints, and read the event again with
453
+ `events.retrieve(event.id)`. Deliveries can arrive out of order: `event.sequence`
454
+ numbers one subscription's events 1, 2, 3..., two events of one change
455
+ included. Apply them by `sequence`, and skip one lower than the last you
456
+ applied for that subscription. An event without `sequence` (written before
457
+ Mesub numbered events, or one it could not number) cannot be placed: read the
458
+ subscription now with `subscriptions.retrieve` or `access` rather than
459
+ applying its `data`.
460
+
461
+ `event.data` is the subscription as it stood right after the change, however
462
+ late the delivery; `event.detail` says what the event is about. A body Mesub
463
+ wrote before this shape, and retries as it was sent, is handed back in the
464
+ same shape: its detail at the top, and `id`, `network` and `api_version` null.
465
+
466
+ A body sent from the dashboard carries `test: true`, on a made-up subscription
467
+ (`sub_test`, `user_123`), whatever its `type`. Change nothing for it: `verify`
468
+ keeps the access cache as it is.
469
+
470
+ A cancellation reaching its end reads `ended` from its `access_until` on. Once
471
+ the access events are on, its `subscription.access_revoked` comes at the
472
+ expiry, then `subscription.ended` with `end_reason: 'cancelled'` (in `data`
473
+ and `detail`) and `detail.changed_at` (the expiry), usually within half an hour. Closing it later
474
+ sends `subscription.ended` again, `end_reason: 'closed'`: one subscription can
475
+ end twice, so key what you do on `end_reason`, not on the event alone.
476
+
477
+ `subscription.created` carries `detail.first_payment`, `{ amount, mint,
478
+ signature }` of the first payment, or null when Mesub recorded none (a period
479
+ paid only in part, which the next charge collects); absent on older events.
480
+
481
+ `subscription.paused` and `subscription.unpaused` (with the access events)
482
+ say a seat was parked because the project holds more subscribers than its tier
483
+ allows, and brought back: `data.paused` true or false, the status unchanged.
484
+ A parked seat keeps access to the end of its paid period; its
485
+ `subscription.access_revoked` comes then, and an `access_granted` follows an
486
+ unpause when access had ended.
487
+
488
+ A `subscription.renewed` can come late, on
489
+ a subscription that is no longer `active`. The events, their bodies and how to
490
+ test: [Webhooks](https://docs.mesub.io/docs/webhooks).
491
+
492
+ `subscription.renewal_upcoming` comes once per period, in its last quarter and
493
+ three days before the charge at most. Its detail says whether the wallet, as
494
+ Mesub just read it, can pay: a reading, not a promise.
495
+
496
+ ```ts
497
+ if (event.type === 'subscription.renewal_upcoming' && !event.detail.can_pay) {
498
+ // renewal_issue: 'balance' (add funds) or 'authority' (the approval is gone)
499
+ await remind(event.data.external_id, event.detail.renewal_issue);
500
+ }
501
+ ```
502
+
503
+ It changes no access, so `verify` leaves the access cache as it is for it.
504
+
505
+ ### Drive access from the access events
506
+
507
+ `subscription.access_granted` and `subscription.access_revoked` are sent
508
+ exactly when the access a subscription grants changes, whatever made it
509
+ change, a date passing included: the first access, a payment that gives it
510
+ back, the end of a cancellation, of a paid period or of the plan. One event
511
+ per change, never two; a renewal paid on time, or a resume while the period
512
+ runs, sends neither. Keep your customer's access from these two alone; the
513
+ other events say why.
514
+
515
+ **They are off until Mesub turns them on**: Mesub is not sending them yet. Until then,
516
+ `hasAccess` (or the guards) stays the way to know, and the other events say
517
+ what changed.
518
+
519
+ ```ts
520
+ switch (event.type) {
521
+ case 'subscription.access_granted':
522
+ case 'subscription.access_revoked': {
523
+ const { id, access, external_id } = event.data;
524
+ if (event.sequence === undefined) {
525
+ // Cannot be placed: read the state now.
526
+ await setAccess(id, external_id, (await mesub.subscriptions.retrieve(id)).access);
527
+ break;
528
+ }
529
+ // Apply only what is newer than the last event applied for it.
530
+ if (event.sequence <= ((await lastSequence(id)) ?? 0)) break;
531
+ await setAccess(id, external_id, access, event.sequence);
532
+ break;
533
+ }
534
+ // The other events say why: payment_failed, stopped, cancelled...
535
+ }
536
+ ```
537
+
538
+ `data.access` is true on `access_granted`, false on `access_revoked`.
539
+ `event.detail.changed_at` is when access changed: a date that passed with
540
+ nothing else happening is that date to the millisecond, even when the event
541
+ was written a few seconds later, its `created_at`. `detail.blocked_reason` says
542
+ whether charges were held when access changed (`data.blocked_reason`'s words,
543
+ null when not). `detail.access_until` is, on `access_revoked`, the date access
544
+ ended (null for a stop or a close), and on `access_granted`, for a held
545
+ subscription, the date access ends unless a charge lands (null otherwise).
546
+ Both are absent on events written before Mesub added them. `verify` drops the
547
+ access answers cached for the customer on both.
548
+
549
+ ### Catch up on missed events
550
+
551
+ Mesub keeps the last 30 days of events, each the very body its webhooks
552
+ carried. Read them after an endpoint was down or turned off:
553
+
554
+ ```ts
555
+ const { data, has_more, available_since } = await mesub.events.list({
556
+ type: 'subscription.payment_failed', // optional
557
+ subscription: id, // optional
558
+ limit: 100, // 1 to 100, 25 by default
559
+ });
560
+ for (const event of data) await handle(event); // the same shape as a webhook's
561
+
562
+ const one = await mesub.events.retrieve(eventId); // the id a webhook body carried
563
+ ```
564
+
565
+ Newest first, and within one millisecond by `sequence`, highest first; page
566
+ with `starting_after` set to the last id. Events before
567
+ `available_since` are not served. An event can appear a moment after its
568
+ `created_at`, so when polling, page on until `created_at` is a minute older
569
+ than the newest you held, and skip the ids you have seen.
570
+
571
+ ## Errors
572
+
573
+ Every failure is a `MesubError` with a `status` (the HTTP status, or `null`)
574
+ and a stable `code` to branch on:
575
+
576
+ | `code` | Meaning |
577
+ | ----------------- | ----------------------------------------------------------------------- |
578
+ | `invalid_request` | 400, e.g. a wallet that is not an address |
579
+ | `unauthorized` | 401, an API key Mesub never issued |
580
+ | `forbidden` | 403, e.g. signed terms that expired |
581
+ | `plan_not_found` | 404, no plan of yours under that slug |
582
+ | `not_found` | any other 404 Mesub answered, e.g. an unknown subscription id |
583
+ | `conflict` | 409, e.g. a wallet already subscribed |
584
+ | `rate_limited` | 429, after the retries, or a quota on create (`pending_cap_reached`) |
585
+ | `unavailable` | 5xx, a timeout or a network error, after the retries |
586
+ | `invalid_webhook` | a webhook that fails verification: signature, headers or timestamp |
587
+ | `unexpected` | any other status, or an answer that is not Mesub's: is `baseUrl` right? |
588
+
589
+ It also carries `apiCode`, Mesub's own finer code (`already_subscribed`,
590
+ `close_too_early`, ...), `retryable`, `retryAfter` in milliseconds, the
591
+ parsed `body`, and `requestId`, the id Mesub gave the request
592
+ (`Mesub-Request-Id`): log it, and quote it to Mesub support. New codes are added: keep a default branch. Every code:
593
+ [Errors](https://docs.mesub.io/reference/errors).
594
+
595
+ `pending_cap_reached` is a quota, not a rate: too many subscriptions wait for
596
+ a signature. `create` is never retried for it, and `retryAfter` says when the
597
+ oldest stops counting, up to an hour. Do not hold a request that long.
598
+
599
+ Reads time out after 5 s and are retried twice. `create`, `transaction` and
600
+ the six manage calls are sent once and never retried.
601
+
602
+ ## Test your integration
603
+
604
+ `@mesub/node/testing` is a fake Mesub for your own tests: no network, no Mesub
605
+ account, and nothing of it in your production bundle.
606
+
607
+ ```ts
608
+ import { FakeMesub } from '@mesub/node/testing';
609
+
610
+ const fake = new FakeMesub();
611
+ const mesub = fake.client(); // a real Mesub, wired to the fake
612
+
613
+ fake.grant({ external_id: 'user_42' }, 'pro');
614
+ await mesub.hasAccess({ external_id: 'user_42' }, 'pro'); // true
615
+
616
+ fake.grantLastPeriod({ external_id: 'user_7' }, 'pro', endsAt); // access until the plan ends
617
+ fake.endPlan('pro'); // the end passed: what is held on it ends with plan_ended
618
+
619
+ fake.grant({ external_id: 'user_9' }, 'pro', {
620
+ blocked_since: new Date().toISOString(), // charges blocked, access still on
621
+ blocked_reason: 'puller_not_allowed',
622
+ });
623
+
624
+ // A plan is in verified USDC unless told: here an unverified token, no symbol, no price.
625
+ new FakeMesub({ plans: ['pro', { slug: 'bonk', mint, token: { name: 'Bonk' } }] });
626
+
627
+ fake.fail('outage'); // every call answers 503, until fail(null)
628
+ fake.fail({ status: 503, code: 'unavailable' }); // Retry-After as Mesub: 2 s on submit, 20 s on retry
629
+ const { body, headers } = await fake.webhook('subscription.renewed'); // signed
630
+ fake.addEvent('subscription.payment_failed', { subscription: { id } }); // on /v1/events
631
+ fake.addEvent('subscription.access_revoked', { subscription: { id }, sequence: 5 });
632
+ fake.park(id); // a seat over the tier's cap: paused, no next charge; unpark(id) brings it back
633
+ fake.reset(); // between tests
634
+ ```
635
+
636
+ Every event the fake signs or serves carries the next `sequence` of its
637
+ subscription (`sequence: null` leaves it out). The access events are off by
638
+ default, as on Mesub: `new FakeMesub({ accessEvents: true })` raises
639
+ `subscription.access_granted` after the `subscription.created` of a submit,
640
+ one `sequence` later, an access event after any other change of the fake
641
+ that flips access, and `subscription.paused` and `subscription.unpaused` on
642
+ `park` and `unpark`. `addEvent` and `webhook` make either type whatever this says.
643
+
644
+ As on Mesub, a client keeps a yes up to five minutes: a change shows to a new
645
+ `fake.client()`, or on the next call with `new FakeMesub({ revalidateAfter: 0 })`.
646
+
647
+ Hand the fake's client to the guards and the routes, which otherwise build
648
+ their own from `MESUB_API_KEY`:
649
+
650
+ ```ts
651
+ const mesub = fake.client();
652
+ app.get('/api/analytics', yourLogin, requirePlan('pro', { client: mesub, customer }), handler);
653
+ app.use('/api/mesub', yourLogin, mesubRoutes({ client: mesub, customer }));
654
+ ```
655
+
656
+ Pay now refuses as Mesub does, from what the fake holds: a parked seat, a row
657
+ not late, a plan past its end, and the wait between two retries, which
658
+ depends on `tier` (`dev` by default; `free` also caps a missed period at
659
+ three and closes at `retry_deadline`). A taken retry pulls nothing: the row
660
+ stays `unpaid`.
661
+
662
+ ```ts
663
+ const fake = new FakeMesub({ revalidateAfter: 0 });
664
+ const late = fake.addSubscription({ wallet, plan: 'pro', status: 'unpaid' }); // late, retry tomorrow
665
+ await fake.client().subscriptions.retry(late.id); // 202, still unpaid
666
+ await fake.client().subscriptions.retry(late.id); // retry_too_soon, retryAfter 20 minutes
667
+ ```
668
+
669
+ `fail()` answers every call, not only the one under test: a route that reads
670
+ the subscription before it pays now gets the error on that read. What only
671
+ the chain decides (`insufficient_balance`, `retry_cannot_succeed`) is tested
672
+ on the SDK call alone, or on a route that makes that one call.
673
+
674
+ Guards, webhooks, subscribing and managing against it:
675
+ [Test your integration](https://docs.mesub.io/docs/testing).
676
+
677
+ ## Options
678
+
679
+ ```ts
680
+ new Mesub({
681
+ apiKey, // default: process.env.MESUB_API_KEY
682
+ baseUrl, // default: https://api.mesub.io, may carry a path
683
+ headers, // extra headers on every call, e.g. for a proxy
684
+ timeout, // per attempt, ms, default 5000
685
+ maxRetries, // default 2
686
+ fetch, // a custom fetch, e.g. bound to your own agent
687
+ cache, // where answers are kept, default: 10,000 entries in memory
688
+ maxStaleMs, // how long a stale answer serves the outage fallback, default 24 h
689
+ guardTimeout, // the guards' budget for the access check, ms, default 2000
690
+ webhookSecret, // the endpoint's whsec_ secret, default: process.env.MESUB_WEBHOOK_SECRET
691
+ });
692
+ ```
693
+
694
+ They are checked once, by `new Mesub()`, which throws a `TypeError` naming
695
+ the option: a `baseUrl` that is not https (plain http only to `localhost` or
696
+ `127.0.0.1`: every call carries the key), a timeout that is not a positive
697
+ number of milliseconds, or `maxRetries` or `maxStaleMs` below 0. On an edge runtime
698
+ without `process.env` (Cloudflare Workers), pass `apiKey` yourself.
699
+
700
+ Behind a proxy, `baseUrl` may carry a path: every call goes under it
701
+ (`https://proxy.example.com/mesub/v1/access`). Add whatever the proxy asks
702
+ for, such as a Cloudflare Access service token:
703
+
704
+ ```ts
705
+ import { Mesub } from '@mesub/node';
706
+
707
+ new Mesub({
708
+ baseUrl: 'https://proxy.example.com/mesub',
709
+ headers: {
710
+ 'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID!,
711
+ 'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET!,
712
+ },
713
+ });
714
+ ```
715
+
716
+ `headers` cannot replace the SDK's own (`Authorization`, `User-Agent`,
717
+ `Accept`, `Content-Type`, `Mesub-Version`).
718
+
719
+ The memory cache is per process and emptied on restart. A store is three
720
+ methods, so Redis is a few lines, and keeps the outage fallback across
721
+ restarts and servers:
722
+
723
+ ```ts
724
+ import { type AccessAnswer, type AccessList, type CacheStore, Mesub } from '@mesub/node';
725
+
726
+ const redisStore: CacheStore<AccessAnswer | AccessList> = {
727
+ get: async (key) => JSON.parse((await redis.get(key)) ?? 'null') ?? undefined,
728
+ set: async (key, entry, ttlMs) => {
729
+ await redis.set(key, JSON.stringify(entry), 'PX', ttlMs);
730
+ },
731
+ delete: async (key) => {
732
+ await redis.del(key);
733
+ },
734
+ };
735
+
736
+ const mesub = new Mesub({ cache: redisStore });
737
+ ```
738
+
739
+ `delete` is what drops a cached no once a subscription lands. It is optional:
740
+ a store without it gets that answer rewritten as stale instead, which also
741
+ makes the next call ask Mesub.
742
+
743
+ Keys read `mesub:access:key-<hash>:<plan>:<kind>:<id>`, and
744
+ `mesub:access-list:key-<hash>:<kind>:<id>` for `accessList`, so several
745
+ projects can share one Redis. `<hash>` is the start of your API key's SHA-256,
746
+ never the key itself: it needs no call to Mesub, so a server restarted during
747
+ an outage still reads what was cached before. `<kind>` is `wallet`,
748
+ `external_id` or `email`; `<id>` is the wallet itself, or for an external id or
749
+ an email an HMAC-SHA256 under your API key, so neither is ever stored in
750
+ clear. Rotating the API key starts a fresh cache: each customer costs one call
751
+ to Mesub, and the old keys expire on their own TTL. So do keys written before
752
+ the `<kind>` segment, which are no longer read.
753
+
754
+ ### API version
755
+
756
+ Every call sends `Mesub-Version: 2026-10-02`, the version of the API this
757
+ release was written against, exported as `API_VERSION`. It is pinned per
758
+ release and cannot be set, so Mesub can change an answer for newer releases
759
+ without breaking one already installed: upgrading the package is what moves
760
+ you to a newer version. Mesub reads it. A version Mesub no longer serves is
761
+ refused with a 400 (`invalid_request`, `apiCode` `unsupported_version`), and
762
+ every call throws, guards included. Mesub keeps a version while releases that
763
+ send it are in use.
764
+
765
+ ## Requirements
766
+
767
+ Node 22 or later. Express, Next and `@nestjs/common` are optional peer
768
+ dependencies: install the one you use. There is no runtime dependency.
769
+
770
+ What CI runs the tests against: Node 22 and 24; Express 5, and Express 4 on
771
+ Node 22; Nest 12. `@mesub/node/next` imports nothing from Next, only the Web
772
+ `Request` and `Response`, so no Next version is installed to test it.
773
+
774
+ ## Development
775
+
776
+ ```sh
777
+ pnpm install
778
+ pnpm test # unit tests, against a fake Mesub
779
+ pnpm test:coverage # the same, with v8 coverage of src/ in coverage/index.html
780
+ pnpm typecheck
781
+ pnpm lint
782
+ pnpm build # dist/, ESM and CJS, with declaration files
783
+ pnpm check:exports # every entry resolves through import and require, shares one MesubError,
784
+ # and the fake Mesub stays in @mesub/node/testing
785
+ ```
786
+
787
+ The pre-commit hook lints and checks the format of the staged files; the
788
+ pre-push hook runs typecheck, test, build and check:exports. CI runs all of
789
+ it on Node 22 and 24, measures coverage on Node 24, fails under 90%, and
790
+ keeps the report as the `coverage` artifact of the run.
791
+
792
+ ### Releasing
793
+
794
+ A version tag on main publishes, once a reviewer approves: see
795
+ [RELEASING.md](./RELEASING.md) for the one-time setup and the checklist, and
796
+ [CHANGELOG.md](./CHANGELOG.md) for what changed.
797
+
798
+ ### Contract test
799
+
800
+ `pnpm test` runs against a fake Mesub. The contract test runs the SDK against
801
+ the real back instead, to catch the back changing a field, a status or an
802
+ error code under the SDK. With the back running locally (`pnpm start:dev` in
803
+ Mesub-io/backend):
804
+
805
+ ```sh
806
+ # in the backend: write a merchant, a key, its plans and their subscribers
807
+ pnpm -s contract:fixture > /tmp/mesub-contract.env
808
+
809
+ # here
810
+ env $(cat /tmp/mesub-contract.env) pnpm test:contract
811
+ ```
812
+
813
+ Without `MESUB_CONTRACT_URL` it is skipped, which is why `pnpm test` never
814
+ needs a back.
815
+
816
+ `pnpm test` also holds what the fake serves to the back's own contract,
817
+ `openapi/v1.json`, copied to `test/fixtures/openapi-v1.json` from the commit
818
+ named in `test/openapi.spec.ts`. When the back's contract moves, copy it
819
+ again and move the commit.
820
+
821
+ The Contract workflow runs it on every push, every morning and by hand (with a
822
+ back branch to try): it checks out Mesub-io/backend, starts Postgres and Redis,
823
+ migrates, seeds the fixture, starts the API and runs `pnpm test:contract`.
824
+ The back is private, so it needs one secret, set once by an admin:
825
+
826
+ 1. `ssh-keygen -t ed25519 -N '' -C node-sdk-contract -f contract_key`
827
+ 2. Mesub-io/backend, Settings, Deploy keys: add `contract_key.pub`, read-only.
828
+ 3. Mesub-io/node-sdk, Settings, Secrets and variables, Actions: a repository
829
+ secret `BACKEND_DEPLOY_KEY` holding `contract_key`. Then delete both files.
830
+
831
+ Without it the job is skipped with a warning. Whoever can push a branch here
832
+ can read the back's code through that key.
833
+
834
+ ## License
835
+
836
+ [MIT](./LICENSE)