Skip to Content
Platform APIBilling & Subscription

Billing & Subscription

platform-api owns the whole money path: plans define what you sell, a subscription (via PayPal) grants credits into a per-user wallet, and those credits are spent by relay when the user runs internal AI. This page covers the user-facing endpoints, the subscription lifecycle, credits/quota, and the admin surface.

Credit unit: 1 credit = 1e-6 USD (so 1,000,000 credits = $1). Credit amounts are bigint in the database and serialized as decimal strings in JSON — never parse them as JS numbers.

The pieces

ModelRole
PlanA sellable plan: priceCents, includedCredits, modelAllowlist, rateLimitRpm, dailyLimit / weeklyLimit, enabled, isDefault
SubscriptionA user’s plan enrollment: status, currentPeriodStart/End, monthlyCredits, providerSubId, cancelAtPeriodEnd
CreditWallet / CreditTransactionThe user’s balance and its append-only ledger (grants, spends, adjustments)
Order / PaymentEventPayment records and the webhook idempotency ledger
AiQuotaCounter / AiQuotaReservationPer-user day/week usage counters, written by atomic reservations in agent-service
BillingOperationIdempotency guard for admin wallet/subscription mutations

User endpoints

All require a PAT or Clerk JWT and are @Roles('user', 'admin').

MethodPathPurpose
GET/api/billing/plansList enabled subscription plans
POST/api/billing/subscriptionsStart a subscription — returns a PayPal approveUrl
GET/api/billing/subscriptionThe caller’s current active subscription (or null)
POST/api/billing/subscription/cancelCancel at period end (keeps access until currentPeriodEnd)
GET/api/billing/walletBalance + recent transactions
GET/api/billing/ai-entitlementThe entitlement/quota snapshot the app uses to gate internal AI

Start a subscription

POST /api/billing/subscriptions { "planId": "plan_xxx", "channel": "paypal" }

channel defaults to paypal (the only enabled channel today). The response is { "approveUrl": "https://www.paypal.com/..." } — redirect the user there. A local Subscription row is created in pending; if the PayPal call fails it is rolled back so no orphan remains. An existing effective subscription (pending / active / past_due) returns 409.

Read entitlement

GET /api/billing/ai-entitlement is the single source the resume app reads to decide whether internal AI is available:

{ "mode": "internal", // or "byok_required" "canUseInternal": true, "reason": null, // daily_quota_exhausted | weekly_quota_exhausted | no_balance | no_active_subscription "balanceCredits": "1250000", "currency": "USD", "currentPlan": { "dailyLimit": 50, "weeklyLimit": 200, "modelAllowlist": [] }, "availableModels": ["gpt-5.6", "claude-opus-4-8"], "usage": { "dailyUsed": 3, "weeklyUsed": 12, "dailyResetAt": "…", "weeklyResetAt": "…" } }

canUseInternal is true only when balance > 0 and the plan’s day/week caps aren’t exhausted. With no active subscription it falls back to the default free plan (isDefault, priceCents: 0). availableModels is the admin-curated set of enabled models, narrowed by the plan’s modelAllowlist (empty = full catalog).

Subscription lifecycle

Create → approve

POST /api/billing/subscriptions creates a pending row and returns approveUrl. The user approves on PayPal.

Activate (webhook)

PayPal calls BILLING.SUBSCRIPTION.ACTIVATED → status active, currentPeriodStart/End set, and the first period’s monthlyCredits are granted to the wallet.

Renew (webhook)

Each PAYMENT.SALE.COMPLETED past the current period rolls the period forward and grants the next month’s credits. Grants are idempotent per period (grantKey = subscription-period:{id}:{periodStart}), so a replayed webhook never double-grants.

Cancel

POST /api/billing/subscription/cancel sets cancelAtPeriodEnd: true (access continues until currentPeriodEnd). An hourly cron flips period-ended cancellations to expired. Provider webhooks (CANCELLED / EXPIRED / SUSPENDED / PAYMENT.FAILED) drive cancelled / expired / suspended / past_due.

Payment webhook

POST /api/webhooks/paypal (@Public)

Lives under the /api/webhooks/* prefix, which preserves the raw request body for signature verification. The handler verifies the PayPal signature, then records the PaymentEvent idempotency marker and applies the side effect in one transaction — if dispatch fails, the marker rolls back too, so PayPal’s retry reprocesses cleanly instead of losing the grant. A duplicate event returns { ok: true, duplicate: true }.

Alipay and WeChat providers are registered in the PaymentRegistry, but subscription creation currently accepts PayPal only — other channels return 400.

Credits & the wallet

  • GET /api/billing/wallet{ balanceCredits, currency, recent[] } (recent CreditTransactions).
  • Credits enter via subscription grants; they leave when relay reserves then settles the real token cost of an AI request; admins can adjust manually.
  • The wallet is authoritative and every movement is a CreditTransaction — the ledger reconciles against relay’s UsageLedger.

AI quota (day / week caps)

Beyond raw balance, a plan can cap dailyLimit / weeklyLimit internal AI calls (0 = unlimited). Windows are computed in UTC (ISO week starts Monday 00:00) so resets are deterministic across instances. The counters (AiQuotaCounter) are written by atomic reservations in agent-service, not here — this service only reads them for entitlement. Over-limit blocks internal AI regardless of balance and surfaces as daily_quota_exhausted / weekly_quota_exhausted.

Admin endpoints

All @Roles('admin') (RBAC defaults to admin when a role isn’t specified).

MethodPathPurpose
GET/api/admin/plansList all plans (incl. disabled)
POST/api/admin/plansCreate a plan (UpsertPlanDto)
PATCH/api/admin/plans/:idUpdate a plan (UpdatePlanDto)
DELETE/api/admin/plans/:idDelete a plan
GET/api/admin/subscriptionsRecent subscriptions (latest 100)
GET/api/admin/customersPaginated customer list (current, size, q)
GET/api/admin/customers/:userIdOne customer’s billing detail
POST/api/admin/wallets/:userId/adjustCredit/debit a wallet (amountCredits, memo?, idempotencyKey?)
POST/api/admin/customers/:userId/subscriptionSet a user’s plan (planId + Idempotency-Key header)
GET/api/admin/ordersPaginated orders
GET/api/admin/usage · /api/admin/usage/usersUsage rollups
GET · POST · PATCH/api/admin/channels · /api/admin/channels/:idManage relay channels (account-pool console)

Admin wallet and subscription mutations are idempotent: pass an idempotencyKey (wallet adjust) or Idempotency-Key header (set plan) so a retried/double-clicked submit applies once. The guard is backed by BillingOperation.

Last updated on