Authentication
Every request to the Offer API carries one bearer credential. Which one you send decides what the request can do.
Base URL
Locally the API runs on http://localhost:6767, started by pnpm dev:api at the repo root. In production it is the URL of your API service, for example the onrender.com address Render gives it (see Deploy on Render). All paths on these pages are relative to that URL, with no version prefix.
Requests and responses are JSON. Send Content-Type: application/json with a body. A body that isn't valid JSON returns 400.
Sending a credential
Put the credential in the Authorization header as a bearer token. A missing or wrong credential, or one that isn't allowed on the route, returns 401 with { "error": "Unauthorized" }.
curl http://localhost:6767/apps/$APP_ID/namespaces/user_42/plan \
-H "Authorization: Bearer $OFFER_SECRET_KEY"Credentials
| Credential | Looks like | Where it comes from | Scope |
|---|---|---|---|
| Admin key | any string | The API's ADMIN_API_KEY variable. dev-admin-key in local development. | Every route, for every app, plus the admin-only routes. |
| Secret key | key_… | api_key in the response to POST /apps, or the dashboard's API keys page. | Every route under its own /apps/:appId, except the admin-only ones. |
| Publishable key | pub_… | public_key in the same response. | A short list of read, usage and checkout routes for its app. |
| Account token | act_… | Minted by your server with POST /apps/:appId/namespaces/:accountId/token. | Cancel sessions and a few reads, for one account only. Expires. |
The MCP server at /apps/:appId/mcp also takes OAuth access tokens (mcp_at_…), which work on that route only. See MCP server.
Publishable key
The publishable key works on these routes of its own app and nowhere else:
GET …/namespaces/:accountId/planGETon the routes under…/namespaces/:accountId/usage, plusPOST …/addandPOST …/amountwith an amount of zero or moreGET /apps/:appId/plans/pricingandGET /apps/:appId/plans/:planId/pricingGET /apps/:appId/offers/:offerId/publicandPOST /apps/:appId/offers/:offerId/checkoutPOST /apps/:appId/plans/:planId/checkoutGET /apps/:appId/checkouts/:checkoutIdandPOST /apps/:appId/checkouts/:checkoutId/complete
It isn't tied to one account: anyone who has it can read any account's plan by id and add usage for it. It can't read private meta (/full-plan returns 401) or lower a counter (/remove returns 401, a negative /amount returns 403).
Account tokens
An account token lets a customer's browser act for one account. Your server mints it with the secret key (body { "ttl_seconds": 3600 }, from 60 to 86,400, default 3,600). It is signed with the app's secret key, so regenerating that key revokes every token. A token can call:
- Every route under
/apps/:appId/cancel-sessions, for sessions of its own account, except the session list GETon its own account's/plan,/full-planand/subscription
Cancel flows shows the token in use.
Admin key
The dashboard calls the API with the admin key on behalf of signed-in users. These routes accept nothing else:
POST /apps: creating an app/orgs: the workspaces the dashboard shows, and which apps each one holds/pauses/*: used by the Render Workflow that applies and resumes pausesPOST /apps/:appId/import: backdated history import/apps/:appId/agent/threads: the dashboard agent's chat threads/apps/:appId/mcp/settings,/connectionsand/calls: the MCP server page/oauth/requests/*: the consent page that approves MCP connections/api/auth/*: dashboard sign-in, which the dashboard proxies
No credential
GET /, GET /health, GET /openapi.json, GET /docs and GET /event-types are open. PayPal calls POST /paypal/webhooks/:appId without a key, and the API verifies each call with PayPal. MCP clients use /.well-known/*, /oauth/register, /oauth/authorize, /oauth/token and /oauth/revoke to sign in.
Getting and rotating keys
Create an app to get its keys. The dashboard does this when you add an app, and shows the keys under Developers → API keys. Creating one through the API takes the admin key.
curl -X POST http://localhost:6767/apps \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "My blog" }'{
"id": "app_AbCdEf",
"name": "My blog",
"api_key": "key_…",
"public_key": "pub_…",
"created_at": "2026-10-01T12:00:00.000Z"
}POST /apps/:appId/keys/regenerate replaces the secret key and returns { "api_key": "key_…" }. POST /apps/:appId/public-key/regenerate replaces the publishable key. The old key stops working at once. PATCH /apps/:appId ignores key fields.
Errors
REST routes return errors as JSON with an error message. A few add fields, noted below. The OAuth routes MCP clients use follow the OAuth format instead (error and error_description), and /oauth/authorize shows an HTML error page.
{ "error": "Plan \"enterprise\" not found" }| Status | When |
|---|---|
| 400 | Invalid JSON, a missing or invalid field, or an unknown event type. |
| 401 | No credential, a wrong one, or one that isn't allowed on this route. |
| 402 | A usage call would go past a limit set to block. The body explains the limit and offers an upgrade. |
| 403 | A write with the secret key of a demo workspace app, a negative /amount with the publishable key, a cancel session started with an account token that named a different account, or a call to an app's MCP server while it is turned off. |
| 404 | The app, account, plan or other record doesn't exist, or an account token asked for another account's session. |
| 405 | GET or DELETE on /apps/:appId/mcp. The MCP server only takes POST. |
| 409 | The request conflicts with current state. For example: a record with that id exists, the offer can't be bought (the body adds reason), PayPal isn't connected, or a cancel session isn't at that step. |
| 500 | An unexpected error. The body is { "error": "Internal server error" }. |
| 502 | PayPal returned an error. The message starts with “PayPal:”. |
402 Payment Required
Entitlements with overage.mode set to block refuse usage past the limit. The body has a plain-language message and an upgrade offer with a checkout link, so a person or an AI agent can act on it directly. Access and entitlements shows the full body and how to handle it.
{
"error": "limit_reached",
"message": "You've used 3 of 3 Posts on the Free plan. Pro includes 75 Posts for $4.50/month for 3 months, then $9/month. Upgrade here: …",
"entitlement": { "id": "posts", "name": "Posts", "usage": 3, "max": 3 },
"offer": { "id": "launch_50", "plan": { "id": "pro", "name": "Pro" }, "interval": "month", "price": 4.5, "checkout_url": "…" },
"retry_after_purchase": true
}CORS
The API answers CORS preflight requests for any origin, so browser code on any domain can call it with the publishable key or an account token.
Lists and pagination
Account search (GET /apps/:appId/namespaces), GET /apps/:appId/namespaces/with-incentive and GET /apps/:appId/plans/:planId/namespaces take page and per_page (default 20, at most 100) and return { data, total, page, per_page }. Logs such as events, deliveries, checkouts and cancel sessions take limit and return newest first. Most cap it at 100; GET /apps/:appId/analytics/events defaults to 50 and allows up to 200, and GET /apps/:appId/analytics/top-namespaces returns 400 outside 1 to 100. Catalog lists return everything in the order it was created.
Health check
GET /health runs a query against Postgres and returns { "ok": true }. Render uses it as the service's health check.
curl http://localhost:6767/health
# {"ok":true}OpenAPI spec
The API serves an OpenAPI 3.1 document at GET /openapi.json and an interactive reference built from it at GET /docs (http://localhost:6767/docs locally). The spec is written by hand and covers the core routes: apps, plans, entitlements, add-ons, incentives, accounts, usage, the main analytics routes, webhooks and orgs. Offers, checkouts, subscriptions, PayPal, cancel flows, account tokens, account add-ons, history import, the agent's threads and MCP aren't in it. Endpoint reference lists every route.