Skip to content

API Authentication

Three credentials open the backend. A staff token opens the admin API, a customer token carries a signed-in shopper, and an application token identifies a machine: a storefront, a widget, a server job.

CredentialSent asOpens
Staff token (JWT)Authorization: Bearer <access>the admin API, within the user’s roles
Customer token (JWT)Authorization: Bearer <access>the customer’s own cart, orders and prices
Application tokenX-API-KEY: ent_api_…the key routes its scopes name

Catalog and content reads of the storefront API need none of them.

The public demo at https://demo-api.entirius.com is shared by everyone who reads these pages. Never send real data to it.

Staff loginviewer / viewer
RoleViewer: read on every admin area, no write
Application tokennone published; catalog and content reads need none
Rate limits60 requests a minute without a token, 300 with one
Resetevery day at 04:00 UTC, back to the same dataset

The viewer reads everything the admin API shows and may change nothing. A 429 on /api/token/ means the shared login has collected too many failed sign-ins within the hour; wait for the window to end.

POST /api/token/ takes a username and a password and answers a token pair. On the demo:

Terminal window
curl -s -X POST https://demo-api.entirius.com/api/token/ \
-H "Content-Type: application/json" \
-d '{"username": "viewer", "password": "viewer"}'

On your own stack:

Terminal window
curl -s -X POST http://localhost:8100/api/token/ \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "admin123"}'
{"refresh": "<refresh token>", "access": "<access token>"}

admin / admin123 is the user the local seed creates. Send the access value as Authorization: Bearer <access> on every admin request.

TokenLivesUse
access5 minutesevery request
refresh1 daya new access token

Both lifetimes are read from the exp and iat claims of a pair issued by the local stack and by the demo (300 and 86400 seconds on both). The service sets no token lifetimes of its own, so they are the defaults of the JWT library, and an installation can change them in its settings.

When the access token runs out, trade the refresh token for a new one:

Terminal window
REFRESH="<refresh token>"
curl -s -X POST https://demo-api.entirius.com/api/token/refresh/ \
-H "Content-Type: application/json" \
-d "{\"refresh\": \"$REFRESH\"}"

On your own stack, the same call goes to http://localhost:8100/api/token/refresh/.

{"access": "<access token>"}

The answer carries a new access token and no new refresh token: after a day the user signs in again.

Wrong password. A login with a wrong password answers 401 in the older envelope of the token routes, not the v2 one:

{"meta":{"status":"UNAUTHORIZED","status_code":401,"message":"no_active_account"},"data":{"detail":"No active account found with the given credentials"}}

Staff only. Admin routes accept the token of a staff user or a superuser. What a staff user may do is decided by the access gate from the user’s roles: every admin route belongs to an area (pim.products, checkout.orders), and a role grants read or write on it. How roles are put together: Access and API Tokens.

GET /api/access/v2/me/ answers the caller’s roles and permissions. It is the one access route in the Storefront API spec, with jwtAuth; the call below uses a staff token. On the demo:

Terminal window
curl -s -H "Authorization: Bearer $ACCESS" https://demo-api.entirius.com/api/access/v2/me/
{
"user": {"id": 3, "username": "viewer", "email": "[email protected]", "first_name": "", "last_name": "", "is_staff": true, "is_superuser": false},
"gate_mode": "enforce",
"manages_access": false,
"roles": [{"key": "viewer", "name": "Viewer"}],
"permissions": {"pim.products": "read", "pim.categories": "read", "checkout.orders": "read", "…": "read"}
}

permissions maps each area to read or write; the demo viewer holds read on all 44 areas (cut here to three). gate_mode is the mode of the access gate: enforce, observe or off.

Wrong passwords are counted, and a login that passes the limit is refused with 429 before the password is checked: Rate Limits.

A storefront signs a customer in with POST /api/accounts/v1/<channel_idx>/customer/tokens/ and a JSON body of email and password. Next to it sit customer/tokens/refresh/ and customer/tokens/blacklist/, which ends a refresh token at sign-out. The access token goes into Authorization: Bearer, the same header a staff token uses.

This is a legacy v1 route. It is not in the storefront spec and has no generated description, yet the v2 operations that act for a customer rely on its tokens: 30 of the 53 operations in the storefront spec list jwtAuth, among them the cart and order operations of checkout and GET /api/matrix/v2/<channel_idx>/prices/. The storefront of the platform is the reference client for the whole flow.

Failed customer logins are counted by the same guard as staff logins. The demo publishes no customer account: there is nothing to sign in with, and the explorers cannot try this route.

Application tokens come with entirius-django-access, a major of each of the seven key modules, and Platform 4.0; the versions are listed under Versions. An installation on an earlier release still uses the per-module keys.

An application token opens the key routes: storefront carts and orders, contact forms, booking, newsletter subscription, GDPR erasure, returns, review moderation and stored cards. It never opens an admin route. Staff use their own login and roles for those.

Both specs declare the token as the security scheme ApiKeyAuth (header X-API-KEY). An operation that takes one lists the scheme in its explorer entry; 24 operations of the storefront spec do.

A token belongs to an application: one per caller (a storefront, a widget, an ERP job). Deactivating the application stops every token it holds.

In the CMS: Access, then Applications, then New application. Open it and add a token: a name, the scopes, an optional channel and the expiry. Only staff with access.manage see the panel (the built-in Administrator role, or a superuser).

From the command line: manage.py access_token create takes --application <name> (with --create-application when it does not exist yet), one --scope per scope, and optionally --channel <idx>, --name and --expires-days.

Over the admin API: the same actions are operations under /api/access/v2/admin/applications/ and /api/access/v2/admin/tokens/ in the CMS Admin API.

The raw value starts with ent_api_ and is shown once: alone on stdout from the command, once in the CMS, which closes the dialog only after you confirm you stored it. Put it in a secret manager or the caller’s encrypted config right away: the server keeps a SHA-256 of it and cannot show it again. Lost it? Rotate.

A key route takes the token in a header, next to the JWT where the operation lists both. Shape of a call, from the spec (GET /api/checkout/v2/{channel_idx}/countries/ lists ApiKeyAuth and jwtAuth):

Terminal window
curl -s -H "X-API-KEY: ent_api_…" https://<your-host>/api/checkout/v2/<channel_idx>/countries/

The demo publishes no token, so this call has no output here.

Send the raw value in the X-API-KEY header of every request to a key route. X-API-ADMIN-KEY is accepted as an alias, so callers that already send that header keep working. Never put a token in Authorization: that header carries the customer’s JWT, often on the same request.

Every failure gets the same answer from the module (its usual 401 or 400): an unknown, expired or revoked token, an inactive application, a missing scope and a channel mismatch all look alike. Check the token in the CMS when a caller is refused.

A token carries one or more scopes. An empty scope list opens nothing.

ScopeOpensKind
checkout.storefrontstorefront carts, orders, countriespublishable
contact_forms.submitcontact form types and submissionspublishable
contact_forms.bookingbooking slots and bookingspublishable
agreements.subscribenewsletter subscriptionpublishable
checkout.eraseanonymise a customer’s orders (GDPR)secret
accounts.erasedelete a customer account (GDPR)secret
returns.apiorder returnssecret
reviews.moderatereview moderationsecret
vault.apistored payment cardssecret

Publishable scopes reach browsers by design: they sit in storefront bundles and widget snippets, so anyone can read them. Treat a publishable token as public. What protects it is its scope, the channel pin, the module’s throttles and rotation, not secrecy.

Secret scopes are server-to-server only. Never put one in a browser bundle, a mobile app or a repository.

One token never mixes the two kinds. The API refuses a scope list with a publishable and a secret scope. A storefront that also erases customers needs two tokens, and the erasing one lives on the server.

A pinned token passes only on routes whose URL channel equals the pin. A route without a channel refuses it and takes an unpinned token only, since the pin cannot be checked there. Pin every storefront and widget token to its channel: a leaked key for one shop then opens nothing in the others.

A pinned erase token erases only in its channel: accounts deletes a customer whose source channel is the pin, checkout anonymises that channel’s orders and carts. An email found only in another channel answers 404, the same as an unknown one. An erase across channels needs an unpinned token.

No token has a maximum lifetime. An expiry is optional for every scope; the API refuses only a date in the past (EXPIRY_IN_PAST). Revoke instead of backdating.

Every token shows its age in days. Once an active token is ACCESS_TOKEN_ROTATION_DAYS old (default 365), the API marks it rotation_due, access_token list and access_legacy_report print rotation due, and the CMS shows Rotation recommended. It is a recommendation: nothing is refused for age. Set the setting to 0 to turn the flag off.

Rotation issues a successor with the same application, scopes and pin. The old token keeps working for the overlap (default 24 hours, up to 8760 over the API and the command line), long enough to deploy the new value.

  1. Rotate: the token’s menu in the CMS, or manage.py access_token rotate <id> with --overlap-hours. The successor gets the expiry you pick in the CMS, or none; on the command line it starts without one (access_token expire sets it).
  2. Store the new value and deploy it to the caller.
  3. Check the old token’s last use in the CMS. Once it stops moving, revoke it.

The token’s menu in the CMS, or manage.py access_token revoke <id>. The next request with it is refused: verification is never cached. A revoked publishable token breaks every storefront or widget that still ships it, at once.

The upgrade imports every existing module key as a token with the same secret, so no caller changes on upgrade day. Each module gets an application named Legacy keys: <module>; its tokens carry the old key’s source (django_checkout.APIKey#3).

Legacy keys never expire by themselves. Rotation is your team’s policy, not a date in code. To see what is still in use:

  • the CMS: Access, then Applications; a legacy application shows a Legacy keys tag, each token its source and last use;
  • the command line: manage.py access_legacy_report (--module django_checkout to narrow it, --json PATH to export).

When the team decides to retire a key, give its token an expiry (Set expiry in the CMS, or manage.py access_token expire <id> with --at <YYYY-MM-DD>), or move the caller to an issued token and revoke the legacy one. Legacy tokens count their age from the import, so they show up as rotation due like any other token.

Two cases the import refuses or reports:

  • A secret found in both a browser key and a secret key (say a checkout shop key reused as an erase key) is not imported. access_import_legacy_keys --check fails on it. Issue a publishable token for the browser caller and a secret one for the server caller, switch both, then delete the old rows with access_purge_legacy_keys --yes --force (it keeps never-imported rows without --force).
  • A secret used on several channels becomes one token per channel, each pinned to its channel. access_import_legacy_keys --report lists them as per_channel. An imported erase key therefore erases only in its channel; an erase across channels needs an unpinned token with accounts.erase or checkout.erase.

The plaintext rows stay in the module tables after the import. Delete them when you are ready with manage.py access_purge_legacy_keys: it lists by default, deletes with --yes, and refuses a row whose token was used in the last 30 days (exit 1) unless you pass --force. Backups taken before the purge still hold the plaintext.

An integration that still sends the legacy x-api-admin-key header and keeps two keys in its config, one for the accounts erase and one for the checkout erase, needs no change: its legacy keys keep working. When the team rotates them, issue a token with accounts.erase and one with checkout.erase (or one token with both scopes in both fields), and put the new values in the integration’s config wherever it is installed. The header stays as it is.

Run the preflight on the release candidate, with the production settings, before traffic reaches it:

CommandChecks
manage.py access_routes --unmappedadmin routes without an access area; exit 1 if any
manage.py access_import_legacy_keys --checkafter migrate: every legacy key is a token; exit 1 if not
manage.py check --deploy --fail-level ERRORE011: the gate would enforce over unmapped admin routes

An unmapped admin route answers 403 UNMAPPED_ROUTE to everyone but superusers once the gate enforces, and E011 stops that deploy. The Entirius modules are mapped by the access module. An installation that runs its own Django apps deploys the first time with ACCESS_GATE_MODE = "observe": the gate logs what it would refuse (logger django_access.gate) and refuses nothing. Map the missing routes, grant the roles the log shows people need, and switch to enforce once access_routes --unmapped is clean.

Existing staff users get the Manager role on the first migrate: everything except access management. Grant Administrator to the people who should manage roles and tokens; until then only superusers can. The Django admin site is for superusers only: every role is refused there, Administrator included. The CMS replaced it.

The full procedure, rollback included, is in the module’s docs/upgrade.md; the commands are in docs/operations.md.

401, no credential or a dead one. An admin request without a token, or with an expired one:

Terminal window
curl -s https://demo-api.entirius.com/api/pim/v2/admin/channels/
{"error": "AUTHENTICATION_REQUIRED", "message": "Authentication credentials were not provided or are invalid.", "debug_id": "6aa324b5", "details": []}

debug_id differs on every call. A malformed Authorization: Bearer value (Bearer abc) answers the same message with error set to INVALID_REQUEST; test the message or the status, not only the code. Refresh the access token and repeat the request.

A key route without a token, or with an unknown one (X-API-KEY: ent_api_invalid), answers the same 401 body on the demo, for example GET /api/checkout/v2/sloik-pl/countries/. Other modules may answer their own refusal (Send It).

403, the access gate refused. The token is valid and the user may not do this. The body is the same envelope with error set to PERMISSION_DENIED and one entry in details whose issue says why:

issueWhen
STAFF_ONLYan authenticated user without staff status
ACCESS_DENIEDstaff without the route’s permission, or a write on a read-only area; description names what is missing (needs pim.products:write)
UNMAPPED_ROUTEan admin route that belongs to no access area

On the demo, the viewer asking for the staff list, which needs the access-management area:

Terminal window
curl -s -H "Authorization: Bearer $ACCESS" https://demo-api.entirius.com/api/access/v2/admin/staff/
{"error": "PERMISSION_DENIED", "message": "You do not have permission to perform this action.", "debug_id": "dcb939a4", "details": [{"field": null, "location": "path", "issue": "ACCESS_DENIED", "description": "needs access.manage:read"}]}

429, too many attempts. A throttled v2 endpoint answers the envelope with RATE_LIMITED. A blocked login answers 429 with a Retry-After header; the token routes are not v2, so the body is not that envelope: Rate Limits.

The envelope and every other error code: Errors.