POST to your endpoint, with a JSON body and three headers:| Header | What it holds |
webhook-id | The delivery's id: one per event and per endpoint, the same on every retry |
webhook-timestamp | When this attempt was sent, in Unix seconds |
webhook-signature | v1, then the signature of this attempt, in base64. Sometimes two, see below |
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.1webhook-signature: v1,<signed with the new secret> v1,<signed with the old secret>
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).| Id | Names | Deduplicate on it to |
id, in the body | The event | Apply each event once, whichever endpoint got it |
webhook-id, header | The delivery | Skip a retry of a delivery you already handled |
id,
two webhook-ids.1234567891011121314{ "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": {} }
| Field | What it says |
id | The event's id, the same in every delivery of it. An opaque string, sometimes a UUID |
object | Always event |
type | The event's name |
created_at | When Mesub wrote the event. On an access event, or a cancellation's end, detail.changed_at is the moment |
network | The Solana network: devnet or mainnet-beta |
api_version | The version data and detail are written in, as Mesub-Version names it |
sequence | Its place among this subscription's events: 1, 2, 3... Absent on some events |
data | The subscription, as Retrieve a subscription answers it, shortened here |
detail | The event's own fields. Empty on most events |
test | true 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.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.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.{ type, created_at, data }, with detail inside data, no
id, and data as it was at the first attempt.| Event | Sent when |
subscription.created | The first payment landed; detail.first_payment |
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 |
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.| Transition | Event |
| A cancellation reaches its expiry | subscription.ended, end_reason: "cancelled", usually within 30 min |
| The ended subscription is closed | subscription.ended again, end_reason: "closed" |
| A seat is parked over the limit | subscription.paused, data.paused true, status unchanged |
| A parked seat is brought back | subscription.unpaused, data.paused false |
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.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.| Field | What it says |
data.access | true on access_granted, false on access_revoked |
data.access_until | When access ends unless a charge renews it. Null without access |
data.blocked_since | Since when charges are held for a cause not the customer's, or null |
data.blocked_reason | Why they are held, or null |
detail.changed_at | When access changed, to the millisecond: the moment data describes |
detail.blocked_reason | Why charges were held when access changed, or null |
detail.access_until | Revoked: 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.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.