GetPay can bill for your own product. It provides hosted checkout, subscriptions, credit packs, usage metering and an entitlements API that your product reads to decide what each user may do. Every payment becomes a real GetPay invoice and receipt in your books, and tax follows your workspace's SST registration.
This page is the source of getpay.cc/developers/billing. The machine-readable
contract is GET https://getpay.cc/api/v1/openapi (the billing tag). The typed
client is the zero-dependency SDK in getpay/packages/billing-sdk.
Concepts
| Term | What it is |
|---|---|
| Workspace | Your GetPay tenant, the legal seller. Its books, SST registration and LHDN settings apply to everything it sells. |
| App | One product you sell through GetPay, for example acme-learn. Plans, learners, API keys, webhooks and events all belong to one app. A workspace can have many apps. |
| Plan | Something an app sells: a subscription (renews every n days, weeks, months or years), a credit_pack (prepaid units such as class sessions), a topup (prepaid usage such as AI tokens) or a one_off. A plan links to a GetPay Product, which supplies the invoice line and its tax type. A plan has one price per variant (regular, referred, cohort prices, and so on). |
| Learner | The person who uses your product, identified only by your own opaque id (learner_ref, for example your user UUID). GetPay never stores a learner's name, e-mail, IC number, school or scores. |
| Payer | Whoever pays, for example a parent. The payer is an ordinary GetPay customer, bound to the learner by the first paid checkout. |
| Entitlement | What a learner may use right now: status, tier, your plan's own entitlement object, add-on quantities and credit balances. GetPay recomputes it on every billing change. |
| Add-on | A subscription plan attached, with a quantity, to a live base subscription of the same app (seat packs, outlets, extra registers). It renews with its base and ends with it. |
| Rated meter | A usage meter billed at month close: a % of billed volume or a price per unit beyond an allowance, at the rate of the learner's tier, capped per month. |
The billing layer is part of the Starter, Growth, Scale and Enterprise plans (and the Pro plan on workspaces that already have it). The owner turns it on in Settings → Billing layer.
Quickstart
This walks the whole loop on one app: app → plan → API key → learner → checkout → webhook → entitlement read. Use the test workspace for it (see Test mode).
1. Create the app
In Settings → Billing layer, the owner adds an app with:
- a slug (
acme-learn) and a display name; - allowed return hosts (
app.acme.example) — checkout and portal links only send payers back tohttps://URLs on these hosts; - grace days (default 7) — how long a learner stays entitled, as
past_due, after a paid period ends.
The app's branding (colour, logo, support e-mail) appears on the hosted
checkout. The copilot, MCP and CLI op billing_app_upsert does the same job.
2. Define a plan
Add a plan to the app, or use the billing_plan_upsert op:
{
"app": "acme-learn",
"code": "monthly",
"name": "Monthly",
"kind": "subscription",
"interval_unit": "month",
"interval_count": 1,
"product_id": 123,
"rank": 10,
"entitlement": { "tier": "monthly", "ai_size": "High", "features": ["ai_tutor"] },
"prices": [{ "variant": "regular", "amount": 49.00 }, { "variant": "referred", "amount": 39.00 }]
}
entitlementbelongs to you. GetPay never interprets it. The entitlements API returns it as-is, andtierandai_sizeare lifted to the top level for convenience.rankdecides which plan wins when a learner has several live ones.- A
credit_packortopupplan also carriescredits: {"unit": "class_session", "qty": 12, "valid_months": 3}. Usevalid_daysinstead ofvalid_monthsfor day-based expiry. - Checkout needs
product_id. The Product's tax type, together with your SST registration, decides the tax. An unregistered seller charges no SST. - A new price amount closes the old price and starts a new one. Price history is kept.
3. Mint an app-bound API key
In Settings → Billing layer → API keys (owner only), choose the app and the scopes:
| Scope | Allows |
|---|---|
entitlements:read | GET /entitlements/{ref}, POST /entitlements:batch |
billing:read | GET /plans, GET /events |
billing:write | learners, checkout sessions, portal sessions, credit consumption, referral codes and attributions, event redelivery |
usage:write | POST /usage |
The key (gp_live_…) is shown once. Keep it on your server. A key bound to
one app can only ever reach that app's data, and a workspace-wide key cannot
call billing endpoints.
import { createGetPayBilling } from '@getpay/billing-sdk'
const getpay = createGetPayBilling({ apiKey: process.env.GETPAY_API_KEY! })
4. Register the learner
await getpay.learners.upsert(user.id) // idempotent: 201 the first time, then 200
curl -X PUT https://getpay.cc/api/v1/billing/learners/3f2a9c1e-1111-4222-8333-444455556666 \
-H "Authorization: Bearer $GETPAY_API_KEY"
learner_ref must be an opaque id: 1–128 letters, digits, ., _, : or -,
with no spaces and no @. GetPay refuses anything shaped like personal data:
9–15 bare digits (an IC or phone number) or a bin/binti name segment. Prefix
numeric ids, for example u_123456789. The SDK refuses such refs before they
leave your server.
5. Create a checkout session and redirect the payer
On your server, when a signed-in user clicks Subscribe:
import { idempotencyKey } from '@getpay/billing-sdk'
const session = await getpay.checkoutSessions.create(
{
learner_ref: user.id,
plan_code: 'monthly',
success_url: 'https://app.acme.example/billing/done',
cancel_url: 'https://app.acme.example/pricing',
},
{ idempotencyKey: idempotencyKey('checkout', order.id) },
)
return Response.json({ url: session.url }) // or redirect(session.url)
- The session URL (
https://getpay.cc/checkout/{token}) shows your app's branding, the plan, and the price with the seller's tax. It then hands off to your workspace's payment processor account (Stripe, configured under Settings → Billing layer → Payments). Connect a new Stripe account in this order, because the webhook URL carries the GetPay account id that only exists after the first save: (1) in Stripe, add a webhook endpoint onhttps://getpay.cc/api/billing/processors/stripe/webhookwith the events the panel lists, and copy its signing secret; (2) save the account id, the secret or restricted key and that signing secret in the panel; (3) set the endpoint's URL to the one the panel then shows (it ends in?account=…). Editing the URL keeps the signing secret. Finish step 3 before the first sale. Until then GetPay refuses the deliveries and books nothing. collection: "charge_automatically"(the default) is a card subscription that the processor renews."send_invoice"takes the first period as a one-off payment (any one-off method your processor account offers, such as card, FPX or e-wallet). After that GetPay e-mails each renewal invoice with a Pay-now link.- The learner is entitled only after the payment is booked. Never grant
access on the redirect back to
success_url. Wait forentitlement.updated, or read the entitlement. - Checkout refuses a learner who already has a live subscription in the plan's
family (
BILLING_SUB_ALREADY_LIVE); send them to the portal to change plan. It also refuses while another checkout for the same family is open. - The
Idempotency-Keyheader is required and is scoped to your app: a retry with the same key returns the same session (200 instead of 201).
6. Receive webhooks
Add an endpoint in Settings → Billing layer → Webhooks (owner). Point it at
an https:// URL on a public host; GetPay refuses private addresses. The
signing secret is shown on creation.
import { constructWebhookEvent, isBillingEvent } from '@getpay/billing-sdk'
export async function POST(req: Request) {
const raw = await req.text() // the RAW body, never re-serialized JSON
let event
try {
event = await constructWebhookEvent({
payload: raw,
signature: req.headers.get('X-GetPay-Signature'),
secret: [process.env.GETPAY_WEBHOOK_SECRET!, process.env.GETPAY_WEBHOOK_SECRET_NEXT ?? ''],
})
} catch {
return new Response('bad signature', { status: 400 })
}
if (!isBillingEvent(event)) return new Response(null, { status: 200 })
if (await alreadyProcessed(event.id)) return new Response(null, { status: 200 }) // dedupe on id
if (event.type === 'entitlement.updated') await entitlements.applyEvent(event)
await markProcessed(event.id)
return new Response(null, { status: 200 })
}
7. Read the entitlement
import { createEntitlementReader } from '@getpay/billing-sdk'
const entitlements = createEntitlementReader({ client: getpay })
const { entitlement } = await entitlements.get(user.id)
if (entitlement.status === 'active' || entitlement.status === 'past_due') {
// unlock entitlement.entitlement.features, entitlement.tier, …
}
That completes the loop. The rest of this page is the reference.
API basics
-
Base URL:
https://getpay.cc/api/v1/billing. Authenticate withAuthorization: Bearer <app-bound key>. -
Errors: every refusal is JSON
{"kind":"error","error","code","fix"}. Branch oncode, never on the message. The gate order is:Status codeMeaning 401 NO_API_KEY,INVALID_API_KEYMissing, unknown, revoked or expired key. A gp_test_key only works on the test workspace404 / 405 NOT_FOUND,METHOD_NOT_ALLOWEDNo such billing endpoint or method 403 APP_BINDING_REQUIREDA workspace-wide key cannot call billing endpoints 403 SCOPE_REQUIREDThe key lacks the endpoint's scope 402 PLAN_FEATUREThe workspace's plan does not include the billing layer 403 APP_NOT_FOUND,APP_DISABLED,APP_MISMATCHThe key's app is gone or disabled, or the request named another app 400 / 422 INVALID_JSON,VALIDATIONThe body or parameters failed the schema 409 / 422 BILLING_*A business rule refused the call (listed per endpoint in OpenAPI) 503 DATABASE_UNAVAILABLE,BILLING_NOT_AVAILABLERetry later -
The app is chosen by the key. Never send
app. If you do and it names another app, the call gets403 APP_MISMATCH. -
Idempotency: mutating calls take an
Idempotency-Keyheader. Checkout creation, credit consumption and event redelivery require it. Derive the key from your own order, session or turn id, so that a retry from any process replays the call instead of repeating it. Keys may not look like personal data (the same shapes alearner_refmay not have); a UUID or a prefixed id such ascheckout:ord_8f2korsess:42:turn-7is safe. Key scope:appfor checkout and portal sessions,learnerfor credit consumption (one group session id can serve every attending learner), andcompanyfor event redelivery. -
Retries: retry network errors, 408, 429 and 5xx with the same key. The SDK does this for you (twice, with backoff).
The entitlements contract
GET /api/v1/billing/entitlements/{learner_ref} (entitlements:read) returns:
{
"object": "entitlement",
"learner_ref": "3f2a9c1e-1111-4222-8333-444455556666",
"app": "acme-learn",
"status": "active",
"tier": "monthly",
"ai_size": "High",
"entitlement": { "tier": "monthly", "ai_size": "High", "features": ["ai_tutor"] },
"plans": [
{
"plan_code": "monthly", "kind": "subscription", "tier": "monthly", "ai_size": "High",
"entitlement": { "tier": "monthly", "ai_size": "High", "features": ["ai_tutor"] },
"status": "active", "cadence": { "unit": "month", "count": 1 },
"current_period_end": "2026-10-25T00:00:00.000Z", "cancel_at_period_end": false, "grace_until": "2026-11-01T00:00:00.000Z",
"items": [
{ "plan_code": "seat_pack", "quantity": 2, "units_per_quantity": 5, "units": 10, "status": "active",
"pending_quantity": null, "pending_effective_at": null, "paid_through": "2026-10-25T00:00:00.000Z", "ends_at": null }
]
}
],
"credits": [{ "grant_id": "…", "unit": "class_session", "granted": 12, "remaining": 10, "expires_at": "2026-12-25T00:00:00.000Z" }],
"credits_remaining": { "class_session": 10 },
"addons": { "seat_pack": 10 },
"version": 42,
"as_of": "2026-09-25T08:00:00.000Z",
"valid_until": "2026-11-01T00:00:00.000Z"
}
- It is always 200 for a well-formed ref. A learner GetPay has never seen
reads
status: "none",tier: "free",version: 0, so the API cannot be used to test whether someone exists. statusisnone,active,past_due,paused,cancelledorexpired. GetPay derives it at read time, so a lapsed period readsexpiredeven before any background job runs.past_duemeans the learner is inside the grace period and still entitled.- Each
plans[]entry names its plan asplan_code: the same value the plan catalogue (GET /plans) callscode. There is noplans[].codekey. tier,ai_sizeandentitlementcome from the highest-rankplan that isactiveorpast_due. Without one they are"free",nulland{}.credits_remainingholds live (unexpired) balances per unit, andcredits[]lists the grants, earliest expiry first.plans[].items[]lists the plan's add-ons with their quantity, andaddonssums theirunits(quantity ×units_per_quantity) per add-on over the plans that entitle now — for example{"seat_pack": 10}for two packs of five seats. A scheduled decrease applies atpending_effective_atwithout any job, an add-on unpaid past its grace readsexpired(0 units), and an add-on never entitles while its base plan does not (inactive).valid_untilis the latest instant this answer may be trusted without re-reading. It is the paid-through time plus grace, capped at the earliest credit expiry and the earliest add-on lapse or scheduled quantity change, ornullwhen nothing is paid.- Responses carry
ETag,Cache-Control: private, max-age=30, stale-while-revalidate=300, stale-if-error=259200andX-GetPay-Entitlement-Version. SendIf-None-Matchto get304when nothing changed. POST /api/v1/billing/entitlements:batchwith{"learner_refs": [...]}reads up to 100 learners and returns them in the order requested.- The body holds no personal data: no payer name or e-mail, no invoice numbers, no amounts.
Fail-safe rule for your product
Your product will read entitlements on hot paths. Do not make a GetPay outage
your outage, and do not let it hand out access nobody paid for.
EntitlementReader in the SDK implements this rule:
- A cached copy younger than 60 s is used as-is.
- Otherwise GetPay is called with an 800 ms timeout, using
If-None-Match. - If GetPay fails or times out, the last copy is used only while
now < valid_until. Access never runs past what was paid for. - With no usable copy, the learner is free (
status: "none"). - A fallback never raises a tier. The reader calls
onOutageonce per outage so you can alert. - A newer
entitlement.updated(fed throughreader.applyEvent) makes the next read go back to GetPay, and caps the last copy at the event's ownvalid_until. After a refund, cancel or chargeback that bound isnullor earlier, so an outage serves the learner as free, not the revoked tier. - A successful GetPay answer is the live state, even at a lower
version(a recreated snapshot, such as the nightly demo reseed, restarts at 1). The one exception is an answer that was in flight when a change signal arrived: it is stored capped and re-read on the next call. An event from a recreated snapshot counts as newer when itscreatedtime is after the cached copy'sas_of, so pass the whole event toapplyEvent. - Every cache write is made against the latest stored entry, and writes for
one learner are serialised in-process. A read in flight can never overwrite
a signal or a newer copy written meanwhile. If several processes share one
cache, implement the cache's optional atomic
update(learnerRef, fn)(Redis WATCH/MULTI, or SQLSELECT … FOR UPDATE) so this also holds across them.
For many learners and many reads, back the reader with your own table (for
example Redis or Postgres) through its cache option, and feed it webhooks.
Webhooks and events
Every billing change writes an event in the same transaction as the
change. GetPay delivers it to every app endpoint subscribed to its type. You
can subscribe to an exact type, a prefix such as subscription.*, or *.
Envelope (api_version 2026-10-01):
{ "id": "evt_0192…", "type": "entitlement.updated", "api_version": "2026-10-01",
"created": 1759000000, "livemode": true, "company": "acme_sdn_bhd", "app": "acme-learn",
"seq": 1234, "data": { … } }
Headers: X-GetPay-Signature, X-GetPay-Event-Id, X-GetPay-Event-Type.
Signature: X-GetPay-Signature: t=<unix>,v1=<hex>, where
v1 = HMAC-SHA256(secret, "<t>.<raw body>"). Reject the delivery when
|now − t| > 300 s. When you rotate the secret, GetPay signs with both the
new and the old secret for 24 hours (two v1= values), so hold both secrets
until you have switched. verifyWebhookSignature in the SDK accepts a list of
secrets.
Delivery: GetPay tries immediately, then retries after 1 m, 5 m, 30 m, 2 h, 6 h, 12 h, 24 h and 24 h (about 3 days in all). After that the delivery is marked failed and the owner is alerted. An endpoint that fails every delivery for 3 days is disabled. Answer with any 2xx quickly and do the work asynchronously.
Ordering and duplicates: deliveries are at-least-once and can arrive out of
order. Dedupe on id. Apply a learner's state only when its version is newer
than the one you hold.
Catching up: GET /api/v1/billing/events?after_seq=<n>&types=a.b,c.*&limit=100
(billing:read) returns events in seq order, kept for 30 days. Page with
next_after_seq while has_more is true. POST /events/{id}/redeliver (with an
Idempotency-Key) re-sends one event. It is rate-limited to once per event per
endpoint every 10 minutes, and 100 per app per hour.
Event catalog
| Type | data (learner_ref only, never personal data) |
|---|---|
entitlement.updated | The learner's stored projection: learner_ref, app, plans[], credits[], credit_totals, version, valid_until. Treat it as a change signal and read GET /entitlements/{ref} for the derived view (status and tier are computed at read time). |
subscription.created · .renewed · .updated · .past_due · .paused · .resumed · .cancelled · .expired | sub_id, learner_ref, plan_code, status, current_period_end, cancel_at_period_end, grace_until, paused_until, pending_plan_code, previous |
checkout.completed / checkout.expired | cs_id, learner_ref, plan_code |
payment.succeeded / payment.failed | payment_id, invoice_id, amount, method, failure_code, learner_ref |
invoice.issued / invoice.paid | invoices the billing layer issued for this app: invoice_id, total, status, learner_ref |
credits.granted · .consumed · .expired | grant_id, learner_ref, unit, delta, remaining, source_ref |
learner.created | learner_ref |
plan.updated / billing_app.updated | plan_code, version / app, status |
coupon.redeemed / coupon.released | redemption_id (redeemed also carries coupon_code, plan_code, learner_ref, list and discount amounts) |
referral.attributed · .converted · .rewarded | attribution_id, code, referred_learner_ref / referrer_learner_ref, reward_kind |
The workspace-level invoice.paid (any invoice of the workspace, with the
older v1 body) goes only to workspace-wide endpoints, never to an app endpoint.
Refunds and card disputes are not billing-layer events yet.
Credits
- A
credit_packortopupplan bought through checkout grantscredits.qtyunits, which expire aftervalid_monthsorvalid_days(credits.granted). - Consume on use:
POST /api/v1/billing/credits:consume{"learner_ref","unit","qty"?,"source_ref"?}with anIdempotency-Keyper consumption of that learner. For a group class, send the class session id as the key for every attending learner. Each learner is debited once. - It draws first-in, first-out by expiry. It is fail-closed: if the
balance is below
qty, nothing is consumed and you get409 BILLING_CREDITS_INSUFFICIENT. A balance never goes negative. - A retry with the same key for the same learner replays (
replayed: true). The same key with a different unit or quantity is refused. - An expiry job zeroes lapsed grants and emits
credits.expiredonce per grant. The entitlement reads expired credits as 0 immediately.
Usage metering
The workspace defines meters per app with the billing_meter_upsert op, for
example ai_tokens. A meter with a drawdown_unit (for example
ai_tokens_topup → ai_tokens) debits prepaid top-up credits for each event.
await getpay.usage.record([
{ idempotency_key: `ai:${conversationId}:${turnId}`, learner_ref: user.id, meter: 'ai_tokens',
qty: 1834, occurred_at: new Date().toISOString() },
])
POST /api/v1/billing/usage(usage:write) takes up to 500 events and answers202with one result per event, in input order:accepted,duplicate(the key was seen before, so retrying is safe) orrejectedwith a reason (unknown_meter,out_of_window,invalid_qty,invalid_learner_ref,invalid_idempotency_key).occurred_atmust fall between now − 35 days and now + 5 minutes. Unknown meters are rejected.- Enforce limits in your product. GetPay aggregates usage. A drawdown meter never takes a balance below zero; any shortfall is reported, never billed. Only a rated meter (below) bills usage after the fact.
- Batch from a queue every 60 s or less. Delivery is at-least-once and safe.
Add-ons and quantity
Sell per-unit extras — seat packs, outlets, registers — as add-ons of a base
plan. An add-on is an ordinary subscription plan (same interval unit as the
price you want per unit) that the workspace configures with the
billing_addon_plan_configure op: addon_of_family is the base plan family,
units_per_quantity what one unit stands for (5 for a 5-seat pack) and an
optional max_quantity. An add-on is never a checkout plan or a subscription
of its own; it exists only as a quantity on a live base subscription.
| Change | When it applies | Money |
|---|---|---|
| Increase (or add) | when its invoice is paid | Charged now for the rest of what is paid: (new − old) × unit price × the unused share of the paid window, one invoice line with a Pay-now link, tax per your SST registration. Unpaid, it is voided after 7 days. |
| Decrease (or remove, quantity 0) | at the end of the paid window | Nothing charged, no credit. |
| Set the current quantity while a decrease is scheduled | now | Withdraws the scheduled decrease. |
- The unused share is measured on the paid window's own grid: whole add-on intervals counted forward from the window's start (each boundary is the start plus k intervals, month-end clamped the same way the base window is built), plus the fraction of the interval the change falls in. The charge is the part from now to the end of the window, so a full month costs exactly the unit price and an annual base window costs 12 monthly units. A card base's month is the processor's cycle, which keeps the anchor day (Jan 31 → Feb 28 → Mar 31): that cycle is one month too, so Feb 28 → Mar 31 costs exactly the unit price, not 31/28 of it.
- Tier prices: give an add-on a price variant named after a base plan code
or tier (for example
starter) to charge that price on that tier; other tiers payregular. - Renewals: each add-on renews with its base — one invoice per add-on per
period, e-mailed with a Pay-now link, up to 7 days ahead. When the base
renews by card, the add-on invoice is issued once the processor has
collected the base's next cycle and covers exactly that cycle, never a
guessed window. An add-on unpaid past the app's grace ends (
lapsed). The grace counts from the later of the paid-through time and the moment the add-on invoice was sent, so a late invoice never shortens it. Until that invoice exists, it counts from the paid-through time, or from the moment the processor renewed a card base. It is capped at the end of the add-on's first unpaid window, so an add-on never runs into the next period unpaid, even when the app's grace is longer than the billing interval. Only one unpaid add-on invoice is open at a time. The next one is issued once the current one is paid (it stays payable until its window ends, even when the base's next period was paid early). On a card base still waiting for its processor renewal, the add-on follows the base's grace. - Blocked renewals: if an add-on invoice cannot be issued (the add-on price
is not in force, the tax type is unknown, the books are locked, or the
workspace is off a paid plan), GetPay logs it once per window
in the audit log as
BILLING_ADDON_RENEWAL_BLOCKEDand retries on every run. The add-on's grace keeps running. If the cause is not fixed in time, the add-on lapses; it is never carried for free. - Pauses: paused time is never billed. On resume, add-on time already paid moves forward by the paused span, including a resume the processor makes itself by collecting a new cycle. An add-on invoice for time before the pause stays payable while the pause is scheduled or running. It still counts against the grace, so an unpaid one lapses the add-on at the end of its window, pause or not.
- Lifecycle: an increase needs an active base (not cancelling or
pausing). Cancelling the base at period end schedules its add-ons to end
with it; resuming restores them. The add-on invoice for the window in use
(before that end) stays payable and is applied when paid. Only invoices for
time after the end are voided. A cancelled or expired base, including one
cancelled at the processor, ends its add-ons at once. Whenever an add-on
ends while already paid beyond that moment, GetPay records
BILLING_ADDON_PAID_BEYOND_BASE_ENDin the audit log once, so you can refund or credit the payer. - Decreases in arrears: when the window in use is unpaid, a decrease takes effect at the end of that window. On a card base waiting for its processor renewal, that is the end of the cycle the processor then collects (it keeps the anchor day), never a date inside a paid cycle.
- Events:
subscription.updatedcarriesitems[]and theitem_change; the add-on invoice emitsinvoice.issued/invoice.paid; the learner'sentitlement.updatedcarries the new quantities.
Selling an add-on from your product. A quantity change invoices the payer, so the payer confirms it:
- Quote it:
GET /api/v1/billing/subscriptions/{sub_id}/items/quote?plan_code=seat_pack&quantity=3(billing:read) returnsdue_now,timing(on_payment/period_end/now) and the window — show it next to your "Add seats" button. - Send the payer to confirm:
POST /api/v1/billing/portal-sessionswith{"learner_ref", "flow": {"type": "addon_quantity", "sub_id", "plan_code", "quantity"}}. The single-use link opens the portal straight on that quantity; the payer reviews the charge, confirms, and pays the add-on invoice. - Read the result from
entitlement.updated(addons,plans[].items[]).
const quote = await getpay.subscriptions.quoteItem({ sub_id, plan_code: 'seat_pack', quantity: 3 })
const { url } = await getpay.portalSessions.create({
learner_ref: workspaceId,
flow: { type: 'addon_quantity', sub_id, plan_code: 'seat_pack', quantity: 3 },
})
GET /api/v1/billing/subscriptions (billing:read) lists the app's
subscriptions with their add-ons (items[]). Operators change quantities with
the confirm-gated billing_subscription_items_change op (chat, MCP, CLI).
Rated usage: tiered, capped rates
A meter can be rated (the billing_meter_rating_set op): its usage is billed
at month close, at the rate of the learner's tier — the base plan of the
meter's plan_family paid for the month's last instant.
| Rating | Usage qty | Monthly charge |
|---|---|---|
percent_of_volume | billed volume in sen | round(volume × rate% / 10000, 2) |
per_unit | units (e.g. payroll heads paid) | max(0, units − included_units) × unit_price |
A month's events add up by the meter's aggregate: sum (default) or max
for a per_unit gauge. Report a gauge as the month's running distinct
count — e.g. after every payroll run, the number of distinct employees paid
so far this month (12 after the regular run, still 12 after an off-cycle bonus
run for the same people) — so a second run never double counts. Rating starts
when it is switched on: usage recorded before that is never billed.
Then min(charge, monthly_cap) and, only in a month the meter was used,
max(…, monthly_minimum). A learner_ref rate (a custom deal) wins over the
tier rate. Example (Shopify-style billing-layer fee): Starter 0.8% capped
RM499, Growth 0.5% capped RM999, Scale 0.3% capped RM1,999, RM199 minimum when
used — RM150,000 billed in a month costs RM499 / RM750 / RM450.
- Month close (Asia/Kuala_Lumpur calendar months) runs automatically after each month ends: one invoice per learner per month, one line per meter, Pay-now link e-mailed. A close counts only usage that occurred in that month, and it is idempotent: every usage event is billed exactly once. A close that cannot run is not retried in a loop: while your workspace's billing layer is off (on a plan without it) or the app is disabled, its months wait until that is fixed; a learner whose close is refused (no payer, a locked period) is retried after 6 hours, after every other due close.
- Late usage (an event dated in a month that already closed — usage can be backdated up to 35 days) is billed by a supplementary close of that same month: the month total is re-rated at the rate, cap and minimum frozen at the month close, and only the difference is invoiced. The cap holds for the whole month and the minimum is never charged twice; late usage never lands in another month. Example: Starter, RM30,000 closed at RM240; a late RM1,000 → the month is RM248 → a RM8 invoice.
- A learner with no tier or rate for the month is reported to the operator
(
unrated); that month's usage is recorded and never billed (not later, not when the learner subscribes afterwards). - Operators preview a month in progress (or re-run one) with
billing_rated_usage_close(preview: trueis free; closing is confirm-gated). Rated meters have no drawdown unit and volume cannot be negative (report net amounts).
Portal sessions
POST /api/v1/billing/portal-sessions {"learner_ref","return_url"?,"flow"?} returns a
15-minute, single-use URL (https://getpay.cc/portal/session/{token}) for
the learner's payer. From there the payer sees their learners (as opaque
labels such as Learner ••3f2a), plans, renewal dates, credits, invoices and
receipts. They can change plan (upgrades are prorated and take effect now;
downgrades apply at period end), pause for 1–2 cycles, cancel at period end,
resume, change add-on quantities (review the prorated charge, then confirm),
and update their card. flow (see Add-ons and quantity) opens the portal on
one add-on quantity to confirm. The call is refused until the learner has a
payer, which the first completed checkout or recorded invoice binds. Put a
Manage billing link next to each learner in your own UI.
The embed snippet
Browsers must never hold your API key. The snippet turns buttons into redirects through your own server endpoint:
<script src="https://getpay.cc/embed/billing.js" defer></script>
<button data-getpay-checkout="/api/billing/checkout" data-getpay-plan="monthly">Subscribe</button>
<a href="#" data-getpay-portal="/api/billing/portal">Manage billing</a>
On click, the snippet POSTs the element's data-getpay-* attributes (here
{"plan":"monthly"}) as JSON to your endpoint, on the same origin as the
page and with the page's cookies. Your endpoint:
- authenticates the user from its own session. Never trust a learner id sent by the page;
- creates the checkout session or portal session with the SDK;
- answers
{"url": session.url}.
// app/api/billing/checkout/route.ts (Next.js)
export async function POST(req: Request) {
const user = await requireUser(req)
const { plan } = await req.json()
const session = await getpay.checkoutSessions.create(
{ learner_ref: user.id, plan_code: String(plan) },
{ idempotencyKey: idempotencyKey('checkout', user.id, String(plan), await openOrderId(user.id)) },
)
return Response.json({ url: session.url })
}
The snippet only navigates to a GetPay hosted checkout (/checkout/{token}) or
portal link (/portal/session/{token}) on the origin it was loaded from. Any
other URL is refused, so a buggy endpoint cannot become an open redirect. It
also refuses an endpoint on another origin. It fires getpay:start and
getpay:error (with detail.code) on the element, and exposes
window.GetPayBilling.checkout(endpoint, payload) and .portal(…) for
programmatic use. The script is served with CORS and a stable body, so you can
pin it with Subresource Integrity; the hash of the current version is shown on
getpay.cc/developers/billing.
Test mode
- The test workspace
getpay_e2eis the only placegp_test_keys can be minted. A test key sent to any other workspace is simply invalid. Its processor account runs in the processor's test mode (for example Stripe test cards), and its events carrylivemode: false. - The public demo (
getpay_demo) is re-seeded every night with a sample app,demo-learn. It has three plans (monthly,pack_12,ai_topup) and two sample learners:demo_learner_a1, with an active monthly subscription and an AI top-up, anddemo_learner_b2, with 10 of 12 class sessions left. Money never moves on the demo. Checkout there is simulated (/checkout/demo/{plan}) and never reaches a payment processor. Anything you create on the demo is wiped at 03:00 MYT.
Recipes
1. Subscription SaaS
- Plans:
starter(rank 10) andpro(rank 20), bothsubscription, monthly, in the sameplan_familyso a learner holds one of them at a time. Put your feature flags inentitlement, for example{"tier":"pro","features":["exports","sso"],"seats":5}. - Pricing page:
data-getpay-checkoutbuttons → your endpoint →checkoutSessions.create(card:charge_automatically). - Gate features with
EntitlementReader, and readentitlement.featuresfrom the answer. - Account page: Manage billing →
portalSessions.create. Upgrades, downgrades, pauses and cancellations happen there, and your product just followsentitlement.updated. - Dunning comes built in. A failed renewal reads
past_duefor the app's grace days, thenexpired.
2. Tuition credit packs
- Plans:
pack_12,pack_24andpack_48ascredit_packwithcredits: {"unit":"class_session","qty":12,"valid_months":3}, and so on. - Parents buy through checkout. A pack is a one-off payment, so any method your processor account offers for one-off payments works: card, and FPX or e-wallets where the account has them enabled. Credits are granted when the payment is booked.
- After each class, the system that knows attendance calls
credits.consumefor every attending learner with the class session id as the key. Retries are safe, and a learner without balance is refused, not overdrawn. - Show
credits_remaining.class_sessionandcredits[].expires_atin your app. The portal shows the same to parents. - Remind parents to top up when
remainingdrops, using thecredits.consumedwebhook.
3. Metered AI usage
- Meters:
ai_tokens(report everything) andai_tokens_topupwithdrawdown_unit: "ai_tokens". - Plans: a subscription whose
entitlementcarries your daily cap (for example{"ai_size":"High","daily_tokens":50000}), plus anai_topuptopupplan (credits: {"unit":"ai_tokens","qty":100000,"valid_months":12}). - Your product enforces the daily cap locally, per turn, with no GetPay call in
the hot path. Past the cap, it spends from
credits_remaining.ai_tokens(minus what it used locally since that version) and reports that part as meterai_tokens_topup. - Report every turn with key
ai:{conversation}:{turn}in 60-second batches. - There is no postpaid overage. A learner without top-up credit is stopped by your product, not billed later.
4. One-off events and camps
- To track attendance, sell a seat as a
credit_pack:code: "camp_dec_2026",credits: {"unit":"camp_dec_2026_seat","qty":1,"valid_days":120}. The buyer's entitlement then showscredits_remaining.camp_dec_2026_seat = 1. At check-in,credits.consumewith keycheckin:camp_dec_2026admits the learner exactly once. - For a plain one-off purchase (merchandise, an exam fee), use a
one_offplan and act oncheckout.completed. It does not change the entitlement. - Cohort or early-bird prices are price variants (
early_bird) or coupons (coupon_codeon the checkout).
5. Retail POS paired with GetPay Counter
GetPay Counter is the in-app till for walk-in sales. It records each sale as a PAID GetPay invoice with its payment, stock movement and journal, in one transaction. To pair it with the billing layer:
- Link each membership or pack plan's
product_idto the same Product the till sells. - After a walk-in sale, the operator records it for the member with
billing_credit_pack_record(credit packs) orbilling_subscription_record(memberships) against the Counter invoice. Both are confirm-gated ops in chat, MCP and CLI, and both are idempotent per invoice and learner. - The member's app, a door check or an online booking system then reads the same entitlements API, whether the customer paid online by checkout or at the till.
- The books stay single-source: the till sale is the invoice, and the billing layer adds no second invoice.
Counter ships separately from the billing layer. Check that it is enabled on your workspace before you plan around it.
Privacy and tax
- Learners may be minors. Send only opaque ids. Idempotency keys and
source_refs are stored and emitted, so they too may not look like an IC number, a phone number or a name. Event and entitlement payloads carrylearner_refonly. - Tax follows the seller. Your workspace's SST registration and the linked Product decide the tax on every checkout and invoice. Product copy never does. Do not advertise "SST included" unless your workspace is registered.
- Your books: each paid period, pack or top-up becomes a GetPay invoice and payment, and LHDN e-invoicing follows your workspace settings.
REST reference
| Method and path | Scope | Success |
|---|---|---|
GET /api/v1/billing/plans | billing:read | 200 {object:"list", app, data: Plan[]} |
PUT /api/v1/billing/learners/{learner_ref} | billing:write | 201 created / 200 existing |
GET /api/v1/billing/entitlements/{learner_ref} | entitlements:read | 200 Entitlement, or 304 |
POST /api/v1/billing/entitlements:batch | entitlements:read | 200 {object:"list", data: Entitlement[]} |
POST /api/v1/billing/checkout-sessions | billing:write | 201, or 200 on replay (Idempotency-Key required) |
POST /api/v1/billing/portal-sessions | billing:write | 201, or 200 on replay |
POST /api/v1/billing/credits:consume | billing:write | 200 (Idempotency-Key required) |
POST /api/v1/billing/usage | usage:write | 202, with one result per event |
GET /api/v1/billing/events | billing:read | 200 {object:"list", data, has_more, next_after_seq} |
POST /api/v1/billing/events/{event_id}/redeliver | billing:write | 202 (Idempotency-Key required) |
POST /api/v1/billing/referral-codes | billing:write | 201 minted / 200 existing |
POST /api/v1/billing/referrals | billing:write | 201 attributed / 200 replay |
GET /api/v1/billing/subscriptions | billing:read | 200 {object:"list", app, data} with items[] per subscription |
GET /api/v1/billing/subscriptions/{sub_id}/items/quote | billing:read | 200 AddonQuote (writes nothing) |
The full schemas are published at GET /api/v1/openapi. The SDK's
src/generated.ts is generated from the same source.