Authentication
Two different credentials exist in this API and they are not interchangeable. Almost every authentication problem a partner hits is really this distinction, so it is worth reading before the rest of the page.
An API access token is what your server holds. You mint it from your client
credentials at POST /oauth/token, and it is the only thing /v1 accepts.
An SDK session token is minted by POST /v1/sdk/sessions for a browser. It
is short-lived and bound to a web origin. Present one to a /v1 route and you
get 403 wrong_token_type — not because it expired or was malformed, but
because it is a credential for a different kind of caller, and accepting it
server-side would erase the origin binding that makes it safe to hand to a
browser at all.
Getting a token
curl -sS -X POST https://{your-base-url}/oauth/token \
-u "$DRIVECARS_CLIENT_ID:$DRIVECARS_CLIENT_SECRET" \
-d grant_type=client_credentials{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "rentals:quote rentals:book bookings:read"
}Credentials go in an HTTP Basic header (-u, which the RFC prefers) or as
client_id and client_secret in the form body. Both are accepted; pick
whichever your HTTP client makes easy. The request body may be
application/x-www-form-urlencoded or application/json.
grant_type=client_credentials is the only grant. Anything else — including
authorization_code, refresh_token and password — is
400 unsupported_grant_type. There is no refresh token: when the hour is up,
you ask for another token exactly the way you asked for the first.
Send it on every /v1 call:
Authorization: Bearer <access_token>Discovery
If your OAuth client library can configure itself from a document, point it at:
GET https://{your-base-url}/oauth/.well-known/oauth-authorization-serverIt reports the issuer, the token endpoint, the one supported grant, both supported client-authentication methods, and every scope that exists.
Narrowing a token’s scope
By default a token carries every scope your credential was granted. You can ask for fewer:
curl -sS -X POST https://{your-base-url}/oauth/token \
-u "$DRIVECARS_CLIENT_ID:$DRIVECARS_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope="rentals:quote bookings:read"scope is a space-separated list. It can only ever narrow what the
credential already holds — asking for a scope your credential was not granted
is 400 invalid_scope, never a quiet upgrade. Worth doing when one service in
your estate only ever reads bookings: if that service is compromised, the token
it was holding cannot book anything.
Two edge cases, so they do not surprise you: a scope parameter that is present
but empty (whitespace only) is 400 invalid_scope rather than a silent fall
back to “everything” — you asked for something, and zero scopes is not a
sensible something. And a scope name that does not exist and a scope name you
simply were not granted both return the same invalid_scope code, deliberately,
so a caller cannot use the error to fingerprint which scope names are real.
Scopes
A credential is granted the scopes its integration needs, and no others.
| Scope | Covers |
|---|---|
rentals:quote | GET /coverage, GET /rentals/countries, GET /rentals/locations, GET /rentals/cars/{slug}, GET /rentals/availability |
rentals:book | POST /rentals/reservations, POST /rentals/reservations/{reference}/book, DELETE /rentals/reservations/{reference}, POST /rentals/bookings |
bookings:read | GET /bookings, GET /bookings/{reference}, GET /bookings/ext/{external_ref}, GET /events, GET /events/{event_id} |
bookings:cancel | POST /bookings/{reference}/cancel |
sdk:sessions | POST /sdk/sessions |
webhooks:manage | every /webhooks route, plus POST /deliveries/{id}/replay |
webhooks:manage is deliberately separate from bookings:read and
bookings:cancel. An integration that reads or cancels bookings has no reason
to also be able to change where booking-lifecycle events get POSTed, and
splitting the two means a leaked read-only credential cannot quietly redirect
your event stream.
GET /v1/me requires no scope at all. The endpoint that reports which scopes
you hold must not itself be gated on holding one.
Reading a failure
POST /oauth/token
| Status | error | What happened |
|---|---|---|
| 400 | unsupported_grant_type | grant_type was not client_credentials. |
| 400 | invalid_scope | You asked for a scope you were not granted, one that does not exist, or an empty scope. |
| 401 | invalid_client | The credentials did not authenticate. |
| 503 | temporarily_unavailable | Token issuance is misconfigured on our side. Not your request; retry, and tell us if it persists. |
invalid_client is deliberately uniform. “No such client” and “wrong secret”
return the byte-identical response, because telling them apart would let anyone
confirm which client ids exist by watching which guesses change the error. This
is why a 401 here can only ever mean “check both halves” — the API is not
being unhelpful, it is refusing to be a client-id oracle.
Note also that the token endpoint is rate limited per source IP, and additionally
per client id when you authenticate with a Basic header. A retry loop that mints
a token per request will eventually earn a 429 carrying Retry-After; caching
the token for its full hour makes that unreachable in normal operation.
Any /v1 route
The checks run in this order, and each one assumes the previous passed.
| Status | error.code | What happened | Can you fix it? |
|---|---|---|---|
| 401 | unauthorized | No Authorization: Bearer header at all. | Yes — send one. |
| 401 | invalid_token | The token did not verify: malformed, expired, or signed for somewhere else. | Yes — mint a fresh one. Check you are not sending a sandbox token to production. |
| 403 | wrong_token_type | A real token, but not an API access token — usually an SDK session token. | Yes — use a token from POST /oauth/token. |
| 403 | partner_inactive | Your partner account is suspended or revoked. | No. Contact your account manager. |
| 403 | insufficient_scope | Your token does not hold the scope this route needs. error.details.required_scope names it. | Yes — mint a credential that includes it. |
| 403 | product_not_enabled | Your account was never enabled for this product. error.details.required_product names it. | No. Contact your account manager. |
| 404 | — | Either the thing does not exist, or it is not yours. | See below. |
Two of those deserve more than a table row.
insufficient_scope and product_not_enabled look identical and are not.
The first is about the credential you minted: you asked for a token without the
scope, and a new credential with the right scope fixes it in minutes, from your
side, today. The second is about the commercial agreement: nobody ever sold your
account that product, so no token you can mint will pass. Retrying, rotating
credentials, or re-reading this page will not change it — only your account
manager can. They are separate codes precisely so you do not spend an afternoon
debugging the second as if it were the first.
Suspension takes effect on the very next request, not when your token expires. Your partner record is re-read on every single call with no caching, so a token minted 50 minutes ago stops working the moment ops suspends the account. That is the point: an account suspended for non-payment should not keep booking for another 59 minutes.
Why ownership answers 404
Ask for a booking, a reservation or a search handle that belongs to another
partner and you get 404, with the same body you would get for a reference
that never existed.
A 403 would confirm the reference is real and merely not yours — which turns
every read endpoint into an oracle. Someone walking references and reading only
the status code could enumerate genuine DriveCars bookings without ever seeing
a response body. So probing another partner’s reference is answered exactly as
if it had never been created.
The cost of that lands on you when you debug. A 404 on a reference you are
certain you created almost always means you are reading it with a credential
belonging to a different partner account — sandbox versus production is the
usual culprit — and by design, no error message will ever tell you that.
GET /v1/me will: check the slug it reports is the account you expect.
Handling tokens
- Cache the token, not the credentials round trip. One token per hour per process, not one per request.
- Refresh slightly early. Mint a new token at around 55 minutes rather than
waiting for a
401in the middle of a booking. - Treat
invalid_tokenas “mint and retry once”. Treat every403as terminal for that request — retrying aninsufficient_scopeor aproduct_not_enabledproduces the identical answer forever. - Never put a client secret, or an access token, in a browser. For anything
browser-side, mint an SDK session token via
POST /v1/sdk/sessionsfrom your server. That is what it exists for.