Token endpoint
This is the one endpoint used for both getting an access token and refreshing it later. It's called directly by the connecting app's own backend, never from a browser, and always returns plain JSON (no envelope wrapper).
POST /oauth/token
Exchange a code for a token (grant_type=authorization_code)
Called once, right after the consent redirect lands on the connecting app's redirect_uri with a code in the query string.
Field | Required | Notes |
|---|---|---|
| yes | literal |
| yes | from the consent redirect |
| yes | must exactly match the one used when the user was sent to the consent screen |
| yes |
|
| confidential clients |
|
| public clients, or any confidential client with PKCE enabled | the original value from Consent screen |
curl -X POST https://your-domain.example/api/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"code": "<code from the redirect>",
"redirect_uri": "https://theirapp.example.com/callback",
"client_id": "aZ9k...",
"client_secret": "<confidential client secret>"
}'
{...}{ "access_token": "<opaque bearer token>", "token_type": "Bearer", "expires_in": 86400, "refresh_token": "<opaque, long-lived>", "scope": "profile email tickets:read" }
The code is single-use. Presenting an already-used code a second time doesn't just fail. It revokes the whole connection, since it's treated as a sign the code may have leaked. The connecting app would need the user to go through consent again from the start.
Refresh an access token (grant_type=refresh_token)
Field | Required | Notes |
|---|---|---|
| yes | literal |
| yes | from a previous call to this same endpoint |
| no | if sent, must be a subset of the already-granted scopes; omit to keep the full set |
curl -X POST https://your-domain.example/api/oauth/token \
-H "Content-Type: application/json" \
-d '{"grant_type": "refresh_token", "refresh_token": "<refresh token>"}'
Response shape is identical to the code exchange above: a brand-new access_token and a brand-new refresh_token every time. Refresh tokens rotate on every use: the one just used stops working the instant the new one is issued, and presenting an already-rotated-out token again, like a replayed code, revokes the whole connection rather than just failing quietly. A refresh token itself never expires on a timer; it only stops working if the connection is revoked (by the user, by you disabling the integration, or by either of the leak-detection cases above).
Errors
Always this shape, with an appropriate 4xx status:
{ "error": "invalid_grant", "error_description": "..." }
| When |
|---|---|
| The most common one. Covers an unknown/expired/already-used code, a replayed refresh token, a |
| A refresh request asked for a scope outside what was already granted. |
|
|