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

Check a customer's access to a plan

Your own login says who the user is. Mesub says whether they have access to a plan. Ask before serving anything paid.

Ask the question

hasAccess answers true or false:
import { Mesub } from '@mesub/node'; const mesub = new Mesub(); // reads MESUB_API_KEY if (!(await mesub.hasAccess({ external_id: user.id }, 'pro'))) { // not subscribed, or no longer }
A guard does the same in one line and answers the refusal itself:
import { requirePlan } from '@mesub/node/express'; app.get( '/api/analytics', yourLogin, requirePlan('pro', { customer: (req) => req.user ? { external_id: req.user.id } : null, }), (req, res) => { const { customer, wallet, answer } = res.locals.mesub; res.json(buildAnalytics(customer)); }, );
customer is required. It must come from a session you verified, never from the request itself: a guard that reads req.query.wallet lets in anyone who types a subscriber's address.
A guard also takes a list, requirePlan(['pro', 'team'], ...): the first plan that grants access lets the request through. Three plans at most.
Without the SDK, it is one authenticated GET:
curl "https://api.mesub.io/v1/access?external_id=user_42&plan=pro" \ -H "Authorization: Bearer $MESUB_API_KEY"
The reference has every parameter and every field of the answer, and lets you send the request from the page: GET /v1/access.

Who to ask about

Name the customer by exactly one of these:
ByUse it whenGood to know
external_idYour app has its own loginFollows the person whichever wallet pays
walletThe connected wallet is the customerA string alone works too
emailA support lookupThe address they typed when subscribing, never verified by Mesub
external_id is the id you passed when the subscription was created. The widget routes pass it for you, from what your customer function returns.

Read the answer

hasAccess keeps only access. To show a customer where they stand, access returns the whole answer:
const answer = await mesub.access({ external_id: user.id }, 'pro');
See a full answer
{ "wallet": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", "plan": "pro", "subscription_id": "cmg1x0abc0001", "access": true, "status": "active", "paused": false, "end_reason": null, "late_reason": null, "stop_reason": null, "payment_status": "paid", "blocked_since": null, "blocked_reason": null, "subscribed_since": "2026-01-01T00:00:05.000Z", "first_subscribed_at": "2026-01-01T00:00:05.000Z", "current_period_end": "2026-01-31T00:00:00.000Z", "cancelled_at": null, "access_until": "2026-01-31T00:00:00.000Z", "closable": false, "closable_from": null, "next_charge_at": "2026-01-31T00:00:00.000Z", "next_retry_at": null, "retry_deadline": null, "revalidate_after": 300 }
FieldWhat it says
accessThe only field a guard needs. False from the plan's end date on
statusWhere the subscription stands: none, pending, active, unpaid, cancelled, stopped, ended, failed, superseded
payment_statuspaid, late or none
subscription_idThe subscription the answer is about, to cancel, resume, close or retry it without listing first. Null with status none
late_reasonWhy an unpaid one is late, or why a stopped one stopped paying: see late payments. Null otherwise
stop_reasonWhy a stopped one stopped: see late payments. Null otherwise
blocked_sinceSince when its charges are held for a reason that is not the customer's. Null otherwise
blocked_reasonWhy they are held: see blocked charges. Null otherwise
access_untilWhen access ends unless a charge renews it, never later than the plan's end date. Null while access is false
next_charge_atThe next charge. Null when a retry is pending instead, or when the plan ends before it
next_retry_atThe next retry of a missed charge. Null when the plan ends before it
closableWhether the customer can close it now and get its deposit back, as Mesub last read the chain
closable_fromWhen it can be closed, if not now. Null when closable is true, or when no date is known yet
walletThe wallet that pays. Null when the customer has nothing on that plan
revalidate_afterSeconds this answer stays true. The SDK caches it for that long
Read access to decide, never status. On a plan with an end date, nobody has access past that date, and for a few minutes after it status can still read active or unpaid with access: false.
When charges are blocked, the status does not change and the answer says since when, why, and when access ends. Here Mesub's puller was taken off a monthly plan that keeps the default retries:
{ "access": true, "status": "active", "payment_status": "paid", "blocked_since": "2026-01-31T00:02:00.000Z", "blocked_reason": "puller_not_allowed", "current_period_end": "2026-01-31T00:00:00.000Z", "access_until": "2026-02-03T00:06:00.000Z" }
Mesub may add a status later, so keep a default branch. Asked with { attempts: true }, the answer also carries the last charges. Read an attempt explains their outcome and reason.
Without a plan, accessList answers every plan the customer has anything on:
const { plans } = await mesub.accessList({ external_id: user.id });

What a guard answers

StatusWhenBody
401customer returned nothing: nobody is signed in{ access: false, reason: 'unauthenticated' }
402Mesub said this customer has no access{ access: false, reason: 'no_access', status }
503Mesub could not answer about a customer it never saw{ access: false, reason: 'unavailable' }
A 402 only ever means Mesub said no. To answer differently, a redirect to your pricing page for instance, pass onDenied.

Errors

A customer who is not subscribed is not an error: GET /v1/access answers 200 with access: false. An error means the question itself was wrong, or could not be answered.
Statuscode in the bodyWhen
400invalid_requestNot exactly one of wallet, external_id, email, or one malformed
400unsupported_versionMesub-Version names a version Mesub does not serve
401missing_api_keyNo API key was sent
401invalid_api_keyAn API key Mesub never issued, or one rotated since
404plan_not_foundNo plan of yours under that slug
429rate_limitedMore than 1,000 access calls a minute on one API key
5xxinternal_error (500), unavailable (503)Mesub failed, or cannot answer right now
With no API key at all nothing is sent: new Mesub() throws a TypeError when the server starts.
Every error body has the same shape. request_id is also in the Mesub-Request-Id header: quote it to Mesub support.
{ "statusCode": 404, "message": "No plan of yours is named pro-max.", "error": "Not Found", "code": "plan_not_found", "retryable": false, "request_id": "req_4f8KzQ2nVb7XcR1mT9wLp3sD" }
With the SDK, each one is thrown as a MesubError. Its code is the SDK's own, one per kind of failure, and apiCode is the body's code above:
Body codeerror.code
invalid_request, unsupported_versioninvalid_request
missing_api_key, invalid_api_keyunauthorized
plan_not_foundplan_not_found
rate_limitedrate_limited
internal_error, unavailableunavailable, also a timeout or a network error
Branch on code:
import { MesubError } from '@mesub/node'; try { await mesub.access({ external_id: user.id }, 'pro'); } catch (error) { if (error instanceof MesubError && error.code === 'plan_not_found') { // the slug is wrong: fix the integration } throw error; }
A MesubError also carries status, retryable, and retryAfter, the time Mesub asked to wait, in milliseconds. hasAccess and the guards turn only an outage into an answer, as described below. A bad key or an unknown plan is always thrown.

When Mesub does not answer

The SDK caches each answer for its revalidate_after. If Mesub then fails, is too slow, or answers that the rate limit is reached:
  • hasAccess and the guards serve the last answer they knew for that customer and plan, for up to 24 hours. A paying customer is not locked out by an outage.
  • For a customer never seen before, hasAccess answers false and a guard answers 503 with the Retry-After Mesub sent, or 30 seconds when it sent none.
  • access throws instead, since it is meant for a screen.
A guard never waits on Mesub more than 2 seconds for this check (guardTimeout).