Skip to content

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 OKProrationPreview

{
  "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

{ "plan": "growth" }
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 OKChangePlanResponse

{
  "message": "Plan upgraded to growth immediately.",
  "is_downgrade": false,
  "effective_at": null
}

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

{ "return_url": "https://app.knora.io/settings/billing" }
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

{ "portal_url": "https://billing.stripe.com/session/bps_test_abc123..." }

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.

curl -s https://api.knora.io/api/v1/billing/overview \
  -H "Authorization: Bearer <token>" | jq .

Response 200 OKOverviewResponse

{
  "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.

curl -s https://api.knora.io/api/v1/billing/status \
  -H "Authorization: Bearer <token>" | jq .

Response 200 OKSubscriptionStatus

{
  "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.

curl -s https://api.knora.io/api/v1/billing/usage \
  -H "Authorization: Bearer <token>" | jq .

Response 200 OKUsageResponse

{
  "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

{ "message": "Subscription will be cancelled at the end of the current billing period." }

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

{ "message": "Subscription reactivated. Cancellation has been removed." }

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

{ "received": true }

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.