Skip to main content

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:

ValueMeaning
activeSubscription is in good standing — quota and rate limits enforced as normal.
past_dueMost 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).
unpaidStripe gave up retrying; subscription is suspended. New job creates return 402 billing_state_blocked.
cancelledSubscription was cancelled (by the user or by Stripe). Plan reverts to free on the next state-update webhook.

Trialing collapses to active. Stripe trialing subscriptions are deliberately mapped to active server-side — there is no separate trialing billing 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 the trial_ends_at timestamp returned by GET /platform/v1/billing/status, or call the Stripe Subscriptions API directly with the project's stripe_subscription_id and read the trial_end field on the subscription object. The Stripe Customer Portal is a hosted UI and does not expose trial_ends_at programmatically.

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

FieldTypeRequiredDescription
plan_idstringYesTarget plan slug. One of starter, pro, or business.
promo_codestringNoStripe 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..."
}
FieldTypeDescription
checkout_urlstringHosted Stripe Checkout URL. Redirect the user here.
session_idstringStripe Checkout Session ID. Useful for diagnostics.

Errors

StatusCodeWhen
400invalid_planplan_id is missing or not one of starter, pro, business.
400invalid_promo_codepromo_code is not active in Stripe or has expired.
401unauthorizedMissing 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_..."
}
FieldTypeDescription
portal_urlstringHosted Stripe Customer Portal URL. Redirect the user here.

Errors

StatusCodeWhen
400no_subscriptionThe authenticated user has no Stripe customer ID yet (never reached checkout).
401unauthorizedMissing 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
}
}
FieldTypeDescription
planstringActive plan slug: free, starter, pro, business.
currencystringISO-4217 currency code for the active plan (currently always "usd").
stripe_subscription_idstring | nullStripe Subscription ID, or null when on the free plan.
stripe_customer_idstring | nullStripe Customer ID, or null if checkout has never been completed.
limits.max_jobs_per_dayintegerDaily job-create quota for the plan.
limits.max_projectsintegerMaximum project count allowed. Returns -1 for unlimited tiers (pro, business) — treat any negative value as "no project cap".
limits.rate_limit_per_hourintegerPer-tenant request rate limit.
usage.jobs_todayintegerJobs created so far today (UTC) across all of the user's projects.
usage.project_countintegerCurrent number of projects owned by the user.

Errors

StatusCodeWhen
401unauthorizedMissing 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

ParameterTypeDefaultDescription
limitinteger20Number 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"
}
]
}
FieldTypeDescription
idstringStripe Invoice ID.
statusstringStripe invoice status: draft, open, paid, uncollectible, void.
amount_totalintegerTotal amount charged in the currency's smallest unit (e.g. cents).
currencystringISO-4217 currency code for the invoice.
stripe_subscription_idstring | nullThe subscription this invoice belongs to.
pdf_urlstring | nullDirect download link for the PDF receipt.
hosted_invoice_urlstring | nullStripe-hosted HTML view of the invoice.
created_atstring (ISO 8601)When the invoice was created in Stripe.

Errors

StatusCodeWhen
400invalid_limitlimit is explicitly out of the 1..100 range.
401unauthorizedMissing 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.net library compares the t=<timestamp> value embedded in the Stripe-Signature header against DateTime.UtcNow and 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 eventServer action
checkout.session.completedLinks the new Stripe subscription + customer ID to the user; activates the purchased plan and limits.
customer.subscription.updatedRe-resolves the project's billing state from the subscription's Stripe status; updates the plan if it changed (mid-cycle upgrade/downgrade).
customer.subscription.deletedReverts the project(s) to the free plan and clears the subscription reference.
customer.subscription.trial_will_endLogged for alerting. No state mutation — the trial-end transition arrives as a follow-up customer.subscription.updated.
customer.deletedClears 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.paidRecords a successful invoice payment for accounting/dunning purposes.
invoice.finalizedRecords the invoice once Stripe has finalized it (after any draft/preview window).
invoice.payment_failedRecords a failed invoice payment — used by the alerting pipeline to surface a failed-payment notification to the user.

Response

200 OK

{ "received": true }

Errors

StatusCodeWhen
400missing_signatureStripe-Signature header is absent.
400invalid_signatureSignature 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

PlanPrice/mo (USD)Jobs/monthOpt-in overageProjectsRate limit/hr
Free$050,00011,000
Starter$29300,000$1.50 / 10k jobs35,000
Pro$993,000,000$1.00 / 10k jobsUnlimited50,000
Business$29930,000,000$0.50 / 10k jobsUnlimited500,000
EnterpriseCustomCustomCustomUnlimitedCustom

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
  • Errors402 payment_required, subscription_inactive, and other billing-gated failures
  • Rate Limits — quota tiers correspond to billing plans