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

  1. Create a customer once per person you bill — POST /api/v1/customers. Keep the cus_… id.
  2. Create a plan with its prices once — POST /api/v1/plans. Keep the price_… ids (e.g. monthly and yearly).
  3. Start a checkout with mode: "subscription", a price_id and the customer_id — POST /api/v1/checkout-sessions.
  4. Activation
    • Price without a trial, or skip_trial: true, or a trial already used: the end user pays on checkout_url. On payment the subscription becomes active with current_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 response checkout_url is already your success_url. in_trial is true and current_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.
  5. Confirm via the subscription.updated webhook or GET /api/v1/subscriptions/{external_ref} — never from the redirect alone.
  6. Reminders go to the customer before the period ends (below).
  7. Renewal: the customer opens the link in the email, pays, and you receive subscription.renewed and subscription.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} reports status: "expired" — treat that as “no access”.
  • The renewal grace window is 7 days: for 7 days after current_period_end the 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 Email
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; use GET /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_id and price_id already 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

This site uses Just the Docs, a documentation theme for Jekyll.