Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

Test a Mesub integration without the network

@mesub/node/testing is a fake Mesub for your own tests. It answers what the SDK asks from what each test sets, with no network and no Mesub account, and none of it ends up in your production bundle.
import { FakeMesub } from '@mesub/node/testing'; const fake = new FakeMesub(); const mesub = fake.client(); // a real Mesub client, wired to the fake
Call fake.reset() between tests, and take a new fake.client() in each one: reset() empties the fake, not the answers a client already cached, which it would serve again during a simulated outage. It also starts every id over (sub_fake_1, evt_fake_1, msg_fake_1, ...), so a test makes the same ids whatever ran before it.

Access

fake.grant({ external_id: 'user_42' }, 'pro'); // active and paid fake.deny({ external_id: 'user_7' }, 'pro', { status: 'stopped' }); await mesub.hasAccess({ external_id: 'user_42' }, 'pro'); // true await mesub.hasAccess({ external_id: 'user_7' }, 'pro'); // false
A customer is answered as named: a wallet you granted is not found by its external_id. For any other answer, fake.setAccess(...) takes the fields you want.
new FakeMesub({ plans: ['pro'] }) makes every other slug answer plan_not_found, as a typo would. Name your plans this way to test the widget routes: they read the plan list, which is empty otherwise.
A plan that ends:
fake.grantLastPeriod({ external_id: 'user_7' }, 'pro', endsAt); // access until the plan ends fake.endPlan('pro'); // the end passed: what is held on it ends with plan_ended

A guard, end to end

Hand the fake's client to the guard, and let the test say who is signed in:
import request from 'supertest'; import { requirePlan } from '@mesub/node/express'; const fake = new FakeMesub({ revalidateAfter: 0 }); // each change shows on the next call const mesub = fake.client(); const customer = () => ({ external_id: 'user_42' }); app.get('/api/analytics', requirePlan('pro', { client: mesub, customer }), handler); fake.grant({ external_id: 'user_42' }, 'pro'); await request(app).get('/api/analytics').expect(200); fake.deny({ external_id: 'user_42' }, 'pro'); await request(app).get('/api/analytics').expect(402);

An outage

fake.fail('outage'); // every call answers 503, until fake.fail(null) fake.fail({ status: 429, code: 'rate_limited', retryAfter: 2 }); fake.fail({ status: 503, code: 'unavailable' }); // Retry-After as Mesub: 2 s on submit, 20 s on retry
fail() answers every call, not only the one under test: a route that reads a subscription before it acts gets the error on that read.
This is how to check what your routes do when Mesub does not answer: a customer seen before keeps their last answer, one never seen gets a 503.
As on Mesub, a client keeps a yes up to five minutes and a no 10 seconds (the revalidate_after Mesub sends), so a change made after a client asked shows to a new fake.client(). new FakeMesub({ revalidateAfter: 0 }) shows every change on the next call. A revalidate_after set on one answer wins, to test the cache.

A webhook

fake.webhook builds a delivery signed with fake.webhookSecret, which the fake's client verifies with:
const { body, headers } = await fake.webhook('subscription.renewed', { subscription: { external_id: 'user_42' }, }); await request(app) .post('/webhooks/mesub') .set(headers) .type('json') .send(body) .expect(200);
It carries the subscription the fake holds under subscription.id when it holds one, and a made-up one otherwise. fake.webhook('test') is Mesub's test delivery: test: true, the id evt_test, on its sample subscription (sub_test, user_123). Nothing is added to the fake.
signWebhook(body, { secret }) signs a body of your own.

Events

What the fake does raises the event Mesub raises (subscription.created on a submit, cancelled, resumed and ended on a confirm), served on /v1/events. addEvent records any other, and changes nothing else:
fake.addEvent('subscription.payment_failed', { subscription: { id } }); fake.addEvent('subscription.access_revoked', { subscription: { id }, sequence: 5 });
Every event carries the next sequence of its subscription; sequence: null leaves it out. The fake raises no access event by default, where Mesub sends them: new FakeMesub({ accessEvents: true }) raises subscription.access_granted or subscription.access_revoked right after a change that flips access, one sequence later, as Mesub does.

Subscribing and managing

subscriptions.create then submit land at once and grant the plan. cancel, resume and close answer a made-up transaction, and their confirms land at once with any signature. A step the status does not allow is refused with Mesub's own code, close_too_early for instance. submit is refused with plan_ended once the plan has ended, and with a 400 without a terms_signature.
fake.addSubscription({ id: 'sub_1', wallet, plan: 'pro', status: 'active' }); fake.setAttempts('sub_1', [{ outcome: 'paid', amount: '9990000' }]);
addSubscription adds one for retrieve and list, sub_fake_1 and on when you give no id, dated as Mesub dates a row of its status: an active one paid for a period ending in 30 days, an unpaid one late with a retry tomorrow, and so on. It throws for a wallet that is not a Solana address. setAttempts gives it charges, answered by subscriptions.attempts with their paid total.

Pay now and the tier

subscriptions.retry is refused as Mesub refuses it, from what the fake holds: a parked seat (subscription_paused), a row not late (subscription_not_late), a plan past its end (plan_ended), and the wait between two retries (retry_too_soon, with retryAfter). The wait depends on the project's tier, dev by default:
tierRetries by hand
free10 minutes apart, three per missed period (retries_spent), none past retry_deadline
dev, businessThe first at once, then 20 minutes, doubling
const fake = new FakeMesub({ tier: 'free', revalidateAfter: 0 }); const late = fake.addSubscription({ wallet, plan: 'pro', status: 'unpaid' }); await fake.client().subscriptions.retry(late.id); // 202, still unpaid await fake.client().subscriptions.retry(late.id); // retry_too_soon, retryAfter 10 minutes
A retry taken pulls nothing: the row stays unpaid. On free, a late row from addSubscription has no access; on the other tiers it keeps access through its retries. What only the chain decides (insufficient_balance, retry_cannot_succeed) or a race (pull_running, subscription_changed) is set with fail(), so test it on the SDK call alone, or on a route that makes that one call.

Over the tier's cap

fake.park(id); // paused, no next charge fake.unpark(id); // charged again at the end of its period
This is a parked seat. Access and status stay as they are. With accessEvents, park and unpark raise subscription.paused and subscription.unpaused.

What was called

fake.requests; // every call: method, path, query, headers, body

With your own client

import { Mesub } from '@mesub/node'; const mesub = new Mesub({ apiKey: fake.apiKey, baseUrl: fake.baseUrl, fetch: fake.fetch, webhookSecret: fake.webhookSecret, });
The fake takes only fake.apiKey: a call without a key is answered 401 missing_api_key, one with another key 401 invalid_api_key, as Mesub tells them apart.