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

Create a subscription from your server

The React widget does this for you. Without it, your server makes three calls and the customer's wallet signs twice in the browser: the terms first, then the transaction. The API key never leaves your server.
The order matters. The terms live five minutes, and a transaction lives 150 blocks, about half a minute. So the customer reads and signs the terms first, and your server asks for the transaction only when the wallet is ready to sign it.
  1. Create, on your server
    const { subscription, transaction, terms, costs } = await mesub.subscriptions.create({ plan: 'pro', wallet, // the wallet that signs and pays external_id: user.id, // optional: your own id for this customer email: user.email, // optional: where their notices go });
    Mesub reserves the subscription and builds what the wallet signs. Keep subscription.id, and send terms and costs to your page. The transaction answered here is already running out: the one to sign comes at the third step.
    Pass external_id when your app has a login: it is how hasAccess finds this customer later, whichever wallet pays.
    See what create answers
    { "subscription": { "id": "cmg1x0abc0001", "status": "pending" }, "transaction": "gAIBBg0Xq3ZkT9yLmR4vP7wJ8sN2aHcVbE5uYtKfDgQxWz1oMiCj6rA0lSnU...", "last_valid_block_height": "301245678", "transaction_expires_at": "2026-01-01T00:00:33.000Z", "terms": { "message": "Mesub: the terms of the subscription...\nAmount: 9.99 USDC every 30 days\n...", "expires_at": "2026-01-01T00:05:00.000Z" }, "costs": { "rent": { "subscription": "1969680", "authority": "1628640", "total": "3598320" }, "fee": { "signatures": 2, "per_signature": "5000", "priority": "0", "total": "10000" }, "total": "3608320" } }
    transaction_expires_at estimates when last_valid_block_height passes, about half a minute after the answer: use it for a countdown, or to ask for a fresh transaction before it runs out. The height is the truth: a transaction the chain refuses as expired is rebuilt, whatever this date said.
    costs is in lamports, as strings. rent is a deposit the customer gets back when the subscription is closed; fee is spent. rent.authority is null when the customer already has an authority for that token.
    The customer's wallet pays both, in SOL: it is the fee payer of the transaction. It must also keep a small reserve, the rent of an empty account, which Solana asks of every wallet left with SOL. A wallet that holds less than costs.total plus that reserve is refused with insufficient_sol, at create and at transaction, before anything is signed. Its message says what the wallet holds and needs. Ask the customer to add SOL, then call transaction again.
  2. Sign the terms, in the browser
    terms.message is plain text, one fact per line: the amount, the period, who is paid, how to cancel, and when the plan ends if it does. Show it and the costs to the customer, then ask the wallet to sign it as a message.
    import bs58 from 'bs58'; // wallet: the connected wallet, for instance useWallet() of // @solana/wallet-adapter-react. const signature = await wallet.signMessage( new TextEncoder().encode(terms.message), ); const terms_signature = bs58.encode(signature);
    The terms run out at terms.expires_at, five minutes after Mesub handed them out. Keep terms.message beside the signature, then ask your server for the transaction.
  3. Ask for the transaction, on your server
    const { transaction, terms } = await mesub.subscriptions.transaction(subscriptionId);
    It answers what create answers, with a transaction whose 150 blocks start now. Send transaction and terms to your page.
    Compare terms.message with the message the wallet signed. The same text means the same terms: the signature you hold is still good. Another text means new terms: have the wallet sign that message before the transaction. The terms come back unchanged while they have more than two minutes left.
  4. Sign the transaction, in the browser
    Ask the wallet to sign the transaction at once, without sending it.
    import { VersionedTransaction } from '@solana/web3.js'; const bytes = Uint8Array.from(atob(transaction), (c) => c.charCodeAt(0)); const signed = await wallet.signTransaction( VersionedTransaction.deserialize(bytes), ); const signedTransaction = btoa(String.fromCharCode(...signed.serialize()));
    Send terms_signature and signedTransaction back to your server. If you asked for the transaction more than once, sign the one of the last call.
  5. Submit, on your server
    const { subscription, reason, reason_code } = await mesub.subscriptions.submit( subscriptionId, { transaction: signedTransaction, terms_signature }, ); if (subscription.access) { // it landed: grant the plan } else { // it did not land: reason_code says why, reason says it to a person }
    Branch on reason_code (transaction_expired, nothing_landed, ...), never on reason: the sentence may be reworded, the code never is. Mesub adds codes, so keep a default branch.
    Mesub checks both signatures, co-signs, sends the transaction and waits for the chain. transaction_expired can come back two ways, so handle both:
    • Thrown, as a 409 MesubError with apiCode transaction_expired: the transaction was signed too late, and nothing was sent.
    • Answered, as reason_code transaction_expired beside the subscription: it was sent, and expired before it landed.
    Either way, go back to the third step for a new transaction.

Show the plan and its token

A pricing page of your own reads the plan on your server and shows what the customer will pay, and in which token.
const plan = await mesub.plans.retrieve('pro'); // or mesub.plans.list()
FieldWhat it says
amount_displayThe price per period, as a person counts it: 9.99
amount_usdThat price in US dollars, or null when it has no price
symbolSet only for a token Mesub vouches for, null otherwise
token.symbolThe token's symbol, as Mesub found it, or null
token.nameThe token's name, or null
token.logo_urlThe token's logo, or null
token.verifiedWhether the token is one a customer can take at its word
mintThe token's address, which is the token
Mesub fills token itself, from the plan's exact mint: nothing in it is typed by you or sent by the browser. verified is true for the tokens Mesub vouches for, USDC, USDT and PYUSD, and for one Jupiter marks as verified.
A plan's token can be an SPL Token or a Token-2022 token. A Token-2022 token can take a fee on every transfer, for its issuer. The customer always pays amount; your wallet receives it less that fee, and amount_received on the attempt says how much. Show the customer amount: it is what leaves their wallet.
Show an unverified token as unverified, and keep its mint in view. A symbol is free text on chain: anyone can create a token and call it USDC, and a fake under a known name is a common scam. Mesub never answers an unverified token under the symbol or the name of a stablecoin it vouches for: that field is null. Any other symbol comes as the token wrote it, so the page says which kind it is:
import type { Plan } from '@mesub/node'; function tokenLabel(plan: Plan): string { const { symbol, verified } = plan.token; const mint = `${plan.mint.slice(0, 4)}...${plan.mint.slice(-4)}`; if (!symbol) return mint; return verified ? symbol : `${symbol}, unverified (${mint})`; }
amount_usd is a decimal string, "9.99". On a plan it is an estimate: the plan's amount at the latest price Mesub knows for the token, so it moves with the market for anything but a stablecoin. Show it as about that much, never as the price: the customer is charged amount of the token, whatever it is worth that day. It is null when Mesub has no price. What each charge was worth is on the attempt.
See these fields on a plan in another token
{ "slug": "supporter", "name": "Supporter", "amount": "3000000000", "amount_display": "30000", "amount_usd": "0.45", "decimals": 5, "symbol": null, "mint": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", "token": { "symbol": "Bonk", "name": "Bonk", "logo_url": "https://arweave.net/hQiPZOsRZXGXBJd_82PhVdlM...", "verified": true } }

A plan with an end date

Nobody has access past a plan's end date, and the last period is charged in full even when the plan ends inside it. Mesub writes both in the terms, so the customer reads them before signing. These are the lines of terms.message that say what is charged, for a plan of 20 USDC every 30 days.
With no end date:
Amount: 20 USDC every 30 days First charge: 20 USDC now, in the transaction you sign next Then: 20 USDC every 30 days, until you cancel
With an end date, two more lines:
Amount: 20 USDC every 30 days First charge: 20 USDC now, in the transaction you sign next Then: 20 USDC every 30 days, until you cancel, or the plan ends on 2027-01-01 Last charge: 20 USDC, in full, for the last period that starts before the plan ends Access: stops when the plan ends on 2027-01-01, even if the last period paid for is not over
When the plan ends before a second charge can be made, the first one is the only one:
Amount: 20 USDC, a single charge Single charge: 20 USDC now, in full, in the transaction you sign next Access: until the plan ends on 2026-10-02, even if the period paid for is not over No further charge: the plan ends on 2026-10-02
The date is the plan's end, as a day in UTC. Show the message as it is: a page that words these lines differently tells the customer something other than what they sign.
Terms that name a second charge are refused at submit with terms_changed if the plan's end has come too close for it meanwhile. Create again: the new terms say it is a single charge. Once the plan has ended, create is refused with plan_ended.

How long submit takes

submit waits for the chain, so it is slow by design: up to 90 seconds for one send, and up to 130 seconds in all with its own retries. That is longer than many serverless functions allow. Lower it there:
await mesub.subscriptions.submit(id, body, { timeout: 25_000, budget: 40_000 });
A retry sends the same request again. Mesub recognises what it already co-signed and answers from the chain, so a customer is never charged twice.
A 503 unavailable with a Retry-After means too many submits are waiting on your project, and Mesub signed and sent nothing. submit sends the same request again after the wait, and past its retries throws it, retryable, with retryAfter.

When the outcome is unknown

If no send got an answer, submit throws a MesubSubmitError. The wallet may still have paid, so do not create a new subscription right away:
import { MesubSubmitError } from '@mesub/node'; try { await mesub.subscriptions.submit(id, body); } catch (error) { if (error instanceof MesubSubmitError) { // error.subscription is the subscription read back, or null const now = await mesub.subscriptions.retrieve(id); // active: it landed. pending: it may still land, read again later. } throw error; }
A pending subscription is settled by Mesub on its own within the hour: it turns active if the transaction landed, expired if it did not. Create a new one only once it is expired.

Refusals

Each one is thrown as a MesubError. apiCode says which:
OnapiCodeWhat to do
createalready_subscribedThis wallet already holds this plan
createinsufficient_balanceThe wallet cannot pay the first period
createinsufficient_solThe wallet lacks SOL for the rent and fee
createplan_not_foundFix the slug
createplan_endedThe plan is past its end date
submitterms_expiredCreate again and sign the fresh terms
submitterms_changedThe plan changed since. Create again
submittransaction_expiredAsk for a new transaction and sign it
submitsubscription_not_foundThe id is not one of your project's
subscriptions.transaction is refused like create when the plan or the wallet no longer allows the subscription, and with not_awaiting_signature when the subscription is no longer pending. Create again if it expired.
Calling create again for the same plan, wallet and customer, while nothing has landed, answers the same subscription with a fresh transaction. The customer is the external_id you gave, or the email when you gave no id. Another customer naming the same wallet gets a subscription of its own: only the one the wallet signs for lands, and the others on that wallet then read expired.

Read a subscription back

await mesub.subscriptions.retrieve(id); await mesub.subscriptions.list({ external_id: user.id }); // { data, has_more } for await (const sub of mesub.subscriptions.listAll({ external_id: user.id })) { // every page }
The raw routes are in the reference: create, transaction, submit, retrieve and list.
list names the customer the way hasAccess does: by external_id, wallet or email, exactly one of them. No call lists every subscription of the project: that list is in the dashboard, under Subscriptions, and in the MCP server. The fields and statuses are the ones described in Lifecycle.