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 POST it 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 /v3 and /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 alg row). 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 JWKS

Build the assertion

Build a new assertion for every token request, including retries. Keep your clock synchronised (NTP).

FieldRule
algES256, PS256 or EdDSA, matching the registered key
kidRequired; unique within your JWKS
typJWT or omitted; never at+jwt
jku, x5u, jwkMust not be present
iss, subBoth your client_id
audhttps://<host> or https://<host>/oauth/token, as a single string
iat, expSeconds since epoch; exp - iat at most 300
jtiA 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'
ParameterRequiredValue
grant_typeyesclient_credentials
client_assertion_typeyesurn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertionyesThe JWT above
client_idnoIf sent, must equal the assertion iss
scopenoSubset 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

StatuserrorCauseWhat to do
400invalid_requestWrong Content-Type, body over 2 KiB, a repeated parameter, client_assertion_type wrong, or grant_type / client_assertion missingFix the request. Do not retry unchanged.
400unsupported_grant_typegrant_type is not client_credentialsFix the request.
400invalid_scopeA requested scope is outside your registrationRequest only granted scopes, or omit scope.
401invalid_clientAssertion 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.
429temporarily_unavailableToken requests are rate limitedWait Retry-After seconds. A client that caches tokens rarely sees this.
503temporarily_unavailableThe token service is temporarily unavailableKeep using your cached token; wait Retry-After seconds, then retry with a new assertion.

API

StatusResponseWhat to do
401WWW-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-AfterRetry 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

  1. Generate a new key pair with its own kid.
  2. Send your account manager your public JWKS containing both keys.
  3. After confirmation, wait at least one minute, then switch your signer to the new kid.
  4. 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.


Did this page help you?
All rights reserved © 2025 deal-engine.com.