1234567const { 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 });
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.external_id when your app has a login: it is how
hasAccess finds this customer later, whichever wallet pays.123456789101112131415161718192021222324{ "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.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.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.12345678import 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);
terms.expires_at, five minutes after Mesub handed them
out. Keep terms.message beside the signature, then ask your server for the
transaction.12const { transaction, terms } = await mesub.subscriptions.transaction(subscriptionId);
create answers, with a transaction whose 150 blocks start
now. Send transaction and terms to your page.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.1234567import { 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()));
terms_signature and signedTransaction back to your server. If you
asked for the transaction more than once, sign the one of the last call.12345678910const { 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 }
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.transaction_expired can come back two ways, so handle both:MesubError with apiCode transaction_expired: the
transaction was signed too late, and nothing was sent.reason_code transaction_expired beside the subscription:
it was sent, and expired before it landed.1const plan = await mesub.plans.retrieve('pro'); // or mesub.plans.list()
| Field | What it says |
amount_display | The price per period, as a person counts it: 9.99 |
amount_usd | That price in US dollars, or null when it has no price |
symbol | Set only for a token Mesub vouches for, null otherwise |
token.symbol | The token's symbol, as Mesub found it, or null |
token.name | The token's name, or null |
token.logo_url | The token's logo, or null |
token.verified | Whether the token is one a customer can take at its word |
mint | The token's address, which is the token |
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.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.123456789import 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.12345678910111213141516{ "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 } }
terms.message
that say what is charged, for a plan of 20 USDC every 30 days.123Amount: 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
12345Amount: 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
1234Amount: 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
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.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:1await mesub.subscriptions.submit(id, body, { timeout: 25_000, budget: 40_000 });
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.submit throws a MesubSubmitError. The wallet may
still have paid, so do not create a new subscription right away:123456789101112import { 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; }
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.MesubError. apiCode says which:| On | apiCode | What to do |
create | already_subscribed | This wallet already holds this plan |
create | insufficient_balance | The wallet cannot pay the first period |
create | insufficient_sol | The wallet lacks SOL for the rent and fee |
create | plan_not_found | Fix the slug |
create | plan_ended | The plan is past its end date |
submit | terms_expired | Create again and sign the fresh terms |
submit | terms_changed | The plan changed since. Create again |
submit | transaction_expired | Ask for a new transaction and sign it |
submit | subscription_not_found | The 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.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.123456await 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 }
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.