---
title: "Exchange client credentials for an access token"
method: POST
path: "/oauth/token"
tags: ["authentication"]
---

# Exchange client credentials for an access token

`POST /oauth/token`

## Request

- Base URL: `https://api.planvortex.com/v1.0.0`
- URL: `https://api.planvortex.com/v1.0.0/oauth/token`
- Auth: none required

The way an integration authenticates. Send the app's `client_id` and `client_secret` and get back a bearer token to put in `Authorization` on every other call.

**This endpoint is different from the rest of the API in three ways, and all three matter:**

1. **It takes no token** — it is the one that gives you one. It is also the only endpoint with no authentication at all.
2. **It accepts `application/x-www-form-urlencoded`**, which is what OAuth 2 says and what a generic OAuth client sends. JSON works too.
3. **Its errors are OAuth 2 errors** — `{error, error_description}` with a 400 or a 401 — and *not* the `{code, message, data}` of everywhere else. This is the single exception in the whole API, and it exists so a generic OAuth client understands the answer.

Credentials travel either in the body (`client_secret_post`) or in `Authorization: Basic base64(client_id:client_secret)` (`client_secret_basic`). Both are standard and both are accepted.

**Cache the token until it expires.** The endpoint is rate limited per `client_id` and IP — 30 attempts a minute, and 10 *failures* in five minutes — so asking for a fresh token on every call will eventually get a 429 with a `Retry-After`. A correct integration asks once and refreshes shortly before `expires_in`.

What comes back is an access token and nothing else: **no refresh token and no id token**. When it expires, ask again.

## Request body

- AppsTokenRequest
  - `grant_type` 'client_credentials', required: Only `client_credentials` is supported.
  - `client_id` string: The app's identifier. Can travel here or in `Authorization: Basic`.
  - `client_secret` string: The app's secret. Can travel here or in `Authorization: Basic`.
  - `scope` string: Optional. Passed through to the identity provider; there are no PlanVortex-defined scopes today.

## Response `200`

The access token. The response is never cached (`Cache-Control: no-store`).

- AppsTokenResponse
  - `access_token` string, required: Send it as `Authorization: Bearer <access_token>`.
  - `token_type` string, required: Always `Bearer`.
  - `expires_in` integer, required: Seconds the token is valid for. Refresh shortly before it runs out; there is no refresh token.
  - `scope` string

## Other responses

- `400`: `invalid_request` when `client_id` or `client_secret` is missing, `unsupported_grant_type` when `grant_type` is not `client_credentials`.
- `401`: `invalid_client`: the credentials are not valid, or the app no longer exists. **The message does not say which**, on purpose — telling the two apart is exactly what somebody enumerating clients wants to know.
- `429`: `slow_down`: too many attempts, or too many failures. The `Retry-After` header says how many seconds to wait.

---

[API](https://skmtc.dev/planvortex/apis/planvortex-api.md) · [All operations](https://skmtc.dev/planvortex/apis/planvortex-api/llms.txt) · [OpenAPI document](https://skmtc.dev/planvortex/apis/planvortex-api/revisions/abedbbc1cd52?raw)
