Overview
Billing — Overview
Complete reference for the billing system: how plans, trials, payments, renewals, add-ons, and account enforcement work — plus the API the frontend integrates against.
This page has two halves:
- Part A — Concepts (business): the model, lifecycle, and rules.
- Part B — Integration (developer): endpoints, payloads, and flows.
The payment gateway is Paymob (paymob.com). Multi-currency; the merchant account is EGP-native but charges are made in the plan's currency (USD by default).
Part A — Concepts
1. The model at a glance
A workspace has exactly one subscription. The subscription tracks the billing state; the workspace tracks which plan and period are applied. Plans are the pricing catalog (Basic, Advanced, Enterprise, …); each has a monthly and/or annual price.
2. Subscription statuses
| Status | Meaning |
|---|---|
trial | Free trial, no charge yet. Full plan access. |
active | Paid and current. Renews automatically each period. |
past_due | A charge failed. In a 3-day grace period; workspace is read-only. |
canceled | User canceled. Keeps access until current_period_end, then becomes an unpaid account (→ suspended, read-only). No renewal. |
suspended | Grace expired without payment. Read-only; on the path to deletion. |
3. Plans & tiers
Plans map to a tenant topology:
- Shared tier (Basic, Advanced, …) — runs on the shared hyper-tenant database.
- Dedicated tier (Enterprise) — provisioned by the team, not self-serve. Subscribing to Enterprise returns
400 "The Enterprise plan is set up by our team. Please contact support."
Self-serve rule. Any non-Enterprise plan can be selected via /change-plan (during the trial it's a free swap; see §6). Only Enterprise is gated — it's provisioned by the team, not self-serve.
3.1 The plan catalog
Three plans exist (basic, advanced, enterprise). They are seeded — there is no create/update API. Prices are USD, per member (seat), per month; the annual figure is the discounted per-month rate when billed yearly (charged as rate × 12 × seats).
| Plan | Slug | Monthly | Annual (per month) | Billing | Popular |
|---|---|---|---|---|---|
| Basic | basic | $25 | $19 | Per member | — |
| Advanced | advanced | $39 | $29 | Per member | ★ |
| Enterprise | enterprise | Custom — contact sales | Custom | Flat negotiated price | — |
3.2 Plan limits (metered resources)
| Limit | Basic | Advanced | Enterprise |
|---|---|---|---|
| WhatsApp (WAHA) sessions | 1 | 1 | Custom / unlimited |
| Workflow executions / month | 5,000 | 30,000 | Custom / unlimited |
| AI credits / month | 300 | 1,000 | Custom / unlimited |
| Storage | 5 GB | 25 GB | Custom / unlimited |
How limits behave:
nullanywhere in the API means unlimited. Enterprise ships withnulllimits unless the super-admin sets per-workspace custom limits.- Effective limit = plan limit → overridden per-field by super-admin
custom_limits(Enterprise) → plus recurring add-on grants (extra WhatsApp sessions). Resolved centrally and cached 5 minutes. - One-time packs (workflow executions, AI credits) do not raise the monthly limit — they credit a persistent pack balance drawn down after the monthly allowance is spent, surviving month rollover (see §8).
- Usage counters are keyed by month, not plan — switching plans never resets consumption, only the ceiling.
- Realtime:
UsageCounterService.recordAiCredits/recordWorkflowExecutionemit an in-processusage.recordedevent after each increment. A realtime listener pushesusage:updatedto the workspace's/data/eventsroom (hyper →workspace:{id}; dedicated → the tenant room), so open clients update usage live without a manual refresh. The push carries{ metric, charged, remaining }—remainingis computed with the samecomputeMetricRemaininghelper asGET /usage, so the client can render the new balance straight from the event (no refetch required). Best-effort — the balance read, compute, and emit are all guarded, so a realtime hiccup never affects the billed write. See FRONTEND_INTEGRATION.md → "Realtime usage updates".
When a limit is hit, the consuming action fails with HTTP 402 and a machine-readable code — the frontend should catch this globally and prompt an upgrade / add-on purchase:
{
"code": "PLAN_LIMIT_EXCEEDED",
"metric": "workflow_executions",
"limit": 5000,
"used": 5000,
"message": "Plan limit exceeded for workflow_executions. Upgrade your plan or purchase an add-on."
}3.3 Plan feature tiers (permissions)
Each plan also grants a fixed permission set (seeded from pricing-plan.constants.ts). All plans include the shared platform features: messaging templates, document templates, integrations, and all messaging provider sessions (WAHA, Facebook, Instagram, WhatsApp Cloud, TikTok). On top of that:
| Capability | Basic | Advanced | Enterprise |
|---|---|---|---|
| Customers | Full CRUD | Create / read / update (no delete) | Full CRUD |
| Products | Create / read | Create / read / update | Full CRUD |
| Orders | Read only | Create / read / update | Full CRUD |
| Invoices | Read only | Create / read / update | Full CRUD |
| Object schema management | — | Create / read / update | Full CRUD + activate |
| Relation management | — | — | Full CRUD |
| User / admin / role management | — | — | Full CRUD |
The exact permission list per plan is returned by GET /admin/pricing/plans (userPermissions).
4. Trial lifecycle
| Constant | Value | Meaning |
|---|---|---|
TRIAL_DAYS | 14 | Default trial length from signup |
TRIAL_EXTENDED_DAYS | 30 | Trial length from signup once a card is saved |
GRACE_DAYS | 3 | Grace window after a failed charge |
- A new workspace starts on a 14-day trial (
trial_ends_at = signup + 14d). - Adding a card extends the trial to 30 days from signup (not from the card-add date). No money is captured at this point — see §5.
- At trial end:
- With a saved card → the first real charge fires (→
activeon capture). - Without a card → the subscription goes
past_due(grace starts, read-only).
- With a saved card → the first real charge fires (→
5. Payment flow (card save → native subscription)
Recurring billing runs on native Paymob subscriptions, which need two integration IDs: a 3DS integration for the first (customer-present) transaction and a MOTO integration for the recurring auto-deductions.
- Adding a payment method (
POST /checkout) creates the subscription via one hosted 3DS checkout (Unified Checkout) — that single transaction saves the card and connects it to the subscription. Returns a redirect URL; there is no separate save-card step and nois_auth.- On a trial → the plan carries the full recurring amount but the first deduction is delayed to trial end via
subscription_start_date(native free trial); the setup transaction is a nominal card verification. - Lapsed / recovering → the subscription starts immediately (the setup transaction charges the full amount, recovering the account).
- On a trial → the plan carries the full recurring amount but the first deduction is delayed to trial end via
- Recurring (native subscription): once created, Paymob auto-debits the card every cycle via the MOTO integration. Each debit arrives as a
charge_capturedwebhook (correlated bysubscription_id) that advances the period — the app schedules no renewals. - Seat/plan changes update the subscription plan's amount in place (an unattended
PUT, no re-checkout); plan upgrades additionally fire a one-time prorated MIT charge for the current period. Add-on packs are one-time MIT charges. Cancelling calls the gateway's cancel endpoint.
6. Plan changes (upgrade / downgrade)
How a plan change is handled depends on one thing: the subscription's status. The rule of thumb: during the trial a plan is just a preview you can swap for free; once you're paying (active) a plan change moves money and is metered precisely.
Endpoint split. Changing the plan and managing the card are two separate endpoints.
POST /change-planonly ever changes the plan and never opens a Paymob page; adding or replacing a card isPOST /checkout(§B). So a plan change never asks for card details, and there is nowith_paymentflag anymore.
6.1 The governing rule — behaviour keys off status
trial→ plan changes are free, immediate swaps, whether or not a card is on file. Nothing is captured during the trial, and the trial-end charge bills whatever plan is attached at that moment, so a card added just to extend the trial does not lock the plan choice.active→ a plan change is a real billing event, charged to the card already on file (§6.3).- lapsed / canceled (
past_due/suspended/canceled) →/change-planis rejected with400 { error_key: "PAYMENT_METHOD_REQUIRED" }. A paid plan is never handed out for free outside the trial — the user must add a payment method / recover viaPOST /checkoutfirst (which reactivates the subscription), then change plan.
6.2 During the trial — free swaps
- Pick / preview / switch to any non-Enterprise plan via
/change-plan→ applied immediately, no charge. Full access to the selected plan for the rest of the trial. - Adding a card is a separate action (
POST /checkout) — that's what stores the card and extends the trial from 14 → 30 days. It does not charge and does not change the plan. - No abuse from switching: usage counters are keyed by month, not by plan, so switching plans never resets consumption — it only changes the ceiling. Switching also never extends the trial (only adding a card does, and that's computed from signup and is idempotent).
6.3 While active (paying) — reuse the saved card, no re-entry
A plan change on an active subscription (POST /change-plan) is charged to the card already on file (an MIT charge, like a renewal). No hosted page, no new card, no waiting for the next renewal. The response carries charged: true when an upgrade fired a payment; the frontend then polls /usage.
| Direction | Charge | When the new plan takes effect | On failure |
|---|---|---|---|
| Upgrade (costs more) | Saved card charged the prorated difference now: (new_base − old_base) × remaining_fraction | On the charge_captured webhook (kind: plan_change) — lands within seconds, not at next renewal | Plan not applied; subscription stays active (non-fatal, no past_due) |
| Downgrade / no-cost | None | Immediately (nothing to confirm) | n/a |
- Downgrades never refund the current period; the lower price folds into the next renewal.
- The charge is auxiliary — it never advances the billing period or changes the subscription status. It only records an invoice line (
plan_change_charged/plan_change_failed).
6.4 How proration is calculated (difference-based)
You're charged only for the increase over your current plan, for the time left in the current period — never the full new-plan price:
remaining_fraction = (period_end − now) / (period_end − period_start)
amount = (new_base_total − old_base_total) × remaining_fractionExample: on a $20/mo plan, upgrading to a $40/mo plan halfway through the month → charged (40 − 20) × 0.5 = $10 now; the next renewal bills the full $40. (base_total = base price × seats for per-member plans.)
Any plan change also drops the cached effective limits, so the new plan's caps take effect at once instead of after the 5-minute cache TTL.
7. Renewals
- Renewals are auto-debited by Paymob (the MOTO subscription) — there is no app renewal scheduler. Each debit's
charge_capturedwebhook advances the period; acharge_failed(after Paymob'sretrial_days) moves the workspace topast_due. - Each attempt stamps
last_renewal_attempt_atwith a 12-hour cooldown so a webhook in flight isn't double-charged. - The charge uses a per-period idempotency key (
renew-{subscriptionId}-{periodEnd}), so a retried request returns the original charge instead of charging twice. - On
charge_capturedthe period advances; oncharge_failedthe subscription goespast_due.
8. Add-ons
Two kinds, both require an active subscription with a saved card:
- Recurring (e.g. extra WhatsApp sessions): no immediate charge — the quantity folds into the next renewal total. Capacity is granted right away. Both
price_monthlyandprice_annualare per-month figures; on an annual plan the add-on billsprice_annual × 12(matching the base plan's full-year charge). E.g. a+1 WhatsApp session (annual)at$14/mobills$14 × 12 = $168/year. - One-time packs (e.g. AI-credit / workflow-execution packs): charges the saved card immediately; the consumable balance is credited only after the
charge_capturedwebhook confirms.
Canceling a recurring add-on stops it at the next renewal — no refund for the current period.
The seeded catalog (returned by GET /user/billing/addons):
| Add-on | Slug | Mode | Grants | Price (USD) |
|---|---|---|---|---|
| +1 WhatsApp Session | whatsapp-session | Recurring | +1 session / month | $19/mo ($14/mo on annual) |
| +10,000 Workflow Executions | wf-pack-10k | One-time | +10,000 pack balance | $8 |
| +50,000 Workflow Executions | wf-pack-50k | One-time | +50,000 pack balance | $30 |
| +250 AI Credits | ai-credits-250 | One-time | +250 pack balance | $6 |
| +1,000 AI Credits | ai-credits-1k | One-time | +1,000 pack balance | $20 |
| +5,000 AI Credits | ai-credits-5k | One-time | +5,000 pack balance | $80 |
9. Seats & proration (per-member plans)
- Per-member plans bill base price × seat count; flat plans bill the base once (
seatCount = 1). - Adding seats mid-cycle charges a prorated amount for the remainder of the period:
fraction = (period_end − now) / (period_end − period_start) amount = per_seat_price × seats_added × fraction - Removing seats never refunds — it just lowers the snapshot, reducing the next renewal.
- Seat changes are deferred — adding/removing a member is never charged mid-cycle; it only updates the seat snapshot and bills
base × seatsat the next renewal (no refund on removal). Plan upgrades and one-time packs are auxiliary charges: they do not advance the billing period or change status. A failed auxiliary charge is non-fatal.
10. Enforcement lifecycle (unpaid accounts)
-
Payment fails → past_due + read-only (immediate, via webhook) The instant Paymob reports charge_failed (a failed renewal or a trial that ended with no card), the subscription flips to past_due, a 3-day grace clock starts (grace_period_ends_at), and the workspace goes read-only right away (read_only_since = now). Read-only means writes are blocked but the app is still viewable — and crucially, billing endpoints stay open so the user can pay to recover.
-
Grace expires → suspended (after 3 days) If they haven't paid within the 3-day grace, the hourly grace scheduler moves them to suspended. Still read-only, now on the path to deletion. The same scheduler also sweeps
canceledsubscriptions whosecurrent_period_endhas passed →suspended(read_only_since = now), so a canceled account becomes an ordinary unpaid account once its paid period ends and follows steps 3–4 identically. -
Unpaid ≥ 90 days → enrolled for deletion After 90 days unpaid, the read-only-delete scheduler stamps deletion_scheduled_at — the workspace is now queued for system deletion (tagged deletion_requested_by_email = 'system@billing' so it's distinguishable from a user deleting their own workspace).
-
Deletion due → data purged (irreversible) When that scheduled time arrives, the workspace-deletion scheduler permanently purges the workspace data. This is the only irreversible step.
At any point before the purge, a successful payment (charge_captured) rescues the account: it clears read_only_since (lifts read-only) and — per the code I just checked (billing-enforcement.service.ts:53-96) — cancels the pending system deletion (clears deletion_scheduled_at etc.), as long as purging hasn't started and it was a billing-initiated deletion (a user's own intentional delete is never auto-cancelled).
11. Schedulers
| Job | Default cron | Env var |
|---|---|---|
| Renewal (charge due subscriptions) | 30 2 * * * (02:30 UTC daily) | BILLING_RENEWAL_CRON |
| Trial expiry (first charge / → past_due) | 0 * * * * (hourly) | BILLING_TRIAL_EXPIRY_CRON |
| Grace expiry (past_due → suspended) | 15 * * * * (hourly :15) | BILLING_GRACE_EXPIRY_CRON |
| Read-only delete (enroll ≥90d unpaid) | 30 * * * * (hourly :30) | BILLING_READONLY_DELETE_CRON |
| Workspace deletion (purge due) | 0 * * * * (hourly :00) | WORKSPACE_DELETION_CRON |
Key windows: GRACE_DAYS = 3, RENEWAL_COOLDOWN_HOURS = 12, BILLING_READONLY_DELETE_AFTER_DAYS = 90, BILLING_DELETE_FINAL_GRACE_DAYS = 0.
Part B — Integration (API)
- Base path: every route is under
/api/v1. - Auth: user-facing endpoints need the user JWT and an active workspace on the user (else
400 "No workspace selected"). - Envelope:
{ "message": string, "data": … }; lists add a top-levelpagination. - Identifiers: always slugs (
plan_slug,addon_slug), never numeric ids. Fields are snake_case.
Two endpoints: plan vs card
POST /change-plan— change the plan. Never opens a Paymob page. Trial swaps and lapsed plan-sets apply immediately; an active change is billed to the saved card (charged: true⇒ poll/usage).POST /checkout— add / replace the card (the only endpoint that opens Paymob). Returns aredirect_url.
Card flow (POST /checkout)
POST /user/billing/checkout (callback_url)
└─► returns { redirect_url } ← Paymob hosted page
browser ─► redirect_url (user enters card + 3DS)
Paymob ─► redirects browser to your callback_url (?payment_id=…&status=…)
│
│ server-to-server (async):
│ Paymob ─► POST /api/v1/webhooks/paymob ─► saves card / activates (lapsed recovery)
▼
frontend ─► GET /user/billing/payment-status?payment_id=… (immediate outcome)
frontend ─► GET /user/billing/usage (final workspace state, poll)The redirect back is not proof of success. Confirm the outcome with
payment-status?payment_id=, and confirm the final workspace/subscription state by polling/usage(the webhook lands a moment after the redirect).
Endpoints
| Action | Method | Path |
|---|---|---|
| List plans | GET | /api/v1/admin/pricing/plans |
| Change plan | POST | /api/v1/user/billing/change-plan |
| Add / replace card | POST | /api/v1/user/billing/checkout |
| Payment status by payment_id | GET | /api/v1/user/billing/payment-status?payment_id=… |
| Usage + subscription state | GET | /api/v1/user/billing/usage |
| 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 |
| Paymob webhook (server only) | POST | /api/v1/webhooks/paymob |
Change plan
POST /user/billing/change-plan → 200. Never returns a redirect — this endpoint only changes the plan; it does not touch the card.
{
"plan_slug": "advanced", // required
"period": "monthly" // optional: "monthly" | "annual"
}Response:
{
"message": "Plan updated", // "Plan change payment initiated" when charged:true
"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
}
}
}trial→ plan applied immediately (free swap),charged:false.active→ billed to the saved card: an upgrade returnscharged:true(plan applies on the webhook — poll/usage); a downgrade applies immediately,charged:false.- lapsed / canceled (
past_due/suspended/canceled) → rejected400 { error_key: "PAYMENT_METHOD_REQUIRED" }; recover viaPOST /checkoutfirst, then change plan. - Errors:
400plan not found / Enterprise (contact support) /PAYMENT_METHOD_REQUIRED(subscription not active).
Add / replace card
POST /user/billing/checkout → 200. The only endpoint that opens a Paymob hosted page.
{
"callback_url": "https://app.example.com/en/settings/billing" // optional
}Response:
{
"message": "Checkout session created",
"data": { "redirect_url": "https://checkout.paymob.com/…" }
}- Send the browser to
redirect_url; after Paymob returns, poll/usage(and optionallypayment-status?payment_id=). - Adding a card is allowed on any plan and in any status — nothing gates it.
past_due/suspendedon a priced plan → charges the full current-plan amount to recover (the charge also saves the card); reactivates on thecharge_capturedwebhook.- everyone else, including free/unpriced plans →
$1authorize + auto-void: saves or replaces the card, captures no money (a trial is extended to 30 days). Applied on thecard_savedwebhook. callback_urlis where Paymob returns the browser (appends?payment_id=…&status=…); falls back to the server default when omitted.- Errors: only
400 "No workspace selected".
Payment status
GET /user/billing/payment-status?payment_id=chg_xxx → live status from Paymob (works for chg_… and trial auth_…).
{
"message": "Payment status retrieved",
"data": {
"status": "success", // "success" | "failed" | "pending"
"gateway_status": "CAPTURED", // raw Paymob status
"message": "Payment completed successfully."
}
}pending → not settled; poll again. Cross-workspace payment_id → 403.
Usage + subscription state
GET /user/billing/usage — the source of truth for the billing screen (no separate "get subscription" endpoint).
{
"period_key": "2026-07",
"subscription": {
"status": "active",
"plan_slug": "advanced",
"plan_name": "Advanced",
"is_per_member": true,
"period": "monthly",
"base_price": 39,
"seat_count": 3,
"recurring_total": 136,
"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
},
"metrics": {
"whatsapp_sessions": {
"limit": 2,
"used": 1,
"remaining": 1,
"unlimited": false
},
"workflow_executions": {
"limit": 30000,
"used": 8500,
"remaining": 31500,
"unlimited": false,
"pack_balance": 10000
},
"ai_credits": {
"limit": 1000,
"used": 400,
"remaining": 850,
"unlimited": false,
"pack_balance": 250
},
"storage": {
"limit": 26843545600,
"used": 1073741824,
"remaining": 25769803776,
"unlimited": false
}
},
"active_addons": [
{
"addon_slug": "whatsapp-session",
"name": "+1 WhatsApp Session",
"type": "whatsapp_session",
"quantity": 1,
"status": "active",
"canceled_at": null
}
],
"consumables": { "workflow_execution_balance": 10000, "ai_credit_balance": 250 }
}recurring_total = base_price × seat_count + recurring add-ons (here: 39 × 3 + 19). unlimited: true ⇒ limit/remaining null. Storage values are bytes; remaining on packs-backed metrics already includes pack_balance (max(0, limit − used) + pack_balance). whatsapp_sessions.used is the live connected-session count, not a monthly counter.
Cancel
POST /user/billing/cancel → { "message": "Subscription canceled successfully" }. Only active subscriptions. Keeps access until current_period_end, no renewal. Once the period ends the account is treated like any unpaid one — an hourly scheduler flips it to suspended (read-only, on the deletion ladder). To come back, the user pays via POST /checkout (full charge → reactivates), exactly like recovering a past_due/suspended account.
Add-ons
GET /user/billing/addons— catalog:addon_slug,name,type(whatsapp_session|workflow_execution_pack|ai_credit_pack),billing_mode(recurring|one_time), prices,grant_*.POST /user/billing/addons—{ "addon_slug": "whatsapp-session", "quantity": 2 }. Requiresstatus:"active"+has_payment_method:true, else400. Recurring → applied now,redirect_url: null; one-time → may return aredirect_url.DELETE /user/billing/addons/{addon_slug}— cancel recurring (next renewal, no refund).
Invoices
GET /user/billing/invoices?page=1&limit=50— paginated (newest first). Items:reference,type(PaymentEventType),status(paid|failed),amount,currency,gateway,occurred_at+ top-levelpagination.GET /user/billing/invoices/{reference}/pdf— branded PDF.
Webhook (server → server, not called by frontend)
POST /api/v1/webhooks/paymob — HMAC-verified. Drives all state changes: activates subscriptions, applies the paid plan switch, credits one-time packs, and moves failed charges to past_due. This is why the frontend must poll /usage after checkout — the state lands here, not on the redirect.
AI usage cost & pricing (platform admin)
Separate from subscription billing: this is how AI runs are costed, priced, and
reported. Everything here is super-admin only (AdminAuthGuard + SuperAdminGuard)
— pricing is global platform config, not tenant data.
The ledger — ai_usage_event (main DB). One immutable row per billed AI run:
workspace_id, admin_id, channel, provider, model_key, input/output/cached
tokens, cost_usd (real dollars), credits, and the pricing row that applied.
run_id is unique (idempotent — a retried run never double-bills). Every AI v2 run
writes one via MeteringCreditGate → AiRunBilling.billRun.
The price catalog — ai_model_pricing (main DB). Effective-dated USD rates
(input / output / cached per-million). A price change is a new row (the old one is
closed), so past charges stay reproducible. PricingResolver reads the current rates to
cost each run.
Real pricing from OpenRouter. OpenRouterPricingService mirrors OpenRouter's
published /api/v1/models rates into the catalog (source='openrouter') — OpenRouter is
the price source only, calls still go direct to Gemini/Kimi. Only models in the
explicit OPENROUTER_MODEL_MAP sync; it upserts only when a rate changed (no
effective-dated churn), and a model OpenRouter doesn't list is skipped, never mis-priced.
Every live provider default (gemini/kimi) must be in the map — a guardrail test
(openrouter-model-map.guardrail.spec) fails CI if one is missing, so a used model can't
ship unpriced.
Accuracy caveat. We call the provider APIs directly, so the true cost is the provider's own rate. OpenRouter's published number is a close reference that can differ (a reasoning/blended tier) — e.g.
gemini-2.5-flashreads ~$0.30/$2.50 per 1M on OpenRouter vs Google's ~$0.15/$0.60 base. Enable the sync only for models whose OpenRouter rate matches your direct invoice; keep the rest admin-set. The sync ships off, so hand-set prices stand untilAI_PRICING_OPENROUTER_SYNC_ENABLED=true.
POST /api/v1/admin/ai-pricing/sync-openrouter— sync now; returns{ updated, unchanged, notFound, failed, models[] }.- Daily
@Cron(OPENROUTER_PRICING_CRON), off unlessAI_PRICING_OPENROUTER_SYNC_ENABLED=true. - Manual overrides still available:
GET/PUT /admin/ai-pricing/models,GET/PUT /admin/ai-pricing/credit-setting,GET /admin/ai-pricing/ledger.
Cost dashboard — admin/ai-analytics (aggregates the ledger). All accept
from / to (ISO; default last 30 days) and optional workspaceId (drilldown):
GET /summary— total USD, credits, runs, workspace count for the range.GET /by-workspace— cost / credits / runs per workspace, ranked, paginated (with name).GET /by-model·GET /by-channel— spend breakdowns.GET /time-series?granularity=day|month— spend over time.
Reference — enums
SubscriptionStatus: trial · active · past_due · canceled · suspended
PaymentEventType (invoices): trial_started · subscription_activated · subscription_renewed · subscription_canceled · charge_captured · charge_failed · card_saved · seat_proration_charged · addon_one_time_charged · addon_charge_failed · plan_change_charged · plan_change_failed — status: "failed" for *_failed, paid otherwise.
ChargeKind (internal, on charge metadata): initial · renewal · seat_proration · addon_one_time · plan_change
AddOnBillingMode: recurring · one_time
Normalized webhook event types: charge_captured · charge_failed · card_saved · ignored
Reference — key config
BILLING_RETURN_URL / FRONTEND_BASE_DOMAIN # default redirect target
APP_BASE_URL # backend base (Paymob webhook + return)
PAYMOB_BASE_URL # regional base (accept.paymob.com …)
PAYMOB_SECRET_KEY # Intention API + saved-token pay + cancel
PAYMOB_PUBLIC_KEY # Unified Checkout URL (client-safe)
PAYMOB_HMAC_SECRET # webhook HMAC verification
PAYMOB_API_KEY # subscription-plan mgmt + transaction inquiry
PAYMOB_INTEGRATION_ID_3DS # first/checkout transaction (customer present)
PAYMOB_INTEGRATION_ID_MOTO # recurring auto-deductions + saved-card MIT
BILLING_RENEWAL_CRON=30 2 * * * # native subs auto-debit; cron only STARTS subscriptions
BILLING_TRIAL_EXPIRY_CRON=0 * * * *
BILLING_GRACE_EXPIRY_CRON=15 * * * *
BILLING_READONLY_DELETE_CRON=30 * * * *
WORKSPACE_DELETION_CRON=0 * * * *
BILLING_READONLY_DELETE_AFTER_DAYS=90
BILLING_DELETE_FINAL_GRACE_DAYS=0
# AI pricing (OpenRouter price sync)
AI_PRICING_OPENROUTER_SYNC_ENABLED=false # daily catalog sync on/off (manual endpoint always works)
OPENROUTER_PRICING_CRON=15 4 * * *
OPENROUTER_MODELS_URL=https://openrouter.ai/api/v1/models
OPENROUTER_API_KEY # optional; the models list is public