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

Webhook events of the Mesub API

Mesub posts an event to your endpoints when a subscription changes. This section is the reference of what is sent. To set an endpoint up and write its handler, read the Webhooks guide.

The request

Each delivery is a POST to your endpoint, with a JSON body and three headers:
HeaderWhat it holds
webhook-idThe delivery's id: one per event and per endpoint, the same on every retry
webhook-timestampWhen this attempt was sent, in Unix seconds
webhook-signaturev1, then the signature of this attempt, in base64. Sometimes two, see below
Deliveries are signed the Standard Webhooks way: an HMAC-SHA256 of webhook-id.webhook-timestamp.body, keyed with the endpoint's signing secret, the part after whsec_ decoded from base64. The signature covers the exact bytes sent, so verify the raw body.
When you regenerate a secret with an overlap, the old secret keeps signing beside the new one until the overlap ends. Every attempt in that window carries two signatures, space separated, the new secret's first:
webhook-signature: v1,<signed with the new secret> v1,<signed with the old secret>
Check each v1, signature, and accept the delivery when any one matches. Then check webhook-timestamp: refuse a delivery more than 5 minutes from your clock, either way, as a replay. Each retry is signed again with a fresh timestamp, so a late retry still passes. mesub.webhooks.verify does all of this for you, with the same 5 minutes (tolerance).

Two ids

IdNamesDeduplicate on it to
id, in the bodyThe eventApply each event once, whichever endpoint got it
webhook-id, headerThe deliverySkip a retry of a delivery you already handled
One event sent to two endpoints of a project is two deliveries: the same id, two webhook-ids.

The event object

Every event has the same shape:
{ "id": "cmg1x0evt0001", "object": "event", "type": "subscription.renewed", "created_at": "2026-01-31T00:01:00.000Z", "network": "mainnet-beta", "api_version": "2026-10-02", "sequence": 4, "data": { "id": "cmg1x0abc0001", "status": "active" }, "detail": {} }
FieldWhat it says
idThe event's id, the same in every delivery of it. An opaque string, sometimes a UUID
objectAlways event
typeThe event's name
created_atWhen Mesub wrote the event. On an access event, or a cancellation's end, detail.changed_at is the moment
networkThe Solana network: devnet or mainnet-beta
api_versionThe version data and detail are written in, as Mesub-Version names it
sequenceIts place among this subscription's events: 1, 2, 3... Absent on some events
dataThe subscription, as Retrieve a subscription answers it, shortened here
detailThe event's own fields. Empty on most events
testtrue on a delivery sent by hand from the dashboard. Absent otherwise
data is the subscription as it stood right after the change that raised the event, however late the delivery. Events raised by the same change share it: a last retry that fails raises subscription.payment_failed and subscription.stopped, both showing status: stopped. detail says what each event is about.
A subscription.renewed written before 20 October 2026 can break this rule: its data may show the subscription before that payment, with the previous period's access_until. /v1/events serves such events for 30 days. Take the period paid from detail.period_start and detail.period_end, and read access with GET /v1/access, not from that data.
Deliveries can arrive in any order. Apply a subscription's events in the order of sequence, and skip one lower than the last you applied. An event without sequence cannot be placed: read the subscription's state now with Retrieve a subscription rather than apply its data.
Bodies written before this shape keep the one they were sent in, on a retry or a resend too: { type, created_at, data }, with detail inside data, no id, and data as it was at the first attempt.

Events

EventSent when
subscription.createdThe first payment landed; detail.first_payment
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.
Missed some deliveries? GET /v1/events serves the same bodies over the last 30 days, newest first: catch up there. An event can appear there below one you have already read, so don't stop at the first id you know: the list says how far to page.
subscription.created carries detail.first_payment: the amount, mint and signature of the first transfer. 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.

Ends, parked seats

TransitionEvent
A cancellation reaches its expirysubscription.ended, end_reason: "cancelled", usually within 30 min
The ended subscription is closedsubscription.ended again, end_reason: "closed"
A seat is parked over the limitsubscription.paused, data.paused true, status unchanged
A parked seat is brought backsubscription.unpaused, data.paused false
At a cancellation's expiry, subscription.access_revoked is sent at the expiry itself and comes first; subscription.ended follows once Mesub reads the chain, with detail.changed_at at the expiry. A parked seat is not a pause the customer asked for: it keeps access to the end of the period it paid for, and its access_revoked or access_granted follows with a higher sequence.

Access events

subscription.access_granted and subscription.access_revoked are sent when the access a subscription grants actually changes, and only then. Every change sends one, whatever made it: a payment, a cancellation reaching its end, a plan's end date, a blocked charge reaching access_until. A renewal paid on time, or a resume while the paid period runs, changes nothing and sends neither.
FieldWhat it says
data.accesstrue on access_granted, false on access_revoked
data.access_untilWhen access ends unless a charge renews it. Null without access
data.blocked_sinceSince when charges are held for a cause not the customer's, or null
data.blocked_reasonWhy they are held, or null
detail.changed_atWhen access changed, to the millisecond: the moment data describes
detail.blocked_reasonWhy charges were held when access changed, or null
detail.access_untilRevoked: the date access ended. Granted: set only on held charges
created_at is when Mesub wrote the event, and can come later than detail.changed_at: a date that passed with nothing else happening is told a few seconds after it.
detail.blocked_reason and detail.access_until describe the change itself. On access_revoked, detail.access_until is the date access ended: the end of a paid period, of a cancellation, of the plan, or of the access held charges keep. It is null when access ended with no date of its own, a stop for example. On access_granted, it is set only while charges are held. Both fields are absent on events written before Mesub added them.
Drive your customer's access from these two events alone; the others say why it changed. For each subscription, keep the sequence of the last access event you applied, skip one with a lower sequence, and the last one applied says whether the customer has access. An access event without sequence cannot be placed: ask GET /v1/access instead.

Your answer

Answer with a 2xx within 10 seconds. Anything else, a redirect included, counts as a failure and is retried: after 1 minute, 5 minutes, 30 minutes, 2 hours, then every 12 hours, for about three days in all.