# Exchange service-account credentials

> Call the client-credentials token endpoint safely, interpret its response, and cache short-lived bearer tokens without a refresh token.

Exchange an active service account's client ID and secret at
`POST /api/v1/oauth/token`. The canonical endpoint does not include an
organization domain: Hodoflow resolves the owning organization from the
server-generated client ID and fails closed if that identity is ambiguous.

## JSON request

Build the request body from secret-manager-provided environment variables and
stream it to cURL. Do not paste a literal secret into the command or URL.

```bash
jq -n '{
  grant_type: "client_credentials",
  client_id: env.CLIENT_ID,
  client_secret: env.CLIENT_SECRET
}' |
  curl --silent --show-error \
    --request POST \
    --header "Content-Type: application/json" \
    --data-binary @- \
    "$HODOFLOW_BASE_URL/api/v1/oauth/token"
```

HTTP Basic authentication is also supported for standards-compatible clients.
The JSON form above makes the complete contract visible and is used by the
first-request guide.

## Success response

```json
{
  "access_token": "<redacted JWT>",
  "token_type": "bearer",
  "expires_in": 900
}
```

`expires_in` is measured in seconds. The default is 900, but deployments can
configure it. There is no refresh token. A client should:

1. retain the bearer token in process memory;
2. calculate an early refresh time from the returned lifetime;
3. reuse the token until that time instead of exchanging per API call;
4. permit one concurrent worker to exchange while peers await its result; and
5. discard the token on process shutdown.

The exchange response and token must be redacted from logs. The token is a
bearer credential: possession is sufficient until runtime revalidation rejects
the underlying account.

## Error contract

| Status | Body | Meaning | Recovery |
|---|---|---|---|
| `400` | `{"error":"unsupported_grant_type"}` | Missing or non-`client_credentials` grant | Correct the request; do not retry unchanged |
| `401` | `{"error":"invalid_client"}` | Missing, invalid, inactive, revoked, or non-promoted credentials | Re-read the configured pair and account state; do not log them |
| `429` | API rate-limit error with `Retry-After` | Exchange rate window exceeded | Wait the stated number of seconds and coordinate refreshes |

The endpoint intentionally does not disclose whether the client ID, secret, or
service account was the invalid part.

## Use the token

Send the token only in an HTTPS authorization header:

```http
Authorization: Bearer <token>
```

Never send it in a query string, cookie, webhook URL, browser storage, or page
source. If an API request returns `401`, exchange once with the current client
credentials. Repeated `401` responses require inspecting account activation,
rotation state, and run-as membership rather than an unbounded retry loop.

See the exact [token endpoint reference](/reference/authentication/token-exchange),
then learn [rotation and revocation](/authentication/rotation-and-revocation).
