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 token | Session JWT | |
|---|---|---|
| Looks like | ct_live_… | A JWT (eyJ…) |
| Acts as | An integration | A signed-in person |
| Obtained from | Settings → API tokens, or POST /api-tokens | POST /auth/login |
| Lifetime | Until revoked, or its expiry date | Around an hour, then refreshed |
| Best for | Server-to-server, cron, ETL | An 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:
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"
}'{
"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 /permissionslists 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
nullfor 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.
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": "…"
}'{
"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.
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.
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.writeA missing permission is a 403 whose details.required names what was needed - so a client can log something actionable rather than 'forbidden'.
{
"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_atwhere 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}/auditafterwards rather than before.