Token
Exchange an authorization code for tokens, or refresh an access token.
Handles two grants, selected by the grant_type body field: authorization_code (with mandatory PKCE) and refresh_token (with rotation and reuse detection). Accepts both application/json and application/x-www-form-urlencoded. Prefer form encoding, since that's what most OAuth libraries send by default.
Access tokens are JWTs signed with ES256, valid for 900 seconds. Refresh tokens are opaque strings, valid for 60 days from whenever they were last issued, and rotate on every use.
Client authentication
Section titled “Client authentication”How the client proves its identity depends on the token_endpoint_auth_method it registered with:
- Public clients (
none) don't send a secret. PKCE (code_verifier) is the only proof. This is the default and covers native, CLI, and other clients that can't keep a secret. - Confidential clients additionally present their
client_secret, on top of PKCE:client_secret_basic: sendclient_idandclient_secretin the HTTPAuthorization: Basicheader (each URL-encoded, joined with:, base64-encoded). With this methodclient_idcan be omitted from the body.client_secret_post: sendclient_secretas a body parameter alongsideclient_id.
A client must use exactly one mechanism. Sending both a Basic header and a body client_secret fails with invalid_request.
Before starting the authorization request, generate:
code_verifier: a high-entropy random string, 43–128 characters, from the unreserved character set (A-Z,a-z,0-9,-,.,_,~). Keep it client-side only.code_challenge: the base64url-encoded SHA-256 hash ofcode_verifier, sent during authorization.
import { createHash, randomBytes } from 'node:crypto';
function base64url(input) {
return Buffer.from(input).toString('base64url');
}
const codeVerifier = base64url(randomBytes(64));
const codeChallenge = base64url(
createHash('sha256').update(codeVerifier).digest(),
);Authorization code grant
Section titled “Authorization code grant”Body Parameters
Section titled “Body Parameters”-
grant_type(string, required) — Must be"authorization_code". -
client_id(string) — Required, except withclient_secret_basic, where theAuthorizationheader already carries it. -
client_secret(string) — Required for a confidential client usingclient_secret_post. Omit for public clients and forclient_secret_basic(send the secret in theAuthorizationheader instead). See Client authentication. -
code(string, required) — The code from the/oauth/authorizecallback. Single-use: redeeming it twice fails withinvalid_grant. Expires 10 minutes after it was issued. -
redirect_uri(string, required) — Must exactly match theredirect_uriused in the authorization request. -
code_verifier(string, required) — The original value that code_challenge was derived from.
curl -X POST 'https://api.resend.com/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code&client_id=550e8400-e29b-41d4-a716-446655440000&code=AUTHORIZATION_CODE&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Foauth%2Fcallback&code_verifier=CODE_VERIFIER_VALUE'curl -X POST 'https://api.resend.com/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-u '550e8400-e29b-41d4-a716-446655440000:CLIENT_SECRET' \
-d 'grant_type=authorization_code&code=AUTHORIZATION_CODE&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Foauth%2Fcallback&code_verifier=CODE_VERIFIER_VALUE'curl -X POST 'https://api.resend.com/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code&client_id=550e8400-e29b-41d4-a716-446655440000&client_secret=CLIENT_SECRET&code=AUTHORIZATION_CODE&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Foauth%2Fcallback&code_verifier=CODE_VERIFIER_VALUE'{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoX2tleSIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "JcL7aYfE7S9h3L4qv0o2e1w8m6n5b3x9RkP2tD4uV6Q",
"scope": "emails:send"
}Body Parameters
Section titled “Body Parameters”-
grant_type(string, required) — Must be"refresh_token". -
client_id(string) — Required, except withclient_secret_basic, where theAuthorizationheader already carries it. -
client_secret(string) — Required for a confidential client usingclient_secret_post. Omit for public clients and forclient_secret_basic. See Client authentication. -
refresh_token(string, required) -
scope(string) — Optionally narrow the scope of the new access token. Must be a subset of what the grant already has. You can't use this to escalate scope.
curl -X POST 'https://api.resend.com/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token&client_id=550e8400-e29b-41d4-a716-446655440000&refresh_token=JcL7aYfE7S9h3L4qv0o2e1w8m6n5b3x9RkP2tD4uV6Q'{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoX2tleSIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "N3wRefreshTokenValue...",
"scope": "emails:send"
}Errors
Section titled “Errors”| Status | error |
When |
|---|---|---|
400 |
invalid_request |
A required field is missing or malformed, or the client sent credentials via more than one mechanism (both a Basic header and a body client_secret). |
400 |
invalid_scope |
Refresh requests a scope outside what the grant already has. |
400 |
invalid_grant |
The code/refresh token is invalid, expired, already used, or reused after rotation (which also revokes the grant). Also returned when PKCE verification fails or redirect_uri doesn't match. |
401 |
invalid_client |
Unknown or disabled client_id, or a confidential client failed authentication (missing or wrong client_secret, or client_id in the Basic header doesn't match the request). |
400 |
unauthorized_client |
The client isn't registered for the grant type it's using. |