hasAccess is enough to gate a route. Webhooks are for reacting when something
changes: a renewal, a missed payment, a cancellation.1MESUB_WEBHOOK_SECRET=whsec_...
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.1234567891011121314151617181920212223242526272829303132333435import 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(); }, );
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.webhook-id, webhook-timestamp and webhook-signature.webhook-signature. Remove the endpoint when you are
done: it would receive your real events too.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.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.| Event | Sent when |
subscription.created | The first payment landed |
subscription.renewal_upcoming | A renewal is near, and whether it can pay |
subscription.renewed | A period was paid |
subscription.payment_failed | A charge missed |
subscription.stopped | Mesub stopped charging after missed payments |
subscription.cancelled | The customer cancelled; access runs to the period's end |
subscription.resumed | A cancellation was undone |
subscription.ended | It is over; data.end_reason says why |
subscription.expired | A checkout was started and never signed |
subscription.paused | A seat was parked over the tier's limit |
subscription.unpaused | A parked seat is back |
subscription.access_granted | Access started, and was off just before |
subscription.access_revoked | Access ended, and was on just before |
test | You sent one from the dashboard |
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.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.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748{ "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" } }
123456789101112131415161718192021222324252627282930313233343536373839404142434445{ "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.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.sequence of the last access event you applied, and apply one only when
its sequence is higher:123456789async 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.subscription.created carries detail.first_payment, what the subscriber's
own transaction paid:12345678{ "first_payment": { "amount": "9990000", "amount_received": "9990000", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "signature": "5wHu1qwD4kLDLoREEPBpKvGvhbxWFxFFfr5nQq7dxAsRRDXvHRqpNvfsCvC1qzjWQhqmPtBb8LnZjKmvpehJqSWk" } }
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.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".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.subscription.unpaused, data.paused
false. Mesub charges it again.access_revoked or
access_granted, with a higher sequence: see
parked seats.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:1234567{ "can_pay": false, "renewal_issue": "balance", "amount": "9990000", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "due_at": "2026-03-02T00:00:00.000Z" }
| Field | What it holds |
can_pay | Whether the charge would go through as the wallet stood when it was read |
renewal_issue | balance, authority, or null when it can pay |
amount | What will be charged, in the token's smallest unit |
mint | The token |
due_at | When 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.subscription.renewed or subscription.payment_failed.| Event | What changes |
subscription.renewal_upcoming | Not sent in the last period: the plan ends before the next charge |
subscription.payment_failed | Can carry next_retry_at: null and retries_left: 0 in detail |
subscription.ended | Sent at the plan's end, with data.end_reason at plan_ended |
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.event.id as the first try.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.