Create a Token
This page describes how to obtain an OAuth 2.0 access token (and refresh token) from the integration service. The endpoint follows the OAuth 2.0 token endpoint contract — expires_in is a lifetime in seconds, not an absolute Unix timestamp.
Endpoint
POST /v2/oauth/token
The endpoint is public at the gateway (no Bearer required) but the integration backend still validates the client credentials and, when configured, the client mTLS certificate / IP allowlist (see Create Client).
Supported Grant Types
| Grant Type | Required Parameters | Purpose |
|---|---|---|
password | client_id, client_secret, username, password | Interactive supervisor login. Returns a supervisor-scoped access token (15 min) and refresh token (30 days). |
client_credentials | client_id, client_secret | Machine-to-machine. Returns a client-scoped access/refresh token pair whose lifetime is controlled by the client's token_expiry policy. |
refresh_token | client_id, client_secret, refresh_token | Exchanges a valid refresh token for a new access/refresh pair. |
The client must have been issued the requested grant type in its grant_types array; the server rejects unknown or unassigned grants with invalid_request.
Password Grant
Request
curl --request POST \
--url \{\{baseUrl\}\}/v2/oauth/token \
--header 'Content-Type: application/json' \
--data '{
"client_id": "c1a9beda-b14c-4eb5-b880-113023e6fc2d",
"client_secret": "01JNVVQ1X3XYRJYX4BJ7Y2QM9K",
"grant_type": "password",
"username": "uber@apex.com",
"password": "Apex1234!"
}'
The supervisor identified by username must belong to the same node as the OAuth client — the password grant is rejected across tenants even when credentials match.
Token Lifetimes (Password Grant)
| Token | Lifetime | Notes |
|---|---|---|
| Access | 15 minutes | Carries exp claim (RFC 7519 §4.1.4). |
| Refresh | 30 days | Carries iat so the absolute max-age guard can fire (see below). |
Client-Credentials Grant
Request
curl --request POST \
--url \{\{baseUrl\}\}/v2/oauth/token \
--header 'Content-Type: application/json' \
--data '{
"client_id": "c1a9beda-b14c-4eb5-b880-113023e6fc2d",
"client_secret": "01JNVVQ1X3XYRJYX4BJ7Y2QM9K",
"grant_type": "client_credentials"
}'
The lifetime is taken from the client's token_expiry policy (enabled + duration). When enabled = false the access token is issued without an exp claim and only the refresh-token max-age cap (see below) protects against indefinite reuse.
Refresh Grant
curl --request POST \
--url \{\{baseUrl\}\}/v2/oauth/token \
--header 'Content-Type: application/json' \
--data '{
"client_id": "c1a9beda-b14c-4eb5-b880-113023e6fc2d",
"client_secret": "01JNVVQ1X3XYRJYX4BJ7Y2QM9K",
"grant_type": "refresh_token",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}'
A refresh-token call always issues a fresh access and refresh token — store both from the response.
Refresh-Token Max-Age Cap
Independent of the per-client token_expiry policy, every refresh token issued by the integration is hard-capped at 365 days from iat. After that, the refresh exchange fails with invalid_request even if the JWT itself has no exp claim. This is a defense-in-depth ceiling that prevents leaked refresh tokens from being usable forever when token_expiry.enabled = false.
Refresh tokens issued before this guard was rolled out (no iat claim) are grandfathered through one rotation cycle.
Response
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ8.",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
"expires_in": 900,
"token_type": "Bearer",
"scope": "public",
"created_at": 1741603326
}
Response Fields
| Field | Type | Description |
|---|---|---|
access_token | string | JWT to be sent as Authorization: Bearer … on subsequent calls. |
refresh_token | string | JWT redeemable on this same endpoint via the refresh_token grant. |
expires_in | number | Access-token lifetime in seconds (OAuth 2.0 §5.1). For the password grant this is 900 (15 min). For the client-credentials grant it is the token_expiry.duration configured on the client. The JWT itself still carries the absolute exp claim per RFC 7519. |
token_type | string | Always Bearer. |
scope | string | Space-separated permission list (password grant: role permissions; client-credentials: configured scopes). |
created_at | number | Unix timestamp the token was issued at. |
Earlier revisions of this page documented expires_in as an absolute Unix timestamp (e.g. 1741604226). That was incorrect and contradicted OAuth 2.0 §5.1. The integration service now returns it as a lifetime in seconds. Clients that subtracted created_at from expires_in to compute the lifetime will over-rotate; update them to use expires_in directly.
Using the Token
Include the access token on every protected call:
curl --request GET \
--url \{\{baseUrl\}\}/v2/admin/tokens/68aeb272d9336c619ce249f6 \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ8.'
Login-issued tokens are additionally validated against a Redis cache keyed by user sub + node aud — invalidate sessions by removing that entry to force re-authentication.
Error Responses
| Status | Body code | Cause |
|---|---|---|
| 400 | invalid_request | Missing or malformed parameters (no username on password grant, no refresh_token on refresh grant, etc.). |
| 401 | invalid_request | Wrong client_id / client_secret, mismatched supervisor password, refresh token expired or past the 365-day max-age, supervisor on a different node than the client. |
| 403 | invalid_request | Client grant_types does not include the requested grant. |
| 403 | ip_not_allowed | Caller IP is not in the client's allowed_ips. |
| 403 | mtls_required | Client has require_certificate = true but the request did not present a valid client certificate. |
| 429 | rate_limit_exceeded | Per-second / minute / hour ceiling hit. Honour the Retry-After header. |
Errors share the integration's standard envelope:
{
"code": -1,
"domain": 0,
"message": "invalid_request"
}