Billing API¶
Base path: /api/v1/billing
Handles subscription plans, Stripe checkout and portal sessions, plan changes, usage tracking, cancellation, reactivation, and incoming Stripe webhooks.
See API Reference for auth, errors, and pagination.
Authorization¶
Most write endpoints require the org_owner role (is_org_owner: true in JWT). Read endpoints (/overview, /status, /usage) require any valid JWT. GET /plans is public.
Subscription guard: All authenticated routes outside of Auth and Billing require an active subscription. Billing endpoints are exempt so owners can always upgrade or re-subscribe.
Endpoints¶
GET /plans¶
Return all available subscription plans with features, pricing, and limits.
Auth: Public. If a valid JWT is present, the caller's current plan is identified via the org_plan JWT claim and marked is_current: true. A malformed or absent JWT is silently ignored.
# Anonymous
curl -s https://api.knora.io/api/v1/billing/plans | jq .
# Authenticated — marks the caller's active plan
curl -s https://api.knora.io/api/v1/billing/plans \
-H "Authorization: Bearer <token>" | jq .
Response 200 OK — array of PlanInfo objects:
[
{
"id": "trial",
"name": "Free Trial",
"price_monthly": 0,
"price_display": "Free",
"description": "14-day free trial with full feature access.",
"features": ["Up to 3 users", "1 integration", "1 location"],
"limits": { "max_users": 3, "max_integrations": 1, "max_locations": 1 },
"is_current": false
},
{
"id": "starter",
"name": "Starter",
"price_monthly": 49,
"price_display": "$49/month",
"description": "For small teams getting started.",
"features": ["Up to 10 users", "3 integrations", "1 location"],
"limits": { "max_users": 10, "max_integrations": 3, "max_locations": 1 },
"is_current": true
},
{
"id": "growth",
"name": "Growth",
"price_monthly": 149,
"price_display": "$149/month",
"description": "For growing organisations.",
"features": ["Up to 50 users", "10 integrations", "5 locations"],
"limits": { "max_users": 50, "max_integrations": 10, "max_locations": 5 },
"is_current": false
},
{
"id": "enterprise",
"name": "Enterprise",
"price_monthly": 499,
"price_display": "$499/month",
"description": "Unlimited scale for large organisations.",
"features": ["Unlimited users", "Unlimited integrations", "Unlimited locations"],
"limits": { "max_users": null, "max_integrations": null, "max_locations": null },
"is_current": false
}
]
limits.* fields are null when the plan has no cap on that resource (internally stored as -1, normalised to null in the response).
POST /checkout¶
Create a Stripe Checkout session so the organisation owner can upgrade to a paid plan.
Auth: JWT required. org_owner .
Body: CheckoutRequest
{
"plan": "growth",
"success_url": "https://app.knora.io/billing?success=true",
"cancel_url": "https://app.knora.io/billing?cancelled=true"
}
Superadmins must also include "org_id": "<uuid>" in the body (or ?org_id=).
curl -s -X POST https://api.knora.io/api/v1/billing/checkout \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"plan": "growth",
"success_url": "https://app.knora.io/billing?success=true",
"cancel_url": "https://app.knora.io/billing?cancelled=true"
}' | jq .
Response 201 Created
{
"checkout_url": "https://checkout.stripe.com/pay/cs_test_abc123...",
"session_id": "cs_test_abc123..."
}
Redirect the user to checkout_url to complete payment on Stripe's hosted page.
Errors
| Status | Code | Cause |
|---|---|---|
400 |
VALIDATION_ERROR |
Missing or invalid body fields. |
400 |
INVALID_PLAN |
plan is not a recognised paid plan slug. |
400 |
BILLING_ERROR |
Stripe returned an error. |
403 |
ORG_OWNER_REQUIRED |
Caller is not the org owner. |
403 |
MISSING_ORG_ID |
Superadmin did not supply a target org_id. |
400 |
INVALID_ORG_ID |
org_id is not a valid UUID. |
404 |
— | Organisation not found. |
GET /preview-change¶
Preview the prorated cost of switching to a different plan mid-cycle. Use this before calling POST /change-plan to show the user what they will be charged.
Auth: JWT required. org_owner .
Query: ?plan=<slug> (required)
curl -s "https://api.knora.io/api/v1/billing/preview-change?plan=growth" \
-H "Authorization: Bearer <token>" | jq .
Response 200 OK — ProrationPreview
{
"proration_amount": 3200,
"new_monthly_rate": 14900,
"currency": "usd",
"current_period_end": "2026-07-01T00:00:00Z"
}
proration_amount and new_monthly_rate are in cents.
Errors
| Status | Code | Cause |
|---|---|---|
400 |
MISSING_PLAN |
?plan query parameter is absent. |
400 |
INVALID_PLAN |
plan is not a recognised slug. |
400 |
BILLING_ERROR |
Stripe returned an error. |
403 |
ORG_OWNER_REQUIRED |
Caller is not the org owner. |
403 |
MISSING_ORG_ID |
Superadmin did not supply a target org_id. |
404 |
— | Organisation not found. |
POST /change-plan¶
Change the subscription plan for an existing subscriber.
Auth: JWT required. org_owner .
Upgrade vs downgrade behaviour: - Upgrades are applied immediately with Stripe prorations. The org gains access to the higher plan at once. - Downgrades are scheduled to take effect at the end of the current billing period. The org retains full access to the higher plan until then.
Body: ChangePlanRequest
curl -s -X POST https://api.knora.io/api/v1/billing/change-plan \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"plan": "growth"}' | jq .
Response 200 OK — ChangePlanResponse
For downgrades, is_downgrade is true and effective_at is the ISO 8601 date when the new plan takes effect.
Errors
| Status | Code | Cause |
|---|---|---|
400 |
VALIDATION_ERROR |
Missing or invalid body fields. |
400 |
INVALID_PLAN |
plan is not a recognised slug. |
400 |
BILLING_ERROR |
Stripe returned an error. |
403 |
ORG_OWNER_REQUIRED |
Caller is not the org owner. |
403 |
MISSING_ORG_ID |
Superadmin did not supply a target org_id. |
404 |
— | Organisation not found. |
POST /portal¶
Create a Stripe Billing Portal session so the organisation owner can manage their subscription (update payment method, download invoices, etc.).
Auth: JWT required. org_owner .
Body: PortalRequest
curl -s -X POST https://api.knora.io/api/v1/billing/portal \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"return_url": "https://app.knora.io/settings/billing"}' | jq .
Response 200 OK
Redirect the user to portal_url. The session is single-use — do not cache or share the URL.
Errors
| Status | Code | Cause |
|---|---|---|
400 |
VALIDATION_ERROR |
Missing or invalid body fields. |
400 |
BILLING_ERROR |
Stripe returned an error (e.g. no Stripe customer exists for this org). |
403 |
ORG_OWNER_REQUIRED |
Caller is not the org owner. |
403 |
MISSING_ORG_ID |
Superadmin did not supply a target org_id. |
404 |
— | Organisation not found. |
GET /overview¶
Return a combined snapshot of subscription status and current resource usage in a single request. Intended for the frontend billing store to avoid two sequential round-trips.
Auth: JWT required. Any authenticated org member.
Response 200 OK — OverviewResponse
{
"subscription": {
"org_id": "11111111-1111-1111-1111-111111111111",
"plan": "growth",
"status": "active",
"current_period_end": "2026-07-01T00:00:00Z",
"trial_ends_at": null,
"cancel_at_period_end": false,
"is_active": true
},
"usage": {
"org_id": "11111111-1111-1111-1111-111111111111",
"plan": "growth",
"users": { "current": 12, "limit": 50, "unlimited": false, "at_limit": false },
"integrations": { "current": 3, "limit": 10, "unlimited": false, "at_limit": false },
"locations": { "current": 2, "limit": 5, "unlimited": false, "at_limit": false }
}
}
Errors
| Status | Code | Cause |
|---|---|---|
401 |
— | No or invalid JWT. |
403 |
MISSING_ORG_CLAIM |
JWT has no org_id claim. |
403 |
INVALID_ORG_CLAIM |
org_id claim is not a valid UUID. |
404 |
— | Organisation not found. |
GET /status¶
Return subscription status for the current organisation.
Auth: JWT required. Any authenticated org member.
Response 200 OK — SubscriptionStatus
{
"org_id": "11111111-1111-1111-1111-111111111111",
"plan": "starter",
"status": "active",
"current_period_end": "2026-07-01T00:00:00Z",
"trial_ends_at": null,
"cancel_at_period_end": false,
"is_active": true
}
status field values
| Value | Meaning |
|---|---|
active |
Subscription is current and paid. |
trialing |
Org is within a free trial period. |
past_due |
Last payment failed; Stripe is retrying. |
cancelled |
Subscription has been cancelled. |
unpaid |
All payment retries exhausted. |
incomplete |
Initial payment attempt failed. |
unknown |
Status could not be determined. |
Stripe customer and subscription IDs are intentionally excluded from this response.
Errors
| Status | Code | Cause |
|---|---|---|
401 |
— | No or invalid JWT. |
403 |
MISSING_ORG_CLAIM |
JWT has no org_id claim. |
403 |
INVALID_ORG_CLAIM |
org_id claim is not a valid UUID. |
404 |
— | Organisation not found. |
GET /usage¶
Return current resource usage compared to plan limits for the organisation.
Auth: JWT required. Any authenticated org member.
Response 200 OK — UsageResponse
{
"org_id": "11111111-1111-1111-1111-111111111111",
"plan": "starter",
"users": { "current": 7, "limit": 10, "unlimited": false, "at_limit": false },
"integrations": { "current": 3, "limit": 3, "unlimited": false, "at_limit": true },
"locations": { "current": 1, "limit": 1, "unlimited": false, "at_limit": true }
}
Errors
| Status | Code | Cause |
|---|---|---|
401 |
— | No or invalid JWT. |
403 |
MISSING_ORG_CLAIM |
JWT has no org_id claim. |
403 |
INVALID_ORG_CLAIM |
org_id claim is not a valid UUID. |
404 |
— | Organisation not found. |
POST /cancel¶
Cancel the organisation's active Stripe subscription. By default the subscription runs to the end of the current billing period (at_period_end: true).
Auth: JWT required. org_owner .
Body: CancelRequest (optional — empty body {} uses defaults)
| Field | Type | Default | Description |
|---|---|---|---|
at_period_end |
boolean | true |
Cancel at period end (true) or immediately (false). |
# Cancel at period end (safe default)
curl -s -X POST https://api.knora.io/api/v1/billing/cancel \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"at_period_end": true}' | jq .
# Cancel immediately
curl -s -X POST https://api.knora.io/api/v1/billing/cancel \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"at_period_end": false}' | jq .
Response 200 OK
Errors
| Status | Code | Cause |
|---|---|---|
400 |
VALIDATION_ERROR |
Body field type mismatch. |
400 |
BILLING_ERROR |
Stripe returned an error (e.g. no active subscription). |
403 |
ORG_OWNER_REQUIRED |
Caller is not the org owner. |
403 |
MISSING_ORG_ID |
Superadmin did not supply a target org_id. |
404 |
— | Organisation not found. |
POST /reactivate¶
Undo a pending at-period-end cancellation. Removes the scheduled cancellation so the subscription renews normally at the next billing date.
Auth: JWT required. org_owner . No request body.
curl -s -X POST https://api.knora.io/api/v1/billing/reactivate \
-H "Authorization: Bearer <token>" | jq .
Response 200 OK
Errors
| Status | Code | Cause |
|---|---|---|
400 |
BILLING_ERROR |
Stripe returned an error (e.g. no pending cancellation). |
403 |
ORG_OWNER_REQUIRED |
Caller is not the org owner. |
403 |
MISSING_ORG_ID |
Superadmin did not supply a target org_id. |
404 |
— | Organisation not found. |
POST /webhook¶
Receive and process Stripe webhook events. Called by Stripe's servers only — do not call this from your application.
Auth: No JWT. Stripe authenticates via HMAC-SHA256 in the Stripe-Signature header, verified against STRIPE_WEBHOOK_SECRET. The raw request body must not be parsed before reaching this endpoint.
Handled events
| Event | Effect |
|---|---|
checkout.session.completed |
Activates the subscription after successful payment. |
invoice.paid |
Marks the period as paid; updates current_period_end. |
invoice.payment_failed |
Records payment failure; may flip the org to past_due. |
customer.subscription.updated |
Syncs plan and status changes from Stripe. |
customer.subscription.deleted |
Marks the subscription as cancelled. |
Response 200 OK
Stripe requires a 2xx response to acknowledge receipt. Unacknowledged events are retried with exponential backoff.
Errors
| Status | Code | Cause |
|---|---|---|
400 |
MISSING_SIGNATURE |
Stripe-Signature header is absent. |
400 |
INVALID_SIGNATURE |
HMAC verification failed. |
400 |
WEBHOOK_PARSE_ERROR |
Payload could not be parsed as a Stripe event. |
500 |
WEBHOOK_ERROR |
Event handler raised an unhandled exception. |
500 |
WEBHOOK_NOT_CONFIGURED |
STRIPE_WEBHOOK_SECRET is not set in server config. |
Configure this URL in the Stripe Dashboard under Developers → Webhooks. Event handlers are idempotent; a 500 response causes Stripe to retry.
Schemas Reference¶
PlanInfo¶
| Field | Type | Description |
|---|---|---|
id |
string | Plan slug: trial, starter, growth, enterprise. |
name |
string | Display name. |
price_monthly |
integer | Monthly price in USD (0 for trial). |
price_display |
string | Human-readable price, e.g. $49/month. |
description |
string | Short description. |
features |
string[] | Feature list. |
limits |
PlanLimits |
Hard resource limits. |
is_current |
boolean | true if this is the caller's active plan (requires JWT with org_plan claim). |
PlanLimits¶
| Field | Type | Description |
|---|---|---|
max_users |
integer or null |
Max users. null = unlimited. |
max_integrations |
integer or null |
Max integrations. null = unlimited. |
max_locations |
integer or null |
Max locations. null = unlimited. |
CheckoutRequest¶
| Field | Type | Required | Description |
|---|---|---|---|
plan |
string | Yes | Target paid plan: starter, growth, enterprise. |
success_url |
string | Yes | Redirect URL after successful Stripe checkout. |
cancel_url |
string | Yes | Redirect URL if user abandons Stripe checkout. |
CheckoutResponse¶
| Field | Type | Description |
|---|---|---|
checkout_url |
string | Stripe-hosted checkout URL. |
session_id |
string | Stripe checkout session ID (prefix cs_). |
ChangePlanRequest¶
| Field | Type | Required | Description |
|---|---|---|---|
plan |
string | Yes | Target plan: starter, growth, enterprise. |
ChangePlanResponse¶
| Field | Type | Description |
|---|---|---|
message |
string | Confirmation message. |
is_downgrade |
boolean | true if the change is a downgrade. |
effective_at |
ISO 8601 or null |
When the plan change takes effect. null for immediate upgrades. |
ProrationPreview¶
| Field | Type | Description |
|---|---|---|
proration_amount |
integer | Net proration charge in cents for the remaining billing period. |
new_monthly_rate |
integer | New plan monthly price in cents. |
currency |
string | Currency code (default: usd). |
current_period_end |
ISO 8601 or null |
When the current billing period ends. |
PortalRequest¶
| Field | Type | Required | Description |
|---|---|---|---|
return_url |
string | Yes | URL Stripe returns to after leaving the portal. |
PortalResponse¶
| Field | Type | Description |
|---|---|---|
portal_url |
string | Stripe-hosted billing portal URL. |
SubscriptionStatus¶
| Field | Type | Description |
|---|---|---|
org_id |
string (UUID) | Organisation identifier. |
plan |
string | Current plan slug. |
status |
string | active | trialing | past_due | cancelled | unpaid | incomplete | unknown. |
current_period_end |
ISO 8601 or null |
Next renewal/cancellation date. |
trial_ends_at |
ISO 8601 or null |
Trial expiry (only set when plan == "trial"). |
cancel_at_period_end |
boolean | Cancellation scheduled at period end. |
is_active |
boolean | Whether the org can use the platform. |
UsageResponse¶
| Field | Type | Description |
|---|---|---|
org_id |
string (UUID) | Organisation identifier. |
plan |
string | Current plan slug. |
users |
UsageLimitItem |
User count vs plan limit. |
integrations |
UsageLimitItem |
Integration count vs plan limit. |
locations |
UsageLimitItem |
Location count vs plan limit. |
UsageLimitItem¶
| Field | Type | Description |
|---|---|---|
current |
integer | Current count of this resource. |
limit |
integer | Plan cap. -1 = unlimited. |
unlimited |
boolean | true when no cap applies. |
at_limit |
boolean | true when current >= limit (and not unlimited). |
CancelRequest¶
| Field | Type | Default | Description |
|---|---|---|---|
at_period_end |
boolean | true |
Cancel at period end (true) or immediately (false). |
OverviewResponse¶
| Field | Type | Description |
|---|---|---|
subscription |
SubscriptionStatus |
Current subscription state. |
usage |
UsageResponse |
Current resource usage vs limits. |
Error Codes¶
All errors use the standard envelope — see API Reference.
| Code | HTTP | Description |
|---|---|---|
VALIDATION_ERROR |
400 |
Request body failed schema validation. |
INVALID_PLAN |
400 |
Unrecognised plan slug. |
MISSING_PLAN |
400 |
?plan query parameter absent from GET /preview-change. |
BILLING_ERROR |
400 |
Stripe API returned an error. |
INVALID_ORG_ID |
400 |
org_id is not a valid UUID. |
ORG_OWNER_REQUIRED |
403 |
Action requires org owner role. |
MISSING_ORG_ID |
403 |
Superadmin request missing org_id. |
MISSING_ORG_CLAIM |
403 |
JWT has no org_id claim. |
INVALID_ORG_CLAIM |
403 |
JWT org_id claim is not a valid UUID. |
SUBSCRIPTION_REQUIRED |
402 |
Organisation subscription is inactive or trial has expired. |
MISSING_SIGNATURE |
400 |
Stripe webhook missing signature header. |
INVALID_SIGNATURE |
400 |
Stripe webhook signature verification failed. |
WEBHOOK_PARSE_ERROR |
400 |
Could not parse Stripe webhook payload. |
WEBHOOK_NOT_CONFIGURED |
500 |
STRIPE_WEBHOOK_SECRET not set in server environment. |
WEBHOOK_ERROR |
500 |
Webhook event handler failed. |