Back to documentation

Authentication

API tokens for integrations, JWT sessions for user-facing apps, and the permission model both are evaluated against.

Last revised: 2026-08-23

Two kinds of credential

Both are presented the same way - Authorization: Bearer <value> - and the server tells them apart by the value itself. Which one you want depends on who is acting.

API tokenSession JWT
Looks likect_live_…A JWT (eyJ…)
Acts asAn integrationA signed-in person
Obtained fromSettings → API tokens, or POST /api-tokensPOST /auth/login
LifetimeUntil revoked, or its expiry dateAround an hour, then refreshed
Best forServer-to-server, cron, ETLAn app your users log in to

API tokens (recommended for integrations)

An API token belongs to the workspace rather than to a person, so an integration keeps working when the colleague who set it up leaves. Create one in the app under Settings → API tokens, or over the API:

bash·Create a token
curl -sS -X POST https://your-host/api/v1/api-tokens \
  -H "Authorization: Bearer <your session token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Storefront sync",
    "description": "Pushes orders from the website",
    "permissions": ["crm.contacts.read", "crm.contacts.write", "sales.orders.write"],
    "expires_at": "2027-01-01T00:00:00"
  }'
json·The response - the only time the token is shown
{
  "id": 12,
  "name": "Storefront sync",
  "token": "ct_live_9Xf2…",
  "token_prefix": "ct_live_9Xf2",
  "permissions": ["crm.contacts.read", "crm.contacts.write", "sales.orders.write"],
  "expires_at": "2027-01-01T00:00:00",
  "created_at": "2026-08-23T10:12:00"
}

Two fields decide how much the token can do, and they are independent:

  • `permissions` - what it may do. Grant the narrowest set the integration actually needs. GET /permissions lists every code the product defines.
  • `acts_as_user_id` - whose *row visibility* it borrows. Omit it and the token inherits the creating user's seat, which is the safe default; send null for an unrestricted token. It never adds permissions - only narrows which rows the granted permissions can reach.

Revoke with POST /api-tokens/{token_id}/revoke; the token stops working immediately. GET /api-tokens/{token_id}/audit shows what it has been doing, which is the first place to look if one is suspected of leaking.

Session tokens

Use these when your application signs a *person* in and acts on their behalf, so every action is attributed to them and evaluated against their own role.

bash·Sign in
curl -sS -X POST https://your-host/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_slug": "acme",
    "email": "sara@example.com",
    "password": "…"
  }'
json·The response
{
  "access_token": "eyJhbGciOi…",
  "refresh_token": "eyJhbGciOi…",
  "token_type": "bearer",
  "user": { "id": 5, "email": "sara@example.com", "first_name": "Sara", "last_name": "Ahmadi" },
  "tenant": { "id": 17, "slug": "acme", "name": "Acme Inc." },
  "permissions": ["crm.contacts.read", "crm.contacts.write", "…"]
}

tenant_slug is required: the same email address may exist in more than one workspace, and the slug is what says which one you are signing in to.

The access token is short-lived. When a call returns 401, exchange the refresh token at POST /auth/refresh - sending the refresh token as the bearer - and retry once. Refresh accepts refresh tokens only; an API token cannot be refreshed and does not need to be.

bash·Refresh
curl -sS -X POST https://your-host/api/v1/auth/refresh \
  -H "Authorization: Bearer <refresh_token>"

Permissions

Permissions are dotted codes shaped module.resource.action, and every protected endpoint names the exact code it requires. There are no scopes separate from permissions: an API token carries a subset of the same codes a role would.

http·Some codes you will meet
crm.contacts.read        crm.contacts.write        crm.contacts.delete
crm.contacts.view_phone  crm.deals.read            crm.tasks.write
webhooks.read            webhooks.write            api_tokens.write
events.read              finance.invoices.read     sales.orders.write

A missing permission is a 403 whose details.required names what was needed - so a client can log something actionable rather than 'forbidden'.

json·A permission refusal
{
  "error": "forbidden",
  "message": "Missing required permissions",
  "details": { "required": ["crm.contacts.write"] }
}

Keeping a token safe

  • Store it in a secret manager or an environment variable - never in the repository, a container image, or a front-end bundle.
  • Grant the narrowest permission set that works, and a distinct token per integration, so revoking one does not take the others down.
  • Set expires_at where the integration has a known lifetime.
  • Rotate on a schedule: create the replacement, deploy it, then revoke the old one - in that order, so there is no gap.
  • Revoke immediately on suspicion, and read GET /api-tokens/{token_id}/audit afterwards rather than before.

Need technical help?

If your question is not answered here, get in touch with our team.

Contact us
Authentication · Cliento Pro