@mesub/node 0.0.0-stage → 0.1.2

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