Whoobe Docs

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

  1. What it is for
  2. Key or token: choose how the application proves who it is
  3. Quick start
  4. The editor, field by field
  5. What a client may call: scopes
  6. The calls
  7. Code examples
  8. Answers and errors
  9. Limits, quota and the counter store
  10. Identity provider tokens in detail
  11. Operating it
  12. Security model
  13. Development and production
  14. When something does not work
  15. What it does not do (yet)
  16. 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)#

  1. Dashboard > Data & Communication > API clients > New client.
  2. Name it, leave A key.
  3. Under What this client may call, choose Add an API..., pick an endpoint (or Data Objects) and tick the routes (or classes).
  4. Create and show key. Copy the key now: it is shown once.
  5. 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#

  1. At the provider: a client for the application, the roles for your users, an audience for Whoobe (section 10 has the Keycloak steps).
  2. Dashboard > API clients > New client > Identity provider token: issuer, audience, optional rules.
  3. Add the APIs and routes the application may call.
  4. 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} and products/{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, not blog); there are no patterns.
  • data:read:* covers every class, including classes created later (the dashboard marks it as wide).
  • data:write:<Class name> or data:write:*: create objects as drafts and change drafts (section 6.3c). It is its own scope: it does not read, and data:read does 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:/products allows /products and /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>, n8n webhook/<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 POST routes 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). null for a private asset, or when there is no public address. In development it may point to localhost, which a phone cannot reach: use contentPath.
  • contentPath: always present. The path (on the same address as the other /client/v1 calls) 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...) answer 400 with 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 same 400.
  • 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.
  • ok is true when the engine answered with a status below 400. response is the engine's reply as it is.
  • outcome is present only if the reply has an "outcome" key that is a plain key (1-32 letters, digits, _, -); anything else is left out. Act on outcome, not on text taken from response: it comes from a third-party flow.
  • An engine error (5xx) answers 502 with { "ok": false, "error": ... } and no body of the engine.
  • Only POST and only a route allowed by an automation:run scope; everything else is the usual 404 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} takes 42). 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-id are passed on.
  • /client/v1/gateway/... is only for endpoints. Data Objects are at /client/v1/data/... (no gateway/ 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 sub has 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):

    1. COMPOSE_PROFILES=local-s3,ratelimit in the env file starts the valkey service (no published port, password, nothing on disk). provision-workspace.sh generates VALKEY_PASSWORD and GATEWAY_REDIS_URL.
    2. Set GATEWAY_RATE_LIMIT_BACKEND=redis, restart the gateway.
    3. GET /health shows rateLimit: { backend: "redis", degraded: false }. If the store goes down the gateway keeps serving, counts in memory, logs a warning and reports degraded: true.

    Details in the configuration reference.

  • Behind nginx TRUST_PROXY must 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)#

  1. In the realm, create the roles that separate your users (orders-reader, orders-admin) and give them to the users.
  2. Create a client for the application (public client for browser or mobile, standard flow with PKCE).
  3. On that client add a mapper Audience with the custom audience whoobe-api (added to the access token); without it aud is only account.
  4. In Whoobe: New client > Identity provider token. Issuer https://<keycloak>/realms/<realm>, audience whoobe-api, authorized party the client's id; rules such as realm_access.roles = orders-reader giving the read routes, orders-admin giving all.
  5. 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 /health on the gateway shows the counter store.
  • Permission: managing clients needs the gateway:manage permission.

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 same 401.
  • Browsers: keys are refused when sent with an Origin header; 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