Billing API
The Billing API exposes the JWT-authenticated endpoints the dashboard uses to upgrade, downgrade, view invoices, and open the Stripe Customer Portal, plus the anonymous signature-verified webhook receiver Stripe calls back into.
Base URL: https://api.zeridion.com/platform/v1
All endpoints except POST /platform/v1/billing/webhook require a valid JWT obtained from POST /platform/v1/auth/login.
Stripe-only. Zeridion uses Stripe for subscription management, invoicing, and Stripe Tax. There is no second billing backend — every monetary action below ultimately resolves to a Stripe API call.
Billing state model
The server tracks subscription health on every project as a BillingState enum:
| Value | Meaning |
|---|---|
active | Subscription is in good standing — quota and rate limits enforced as normal. |
past_due | Most recent invoice failed; Stripe is still retrying. Existing in-flight jobs continue to be processed by workers, but new job creates are blocked unconditionally and return 402 billing_state_blocked until the invoice is paid (any billing state other than active is gated, regardless of plan tier). |
unpaid | Stripe gave up retrying; subscription is suspended. New job creates return 402 billing_state_blocked. |
cancelled | Subscription was cancelled (by the user or by Stripe). Plan reverts to free on the next state-update webhook. |
Trialing collapses to
active. Stripetrialingsubscriptions are deliberately mapped toactiveserver-side — there is no separatetrialingbilling state. A trialing tenant gets the same quota and 402-gate treatment as a paying tenant. To differentiate "still on trial" in the UI, read thetrial_ends_attimestamp returned byGET /platform/v1/billing/status, or call the Stripe Subscriptions API directly with the project'sstripe_subscription_idand read thetrial_endfield on the subscription object. The Stripe Customer Portal is a hosted UI and does not exposetrial_ends_atprogrammatically.
State transitions
active is the only state that admits new job creates. Every other state returns 402 billing_state_blocked on POST /flare/v1/jobs and any other create-shaped endpoint. Existing in-flight jobs continue processing in all states except cancelled.
POST /platform/v1/billing/checkout
Create a Stripe Checkout Session for a plan upgrade and return its hosted-page URL. The user's browser is expected to redirect to the returned checkout_url to complete payment.
Authentication: JWT required (Authorization: Bearer <jwt> or the zf_jwt cookie).
The server derives a per-call idempotency key from {userId}:checkout:{epochMinute} so two rapid clicks within the same minute collapse to a single Stripe session rather than two duplicate ones.
Request
POST /platform/v1/billing/checkout
Authorization: Bearer <jwt_token>
Content-Type: application/json
Body
| Field | Type | Required | Description |
|---|---|---|---|
plan_id | string | Yes | Target plan slug. One of starter, pro, or business. |
promo_code | string | No | Stripe promotion code to apply (case-insensitive). Forwarded verbatim to Stripe; both percentage and fixed-amount coupons are supported. |
{
"plan_id": "starter",
"promo_code": "LAUNCH50"
}
Response
200 OK
{
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_a1b2...",
"session_id": "cs_test_a1b2..."
}
| Field | Type | Description |
|---|---|---|
checkout_url | string | Hosted Stripe Checkout URL. Redirect the user here. |
session_id | string | Stripe Checkout Session ID. Useful for diagnostics. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_plan | plan_id is missing or not one of starter, pro, business. |
| 400 | invalid_promo_code | promo_code is not active in Stripe or has expired. |
| 401 | unauthorized | Missing or invalid JWT. |
POST /platform/v1/billing/portal
Create a Stripe Customer Portal session so the user can update their payment method, cancel, or download invoices and tax receipts directly from Stripe's hosted UI.
Authentication: JWT required.
Request
POST /platform/v1/billing/portal
Authorization: Bearer <jwt_token>
No request body.
Response
200 OK
{
"portal_url": "https://billing.stripe.com/p/session/test_..."
}
| Field | Type | Description |
|---|---|---|
portal_url | string | Hosted Stripe Customer Portal URL. Redirect the user here. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | no_subscription | The authenticated user has no Stripe customer ID yet (never reached checkout). |
| 401 | unauthorized | Missing or invalid JWT. |
GET /platform/v1/billing/status
Return the current plan, payment-method status, plan limits, and today's usage counters for the authenticated user.
Authentication: JWT required.
Request
GET /platform/v1/billing/status
Authorization: Bearer <jwt_token>
Response
200 OK
{
"plan": "starter",
"currency": "usd",
"stripe_subscription_id": "sub_1NXY...",
"stripe_customer_id": "cus_NXY...",
"limits": {
"max_jobs_per_day": 10000,
"max_projects": 3,
"rate_limit_per_hour": 5000
},
"usage": {
"jobs_today": 142,
"project_count": 2
}
}
| Field | Type | Description |
|---|---|---|
plan | string | Active plan slug: free, starter, pro, business. |
currency | string | ISO-4217 currency code for the active plan (currently always "usd"). |
stripe_subscription_id | string | null | Stripe Subscription ID, or null when on the free plan. |
stripe_customer_id | string | null | Stripe Customer ID, or null if checkout has never been completed. |
limits.max_jobs_per_day | integer | Daily job-create quota for the plan. |
limits.max_projects | integer | Maximum project count allowed. Returns -1 for unlimited tiers (pro, business) — treat any negative value as "no project cap". |
limits.rate_limit_per_hour | integer | Per-tenant request rate limit. |
usage.jobs_today | integer | Jobs created so far today (UTC) across all of the user's projects. |
usage.project_count | integer | Current number of projects owned by the user. |
Errors
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
GET /platform/v1/billing/invoices
Return the most recent invoices for the authenticated user, ordered newest first. Backed by the Stripe Invoices list API.
Authentication: JWT required.
Request
GET /platform/v1/billing/invoices?limit=20
Authorization: Bearer <jwt_token>
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 20 | Number of invoices to return. Must be between 1 and 100 inclusive. |
Response
200 OK
{
"data": [
{
"id": "in_1NXY...",
"status": "paid",
"amount_total": 2900,
"currency": "usd",
"stripe_subscription_id": "sub_1NXY...",
"pdf_url": "https://pay.stripe.com/invoice/.../pdf",
"hosted_invoice_url": "https://invoice.stripe.com/i/...",
"created_at": "2026-04-01T12:00:00Z"
}
]
}
| Field | Type | Description |
|---|---|---|
id | string | Stripe Invoice ID. |
status | string | Stripe invoice status: draft, open, paid, uncollectible, void. |
amount_total | integer | Total amount charged in the currency's smallest unit (e.g. cents). |
currency | string | ISO-4217 currency code for the invoice. |
stripe_subscription_id | string | null | The subscription this invoice belongs to. |
pdf_url | string | null | Direct download link for the PDF receipt. |
hosted_invoice_url | string | null | Stripe-hosted HTML view of the invoice. |
created_at | string (ISO 8601) | When the invoice was created in Stripe. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_limit | limit is explicitly out of the 1..100 range. |
| 401 | unauthorized | Missing or invalid JWT. |
POST /platform/v1/billing/webhook
Stripe webhook receiver. Stripe POSTs subscription, invoice, and customer events here; the server verifies the signature, deduplicates, and applies the resulting state changes to the relevant Project rows.
Authentication: Anonymous (no JWT, no API key). Authenticity is enforced via the Stripe-Signature header.
Request
POST /platform/v1/billing/webhook
Stripe-Signature: t=<timestamp>,v1=<HMAC-SHA256>
Content-Type: application/json
The body MUST be the raw JSON Stripe sent — do NOT parse, re-serialize, pretty-print, normalize whitespace, or otherwise mutate it before forwarding to this endpoint. Any byte-level change invalidates the HMAC signature and the request will be rejected with 400 invalid_signature. This is the most common cause of "all my Stripe webhooks are failing" reports — middleware that JSON-decodes the request body before the handler runs is the usual culprit.
Signature verification
Each request is validated with Stripe.EventUtility.ConstructEvent against the configured Stripe:WebhookSecret.
Replay tolerance: 5 minutes (300 seconds). The
Stripe.netlibrary compares thet=<timestamp>value embedded in theStripe-Signatureheader againstDateTime.UtcNowand rejects any event whose timestamp is more than 300 seconds in the past. If your server clock drifts beyond that window relative to Stripe's clock, valid webhooks will be rejected as replayed — keep NTP enabled on every webhook receiver host. The window is symmetric: events from the future are also rejected.
Secret rotation is supported: during a rotation window the server holds both the current and the previous webhook signing secret and accepts events signed by either, so events signed mid-rotation still land cleanly.
Idempotency / dedup
Every successfully verified event is recorded server-side and keyed by Stripe event ID. Duplicate deliveries (Stripe retries on any non-2xx response) are detected and silently ignored — the server still returns 200 OK so Stripe does not keep retrying.
Events handled
| Stripe event | Server action |
|---|---|
checkout.session.completed | Links the new Stripe subscription + customer ID to the user; activates the purchased plan and limits. |
customer.subscription.updated | Re-resolves the project's billing state from the subscription's Stripe status; updates the plan if it changed (mid-cycle upgrade/downgrade). |
customer.subscription.deleted | Reverts the project(s) to the free plan and clears the subscription reference. |
customer.subscription.trial_will_end | Logged for alerting. No state mutation — the trial-end transition arrives as a follow-up customer.subscription.updated. |
customer.deleted | Clears the Stripe customer reference and the subscription reference on every project the user owns, AND sets each project's billing state to cancelled — new job creates on those projects immediately return 402 billing_state_blocked until a fresh checkout. If a corresponding subscription still exists in Stripe, a follow-up customer.subscription.deleted event arrives shortly after and is the canonical clear of the subscription reference. The cancelled transition is not rolled back even if customer.subscription.deleted lands first — both handlers set the same terminal state. |
invoice.paid | Records a successful invoice payment for accounting/dunning purposes. |
invoice.finalized | Records the invoice once Stripe has finalized it (after any draft/preview window). |
invoice.payment_failed | Records a failed invoice payment — used by the alerting pipeline to surface a failed-payment notification to the user. |
Response
200 OK
{ "received": true }
Errors
| Status | Code | When |
|---|---|---|
| 400 | missing_signature | Stripe-Signature header is absent. |
| 400 | invalid_signature | Signature did not verify against the primary or previous webhook secret. |
See Webhooks → Inbound Stripe webhook for the operator-side setup walkthrough (creating the endpoint in the Stripe dashboard, choosing the event subscriptions, and using the Stripe CLI for local dev).
Plans
| Plan | Price/mo (USD) | Jobs/month | Opt-in overage | Projects | Rate limit/hr |
|---|---|---|---|---|---|
| Free | $0 | 50,000 | — | 1 | 1,000 |
| Starter | $29 | 300,000 | $1.50 / 10k jobs | 3 | 5,000 |
| Pro | $99 | 3,000,000 | $1.00 / 10k jobs | Unlimited | 50,000 |
| Business | $299 | 30,000,000 | $0.50 / 10k jobs | Unlimited | 500,000 |
| Enterprise | Custom | Custom | Custom | Unlimited | Custom |
Job allowances are monthly, aligned to your billing period. When the
allowance is reached, new job creates return 429 monthly_allowance_exceeded
— unless opt-in overage is enabled for the project, in which case jobs
continue and the extra volume is metered at the plan's per-10k rate. Set a
spend cap to bound monthly overage charges; once the cap is reached, new
job creates return 429 spend_cap_reached until the next period.
Tax
VAT and sales tax are collected automatically for EU and UK customers via Stripe Tax. No configuration is required on your side — Stripe determines the applicable rate from the billing address you enter during checkout.
- VAT receipts — every invoice includes a full tax breakdown line. Download the PDF receipt from the Stripe Customer Portal (
POST /platform/v1/billing/portal). - Tax IDs — VAT-registered organisations can enter their VAT/GST number at checkout. Stripe validates the ID in real time and applies the zero-rate B2B rule (reverse charge) where applicable.
- Supported regions — EU member states, United Kingdom, Australia, and any other jurisdictions covered by Stripe Tax.
Currency
The currency field on GET /platform/v1/billing/status returns the ISO-4217 code for the active plan. All plans currently bill in usd. Dashboard price displays read this field so future multi-currency support requires no frontend change.
See also
- Account API — read the authenticated user's account and delete-account flow
- Errors —
402 payment_required,subscription_inactive, and other billing-gated failures - Rate Limits — quota tiers correspond to billing plans