Corteksa
GuidesBilling

Frontend Integration

Billing — Frontend Integration

How the frontend integrates with the billing API. All endpoints are on our backend; the Paymob payment gateway is only reached via a redirect_url we hand you.

  • Base path: every route below is prefixed with /api/v1.
  • Auth: all endpoints require the user JWT (Authorization: Bearer <token>) and an active workspace on the user. No workspace selected → 400 { "message": "No workspace selected" }.
  • Envelope: success responses are { "message": string, "data": … }. Paginated lists add a top-level pagination object.
  • Identifiers: always slugs (plan_slug, addon_slug), never numeric ids. Request fields are snake_case.

The mental model — two endpoints

Changing the plan and managing the card are two separate calls:

You want to…CallOpens Paymob?
Change / upgrade / downgrade the planPOST /change-plan❌ Never
Add or replace the card / pay to recoverPOST /checkout✅ Returns redirect_url
Read the current billing stateGET /usage
  • POST /change-plan only ever changes the plan. It never asks for card details. If money is owed (an active upgrade), it silently charges the card already on file.
  • POST /checkout is the only endpoint that returns a Paymob redirect_url. Use it whenever the user needs to enter/replace a card, or to pay to recover a lapsed account.

There is no with_payment flag anymore, and /change-plan no longer returns a redirect_url.


The golden rule — poll /usage after anything that charges

The redirect back from Paymob, and the immediate response of a charge, are not proof of success. The real state change lands via an async webhook a moment later.

After any charge (a /checkout redirect return, or a /change-plan response with charged: true) → poll GET /usage until it reflects the change. Poll a few times over ~10–15s.


Realtime usage updates (no polling for metered spend)

Metered usage — AI credits and workflow executions — updates live over the existing /data/events WebSocket, so the header usage bar drops the moment an AI turn or workflow run is billed. No manual refresh, no polling for these two metrics. Stop telling users to reload to see how many credits an AI action spent.

The event (server → client, on the /data/events namespace DataEventsProvider already owns):

'usage:updated': {
  metric: 'ai_credits' | 'workflow_executions',
  charged: number,          // amount just billed in this event
  remaining: number | null, // balance AFTER this charge; null = unlimited plan
}
  • Every teammate in the workspace gets it — by design. Credits and workflow executions are a shared workspace pool, not per-user. So the push goes to the whole workspace, and one person's spend updates the header for everyone currently online in it. On the shared (hyper) tenant it targets a workspace:{id} room; on dedicated tenants the tenant room. It never uses the shared hyper tenant: room, so it reaches every teammate in your workspace and no one outside it.
  • metric tells you which counter changed, so you can update just that number.
  • remaining is the number to render — it is computed with the exact same formula as GET /usage (max(0, limit - used) + pack_balance), so the event value equals what a refetch would return. No follow-up GET /usage is needed to show the new balance. null means the plan is unlimited for that metric.

Two ways to consume it:

  1. Read straight from the event (no refetch). Take remaining and update the header number directly. This is the whole point of carrying the numbers on the wire — the credit drops with zero extra HTTP.
  2. Invalidate QUERY_BILLING.USAGE (['billing','usage']) to refetch the whole overview. Use this if a screen shows more than the single metric (limit, pack balance, other counters) and you want every field refreshed together. Costs one GET /usage.

DataEventsProvider is mounted app-wide (both apps/user and apps/admin dashboard layouts), so the socket is connected on every page and the global header always receives the event — you do nothing per screen to receive it. Which of the two strategies above the handler uses is a frontend choice.

Building a custom usage view? Read through useBillingUsage() (or anything keyed on QUERY_BILLING.USAGE) and it inherits whichever strategy the handler applies — do not open a second socket or poll on a timer for these metrics.

Scope — this covers ONLY metered consumption. It complements (does not replace) the golden rule above: plan/card changes still need /usage polling because their real state lands via the async Paymob webhook, not this counter path.

Verify: note the AI-credits number → run an AI action that spends credits → it drops within ~a second with no reload (from the event's remaining, or one GET /usage if the handler refetches) → open a second teammate's session in the same workspace and confirm their bar drops too on the same spend → confirm another workspace's spend never moves your bar.


Subscription statuses

subscription.status:

ValueMeaningUI
trialFree trial, no charge yetTrial banner + trial_ends_at countdown
activePaying subscriptionNormal
past_dueA charge failed; in grace, read-onlyWarn; prompt to pay via /checkout
canceledCanceled; access until current_period_end, then → suspended"Ends on …"
suspendedGrace expired → read-onlyUrgent recovery banner

Trial is 14 days, extended to 30 days once a card is saved. Grace after a failed charge is 3 days.

Don't infer "restricted" from status + dates — read the authoritative fields. GET /usage (and the /change-plan response) now expose the exact signal the backend enforces:

FieldMeaning
read_only (bool)true ⇒ the workspace is billing read-only. This is precisely what the server gate keys off — when true, every write returns 402 PAYMENT_REQUIRED except billing/payment and workspace-deletion routes. Reads (GET) are never blocked.
access ('full' | 'restricted')Derived convenience form of read_only (restrictedread_only: true). A soft-grace 'grace' value is reserved for a future change.
read_only_sinceWhen read-only began; null while paying.
delete_scheduled_atProjected auto-deletion date (read_only_since + 90 days) if never paid; null while paying.

Gate the UI on read_only/access, not on the status string — past_due and suspended both mean read-only, and a card-on-file trial can sit past trial_ends_at (charge in flight) while still having full access.


Endpoints

1. List plans

GET /admin/pricing/plans — catalog (any authenticated user).

data[]: slug, name, description, features[], currency, isPopular, isSubscribed, timePeriodPricing: { monthly, annual } (null for free plans), buttonText, buttonVariant.

Use slug as plan_slug.


2. Change plan

POST /user/billing/change-plan200. Never opens Paymob; never returns a redirect.

Request:

{
  "plan_slug": "advanced",   // required
  "period": "monthly"         // optional: "monthly" | "annual"
}

Response:

{
  "message": "Plan updated",              // or "Plan change payment initiated" when charged
  "data": {
    "charged": false,                      // true → an active upgrade fired a charge → poll /usage
    "subscription": {
      "status": "trial",
      "trial_ends_at": "2026-07-31T12:00:00.000Z",
      "current_period_end": null,
      "grace_period_ends_at": null,
      "canceled_at": null,
      "gateway": "tap",
      "has_payment_method": false,
      "seat_count": 1,
      "read_only": false,
      "read_only_since": null,
      "delete_scheduled_at": null
    }
  }
}

Behaviour by current status:

  • trial → plan applied immediately, free. charged: false. Just refetch /usage.
  • active → billed to the saved card:
    • Upgradecharged: true. The new plan applies on the webhook (seconds later) → poll /usage.
    • Downgradecharged: false, applied immediately (cheaper next renewal, no refund).
  • lapsed / canceled (past_due / suspended / canceled) → rejected with 400 { "data": { "error_key": "PAYMENT_METHOD_REQUIRED" } }. Route the user to POST /checkout (recover), then let them change plan once active.

Errors: 400 plan not found / Enterprise ("contact support") / PAYMENT_METHOD_REQUIRED (subscription not active — send them to /checkout).


3. Add / replace card ← the only endpoint that opens Paymob

POST /user/billing/checkout200.

Request:

{
  "callback_url": "https://app.yourdomain.com/billing/callback"  // optional
}

Response:

{
  "message": "Checkout session created",
  "data": { "redirect_url": "https://checkout.paymob.com/?..." }
}

Flow:

  1. Call payment-method.
  2. Redirect the browser to redirect_url (Paymob hosted page — card entry + 3-D Secure).
  3. Paymob redirects back to your callback_url (appends ?payment_id=…&status=…).
  4. On the callback page → GET /payment-status?payment_id=… for the immediate outcome, and poll GET /usage for the final state.

Behaviour by current status:

  • trial / active / canceled$1 authorize + auto-void: saves or replaces the card, captures no money (a trial is extended to 30 days). Use this for "add card" and "change card".
  • past_due / suspended → charges the full current-plan amount to recover (also saves the card). Reactivates on the webhook.

callback_url falls back to the server default when omitted. Adding a card is allowed on any plan and in any status (including free plans) — the only error is 400 "No workspace selected".


4. Usage + full subscription state ← the "get my subscription" call

GET /user/billing/usage → the single source of truth for the billing screen. There is no separate "get subscription" endpoint.

{
  "period_key": "2026-07",
  "subscription": {
    "status": "active",
    "plan_slug": "advanced",
    "plan_name": "Advanced Plan",
    "is_per_member": true,
    "period": "monthly",
    "base_price": 49.99,
    "seat_count": 3,
    "recurring_total": 149.97,
    "trial_ends_at": null,
    "current_period_start": "2026-07-01T00:00:00.000Z",
    "current_period_end": "2026-08-01T00:00:00.000Z",
    "grace_period_ends_at": null,
    "canceled_at": null,
    "gateway": "tap",
    "has_payment_method": true,
    "read_only": false,
    "read_only_since": null,
    "delete_scheduled_at": null,
    "access": "full"
  },
  "metrics": {
    "whatsapp_sessions":   { "limit": 10,   "used": 3,   "remaining": 7,   "unlimited": false },
    "workflow_executions": { "limit": 1000, "used": 850, "remaining": 150, "unlimited": false, "pack_balance": 500 },
    "ai_credits":          { "limit": 5000, "used": 2100,"remaining": 2900,"unlimited": false },
    "storage":             { "limit": 10737418240, "used": 1073741824, "remaining": 9663676416, "unlimited": false }
  },
  "active_addons": [
    { "addon_slug": "extra_sessions", "name": "Extra WhatsApp Sessions", "type": "whatsapp_session", "quantity": 5, "status": "active", "canceled_at": null }
  ],
  "consumables": { "workflow_execution_balance": 500, "ai_credit_balance": 100 }
}

recurring_total = base_price × seat_count + recurring add-ons. unlimited: truelimit/remaining are null. pack_balance appears only on consumable metrics.


5. Payment status by payment_id

GET /user/billing/payment-status?payment_id=chg_xxx — live status straight from Paymob (doesn't wait for the webhook). Use on your callback page for the immediate outcome.

payment_id is the id Paymob appends to your callback_url (works for both chg_… charges and auth_… card-saves).

{
  "message": "Payment status retrieved",
  "data": {
    "status": "success",            // "success" | "failed" | "pending"
    "gateway_status": "CAPTURED",   // raw Paymob status
    "message": "Payment completed successfully."
  }
}
  • pending → not settled yet; poll again.
  • Querying a payment_id from another workspace → 403.
  • This confirms the charge outcome only; the plan/card is applied by the async webhook, so also poll /usage.

6. Cancel subscription

POST /user/billing/cancel200, { "message": "Subscription canceled successfully" }.

Only when active. After: status → canceled, canceled_at set, usable until current_period_end, no renewals. Once the period ends it becomes suspended (read-only), like any unpaid account — to come back the user pays via POST /checkout (full charge, reactivates).


7. Add-ons — catalog

GET /user/billing/addons.

data[]: addon_slug, name, type (whatsapp_session | workflow_execution_pack | ai_credit_pack), billing_mode (recurring | one_time), currency, price_monthly, price_annual, and one of grant_whatsapp_sessions / grant_workflow_executions / grant_ai_credits.


8. Add-ons — purchase

POST /user/billing/addons200.

{ "addon_slug": "extra_sessions", "quantity": 2 }   // quantity optional, >=1, recurring only

Response:

{
  "message": "Recurring add-on applied",
  "data": {
    "billing_mode": "recurring",
    "addon": { "addon_slug": "extra_sessions", "name": "…", "type": "whatsapp_session", "quantity": 2, "status": "active", "canceled_at": null },
    "redirect_url": null
  }
}

All add-on purchases require an active subscription with a saved card (status:"active" and has_payment_method:true) — else 400. Gate the buy button on /usage.

  • Recurring add-on → no charge now, folds into the next renewal; capacity granted immediately; redirect_url: null.
  • One-time pack → addon null; may return a redirect_url (handle like a checkout); the balance is credited only after the charge webhook confirms.

9. Add-ons — cancel recurring

DELETE /user/billing/addons/{addon_slug}200, { "message": "Recurring add-on canceled" }. Takes effect next renewal; no refund for the current period.


10. Invoices / payment history

GET /user/billing/invoices?page=1&limit=50 → paginated (newest first).

data[]: reference, type, status (paid | failed), amount, currency, gateway, occurred_at. Plus top-level pagination: { total, page, limit, totalPages }.

Download one: GET /user/billing/invoices/{reference}/pdfapplication/pdf.


End-to-end flows

Add a card (during trial)

POST /checkout { callback_url }  →  { redirect_url }
→ redirect browser to redirect_url  (Paymob: $1 auth + void, no money)
→ Paymob returns to callback_url (?payment_id=…)
→ GET /payment-status?payment_id=…   (immediate outcome)
→ poll GET /usage                 (has_payment_method: true, trial now 30d)

Switch plan on trial (free)

POST /change-plan { plan_slug }  →  { charged: false }
→ refetch GET /usage

Upgrade while active (paying)

POST /change-plan { plan_slug }  →  { charged: true }   (charged to the saved card)
→ poll GET /usage  until the new plan shows

Downgrade returns charged: false and applies immediately.

Change the card while active

POST /checkout { callback_url }  →  { redirect_url }
→ Paymob ($1 auth + void) replaces the card; plan untouched
→ poll GET /usage

Recover a past_due / suspended workspace

POST /checkout { callback_url }  →  { redirect_url }   (full-amount charge)
→ Paymob captures → webhook reactivates → read-only lifted
→ GET /payment-status?payment_id=…  +  poll GET /usage

Cancel

POST /cancel  →  runs until current_period_end, no renewal

Endpoint summary

ActionMethodPath
List plansGET/api/v1/admin/pricing/plans
Change planPOST/api/v1/user/billing/change-plan
Add / replace card (opens Paymob)POST/api/v1/user/billing/checkout
Usage + subscription stateGET/api/v1/user/billing/usage
Payment status by payment_idGET/api/v1/user/billing/payment-status?payment_id=…
Cancel subscriptionPOST/api/v1/user/billing/cancel
Add-on catalogGET/api/v1/user/billing/addons
Purchase add-onPOST/api/v1/user/billing/addons
Cancel recurring add-onDELETE/api/v1/user/billing/addons/{addon_slug}
InvoicesGET/api/v1/user/billing/invoices
Invoice PDFGET/api/v1/user/billing/invoices/{reference}/pdf

Integration checklist

  1. Render plans from /admin/pricing/plans (use slug).
  2. Change planPOST /change-plan { plan_slug }. If the response has charged: true, poll /usage.
  3. Add / change cardPOST /checkout, redirect to redirect_url, then poll /usage. (Never expect a redirect from /change-plan.)
  4. Recover past_due/suspendedPOST /checkout (charges to reactivate).
  5. Drive the whole billing screen from /usage.
  6. Never trust the Paymob redirect alone — confirm with payment-status?payment_id= and poll /usage.
  7. Handle past_due / suspended / read_only with a recovery banner using grace_period_ends_at / delete_scheduled_at.

On this page