Docs menu

Licenses API

Goal: Let your own app show your plans, sell a licence to a signed-in user, and check what that user holds, through your workspace's own Stripe account.

This page answers which /ox-api/licenses routes exist, which right each one needs, what each answers and refuses, and how an app uses them together. Setting up what is sold (licences, plans, prices, coupons, the Stripe keys and Deploy) is done in the Licenses window; these routes sell and read what that window set up.

The routes are under /ox-api/ on your workspace host, https://openx.fathym.com. Which token and headers to send is in Call the API from your app. Every route except Stripe's webhook needs a token.

The routes

RouteRight it needsWhat it does
GET /ox-api/licenses/catalogueNoneThe plans and prices an app shows
GET /ox-api/licenses/mine/{licLookup}None: the caller's own licenceThe caller's own licence and subscription
POST /ox-api/licenses/mine/{licLookup}None: the caller's own licenceSubscribe the caller to a plan and price
DELETE /ox-api/licenses/mine/{licLookup}None: the caller's own licenceCancel the caller's own subscription
POST /ox-api/licenses/mine/{licLookup}/checkoutNone: the caller's own licenceA Stripe Checkout address for the caller to pay
POST /ox-api/licenses/mine/{licLookup}/portalNone: the caller's own licenceA Stripe customer-portal address for the caller
GET /ox-api/licenses/planLicenses.ReadWhat the next Deploy would change in Stripe
GET /ox-api/licenses/healthLicenses.ReadEach licence's Stripe connection health
GET /ox-api/licenses/eventsLicenses.ReadThe Stripe events each licence received
GET /ox-api/licenses/statsLicenses.Stats.ReadEach licence's main Stripe figures
GET /ox-api/licenses/subscribersLicenses.ManageWho holds which plan
POST /ox-api/licenses/webhook/{entLookup}/{licLookup}None: Stripe's signatureStripe's call to your workspace

{licLookup} is a licence's lookup in your workspace, the Lookup you gave it in the Licenses window.

Rights

The three Licenses rights are flat: none implies another. Licenses.Manage does not give Licenses.Read, and Licenses.Stats.Read gives neither. plan, health and events need Licenses.Read, stats needs Licenses.Stats.Read alone, and subscribers needs Licenses.Manage alone because its answer carries an email. A workspace's owners hold all three; anyone else needs them on an access configuration.

A caller without the right is refused before anything is read:

{
  "Code": "ACCESS_RIGHT_REQUIRED",
  "Error": "You do not hold the access right Licenses.Manage in this workspace, which is needed to see who holds which plan of this workspace's licences.",
  "Remedy": "Ask a workspace owner, or someone who holds Workspace.Access.Manage, for access that confers Licenses.Manage.",
  "MissingRights": ["Licenses.Manage"]
}

The status is 403. The routes tagged for your own licence need no Licenses right, because each one acts only on the caller's own licence: the API takes the caller from the token, never from a name in the request, and a username query value that is not the caller's own is refused.

Who the door lets in

"No right" is not "no token". The API door admits no call without one, so even the catalogue is called as an app (its App Identity) or as a signed-in person. Through an App Identity, a signed-in person is let in only if they hold at least one access right in the workspace. For the own-licence routes, use the signed-in person's token, since the licence is theirs. See Call the API from your app and API refusals for the plain 401 and OX_DOOR_ROUTE_NOT_SERVED.

Every route except Stripe's webhook can also answer 503 WORKSPACE_EAC_UNAVAILABLE when the server could not read the workspace. That is not a statement about how the workspace is set up.

Answers never carry a key or payment data

Every answer is built field by field. No Stripe key, card, address or invoice reaches a caller, and the reads that can name people (subscribers) need Licenses.Manage.

The catalogue

GET /ox-api/licenses/catalogue

The plans and prices of every enabled licence, for an app to show. It reads your saved workspace; it does not call Stripe, so it names no subscriber and no key.

{
  "Licenses": {
    "my-licence": {
      "Name": "My licence",
      "Description": "What this licence is",
      "Plans": [
        {
          "Lookup": "standard",
          "Name": "Standard",
          "Description": "For one team",
          "Features": ["Feature one", "Feature two"],
          "Priority": 1,
          "TrialPeriodDays": 14,
          "Prices": [
            { "Lookup": "monthly", "Name": "Monthly", "Currency": "usd", "Value": 49, "Interval": "month" }
          ]
        }
      ]
    }
  }
}

Value is in whole currency units, as the licence holds it. A plan carries Description, Featured, Priority and TrialPeriodDays only when it has them. A plan with no name, and a price without a currency, amount or interval, are left out. A licence that is not enabled is not listed.

A buyer's own licence

All five routes act on the caller's own licence of this workspace. A licence the workspace does not hold is { "Active": false } on the read, 404 LICENSE_NOT_FOUND on subscribe, checkout and portal, and 404 LICENSE_NOT_SUBSCRIBED on cancel. Refusals of these routes have the shape { "Code": "...", "error": "..." }.

Read it

GET /ox-api/licenses/mine/{licLookup}
{
  "Active": true,
  "License": { "PlanLookup": "standard", "PriceLookup": "monthly", "CouponLookup": null },
  "Subscription": { "Id": "sub_...", "Status": "active", "CurrentPeriodEnd": "2026-11-23T00:00:00.000Z" }
}

Active is false when the caller holds nothing; License and Subscription are left out when there is none. Status is Stripe's own word for the subscription, such as active, trialing or past_due. If the read fails, the answer is 502 LICENSE_READ_FAILED: that is not "you hold nothing".

Subscribe

POST /ox-api/licenses/mine/{licLookup}
{ "PlanLookup": "standard", "PriceLookup": "monthly", "CouponLookup": "optional" }

Subscribes the caller to that plan and price (and a coupon, if named) and answers the same License and Subscription fields as the read. Taking payment goes through a checkout address, below, not through this call.

Coming

A payment step that does not depend on one payment provider is being added to this answer. Until it is released, this page describes the answer as it is today: { License, Subscription } only.

StatusCodeWhen
404LICENSE_NOT_FOUNDThe workspace does not hold that licence
404LICENSE_PLAN_NOT_FOUND"This workspace's licence has no such plan."
404LICENSE_PRICE_NOT_FOUND"This plan has no such price."
404LICENSE_COUPON_NOT_FOUND"This workspace's licence has no such coupon."
409STRIPE_PRICE_NOT_FOUND"This price has not been deployed to Stripe yet." Deploy first
502SUBSCRIPTION_FAILED"The subscription could not be configured."

Cancel

DELETE /ox-api/licenses/mine/{licLookup}

Cancels the caller's own subscription and answers { "Cancelled": true }. Having nothing to cancel, or a workspace that does not hold the licence, is 404 LICENSE_NOT_SUBSCRIBED. A cancel that fails is 502 LICENSE_CANCEL_FAILED.

Checkout

POST /ox-api/licenses/mine/{licLookup}/checkout
{ "PlanLookup": "standard", "PriceLookup": "monthly" }
{ "SessionID": "cs_...", "SessionURL": "https://checkout.stripe.com/..." }

Send the buyer to SessionURL. Stripe sends them back to the Checkout success URL or Checkout cancel URL saved on the licence in the Licenses window; the request carries no URL, and one in the body is never used.

StatusCodeWhen
404LICENSE_NOT_FOUNDThe workspace does not hold that licence
404LICENSE_PLAN_NOT_FOUNDThe licence has no such plan
404LICENSE_PRICE_NOT_FOUNDThe plan has no such price
409LICENSE_CHECKOUT_URLS_UNSET"This workspace's licence has no checkout success or cancel address set."
409STRIPE_PRICE_NOT_FOUND"This price has not been deployed to Stripe yet."
502LICENSE_CHECKOUT_FAILED"The checkout session could not be made just now."

Portal

POST /ox-api/licenses/mine/{licLookup}/portal

No body. Answers { "SessionID": "bps_...", "SessionURL": "https://billing.stripe.com/..." } for the caller's own Stripe customer, never one the request names. Stripe sends them back to the Portal return URL saved on the licence.

StatusCodeWhen
404LICENSE_NOT_FOUNDThe workspace does not hold that licence
409LICENSE_PORTAL_URL_UNSET"This workspace's licence has no billing portal return address set."
502LICENSE_PORTAL_FAILED"The billing portal session could not be made just now."

Stripe's webhook

POST /ox-api/licenses/webhook/{entLookup}/{licLookup}

This is Stripe's call, not yours: register this address as the endpoint in your Stripe account, with {entLookup} your workspace lookup and {licLookup} the licence. It takes no token. Its only gate is Stripe's signature: the Stripe-Signature header is checked over the raw body with the licence's Webhook signing secret, within 300 seconds. The body is capped at 512 KiB.

AnswerWhen
200 { "Received": true }A verified event: applied, or a repeat of one already applied, or a type not in the list below
400 { "Code": "STRIPE_WEBHOOK_REFUSED", "error": "This webhook call could not be verified." }Anything else

Every refusal is the same 400 on purpose: an unknown workspace, an unknown licence, no signing secret, a missing or wrong signature, a stale time, an unreadable event and a body over the cap cannot be told apart from outside. The reason is kept as a code for the licence's health and the server log, and is never shown to the caller.

These event types are applied, each exactly as Stripe spells it: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, customer.subscription.paused, customer.subscription.resumed, customer.subscription.trial_will_end, invoice.paid and invoice.payment_failed. Any other type is answered 200 and ignored. An event is applied once: its id is remembered for 30 days, along with its type and time, never its payload.

An applied event is also handed to the Stripe Events nodes that chose its type. A completed checkout, or a created subscription, also records the buyer's licence, which is what the read at mine/{licLookup} shows.

Reading the workspace's licences

These five reads take an optional licenses query value: a comma list of licence lookups, such as ?licenses=my-licence. Absent, every licence the workspace holds is read. Each licence is answered on its own, and a licence that could not be read is Known: false with a Reason code, never zeros and never "nothing to change". They change nothing.

Plan

GET /ox-api/licenses/plan          needs Licenses.Read

What the next Deploy would create, change and deactivate in Stripe, read from Stripe now:

{ "Licenses": { "my-licence": { "Known": true, "Changes": false, "Products": { "Create": [], "Update": [] }, "Prices": { "Create": [], "Activate": [], "Deactivate": [] } } } }

Products, Prices, Coupons and PromotionCodes list what would be created, updated, activated or deactivated, with Counts beside them. Changes: false means a Deploy would change nothing, and is said only after a read that happened. A steward that cannot be asked is 503 PLAN_UNAVAILABLE.

Health

GET /ox-api/licenses/health        needs Licenses.Read
{ "WindowHours": 24, "Licenses": { "my-licence": { "Status": "Healthy", "Problems": [], "Checks": { "Key": { "Known": true, "Valid": true } } } } }

Status is Healthy, Degraded, Unhealthy or Unknown; Unknown means a check could not be read, and is never healthy. Each problem has a Code, the Check it belongs to, a Severity and a sentence. The checks are Key, Mode (live or test, reported not judged), Webhook, WebhookSecret (inferred from the calls received), LastEvent, Drift and ErrorRate. A check that was not read says Known: false with a reason. No key and no raw Stripe error appear.

Stats

GET /ox-api/licenses/stats         needs Licenses.Stats.Read
{ "Licenses": { "my-licence": { "Known": true, "AsOf": "2026-10-23T09:00:00.000Z", "Model": { "Lookup": "my-licence", "Window": { "Days": 30, "From": "2026-09-23T09:00:00.000Z", "To": "2026-10-23T09:00:00.000Z" }, "Mrr": { "usd": 490000 }, "MrrUnpriced": 0, "Active": 100, "ActiveByPlan": { "standard": 100 }, "Trials": 4, "PastDue": 1, "Recent": { "New": 7, "Canceled": 2, "FailedPayments": 1, "Revenue": { "usd": 343000 } } } } } }

Mrr and Recent.Revenue are sums per currency, in that currency's smallest unit (cents for usd), never added across currencies. MrrUnpriced counts live subscriptions that are not in Mrr. AsOf is when Stripe was read; a known answer is kept for up to 5 minutes. A licence that could not be read answers Known: false with a Reason and a Message. Counts and sums only: no customer, email, id or key. The figures above are an example.

Events

GET /ox-api/licenses/events        needs Licenses.Read
{ "WindowHours": 24, "EventDays": 30, "Licenses": { "my-licence": { "Known": true, "Truncated": false, "Events": [{ "Type": "invoice.paid", "ReceivedAt": "2026-10-23T08:55:12.000Z" }], "Counts": { "Verified": 12, "Refused": 0, "RefusedBy": {}, "LastVerifiedAt": "2026-10-23T08:55:12.000Z", "LastRefusedAt": null, "LastRefusedReason": null } } } }

Events are the applied events of the last 30 days, newest first, at most 50; Truncated is true when more were stored than one read takes. Counts cover the last 24 hours. Event type and time only: no payload, customer, email or Stripe id. A licence the workspace does not hold is left out of the answer; one whose events could not be read is Known: false with EVENTS_UNREAD. This is what the Licenses window's Events tab shows.

Subscribers

GET /ox-api/licenses/subscribers   needs Licenses.Manage
{ "Licenses": { "my-licence": { "Known": true, "Subscribers": [{ "Id": "sub_...", "Subscriber": "person@example.com", "Plan": "standard", "Price": "monthly", "Status": "active", "CurrentPeriodEnd": "2026-11-23T00:00:00.000Z" }], "Total": 1, "Counts": { "active": 1 }, "LiveCount": 1, "HasLiveSubscribers": true } } }

Each subscriber has these six fields only, never payment data. LiveCount leaves out canceled and incomplete_expired. A steward that cannot be asked is 503 SUBSCRIBERS_UNAVAILABLE, which is not "nobody".

A worked example: your app sells its own licence

Your app, whatever it is, shows plans, sends a signed-in user to pay, and checks what they hold. The workspace has a licence with lookup my-licence, a plan standard and a price monthly, Deployed to Stripe, and the licence has its checkout success, cancel and portal return URLs saved. The calls below use placeholders; keep real tokens out of your page and your source.

BASE='https://openx.fathym.com/ox-api'
WS='<your workspace id>'

1. Show the plans. Call this with your app's own token, not the buyer's:

curl "$BASE/licenses/catalogue" \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "x-oi-workspace: $WS"

2. Check what the signed-in user holds. Use that user's token. Active: false means show the plans:

curl "$BASE/licenses/mine/my-licence" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "x-oi-workspace: $WS"

3. Send them to pay. Open SessionURL from the answer in the user's browser:

curl -X POST "$BASE/licenses/mine/my-licence/checkout" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "x-oi-workspace: $WS" \
  -H 'Content-Type: application/json' \
  -d '{ "PlanLookup": "standard", "PriceLookup": "monthly" }'

4. When they come back, read step 2 again. A purchase is applied when Stripe's webhook reaches your workspace, which can be a moment after the buyer returns; if Active is still false, read again shortly rather than telling them it failed.

5. Let them manage billing. The portal address lets them change their card or cancel:

curl -X POST "$BASE/licenses/mine/my-licence/portal" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "x-oi-workspace: $WS"

6. Cancel from your own page, if you prefer, with DELETE $BASE/licenses/mine/my-licence and the same headers.

Your own back-office can read the sales side with a token that holds the rights: GET $BASE/licenses/stats for the figures, GET $BASE/licenses/subscribers for who holds what, GET $BASE/licenses/health for the Stripe connection and GET $BASE/licenses/events for the events Stripe sent. In your Stripe account, the secret key, publishable key and webhook signing secret (shown here only as placeholders such as sk_test_REDACT-ME) are entered once in the Licenses window and are never returned by any of these routes.

Next steps

On this page