Subscription lifecycle
PayGate owns your subscriptions: it tracks the billing period, reminds the customer by email, and takes payment through any payment method your app has enabled (Stripe, PayOS, bank transfer, …). There is no automatic card charge — a subscription renews when the customer pays again.
End-to-end flow
- Create a customer once per person you bill —
POST /api/v1/customers. Keep thecus_…id. - Create a plan with its prices once —
POST /api/v1/plans. Keep theprice_…ids (e.g. monthly and yearly). - Start a checkout with
mode: "subscription", aprice_idand thecustomer_id—POST /api/v1/checkout-sessions. - Activation
- Price without a trial, or
skip_trial: true, or a trial already used: the end user pays oncheckout_url. On payment the subscription becomesactivewithcurrent_period_end= now + the price’s interval. - Price with a free trial: the subscription is activated during the checkout call itself (
activated: true) and the responsecheckout_urlis already yoursuccess_url.in_trialistrueandcurrent_period_end(=trial_ends_at) is the end of the trial. - Price with a paid trial: the end user pays the trial amount; the first period lasts for the trial’s interval.
- Price without a trial, or
- Confirm via the
subscription.updatedwebhook orGET /api/v1/subscriptions/{external_ref}— never from the redirect alone. - Reminders go to the customer before the period ends (below).
- Renewal: the customer opens the link in the email, pays, and you receive
subscription.renewedandsubscription.updated.
Access, expiry and the grace window
- Access ends at
current_period_end. As soon as that moment passes,GET /api/v1/subscriptions/{external_ref}reportsstatus: "expired"— treat that as “no access”. - The renewal grace window is 7 days: for 7 days after
current_period_endthe renewal link still works so the customer can pay again. This is only a window to pay; it does not extend access. - A payment made while the subscription is still valid extends it from the current
current_period_end(no time is lost). A payment made after it expired starts a new period from the time of payment. - Renewals are always charged at the price’s regular amount, never at the trial amount.
- A trial ending behaves exactly like an expiry: nothing is charged automatically; the customer gets the same reminders and renews by paying.
Reminder emails and the renewal page
For each billing period PayGate emails the customer:
| When | |
|---|---|
3 days before current_period_end | Renewal reminder |
1 day before current_period_end | Renewal reminder |
After current_period_end (within the grace window) | One “expired” notice |
Each email goes To the customer’s email, with the customer’s cc_emails as Cc and bcc_emails as Bcc, written in the customer’s locale (en or vi). A stage that no longer applies is skipped (for example, a period shorter than 3 days gets no 3-day reminder). Each stage is sent at most once per period. Reminders need a customer attached to the subscription (see PUT .../customer).
Every email links to https://<gateway>/renew/{token}, a hosted page that lists every payment method your app has enabled for the customer to pay with. PayGate hosts the whole renewal; confirmation reaches you through webhooks, not a redirect to your success_url. The email also includes an opt-out link; a customer who uses it stops receiving reminders for that subscription. You are notified of each reminder with the subscription.renewal_reminder_sent webhook (it never contains the link).
Webhooks you receive during the lifecycle: subscription.updated (every change), subscription.renewed (payment applied to an existing subscription, including when a trial converts), subscription.renewal_reminder_sent — see 04 — Webhooks.
Firebase Realtime Database sync (Firebase ID Token apps)
An app registered with Firebase ID Token and a service account does not have to call the API to know who is premium: PayGate PUTs the state to the app’s own Realtime Database, at the path configured on the app (typically users/{uid}/subscription, where {uid} is the Firebase UID).
{ "plan": "premium", "expiresAt": 1792217083000, "purchaseSource": "web", "updatedAt": 1789500000000 }
| Field | Type | Meaning |
|---|---|---|
plan | "premium" or "free" | premium while the subscription is in force (active and current_period_end not passed), otherwise free |
expiresAt | number (epoch milliseconds) or null | End of the paid (or trial) period. With plan: "premium", null means lifetime; with plan: "free" there is nothing to expire |
purchaseSource | "web" | Always web |
updatedAt | number (epoch milliseconds) | When PayGate wrote it |
What is written in each case
E = expiresAt. “Nothing written” means the node keeps its previous value.
| Case | Written |
|---|---|
| Never bought | No node (null) — treat as free |
| Paid monthly/yearly price | premium, E = end of the paid period |
| Free trial | premium, E = end of the trial, written during the checkout call itself (activated: true) |
| Paid trial | Nothing until the trial price is paid, then premium, E = end of the trial |
| Period ran out, customer has not paid | Nothing written: still premium with a past E — compare E with the time on the client |
| Renewal while still valid | E moves to the end of the new period (time is added on, nothing lost) |
| Renewal after it expired | New period from the time of payment; the 7-day grace window only lets the customer pay, it grants no access |
| Lifetime price | premium, E: null |
Monthly subscriber switched to a lifetime price (PUT .../plan) | Unchanged until they pay the lifetime price at the next renewal, then premium, E: null |
| Payment completing after the user already owns lifetime | Not applied, nothing written (flagged for a manual refund) |
Price switched with PUT .../plan | Same values rewritten (new updatedAt) |
| Firebase client buys another price of the same plan while valid | E extended |
| Firebase client buys a price of another plan while valid | New period from the time of payment — the remaining time of the old plan is not added (no proration) |
Customer attached with PUT .../customer | Same values rewritten |
| App disabled | Payments already started still complete and are written |
| Duplicate provider webhook | Same data rewritten (idempotent) |
| Write to RTDB fails | Subscription is already correct in PayGate; node keeps the old value, the event is retried manually; GET /subscriptions stays accurate |
| Re-sync after the subscription expired | free, E: null — the only time a node becomes free |
It is written after every payment applied to the subscription (first activation, renewal, a free trial starting) and when you call PUT /subscriptions/{external_ref}/customer or /plan. Nothing is written when a period simply runs out, so the node keeps plan: "premium" with a past expiresAt until the next write. Always compare expiresAt with the current time on the client:
const s = snapshot.val();
const isPremium = !!s && s.plan === 'premium' && (s.expiresAt === null || s.expiresAt > Date.now());
- Each write replaces the whole node — don’t keep other data under it.
- The node carries no
plan_ref, price or trial details; useGET /api/v1/subscriptions/{external_ref}or a webhook for those. - Recommended rules: clients may only read their own node and never write it, e.g.
".read": "auth.uid === $uid", ".write": false. PayGate writes with a service account, which bypasses rules.
Lifetime subscriptions
A lifetime price is paid once. The subscription becomes active with current_period_end: null: it never expires, gets no reminder emails and has no renewal link. Treat status: "active" as “has access” as usual — just don’t assume current_period_end is a date. A user who already owns one cannot start another checkout (409 already_lifetime); a payment that still completes against it afterwards is not applied (it is flagged for a manual refund). PUT .../plan with a lifetime price converts a running subscriber at their next renewal.
Switching monthly to yearly
Call PUT /api/v1/subscriptions/{external_ref}/plan with the yearly price_id (HMAC apps only):
- The current period is untouched and nothing is refunded or prorated.
- From the next renewal, the reminders, the renewal link and the amount use the new price, and the subscription’s
plan_ref,plan_idandprice_idalready report the new values. - Any price on sale works — another price of the same plan, or a price of a different plan.
One trial per end user and plan
A trial is granted at most once per end user and plan, across all of the plan’s prices. If someone used the trial on Pro monthly, a later checkout on Pro yearly gets no trial. skip_trial: true skips it even when it would be available.
Quick reference
| Question | Answer |
|---|---|
| Is anything charged automatically? | No — the customer pays via the renewal link or a new checkout |
| When does access stop? | At current_period_end |
| How long can the customer still renew after that? | 7 days (grace window), starting a new period from the payment time |
| Payment before the end? | Extends from current_period_end |
| Which language are emails in? | The customer’s locale |