Developers · Billing layer

GetPay billing layer for developers

Sell your own product through GetPay. Your product creates hosted checkouts and portal links on the server, reads what each learner is entitled to, and receives signed webhooks. Every payment becomes an invoice and a receipt in your GetPay books, taxed according to your workspace's SST registration.

Try the hosted checkout

These buttons use the real embed snippet against the public demo's sample app. The checkout is simulated: nothing is charged and no payment provider is contacted.

Embed snippet (v1)

<script src="https://getpay.cc/embed/billing.js" defer
        integrity="sha384-89g20kwEtMiZ96a/FjAeBAr0cN6yHaE0nFGX7ZiBGrVORLABtcK5L54wxqSP9HLH" crossorigin="anonymous"></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>

The integrity value pins this exact version. Remove it to always get the latest version. Your endpoint must answer {"url": session.url}. See the embed snippet.

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

TermWhat it is
WorkspaceYour GetPay tenant, the legal seller. Its books, SST registration and LHDN settings apply to everything it sells.
AppOne 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.
PlanSomething 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).
LearnerThe 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.
PayerWhoever pays, for example a parent. The payer is an ordinary GetPay customer, bound to the learner by the first paid checkout.
EntitlementWhat 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-onA 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 meterA 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 to https:// 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 }]
}
  • entitlement belongs to you. GetPay never interprets it. The entitlements API returns it as-is, and tier and ai_size are lifted to the top level for convenience.
  • rank decides which plan wins when a learner has several live ones.
  • A credit_pack or topup plan also carries credits: {"unit": "class_session", "qty": 12, "valid_months": 3}. Use valid_days instead of valid_months for 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:

ScopeAllows
entitlements:readGET /entitlements/{ref}, POST /entitlements:batch
billing:readGET /plans, GET /events
billing:writelearners, checkout sessions, portal sessions, credit consumption, referral codes and attributions, event redelivery
usage:writePOST /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 on https://getpay.cc/api/billing/processors/stripe/webhook with 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 for entitlement.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-Key header 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 with Authorization: Bearer <app-bound key>.

  • Errors: every refusal is JSON {"kind":"error","error","code","fix"}. Branch on code, never on the message. The gate order is:

    StatuscodeMeaning
    401NO_API_KEY, INVALID_API_KEYMissing, unknown, revoked or expired key. A gp_test_ key only works on the test workspace
    404 / 405NOT_FOUND, METHOD_NOT_ALLOWEDNo such billing endpoint or method
    403APP_BINDING_REQUIREDA workspace-wide key cannot call billing endpoints
    403SCOPE_REQUIREDThe key lacks the endpoint's scope
    402PLAN_FEATUREThe workspace's plan does not include the billing layer
    403APP_NOT_FOUND, APP_DISABLED, APP_MISMATCHThe key's app is gone or disabled, or the request named another app
    400 / 422INVALID_JSON, VALIDATIONThe body or parameters failed the schema
    409 / 422BILLING_*A business rule refused the call (listed per endpoint in OpenAPI)
    503DATABASE_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 gets 403 APP_MISMATCH.

  • Idempotency: mutating calls take an Idempotency-Key header. 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 a learner_ref may not have); a UUID or a prefixed id such as checkout:ord_8f2k or sess:42:turn-7 is safe. Key scope: app for checkout and portal sessions, learner for credit consumption (one group session id can serve every attending learner), and company for 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.
  • status is none, active, past_due, paused, cancelled or expired. GetPay derives it at read time, so a lapsed period reads expired even before any background job runs. past_due means the learner is inside the grace period and still entitled.
  • Each plans[] entry names its plan as plan_code: the same value the plan catalogue (GET /plans) calls code. There is no plans[].code key.
  • tier, ai_size and entitlement come from the highest-rank plan that is active or past_due. Without one they are "free", null and {}.
  • credits_remaining holds live (unexpired) balances per unit, and credits[] lists the grants, earliest expiry first.
  • plans[].items[] lists the plan's add-ons with their quantity, and addons sums their units (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 at pending_effective_at without any job, an add-on unpaid past its grace reads expired (0 units), and an add-on never entitles while its base plan does not (inactive).
  • valid_until is 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, or null when nothing is paid.
  • Responses carry ETag, Cache-Control: private, max-age=30, stale-while-revalidate=300, stale-if-error=259200 and X-GetPay-Entitlement-Version. Send If-None-Match to get 304 when nothing changed.
  • POST /api/v1/billing/entitlements:batch with {"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:

  1. A cached copy younger than 60 s is used as-is.
  2. Otherwise GetPay is called with an 800 ms timeout, using If-None-Match.
  3. If GetPay fails or times out, the last copy is used only while now < valid_until. Access never runs past what was paid for.
  4. With no usable copy, the learner is free (status: "none").
  5. A fallback never raises a tier. The reader calls onOutage once per outage so you can alert.
  6. A newer entitlement.updated (fed through reader.applyEvent) makes the next read go back to GetPay, and caps the last copy at the event's own valid_until. After a refund, cancel or chargeback that bound is null or earlier, so an outage serves the learner as free, not the revoked tier.
  7. 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 its created time is after the cached copy's as_of, so pass the whole event to applyEvent.
  8. 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 SQL SELECT … 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

Typedata (learner_ref only, never personal data)
entitlement.updatedThe 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 · .expiredsub_id, learner_ref, plan_code, status, current_period_end, cancel_at_period_end, grace_until, paused_until, pending_plan_code, previous
checkout.completed / checkout.expiredcs_id, learner_ref, plan_code
payment.succeeded / payment.failedpayment_id, invoice_id, amount, method, failure_code, learner_ref
invoice.issued / invoice.paidinvoices the billing layer issued for this app: invoice_id, total, status, learner_ref
credits.granted · .consumed · .expiredgrant_id, learner_ref, unit, delta, remaining, source_ref
learner.createdlearner_ref
plan.updated / billing_app.updatedplan_code, version / app, status
coupon.redeemed / coupon.releasedredemption_id (redeemed also carries coupon_code, plan_code, learner_ref, list and discount amounts)
referral.attributed · .converted · .rewardedattribution_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_pack or topup plan bought through checkout grants credits.qty units, which expire after valid_months or valid_days (credits.granted).
  • Consume on use: POST /api/v1/billing/credits:consume {"learner_ref","unit","qty"?,"source_ref"?} with an Idempotency-Key per 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 get 409 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.expired once 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 answers 202 with one result per event, in input order: accepted, duplicate (the key was seen before, so retrying is safe) or rejected with a reason (unknown_meter, out_of_window, invalid_qty, invalid_learner_ref, invalid_idempotency_key).
  • occurred_at must 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.

ChangeWhen it appliesMoney
Increase (or add)when its invoice is paidCharged 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 windowNothing charged, no credit.
Set the current quantity while a decrease is schedulednowWithdraws 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 pay regular.
  • 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_BLOCKED and 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_END in 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.updated carries items[] and the item_change; the add-on invoice emits invoice.issued / invoice.paid; the learner's entitlement.updated carries the new quantities.

Selling an add-on from your product. A quantity change invoices the payer, so the payer confirms it:

  1. Quote it: GET /api/v1/billing/subscriptions/{sub_id}/items/quote?plan_code=seat_pack&quantity=3 (billing:read) returns due_now, timing (on_payment / period_end / now) and the window — show it next to your "Add seats" button.
  2. Send the payer to confirm: POST /api/v1/billing/portal-sessions with {"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.
  3. 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.

RatingUsage qtyMonthly charge
percent_of_volumebilled volume in senround(volume × rate% / 10000, 2)
per_unitunits (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: true is 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:

  1. authenticates the user from its own session. Never trust a learner id sent by the page;
  2. creates the checkout session or portal session with the SDK;
  3. 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_e2e is the only place gp_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 carry livemode: 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, and demo_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) and pro (rank 20), both subscription, monthly, in the same plan_family so a learner holds one of them at a time. Put your feature flags in entitlement, for example {"tier":"pro","features":["exports","sso"],"seats":5}.
  • Pricing page: data-getpay-checkout buttons → your endpoint → checkoutSessions.create (card: charge_automatically).
  • Gate features with EntitlementReader, and read entitlement.features from the answer.
  • Account page: Manage billing → portalSessions.create. Upgrades, downgrades, pauses and cancellations happen there, and your product just follows entitlement.updated.
  • Dunning comes built in. A failed renewal reads past_due for the app's grace days, then expired.

2. Tuition credit packs

  • Plans: pack_12, pack_24 and pack_48 as credit_pack with credits: {"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.consume for 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_session and credits[].expires_at in your app. The portal shows the same to parents.
  • Remind parents to top up when remaining drops, using the credits.consumed webhook.

3. Metered AI usage

  • Meters: ai_tokens (report everything) and ai_tokens_topup with drawdown_unit: "ai_tokens".
  • Plans: a subscription whose entitlement carries your daily cap (for example {"ai_size":"High","daily_tokens":50000}), plus an ai_topup topup plan (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 meter ai_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 shows credits_remaining.camp_dec_2026_seat = 1. At check-in, credits.consume with key checkin:camp_dec_2026 admits the learner exactly once.
  • For a plain one-off purchase (merchandise, an exam fee), use a one_off plan and act on checkout.completed. It does not change the entitlement.
  • Cohort or early-bird prices are price variants (early_bird) or coupons (coupon_code on 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_id to 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) or billing_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 carry learner_ref only.
  • 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 pathScopeSuccess
GET /api/v1/billing/plansbilling:read200 {object:"list", app, data: Plan[]}
PUT /api/v1/billing/learners/{learner_ref}billing:write201 created / 200 existing
GET /api/v1/billing/entitlements/{learner_ref}entitlements:read200 Entitlement, or 304
POST /api/v1/billing/entitlements:batchentitlements:read200 {object:"list", data: Entitlement[]}
POST /api/v1/billing/checkout-sessionsbilling:write201, or 200 on replay (Idempotency-Key required)
POST /api/v1/billing/portal-sessionsbilling:write201, or 200 on replay
POST /api/v1/billing/credits:consumebilling:write200 (Idempotency-Key required)
POST /api/v1/billing/usageusage:write202, with one result per event
GET /api/v1/billing/eventsbilling:read200 {object:"list", data, has_more, next_after_seq}
POST /api/v1/billing/events/{event_id}/redeliverbilling:write202 (Idempotency-Key required)
POST /api/v1/billing/referral-codesbilling:write201 minted / 200 existing
POST /api/v1/billing/referralsbilling:write201 attributed / 200 replay
GET /api/v1/billing/subscriptionsbilling:read200 {object:"list", app, data} with items[] per subscription
GET /api/v1/billing/subscriptions/{sub_id}/items/quotebilling:read200 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.

The billing layer is included on Starter, Growth, Scale and Enterprise, at a monthly fee that falls with your plan and is capped. See pricing.