Skip to Content
Building an integrationGranting and revoking access

Granting and revoking access

A paid community, a members-only course, a private podcast feed. The 7 subscription.* events are what drives access, and the whole difficulty is in two pairs of events that look interchangeable and are not.

The two mistakes

Revoking on subscription.cancelled.

That event is the member asking to stop. They have paid through currentPeriodEnd and keep their access until it. Revoking there takes away a month somebody already bought — which produces an angry customer and a refund request, and no error anybody sees.

Revoke on subscription.ended.

Revoking on subscription.payment_failed.

That is a card that did not go through. Stripe retries for several days and most of them recover. Email the member; do not cut them off. If it never clears, subscription.ended arrives and that is the signal.

What each event means for access

EventAccess
subscription.createdGrant
subscription.renewedKeep — extend any expiry you cache
subscription.resumedKeep, and clear any pending revocation
subscription.plan_changedKeep, and re-evaluate the tier
subscription.payment_failedKeep. Email them.
subscription.cancelledKeep until currentPeriodEnd.
subscription.endedRevoke

Deriving access from the payload

Better than a state machine over event names: every subscription.* event carries the complete membership object, so you can compute access from the payload and ignore which event delivered it.

type Subscription = { status: string; currentPeriodEnd: string | null; cancelAtPeriodEnd: boolean; }; function hasAccess(sub: Subscription, now = new Date()): boolean { // `past_due` is included on purpose: a failed renewal is a retry window, // not an ending. Stripe gives it days before moving to `unpaid`. if (["active", "trialing", "past_due"].includes(sub.status)) return true; // Cancelled but paid through — the month they already bought. if (sub.currentPeriodEnd && new Date(sub.currentPeriodEnd) > now) return true; return false; }

That function is order-independent and idempotent, which matters because events are not ordered. A renewed that needed a retry can arrive after a payment_failed that did not.

A handler

export async function handleSubscription(event: SailoEvent) { const sub = event.data as Subscription & { id: string; clientId: string | null }; // Events are not ordered. Ignore one older than what we have stored. const known = await load(sub.id); if (known && known.updatedAt >= event.timestamp) return; await save(sub.id, { ...sub, updatedAt: event.timestamp }); const allowed = hasAccess(sub); const member = await memberFor(sub.clientId); if (!member) return; if (allowed) await discord.addRole(member, MEMBER_ROLE); else await discord.removeRole(member, MEMBER_ROLE); // The one event that is about a person rather than a state. if (event.type === "subscription.payment_failed") { await email(member, "yourCardDidNotGoThrough"); } }

Note what it does not do: switch on the event type to decide access. hasAccess reads the state; the event type only chooses the email.

Scheduled revocation

hasAccess returns true for a cancelled member until currentPeriodEnd passes — but no event fires at that moment on a manual membership, and on a Stripe one subscription.ended is what you are waiting for.

Two ways to close that gap:

Wait for the event. Simplest, and correct for billingMode: "stripe". subscription.ended arrives when the period actually runs out.

Schedule it. On subscription.cancelled, queue a revocation for currentPeriodEnd; on subscription.resumed, cancel it. Necessary if your system needs the revocation to be exact rather than event-driven, and worth it for anything where a few minutes of extra access matters.

Do both if you like — hasAccess makes a double revocation a no-op.

Manual memberships

Read billingMode. A manual membership will never send you anything from Stripe. Sailo raises a renewal order that a human settles — cash at the door, a bank transfer — so an integration waiting for a card renewal on one waits forever.

paymentMethod names the rail. It is null on a card subscription.

Access still works the same way: status and currentPeriodEnd are maintained either way, and that is the point of the field existing. The difference is only in what causes them to move.

Trials

status: "trialing" with trialEndsAt set. Grant access — hasAccess does.

A trial that converts moves to active. One that does not moves through incomplete or straight to canceled, and subscription.ended follows.

Plan changes

subscription.plan_changed carries the new price and interval. If your access has tiers, re-evaluate from productId — the membership names the product it is a subscription to, and that product is readable with GET /products/{id}.

Bootstrapping

There is no GET /api/v1/subscriptions, so you cannot page existing members at setup.

The practical approach: turn the integration on, let it build state from events as they arrive, and grant access on demand for anybody it has not seen — a member who logs in and is unknown gets checked against your own records or granted provisionally. Within a billing cycle every active membership has sent you at least a renewed.

If that does not work for your case, get in touch — it is a real gap rather than a decision.

Last updated on