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
| Route | Right it needs | What it does |
|---|---|---|
GET /ox-api/licenses/catalogue | None | The plans and prices an app shows |
GET /ox-api/licenses/mine/{licLookup} | None: the caller's own licence | The caller's own licence and subscription |
POST /ox-api/licenses/mine/{licLookup} | None: the caller's own licence | Subscribe the caller to a plan and price |
DELETE /ox-api/licenses/mine/{licLookup} | None: the caller's own licence | Cancel the caller's own subscription |
POST /ox-api/licenses/mine/{licLookup}/checkout | None: the caller's own licence | A Stripe Checkout address for the caller to pay |
POST /ox-api/licenses/mine/{licLookup}/portal | None: the caller's own licence | A Stripe customer-portal address for the caller |
GET /ox-api/licenses/plan | Licenses.Read | What the next Deploy would change in Stripe |
GET /ox-api/licenses/health | Licenses.Read | Each licence's Stripe connection health |
GET /ox-api/licenses/events | Licenses.Read | The Stripe events each licence received |
GET /ox-api/licenses/stats | Licenses.Stats.Read | Each licence's main Stripe figures |
GET /ox-api/licenses/subscribers | Licenses.Manage | Who holds which plan |
POST /ox-api/licenses/webhook/{entLookup}/{licLookup} | None: Stripe's signature | Stripe'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.
| Status | Code | When |
|---|---|---|
| 404 | LICENSE_NOT_FOUND | The workspace does not hold that licence |
| 404 | LICENSE_PLAN_NOT_FOUND | "This workspace's licence has no such plan." |
| 404 | LICENSE_PRICE_NOT_FOUND | "This plan has no such price." |
| 404 | LICENSE_COUPON_NOT_FOUND | "This workspace's licence has no such coupon." |
| 409 | STRIPE_PRICE_NOT_FOUND | "This price has not been deployed to Stripe yet." Deploy first |
| 502 | SUBSCRIPTION_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.
| Status | Code | When |
|---|---|---|
| 404 | LICENSE_NOT_FOUND | The workspace does not hold that licence |
| 404 | LICENSE_PLAN_NOT_FOUND | The licence has no such plan |
| 404 | LICENSE_PRICE_NOT_FOUND | The plan has no such price |
| 409 | LICENSE_CHECKOUT_URLS_UNSET | "This workspace's licence has no checkout success or cancel address set." |
| 409 | STRIPE_PRICE_NOT_FOUND | "This price has not been deployed to Stripe yet." |
| 502 | LICENSE_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.
| Status | Code | When |
|---|---|---|
| 404 | LICENSE_NOT_FOUND | The workspace does not hold that licence |
| 409 | LICENSE_PORTAL_URL_UNSET | "This workspace's licence has no billing portal return address set." |
| 502 | LICENSE_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.
| Answer | When |
|---|---|
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
- Set up what is sold -> Licenses
- Stream the events into a data connection -> Stripe Events node
- Read these from an AI -> call_api
- Tokens, headers and refusals -> Call the API from your app