OAuth 2.0 client credentials
Machine-to-machine access with a signed JWT assertion, for the GraphQL API (/v3) and the REST API (/api/v1), on every tenant and environment.
How it works
- You sign a short-lived JWT (the client assertion) with your private key.
- You
POSTit to/oauth/token(private_key_jwt, RFC 7523). - You receive a Bearer access token valid for 15 minutes and present it on every call to
/v3and/api/v1.
The host is your tenant's API host for the environment you target (Environments and access); the issuer is https://<host> ($HOST in the curl examples). Server metadata is published at GET https://<host>/.well-known/oauth-authorization-server.
What you need
client_id: issued by Deal Engine when your client is provisioned through your account manager. Not secret. It expires (12 months by default); you receive the date at provisioning and request a renewal through your account manager before it.- A signing key pair you generate (P-256/ES256 shown; see the
algrow). Store the private key in a secrets manager; never commit it, never send it to us. - Your public JWKS, sent to your account manager at provisioning. Only keys in this set can sign assertions.
The snippet below is Node 20+, jose, ESM (top-level await). Generate the pair and print the public JWKS:
import { writeFile } from 'node:fs/promises';
import { generateKeyPair, exportJWK, exportPKCS8, calculateJwkThumbprint } from 'jose';
const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true });
const jwk = await exportJWK(publicKey);
const kid = await calculateJwkThumbprint(jwk);
console.log(JSON.stringify({ keys: [{ ...jwk, kid, alg: 'ES256', use: 'sig' }] }));
await writeFile('private.pem', await exportPKCS8(privateKey), { mode: 0o600, flag: 'wx' }); // move to your secrets manager; never put "d" in the JWKSBuild the assertion
Build a new assertion for every token request, including retries. Keep your clock synchronised (NTP).
| Field | Rule |
|---|---|
alg | ES256, PS256 or EdDSA, matching the registered key |
kid | Required; unique within your JWKS |
typ | JWT or omitted; never at+jwt |
jku, x5u, jwk | Must not be present |
iss, sub | Both your client_id |
aud | https://<host> or https://<host>/oauth/token, as a single string |
iat, exp | Seconds since epoch; exp - iat at most 300 |
jti | A fresh random value each time; single use |
Request a token
curl -sS -X POST https://$HOST/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
--data-urlencode "client_assertion=$ASSERTION" \
--data-urlencode 'scope=quotes:read quotes:write'| Parameter | Required | Value |
|---|---|---|
grant_type | yes | client_credentials |
client_assertion_type | yes | urn:ietf:params:oauth:client-assertion-type:jwt-bearer |
client_assertion | yes | The JWT above |
client_id | no | If sent, must equal the assertion iss |
scope | no | Subset of refunds:read refunds:write quotes:read quotes:write granted to you; omit for all. Informational: the role bound to your client decides what it may do. |
Response:
{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 900, "scope": "quotes:read quotes:write" }Treat the token as opaque.
Call the API
curl -sS -X POST https://$HOST/v3 \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
--data '{"query":"{ user { id email } }"}'The email returned for a client identity is a placeholder, not a mailbox; a 200 with a user confirms the token works.
Cache the token
- One token per process; do not request a token per API call.
- Refresh at 80% of
expires_in(12 minutes); let concurrent callers share one refresh. - If a refresh fails, keep using the cached token until it expires; retry no sooner than
Retry-After(or 60 s on a 4xx). - On an API
401 invalid_token, discard the cached token, request a new one and retry the call once.
Any OAuth client library that supports private_key_jwt (Authlib, Nimbus OAuth 2.0 SDK, Spring Security, node openid-client) can sign the assertion and request the token; the caching rules above are yours to apply.
Errors
Every OAuth error body has the same shape:
{ "error": "invalid_client", "error_description": "Client authentication failed", "request_id": "…" }Token endpoint
| Status | error | Cause | What to do |
|---|---|---|---|
| 400 | invalid_request | Wrong Content-Type, body over 2 KiB, a repeated parameter, client_assertion_type wrong, or grant_type / client_assertion missing | Fix the request. Do not retry unchanged. |
| 400 | unsupported_grant_type | grant_type is not client_credentials | Fix the request. |
| 400 | invalid_scope | A requested scope is outside your registration | Request only granted scopes, or omit scope. |
| 401 | invalid_client | Assertion rejected, or the client is expired or disabled; the body never says why. | Do not retry unchanged. Re-check every row of the assertion table and your clock; then contact support with the request_id. |
| 429 | temporarily_unavailable | Token requests are rate limited | Wait Retry-After seconds. A client that caches tokens rarely sees this. |
| 503 | temporarily_unavailable | The token service is temporarily unavailable | Keep using your cached token; wait Retry-After seconds, then retry with a new assertion. |
API
| Status | Response | What to do |
|---|---|---|
| 401 | WWW-Authenticate: Bearer error="invalid_token" and { "error": "invalid_token", … } | The token is malformed, expired or revoked, sent to another host, or sent as a cookie instead of the Authorization header. Discard the cached token, request a new one and retry once. |
| 403 | { "error": "unsupported_for_client_credentials", … } | The /api/v1/quotes/ndjson* routes are not available to OAuth clients. |
| 503 | { "error": "temporarily_unavailable", … } with Retry-After | Retry after Retry-After seconds with the same token. |
GraphQL and REST return the same JSON body for these errors, not a GraphQL errors array.
Key rotation and revocation
- Generate a new key pair with its own
kid. - Send your account manager your public JWKS containing both keys.
- After confirmation, wait at least one minute, then switch your signer to the new
kid. - Once every signer uses the new
kid, send a JWKS with only the new key and destroy the old private key.
Your client_id and scopes do not change.
If a private key may have been exposed, contact Deal Engine Support immediately and ask for the client to be disabled. Existing tokens stop working within about a minute. A disabled client is not re-enabled; a new client_id is issued for your new key.
Contacting support
Send the request_id, your client_id, the kid you signed with and the UTC time. Never send a private key, an assertion or a token.
Updated 8 days ago