API clients: letting applications outside Whoobe use your integrations and data#
An API client is the registration of one application (a back-end, a job, a mobile app, a web app on another domain) that calls
Whoobe through the public surface /client/v1. It sees only what you allow, with its own limits, and the credentials of the
third-party services stay inside Whoobe. No user account is created in Whoobe for it.
This guide is the complete reference. The Integrations guide (section 4) is the short version; the
design and the decisions are in docs/plans/api-gateway/client-access-hub.md.
Contents
- What it is for
- Key or token: choose how the application proves who it is
- Quick start
- The editor, field by field
- What a client may call: scopes
- The calls
- Code examples
- Answers and errors
- Limits, quota and the counter store
- Identity provider tokens in detail
- Operating it
- Security model
- Development and production
- When something does not work
- What it does not do (yet)
- Reference: the administration API
1. What it is for#
Typical uses:
- A Flutter or native app shows data from your Data Objects and calls a CRM through Whoobe, without ever holding the CRM's secret.
- A back-end or a job (ERP sync, a nightly import) reads the published catalogue or calls an Integrations route.
- A web app on another domain (not generated by Whoobe) reads published content.
- An organisation uses Whoobe as a hub: one place where the credentials, the routes and the data of its services are defined, and many applications use them.
What an application can reach today:
| Surface | Address | Allowed by |
|---|---|---|
| Integrations routes (the endpoints you configured: CRM, ERP, mail...) | /client/v1/gateway/<endpoint>/<route> |
gateway:call:... scopes |
| Published Data Objects (read) | /client/v1/data/<class> and /client/v1/data/<class>/<id> |
data:read:<Class> scopes |
| Data Objects (create and change drafts) | POST /client/v1/data/<class>, PATCH /client/v1/data/<class>/<id> |
data:write:<Class> scopes |
| Assets: the files of the DAM (metadata, address, the file itself) | /client/v1/assets, /client/v1/assets/<id>, /client/v1/assets/<id>/content |
assets:read:<folder> scopes |
| Automations (start a flow in Activepieces / n8n and get its answer) | /client/v1/automations/<endpoint>/<route> |
automation:run:... scopes |
| Who am I (diagnostics) | /client/v1/me |
any valid credential |
Publishing, deleting and uploading assets are not on /client/v1 (section 15).
2. Key or token: choose how the application proves who it is#
| A key | Identity provider token | |
|---|---|---|
| For | a server: back-end, job, another system | a browser or an app: Flutter, React, a mobile app |
| The application sends | Authorization: Bearer wbk_... or X-Whoobe-Key: wbk_... |
Authorization: Bearer <access token> of your identity provider (Keycloak, Entra ID, Cognito, Auth0...) |
| Secret to keep | yes: a static key, shown once | none: Whoobe validates the provider's signature |
| Who is the caller | the client | the client and the signed-in user (sub) |
| Per-user permissions | no | yes: rules on the token's claims (roles, groups) |
| Revocation | revoke the key, effective at once | the token lives until it expires (provider's setting); suspend the client to stop all |
The rule: a static key never goes into a web page or a mobile app, because anyone can read it there. A browser or an app signs in
with your identity provider and sends the token it receives. Whoobe enforces it: a request that carries a key and an Origin
header (what a browser adds) is refused with 403 and logged.
The mode is chosen when the client is created and cannot be changed afterwards (create another client instead).
3. Quick start#
3.1 A server with a key (2 minutes)#
- Dashboard > Data & Communication > API clients > New client.
- Name it, leave A key.
- Under What this client may call, choose Add an API..., pick an endpoint (or Data Objects) and tick the routes (or classes).
- Create and show key. Copy the key now: it is shown once.
- Try it:
curl -H "Authorization: Bearer wbk_XXXXXXXX_..." https://<host>/client/v1/me
curl -H "Authorization: Bearer wbk_XXXXXXXX_..." "https://<host>/client/v1/data/Blog?limit=5"
curl -H "Authorization: Bearer wbk_XXXXXXXX_..." https://<host>/client/v1/gateway/Shop/orders.json
3.2 An app with the identity provider token#
- At the provider: a client for the application, the roles for your users, an audience for Whoobe (section 10 has the Keycloak steps).
- Dashboard > API clients > New client > Identity provider token: issuer, audience, optional rules.
- Add the APIs and routes the application may call.
- The application signs in with the provider and sends
Authorization: Bearer <access token>to/client/v1/....
4. The editor, field by field#
Creating or editing a client opens a tab (not a dialog), so you can keep the Integrations tab open beside it. Unsaved changes are marked with a dot on the tab, and closing it asks first.
General#
- Name (required, unique). It appears in the list and in every log line of the client's calls.
- Description (optional): who owns it, where it runs.
Authentication#
- A key or Identity provider token (section 2). For a token client the registration fields appear (section 10).
Limits#
| Field | Meaning |
|---|---|
| Requests per second | the client's allowance, counted over a sliding second (default 10, up to 1000) |
| Daily quota | maximum calls per UTC day; empty = none |
| Expires | after this date the client is refused |
| Allowed addresses | optional list of addresses or IPv4 ranges (203.0.113.5, 10.0.0.0/8) from which the credential is accepted. For a key this is the strongest control you have |
What this client may call#
The select Add an API... lists every Integrations endpoint and Data Objects. An added API becomes a card:
- an endpoint card lists its routes (
GET orders.json,POST contacts...) to tick, and all routes (every route of the endpoint, any method, including routes added later: use it only for a client you fully trust); - the Data Objects card lists the Data Classes to tick, and all classes (including classes created later);
- the Automations card lists the automations found in your pages and snippets (forms that submit to an Integrations endpoint, by the name given in Form settings, with the endpoint and route) to tick;
- the number after the name says how many permissions the card gives; the x removes the API and its permissions from the client.
Opening an existing client shows the cards of the APIs it already uses. A permission the picker does not recognise (for example an endpoint that was renamed) is listed under Other scopes kept as they are, so saving never drops it silently; remove it with its x.
The list#
The list shows each client with its mode (key / token), state, what it may call (click an entry to copy the full address), its
limits, its keys (prefix only), and when it was last used. Actions: edit, add a key (rotation), suspend / activate, delete.
5. What a client may call: scopes#
A scope is one text. The dashboard writes them for you; you read them in the list, in /client/v1/me and in the administration API.
5.1 Integrations routes#
gateway:call:<endpoint name>:<METHOD or *>:<route>
| Scope | Allows |
|---|---|
gateway:call:Shop:GET:orders.json |
that route |
gateway:call:Shop:GET:customers/{id} |
a route with a parameter |
gateway:call:Shop:GET:customers/* |
every route whose path starts with customers/ |
gateway:call:Shop:*:* |
every route of the endpoint, any method |
- The scope is checked against the route the request path was matched to, never against the raw path.
- The endpoint is always named, never a wildcard, and its name cannot contain a
:(rename it in Integrations to use it). - Renaming an endpoint makes its scopes stop matching until you edit the client: it fails closed.
- Two equally specific routes (
products/{id}andproducts/{name}) are resolved among the ones the client may call.
5.2 Data Objects#
data:read:<Class name> or data:read:*
- Read of the published objects of one Data Class. The class is named exactly (case counts:
Blog, notblog); there are no patterns. data:read:*covers every class, including classes created later (the dashboard marks it as wide).data:write:<Class name>ordata:write:*: create objects as drafts and change drafts (section 6.3c). It is its own scope: it does not read, anddata:readdoes not write. Nothing here publishes; a draft is published by an editor in the dashboard.
5.2b Assets#
assets:read:<folder> or assets:read:*
- Read of the assets of a DAM folder and its sub-folders:
assets:read:/productsallows/productsand/products/summer, not/products-old. Folder names are written as in the DAM tree;*covers every folder, including folders created later (the dashboard marks it as wide). - The scope decides, not the folder's public / private setting: a client with the scope reads private files too. An asset outside the scopes, an unknown id and a malformed id all answer
404. - There is no upload scope yet.
5.3 Automations#
automation:run:<endpoint name>:<route> (or automation:run:<endpoint name>:* for every POST route of the endpoint)
- Lets the client start the flow behind that route and receive its answer. The route is the webhook route configured in
Integrations for the engine (Activepieces
webhooks/<flow id>, n8nwebhook/<path>); see marketing automation. - It is a separate permission from
gateway:call: an automation scope does not allow calling the route through/gateway/, and a gateway scope does not allow/automations/. - Only
POSTroutes are reachable. There are no prefix patterns (webhook/*is refused), only an exact route or*. - The picker proposes the automations it finds in your pages and snippets, but the scope is just the endpoint and the route: renaming the form later changes nothing, and a client may be given a route no form uses (type it in the administration API).
5.4 Scopes of a token client#
A token client has the scopes ticked in the editor as its ceiling. With claim rules (section 10) each signed-in user gets only the part of the ceiling that their claims give.
6. The calls#
Base address: https://<host>/client/v1 (in development the gateway's own port, for example http://localhost:3001/client/v1).
Send the credential as Authorization: Bearer ...; a key may also go in X-Whoobe-Key.
6.1 GET /client/v1/me#
Name, mode, the user's sub (token clients), the scopes that apply to this call, limits, expiry and the state of the counter store.
No secret. Use it first to check a credential.
{ "name": "mobile-app", "authMode": "jwt", "subject": "7f3c...", "scopes": ["data:read:Blog"],
"rateLimitRps": 10, "dailyQuota": null, "expiresAt": null, "limiter": { "backend": "memory", "degraded": false } }
6.2 GET /client/v1/data/<class> - list published objects#
| Parameter | Meaning |
|---|---|
filter=field:operator:value |
repeat up to 5 times; operators eq, neq, contains, gt, lt; all conditions must hold |
orderBy=field, dir=asc|desc |
sort (default: newest published first) |
limit |
1-100, default 50 |
offset |
0 and up, for paging |
locale |
one locale, for example it |
A field is one of the class's own fields, or key, locale, updatedAt. Anything else is refused with 400.
curl -H "Authorization: Bearer $KEY" \
"$BASE/data/Blog?filter=title:contains:ai&orderBy=updatedAt&dir=desc&limit=10&locale=en"
{ "class": "Blog",
"objects": [ { "id": "8ab24ebb-...", "key": "2c1814ac-...", "locale": "en", "translationGroupId": "505bba76-...",
"publishedAt": "2026-10-03T07:15:26.807Z", "updatedAt": "2026-10-07T16:14:28.182Z",
"attributes": { "title": "Using AI as a structural co-pilot", "abstract": "<p>...</p>" } } ],
"limit": 10, "offset": 0 }
6.3 GET /client/v1/data/<class>/<id> - one object#
Returns { "object": { ... } } with the same shape. The object must be published and belong to that class.
What an object contains: id, key, locale, translationGroupId (the same value links the translations of one record),
publishedAt, updatedAt and attributes (only the fields the Data Class defines). The class id, folder path, parent, workflow
state, import id and creation date are not returned. Relations come back as ids and asset fields as references ({ "assetId": "..." }); add ?expand=assets to get the file's facts and addresses next to them (section 6.3b).
Never returned: drafts. A draft, a class outside the scopes, an unknown class, a malformed id and the id of an object of another
class all answer the same 404 Not found.
6.3b Assets: ?expand=assets, /assets, /assets/<id>, /assets/<id>/content#
A data object keeps an asset as a reference, { "assetId": "74046bf3-..." }, because the file's address depends on where the workspace is
(a CDN, a public bucket, or only a private store that the application cannot reach). The client reaches the file in one of three ways.
1. Ask for the addresses with the data: ?expand=assets on /data/<class> and /data/<class>/<id>. Next to each { assetId } of an
image, video or asset field (also in a list) comes the asset:
"cover": { "assetId": "74046bf3-...",
"asset": { "id": "74046bf3-...", "filename": "builder.png", "folder": "/images", "mimeType": "image/png", "type": "PNG",
"size": 682726, "width": 1024, "height": 1024, "updatedAt": "2026-09-12T10:45:04.711Z",
"url": "https://cdn.example/builder.webp", "contentPath": "/client/v1/assets/74046bf3-.../content" } }
Only assets of folders the client's assets:read: scopes allow are expanded; any other reference stays as { "assetId": "..." }, so
the data does not tell a client that a file it may not read exists. Without an asset scope, expand changes nothing. At most 200 distinct
assets are expanded per call.
url: an address that needs no credential: the CDN mirror when the asset has one, otherwise the public address of a public asset (when the workspace has a public address for its files).nullfor a private asset, or when there is no public address. In development it may point tolocalhost, which a phone cannot reach: usecontentPath.contentPath: always present. The path (on the same address as the other/client/v1calls) that streams the file with the client's credential.
2. GET /client/v1/assets?folder=/products&limit=50&offset=0: one page (limit 1-100, default 50) of the assets of a folder and its
sub-folders, in a stable order, with the same asset shape: { "folder", "assets": [...], "limit", "offset" }. folder is required unless
the client has assets:read:*; a folder outside the scopes answers 404.
3. GET /client/v1/assets/<id> returns { "asset": { ... } }.
GET /client/v1/assets/<id>/content returns the file itself, with its Content-Type and Content-Length; Range is supported (video,
resumed downloads). It works for private assets and when the application cannot reach the storage, because the gateway reads the file
from the asset processor. The file needs the Authorization header: a browser <img src> cannot send it, so for web pages use url
(public assets and CDN) or fetch the file with the token and show it from a blob: address. Files a browser would run (HTML, SVG, XML,
JavaScript) are sent as a download. The storage key and the bucket are never shown.
curl -H "Authorization: Bearer $KEY" "$BASE/data/Blog?limit=1&expand=assets"
curl -H "Authorization: Bearer $KEY" "$BASE/assets?folder=/images&limit=20"
curl -H "Authorization: Bearer $KEY" -o cover.png "$BASE/assets/74046bf3-.../content"
6.3c Writing: POST /client/v1/data/<class>, PATCH /client/v1/data/<class>/<id>#
Scope data:write:<Class>. A flow that writes back should add the header X-Whoobe-Flow: stop: the write is kept but starts no object event flow (see Stop a loop there). The <id> is the record's id: in the dashboard, right click the row in Objects > Copy id. A client creates drafts and changes drafts; the API has no way to publish, and a published object cannot be
changed through it. An editor reviews the draft in the dashboard (Objects) and publishes it. The new object is invisible to every
GET (they return published objects only).
Create (Content-Type: application/json, at most 1 MB):
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" "$BASE/data/Blog" \
-d '{ "key": "my-first-post", "locale": "en", "attributes": { "title": "From the app", "tags": ["news"] } }'
| Property | Meaning |
|---|---|
attributes |
required: the fields of the Data Class; every mandatory field must be present |
key |
optional, 1-120 characters without /; default api-<random>. Unique per class and language |
locale |
optional, one language per call (default en) |
Answer 201 { "draft": true, "object": { ... } } with the same shape as a read (keep object.id and object.updatedAt).
Change a draft: send only the fields to change; null clears an optional field; key and locale cannot be changed.
curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H 'If-Match: "2026-10-13T13:02:52.712Z"' "$BASE/data/Blog/<id>" -d '{ "attributes": { "title": "Edited" } }'
If-Match (optional) takes the updatedAt you read: if the draft changed in the meantime the answer is 412 and nothing is written.
Rules, all enforced by the server:
- The values are validated against the Data Class like in the dashboard. An unknown field, a wrong type, a missing mandatory field and any property other than the ones above (
published,workflowState,path,classId...) answer400with the reason. - Rich text (
wysiwyg) fields are cleaned of script and unsafe markup before they are stored. - A
{ "assetId" }(also inside lists and blocks) must be an asset the client may read (assets:read:); unknown and not allowed answer the same400. - Relations to other objects are stored as sent (their ids are not checked against the client's scopes).
409: the key already exists, or the object is published.404: unknown class, no scope, malformed id, or an id of another class.- Writes count against the rate limit and the daily quota, and each one is logged with the client and, for a token, its subject.
6.4 POST /client/v1/automations/<endpoint>/<route> - start a flow and wait for the answer#
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"email":"anna@example.com","name":"Anna"}' "$BASE/automations/n8n/webhook/newsletter-signup"
{ "ok": true, "outcome": "qualified", "response": { "outcome": "qualified", "score": 82 } }
- The JSON body (up to 1 MB) is sent to the engine's webhook with the endpoint's credentials, which the client never sees. The call waits for the flow's last step (up to the endpoint's timeout, at most 30 seconds). A flow that takes longer than that cannot be used this way: have it answer at once and report back by another route.
okistruewhen the engine answered with a status below 400.responseis the engine's reply as it is.outcomeis present only if the reply has an"outcome"key that is a plain key (1-32 letters, digits,_,-); anything else is left out. Act onoutcome, not on text taken fromresponse: it comes from a third-party flow.- An engine error (
5xx) answers502with{ "ok": false, "error": ... }and no body of the engine. - Only
POSTand only a route allowed by anautomation:runscope; everything else is the usual404 Not found.
6.5 ANY /client/v1/gateway/<endpoint>/<route> - call an Integrations route#
The address is the endpoint's name (spaces as %20) and the route path under it:
curl -H "Authorization: Bearer $KEY" "$BASE/gateway/Shop/customers/42?expand=orders"
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"email":"a@b.c"}' "$BASE/gateway/Crm/contacts"
- The path is matched to a configured route (
customers/{id}takes42). The query string is passed on. - A JSON body (up to 1 MB,
Content-Type: application/json) is passed on for write methods. - Whoobe adds the credentials of the endpoint; the application never receives them.
- The service's status and body are returned. Only JSON and text replies are relayed; of the headers only
cache-control,etag,last-modified,date,x-request-idare passed on. /client/v1/gateway/...is only for endpoints. Data Objects are at/client/v1/data/...(nogateway/in front).
7. Code examples#
7.1 Node (a server, with a key)#
const BASE = 'https://cms.example.com/client/v1'
const KEY = process.env.WHOOBE_KEY // from the server's secret store, never in code
const res = await fetch(`${BASE}/data/Blog?limit=20&orderBy=updatedAt&dir=desc`, {
headers: { Authorization: `Bearer ${KEY}` },
})
if (res.status === 429) await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 1) * 1000))
const { objects } = await res.json()
7.2 Flutter / Dart (an app, with the identity provider token)#
import 'dart:convert';
import 'package:http/http.dart' as http;
// accessToken comes from the identity provider's sign-in (for example via flutter_appauth); the app holds no Whoobe secret
Future<List<dynamic>> latestPosts(String accessToken) async {
final res = await http.get(
Uri.parse('https://cms.example.com/client/v1/data/Blog?limit=10'),
headers: {'Authorization': 'Bearer $accessToken'},
);
if (res.statusCode == 401) throw Exception('Token expired: refresh it and retry');
if (res.statusCode != 200) throw Exception('Whoobe answered ${res.statusCode}');
return jsonDecode(res.body)['objects'] as List<dynamic>;
}
7.3 A server that starts an automation#
const res = await fetch(`${BASE}/automations/n8n/webhook/newsletter-signup`, {
method: 'POST',
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name }),
})
const { ok, outcome } = await res.json()
if (ok && outcome === 'qualified') { /* ... */ }
An engine can also be a client itself: a flow in Activepieces or n8n that calls Whoobe (for example GET /client/v1/data/Product) holds
a key and sends it with its HTTP step, because it is a server.
7.4 A browser app (token)#
const res = await fetch('https://cms.example.com/client/v1/data/Blog/' + id, {
headers: { Authorization: `Bearer ${accessToken}` }, // from the provider's PKCE flow; no cookie, no key
})
Cross-origin calls are allowed for /client/v1 (without cookies). Never put a wbk_ key here: it would be refused anyway.
7.5 Insomnia / Postman#
Create a request, method GET, address as above, Auth > Bearer token with the key or the access token (or the header
X-Whoobe-Key). In the list of clients, clicking an entry in Allowed copies the right address.
8. Answers and errors#
| Answer | Meaning |
|---|---|
200..4xx |
on gateway/ calls: what the service answered |
400 |
an invalid path (empty or relative segment), or an invalid data query (unknown field or operator, limit out of range, bad locale); the text says which |
401 |
missing, wrong, revoked or expired credential; a token that is not valid for this client (signature, issuer, audience, expiry, authorized party); a suspended client. Always the same message: the reason is only in the server log |
403 |
the credential cannot be used from this address; a key sent by a browser; or a valid token whose claims give no access |
404 Not found |
the endpoint, route, class, automation or object does not exist or is outside the client's scopes, or the object is a draft. Same answer for all, so a client cannot learn what else exists |
413 / 415 |
body over 1 MB / not JSON |
429 |
over the per-second limit (client or user), the daily quota, or after 20 failed credentials from one address. Retry-After gives the seconds |
502 |
the service could not be reached or answered 5xx (its body is not relayed) |
RateLimit-Limit and RateLimit-Remaining are on every answer.
9. Limits, quota and the counter store#
Per client: requests per second (sliding window) and an optional daily quota (UTC day).
Per user (token clients): each
subhas its own per-second limit inside the client's (default 5), so one user cannot use the whole allowance.Failed credentials: after 20 invalid credentials from one address in a minute, that address waits a minute (
429), checked before any database work.Where the counters live. By default in the gateway's memory: correct for one process. With several gateway processes, or to keep counts across restarts, use Valkey (BSD-licensed Redis fork; any Redis-protocol server works):
COMPOSE_PROFILES=local-s3,ratelimitin the env file starts thevalkeyservice (no published port, password, nothing on disk).provision-workspace.shgeneratesVALKEY_PASSWORDandGATEWAY_REDIS_URL.- Set
GATEWAY_RATE_LIMIT_BACKEND=redis, restart the gateway. GET /healthshowsrateLimit: { backend: "redis", degraded: false }. If the store goes down the gateway keeps serving, counts in memory, logs a warning and reportsdegraded: true.
Details in the configuration reference.
Behind nginx
TRUST_PROXYmust be set, otherwise every request appears to come from the proxy and the address controls and failure lock see one address.
10. Identity provider tokens in detail#
A token client is the registration of an application with your identity provider. It holds no secret.
| Setting | Meaning |
|---|---|
| Issuer | the iss of the tokens, exactly as the provider writes it (Auth0 ends it with /, Keycloak and Entra do not). https; plain http only outside production, for a local provider |
| Audience | the aud the tokens must carry: the value configured at the provider for this API |
| Signing algorithms | RS256 and ES256 by default; PS*, ES384/512, EdDSA can be added. HS256 and none are never accepted |
| Keys address (JWKS) | optional; otherwise read from the issuer's discovery document, which must name the same issuer |
| Authorized party | optional: the azp (or client_id) the token must carry, so a token made for another application of the same provider is refused |
| The token must carry | optional claims, all required (for example groups = staff) |
| What each kind of user may call | optional rules if the claim has this value, the user gets these routes. No rules: every valid token gets all the client's scopes. With rules: the union of the matching ones; a user who matches none gets 403. Rules never reach beyond the ceiling |
| Requests per second, per user | default 5 |
What Whoobe checks on every call: the token's size and shape; the algorithm against the list; the signature with a key of the
provider (cached 10 minutes; if a token names an unknown key id the keys are read again, at most every 30 seconds); iss; aud;
exp (required); nbf; sub (required); azp when set. A clock difference up to 60 seconds is tolerated. Keys and discovery are
fetched through the outbound guard (no private addresses). Signing in, refreshing and logging out are the provider's job; Whoobe never
sees a password or a refresh token and creates no user. The sub appears in the log line of each call.
Keycloak (tested)#
- In the realm, create the roles that separate your users (
orders-reader,orders-admin) and give them to the users. - Create a client for the application (public client for browser or mobile, standard flow with PKCE).
- On that client add a mapper Audience with the custom audience
whoobe-api(added to the access token); without itaudis onlyaccount. - In Whoobe: New client > Identity provider token. Issuer
https://<keycloak>/realms/<realm>, audiencewhoobe-api, authorized party the client's id; rules such asrealm_access.roles=orders-readergiving the read routes,orders-admingiving all. - The application sends
Authorization: Bearer <access token>.
Auth0, Entra ID, Cognito#
They issue JWT access tokens and work the same way. Keycloak and Auth0 have been run against Whoobe (Auth0 with a machine-to-machine application and client credentials); Entra ID and Cognito have not. Where the values come from:
| Provider | Issuer | Audience | Roles |
|---|---|---|---|
| Entra ID | https://login.microsoftonline.com/<tenant>/v2.0 |
application ID URI or client id of the API registration | roles (app roles) or groups |
| Cognito | https://cognito-idp.<region>.amazonaws.com/<pool id> |
app client id (ID tokens); for access tokens set the authorized party to the client id | cognito:groups |
| Auth0 | https://<tenant>.<region>.auth0.com/ (with the slash and, for tenants outside the US, the region: moodgiver.eu.auth0.com; copy it from the iss of a token) |
the API identifier | permissions or scope |
Opaque (non-JWT) access tokens are not supported.
11. Operating it#
- Rotating a key without a gap: add a key to the client, move the application to it, then revoke the old one. A client may have several keys.
- Stopping a client: Suspend stops every key and every token of the client at once; Activate restores it.
- Delete removes the client and its keys.
- Logs. The gateway writes one line per call with the client name, the user's
sub(token clients), the endpoint or class, status and time; refusals carry a reason (unknown issuer,no scope for these claims,outside the scopes...). Raw keys and tokens are never logged. - Last used in the list shows when each client last called.
- Health:
GET /healthon the gateway shows the counter store. - Permission: managing clients needs the
gateway:managepermission.
12. Security model#
- Keys are random (256 bits), shown once, stored only as a keyed hash (HMAC-SHA-256 with
SITE_API_KEY_PEPPER), revocable, rotatable. - Deny by default: a client with no scopes can call nothing. Scopes are checked against the configured route or class, never the raw path.
- Nothing leaks about what exists: every refusal about what can be called is the same
404; credential problems are the same401. - Browsers: keys are refused when sent with an
Originheader; browsers use tokens. A script can omit the header, so set allowed addresses for key clients: that is the real control. - Tokens: signature, algorithm allow-list, issuer, audience, expiry, authorized party; keys fetched through the SSRF guard.
- Limits: per client, per user, failure lock per address, body size, upstream timeout.
- Known limit: a stolen token that is still valid works until it expires; there is no introspection or revocation check. Keep token lifetimes short at the provider and suspend the client in an emergency.
See the threat model and the authentication document.
13. Development and production#
Use two workspaces, one for development and one for production, each with its own clients and keys. The application changes a base URL and a key. The credentials of the third-party services are entered separately in each (a sandbox account in development). Exporting the configuration from one to the other is planned (plan, section 4.5).
14. When something does not work#
| Symptom | Likely cause |
|---|---|
404 on /client/v1/gateway/data/Blog |
wrong address: Data Objects are at /client/v1/data/Blog, gateway/ is only for Integrations endpoints |
404 on an asset call |
the id does not exist, or its folder is outside the assets:read: scopes (the answer is the same), or GET /assets was sent without folder to a client that has no assets:read:*; the gateway is running an older build (restart it) |
400 on a write |
the reason is in the message: unknown field, wrong type, missing mandatory field, a property that is not allowed, or an asset the client cannot read |
409 on a write |
the key exists for that class and language, or the object is published (the API changes drafts only) |
404 on a data call |
class name differs (case counts); no data:read: scope for it; the object is a draft; the id belongs to another class; the gateway is running an older build (restart it) |
404 on an automation call |
the scope is automation:run:, not gateway:call:; the route is not a POST route of the endpoint; the endpoint name or route differs from Integrations |
404 on a gateway call |
the endpoint was renamed (its scopes no longer match); the route is not ticked; GET ticked but the call is POST; the endpoint is inactive |
401 |
wrong or revoked key; expired client; for a token: expired, wrong issuer or audience, azp mismatch. The server log has the reason |
403 "A key is for servers" |
the key was sent by a browser. Use a token client for browsers and apps |
403 "This token does not give access" |
the token is valid but no rule matches its claims; check the claim name and value (realm_access.roles vs roles) |
403 "cannot be used from this address" |
the client has allowed addresses; behind nginx check TRUST_PROXY |
429 |
per-second limit, daily quota, or the user's own limit; wait Retry-After |
400 Unknown field in filter |
the field is not in the Data Class (or is an internal column); only class fields, key, locale, updatedAt |
| A token works in Insomnia but not in the app | the audience differs for the app's client: decode the token and compare iss, aud, azp with the client's settings |
GET /health says degraded: true |
the Valkey store is unreachable; counts are in memory until it is back |
15. What it does not do (yet)#
- Publishing or deleting Data Objects, writing several languages in one call, and uploading assets on
/client/v1(the plan's next steps), and short-lived signed links for private files (today the file goes through/assets/<id>/content). - Long-running automations: the call waits at most 30 seconds; there is no "accepted, ask later" mode yet.
- Binary replies, multipart bodies and streaming through
gateway/calls. - Opaque tokens, introspection, revocation lists for identity provider tokens.
- Usage statistics per hour in the dashboard (only counters and the log).
- A separate permission for managing clients (it uses
gateway:manage). - Configuration export / import between development and production workspaces.
16. Reference: the administration API#
Used by the dashboard; session cookie and the gateway:manage permission. Full table in the API reference.
| Call | Purpose |
|---|---|
GET /api/v1/gateway/clients |
list clients with their keys' prefixes (never a key or a hash) |
POST /api/v1/gateway/clients |
create; authMode key or jwt (a jwt client carries the registration in jwt); a key client gets its first key in the reply, once |
GET /api/v1/gateway/clients/:id |
one client |
PATCH /api/v1/gateway/clients/:id |
name, status, scopes, limits, addresses, expiry, token settings (the mode does not change) |
DELETE /api/v1/gateway/clients/:id |
delete with its keys |
POST /api/v1/gateway/clients/:id/keys |
add a key (rotation); returned once |
DELETE /api/v1/gateway/clients/:id/keys/:keyId |
revoke a key |
Creating a client with a key, from a script:
curl -X POST -H "Cookie: wcms_session=..." -H "Content-Type: application/json" https://<host>/api/v1/gateway/clients \
-d '{"name":"erp-sync","authMode":"key","scopes":["data:read:Blog","gateway:call:Shop:GET:orders.json"],"rateLimitRps":20}'
Last updated