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-levelpaginationobject. - 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… | Call | Opens Paymob? |
|---|---|---|
| Change / upgrade / downgrade the plan | POST /change-plan | ❌ Never |
| Add or replace the card / pay to recover | POST /checkout | ✅ Returns redirect_url |
| Read the current billing state | GET /usage | — |
POST /change-planonly 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 /checkoutis the only endpoint that returns a Paymobredirect_url. Use it whenever the user needs to enter/replace a card, or to pay to recover a lapsed account.
There is no
with_paymentflag anymore, and/change-planno longer returns aredirect_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 hypertenant:room, so it reaches every teammate in your workspace and no one outside it. metrictells you which counter changed, so you can update just that number.remainingis the number to render — it is computed with the exact same formula asGET /usage(max(0, limit - used) + pack_balance), so the event value equals what a refetch would return. No follow-upGET /usageis needed to show the new balance.nullmeans the plan is unlimited for that metric.
Two ways to consume it:
- Read straight from the event (no refetch). Take
remainingand update the header number directly. This is the whole point of carrying the numbers on the wire — the credit drops with zero extra HTTP. - 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 oneGET /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:
| Value | Meaning | UI |
|---|---|---|
trial | Free trial, no charge yet | Trial banner + trial_ends_at countdown |
active | Paying subscription | Normal |
past_due | A charge failed; in grace, read-only | Warn; prompt to pay via /checkout |
canceled | Canceled; access until current_period_end, then → suspended | "Ends on …" |
suspended | Grace expired → read-only | Urgent 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:
| Field | Meaning |
|---|---|
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 (restricted ⇔ read_only: true). A soft-grace 'grace' value is reserved for a future change. |
read_only_since | When read-only began; null while paying. |
delete_scheduled_at | Projected 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-plan → 200. 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:- Upgrade →
charged: true. The new plan applies on the webhook (seconds later) → poll/usage. - Downgrade →
charged: false, applied immediately (cheaper next renewal, no refund).
- Upgrade →
- lapsed / canceled (
past_due/suspended/canceled) → rejected with400 { "data": { "error_key": "PAYMENT_METHOD_REQUIRED" } }. Route the user toPOST /checkout(recover), then let them change plan onceactive.
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/checkout → 200.
Request:
{
"callback_url": "https://app.yourdomain.com/billing/callback" // optional
}Response:
{
"message": "Checkout session created",
"data": { "redirect_url": "https://checkout.paymob.com/?..." }
}Flow:
- Call
payment-method. - Redirect the browser to
redirect_url(Paymob hosted page — card entry + 3-D Secure). - Paymob redirects back to your
callback_url(appends?payment_id=…&status=…). - On the callback page →
GET /payment-status?payment_id=…for the immediate outcome, and pollGET /usagefor the final state.
Behaviour by current status:
trial/active/canceled→$1authorize + 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: true ⇒ limit/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_idfrom 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/cancel → 200, { "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/addons → 200.
{ "addon_slug": "extra_sessions", "quantity": 2 } // quantity optional, >=1, recurring onlyResponse:
{
"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 →
addonnull; may return aredirect_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}/pdf → application/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 /usageUpgrade while active (paying)
POST /change-plan { plan_slug } → { charged: true } (charged to the saved card)
→ poll GET /usage until the new plan showsDowngrade 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 /usageRecover 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 /usageCancel
POST /cancel → runs until current_period_end, no renewalEndpoint summary
| Action | Method | Path |
|---|---|---|
| List plans | GET | /api/v1/admin/pricing/plans |
| Change plan | POST | /api/v1/user/billing/change-plan |
| Add / replace card (opens Paymob) | POST | /api/v1/user/billing/checkout |
| Usage + subscription state | GET | /api/v1/user/billing/usage |
| Payment status by payment_id | GET | /api/v1/user/billing/payment-status?payment_id=… |
| Cancel subscription | POST | /api/v1/user/billing/cancel |
| Add-on catalog | GET | /api/v1/user/billing/addons |
| Purchase add-on | POST | /api/v1/user/billing/addons |
| Cancel recurring add-on | DELETE | /api/v1/user/billing/addons/{addon_slug} |
| Invoices | GET | /api/v1/user/billing/invoices |
| Invoice PDF | GET | /api/v1/user/billing/invoices/{reference}/pdf |
Integration checklist
- Render plans from
/admin/pricing/plans(useslug). - Change plan →
POST /change-plan { plan_slug }. If the response hascharged: true, poll/usage. - Add / change card →
POST /checkout, redirect toredirect_url, then poll/usage. (Never expect a redirect from/change-plan.) - Recover
past_due/suspended→POST /checkout(charges to reactivate). - Drive the whole billing screen from
/usage. - Never trust the Paymob redirect alone — confirm with
payment-status?payment_id=and poll/usage. - Handle
past_due/suspended/read_onlywith a recovery banner usinggrace_period_ends_at/delete_scheduled_at.