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

Handle Mesub webhooks on your server

hasAccess is enough to gate a route. Webhooks are for reacting when something changes: a renewal, a missed payment, a cancellation.

Set up your endpoint

In the dashboard, open Developers, then Webhooks, add your URL and pick its events. Put the endpoint's signing secret in your server's environment:
MESUB_WEBHOOK_SECRET=whsec_...
An endpoint must be a public HTTPS URL. A project can have up to 16.

Rotate the secret

Regenerate the secret on the endpoint's page in the dashboard, with an overlap of up to 24 hours. The old secret keeps signing beside the new one until the overlap ends: each delivery in that window carries two signatures in webhook-signature, the new secret's first, and verify accepts it when any one matches. So nothing changes on your side until you deploy the new secret as MESUB_WEBHOOK_SECRET, and nothing fails if you deploy it before the overlap ends.
With no overlap, the old secret stops at once: every delivery fails verification until the new one is deployed. Use no overlap when the old secret leaked. Rotating again during an overlap ends the oldest secret.

Create a handler

Mesub signs each delivery the Standard Webhooks way. The signature covers the exact bytes sent, so verify the raw body, never one a JSON parser read and wrote again.
import express from 'express'; import { Mesub, MesubError } from '@mesub/node'; const mesub = new Mesub(); // reads MESUB_API_KEY and MESUB_WEBHOOK_SECRET // express.raw on this route, before any express.json() reaches it. app.post( '/webhooks/mesub', express.raw({ type: 'application/json' }), async (req, res, next) => { let event; try { event = await mesub.webhooks.verify(req.body, req.headers); } catch (error) { // Not Mesub's, or not now: refuse it. if (error instanceof MesubError && error.code === 'invalid_webhook') { return res.status(400).end(); } // Signed by Mesub, but this release cannot read it: answer 500. return next(error); // Express 4 does not catch a throw in an async handler } if (await alreadyHandled(event.id)) return res.status(200).end(); switch (event.type) { case 'subscription.renewed': // event.data is the subscription, event.detail what was paid break; case 'subscription.payment_failed': // event.detail: reason, retries_left, next_retry_at break; } res.status(200).end(); }, );
Answer 400 only for invalid_webhook, a delivery that fails verification. Any other MesubError is a body Mesub signed that your release of @mesub/node cannot read: log it and answer a 5xx. Mesub then retries the same body for three days, so the event still gets through once you update; a 2xx would lose it.
Answer with a 2xx within 10 seconds, before any slow work of your own. Anything else, a redirect included, counts as a failure.

Test your handler

See a delivery before writing any code

webhook.site gives you a public URL that shows every request it receives. It is the quickest way to see what Mesub sends.
  1. Open webhook.site and copy Your unique URL.
  2. In the dashboard, under Developers then Webhooks, add that URL as an endpoint.
  3. On that endpoint, click Send test.
  4. Back on webhook.site, the request appears with its JSON body and three headers: webhook-id, webhook-timestamp and webhook-signature.
The signing secret is not in the request. It stays in the dashboard, and your handler uses it to check webhook-signature. Remove the endpoint when you are done: it would receive your real events too.

Send a test event to your own handler

In the dashboard, on your endpoint, pick the event to simulate and click Send test. With no event picked, the plain test event is sent. A test delivery is signed like a real one and carries "test": true at the top of its body, with a made-up subscription whose id is sub_test, so your handler can recognise and ignore it.
Every test delivery has the same event id, evt_test. A handler that skips the ids it has seen, as the one above does, skips every test after the first. Check event.test before you look up the id, or don't store the id of a test.
The endpoint must be enabled, and one test is pending at a time: wait for it to be delivered before sending the next.
A handler running on your machine needs a public URL. A tunnel such as ngrok gives it one.

Events

EventSent when
subscription.createdThe first payment landed
subscription.renewal_upcomingA renewal is near, and whether it can pay
subscription.renewedA period was paid
subscription.payment_failedA charge missed
subscription.stoppedMesub stopped charging after missed payments
subscription.cancelledThe customer cancelled; access runs to the period's end
subscription.resumedA cancellation was undone
subscription.endedIt is over; data.end_reason says why
subscription.expiredA checkout was started and never signed
subscription.pausedA seat was parked over the tier's limit
subscription.unpausedA parked seat is back
subscription.access_grantedAccess started, and was off just before
subscription.access_revokedAccess ended, and was on just before
testYou sent one from the dashboard
Mesub may add a type later: keep a default branch. The reference has one page per event, with its payload.

The event object

Every event has the same shape: its id, its type, when Mesub wrote it (created_at), its sequence among the subscription's events, data, the subscription as subscriptions.retrieve answers it, and detail, which is the event's own. event.id is the same in every delivery of the event: apply each event once by it. It is an opaque string, sometimes a UUID: store it as it comes, and don't parse it. The SDK gives the delivery's own id, the webhook-id header, as event.delivery_id.
Deliveries can arrive in any order, and more than once. Two events of one change, such as subscription.created and subscription.access_granted, can reach you a millisecond apart, the later one first. Apply a subscription's events in the order of sequence, and skip one lower than the last you applied: the reference says how.
See a full subscription.payment_failed event
{ "id": "cmg1x0evt0001", "object": "event", "type": "subscription.payment_failed", "created_at": "2026-01-31T00:01:00.000Z", "network": "mainnet-beta", "api_version": "2026-10-02", "sequence": 3, "data": { "id": "cmg1x0abc0001", "status": "unpaid", "paused": false, "end_reason": null, "late_reason": "insufficient_balance", "stop_reason": null, "access": true, "payment_status": "late", "blocked_since": null, "blocked_reason": null, "plan": "pro", "wallet": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", "email": "[email protected]", "external_id": "user_123", "current_period_start": "2026-01-01T00:00:00.000Z", "current_period_end": "2026-01-31T00:00:00.000Z", "next_charge_at": null, "next_retry_at": "2026-02-01T00:01:00.000Z", "retry_deadline": null, "next_retry_number": 1, "retries_allowed": 3, "access_until": "2026-02-03T00:05:00.000Z", "closable": false, "closable_from": null, "created_at": "2026-01-01T00:00:00.000Z", "confirmed_at": "2026-01-01T00:00:05.000Z" }, "detail": { "reason": "insufficient_balance", "amount": "9990000", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "period_start": "2026-01-31T00:00:00.000Z", "period_end": "2026-03-02T00:00:00.000Z", "next_retry_at": "2026-02-01T00:01:00.000Z", "retry_deadline": null, "retries_left": 3, "retry_mode": "scheduled" } }
See a full subscription.renewed event
{ "id": "cmg1x0evt0001", "object": "event", "type": "subscription.renewed", "created_at": "2026-01-31T00:01:00.000Z", "network": "mainnet-beta", "api_version": "2026-10-02", "sequence": 3, "data": { "id": "cmg1x0abc0001", "status": "active", "paused": false, "end_reason": null, "late_reason": null, "stop_reason": null, "access": true, "payment_status": "paid", "blocked_since": null, "blocked_reason": null, "plan": "pro", "wallet": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", "email": "[email protected]", "external_id": "user_123", "current_period_start": "2026-01-31T00:00:00.000Z", "current_period_end": "2026-03-02T00:00:00.000Z", "next_charge_at": "2026-03-02T00:00:00.000Z", "next_retry_at": null, "retry_deadline": null, "next_retry_number": null, "retries_allowed": null, "access_until": "2026-03-02T00:00:00.000Z", "closable": false, "closable_from": null, "created_at": "2026-01-01T00:00:00.000Z", "confirmed_at": "2026-01-01T00:00:05.000Z" }, "detail": { "amount": "9990000", "amount_received": "9990000", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "period_start": "2026-01-31T00:00:00.000Z", "period_end": "2026-03-02T00:00:00.000Z", "signature": "5wHu1qwD4kLDLoREEPBpKvGvhbxWFxFFfr5nQq7dxAsRRDXvHRqpNvfsCvC1qzjWQhqmPtBb8LnZjKmvpehJqSWk" } }
data is the subscription as it stood right after the change that raised the event, however late the delivery. One exception: a subscription.renewed written before 20 October 2026 can show the subscription as it stood before that payment, with the previous period's access_until. /v1/events keeps such events for 30 days. Take the period paid from detail.period_start and detail.period_end, and read access with hasAccess rather than from that data. created_at is when Mesub wrote the event. An access event, or a cancellation that reached its end, can be written a few seconds after the fact: detail.changed_at is the moment itself.

Drive access from the access events

subscription.access_granted and subscription.access_revoked are sent when a subscription's access actually changes, and only then: every change sends one, a date passing included, and a renewal paid on time sends neither.
They are the two to drive a customer's access from. Store, per subscription, the sequence of the last access event you applied, and apply one only when its sequence is higher:
async function onAccessEvent(event) { if (event.sequence === undefined) { // Cannot be placed among the others: read the state now. return refreshAccess(event.data.id); } const last = await lastAccessSequence(event.data.id); if (last !== null && event.sequence <= last) return; // older than what you hold await setAccess(event.data.id, event.data.access, event.sequence); }
data.access is the answer, detail.changed_at the moment it changed. detail.blocked_reason says whether charges were held on the plan's side or Mesub's when access changed, rather than missed by the customer: see blocked charges. On access_revoked, detail.access_until is the date access ended, null when it ended with no date of its own, such as a stop. Both may be absent on older events.

The first payment

subscription.created carries detail.first_payment, what the subscriber's own transaction paid:
{ "first_payment": { "amount": "9990000", "amount_received": "9990000", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "signature": "5wHu1qwD4kLDLoREEPBpKvGvhbxWFxFFfr5nQq7dxAsRRDXvHRqpNvfsCvC1qzjWQhqmPtBb8LnZjKmvpehJqSWk" } }
It is null when the subscription landed in a period paid only in part, which the next charge collects, and absent on events written before Mesub added it.
amount is what the subscriber paid. amount_received is what your wallet got: less on a Token-2022 token with a transfer fee, the same on any other. It is null when Mesub could not tell, and absent on events written before it was added. subscription.renewed carries both too.

Cancellations that end, parked seats

Three transitions get their own event, beside the access events:
  • A cancellation reaches its expiry. subscription.access_revoked is sent at the expiry itself. subscription.ended, with end_reason: "cancelled", follows once Mesub has read the chain, usually within half an hour, with detail.changed_at at the expiry. Closing the subscription later sends subscription.ended again, with end_reason: "closed".
  • A seat is parked over the project's tier limit: subscription.paused. It is not a pause the customer asked for. The status stays as it was, data.paused is true, and access runs to the end of the period paid for.
  • A parked seat is brought back: subscription.unpaused, data.paused false. Mesub charges it again.
A parked seat's access change still comes as access_revoked or access_granted, with a higher sequence: see parked seats.

Before a renewal

subscription.renewal_upcoming is sent once per period, when a subscription enters the last quarter of it, and three days before the charge at most: three days ahead on a monthly plan, six hours on a daily one. Mesub reads the customer's wallet at that moment, and detail says what the coming charge would meet:
{ "can_pay": false, "renewal_issue": "balance", "amount": "9990000", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "due_at": "2026-03-02T00:00:00.000Z" }
FieldWhat it holds
can_payWhether the charge would go through as the wallet stood when it was read
renewal_issuebalance, authority, or null when it can pay
amountWhat will be charged, in the token's smallest unit
mintThe token
due_atWhen the charge is due
balance means the wallet holds less than the amount, or has no account for the token: the customer adds funds. authority means Mesub can no longer move the tokens: the approval was replaced or is too small, or the account is frozen. Subscribing again gives a new approval.
It is sent whether the charge can pay or not, so it also works as a plain reminder before a renewal. It is a reading, not a promise: a wallet topped up or emptied afterwards changes the outcome, and the charge itself answers with subscription.renewed or subscription.payment_failed.
An endpoint receives it only if that event is selected for it in the dashboard: an endpoint created before it existed does not get it by itself. Mesub also mails the customer when the charge cannot pay and the subscription carries an email.
It is never sent for a charge that cannot happen. A subscription in the last period of a plan with an end date has no renewal: no event, and no mail.

At a plan's end

A plan can have an end date, and three events read differently around it.
EventWhat changes
subscription.renewal_upcomingNot sent in the last period: the plan ends before the next charge
subscription.payment_failedCan carry next_retry_at: null and retries_left: 0 in detail
subscription.endedSent at the plan's end, with data.end_reason at plan_ended
A charge that misses when the plan ends before the next retry has no retry to announce: next_retry_at is null, retries_left is 0 and retry_mode is null. The subscription stays unpaid until the plan's end, then ends. On Free, retry_deadline is the plan's end when that comes before the deadline.
subscription.ended arrives within a few minutes of the plan's end, for a subscription that was still active or unpaid. Access has stopped at the end itself: an event delivered in between can carry status: "active" with access: false.

Delivery behavior

Retries

If your endpoint does not answer with a 2xx, Mesub tries the same event again: after 1 minute, 5 minutes, 30 minutes, 2 hours, then every 12 hours, for about three days in all. Each retry carries the same event.id as the first try.
An endpoint that fails for three days with no success in between is disabled. Fix it, then enable it again in the dashboard.

Duplicates

The same event can arrive more than once. Store the ids you handled and drop a repeat.

Order

Order is not guaranteed: a retry can land after a later event. Each event carries sequence, its place among the subscription's events (1, 2, 3...): apply a subscription's events in that order and skip one lower than the last you applied. An event without sequence cannot be placed: ask hasAccess, or retrieve the subscription, rather than trust its data.