client_id comes from one of three places:
- Pre-registration: Resend issues a fixed
client_id, plus aclient_secretif the client has a backend. - Client ID Metadata Document (CIMD): the
client_idis the HTTPS URL of a JSON document you host, which Resend fetches to read the client’s metadata. - Dynamic Client Registration (DCR): the client registers itself at runtime via POST /oauth/register and is issued a
client_idthen.
- Public clients authenticate with PKCE alone. Register them with
token_endpoint_auth_method: "none"(the default), or declare that value in the metadata document. Use a public client when it can’t keep a secret, such as native apps, CLIs, and single-page apps. A CIMD client is always public, since its document is world-readable and can’t carry a secret. - Confidential clients additionally present a
client_secretat the token and revocation endpoints. Register them withtoken_endpoint_auth_method: "client_secret_basic"(orclient_secret_post) to have Resend issue the secret. Use a confidential client when it has a backend that can store the secret privately, such as a server-side web app.
Recommended implementation paths
The standard case is registering beforehand, and the worked examples below use it. A metadata document is the next best thing for clients that can’t predict their own deployment details ahead of time, like an MCP server: it works for hosted apps and local tools alike, and one document serves every install. Register dynamically only when the client can’t host a document. Also decide whether the client is public or confidential. A client running entirely on the user’s machine, such as a native app, CLI, or single-page app, can’t hide a secret, so register it as public (none). A client with a server-side backend should register as confidential (client_secret_basic) and keep the issued client_secret out of any user-facing code. That choice only applies to a registered client, since a CIMD client is always public. The pre-registered remote client section below covers the confidential path, and the local client section covers the public one.
Scopes
Use the smallest scope that works for your integration:emails:sendis enough for send-only routes, such asPOST /emails,POST /email,POST /emails/sending,POST /email/sending, andPOST /broadcasts/:broadcastId/send.full_accessis required for other API routes.
scope gets both scopes by default, whether it registered dynamically or declared no scope in its metadata document. Pass scope explicitly during registration and authorization instead of relying on that default.
Request encoding and resource
Dynamic client registration uses JSON, and so does a metadata document. The token and revocation endpoints accept both JSON and application/x-www-form-urlencoded, but prefer form encoding for /oauth/token and /oauth/revoke, since that’s what most OAuth libraries send by default.
Resend does not support RFC 8707 resource indicators yet. Don’t send or rely on resource in authorization or token requests. It’s accepted but ignored.
Generating PKCE values and state
Before starting the authorization request, generate three values:code_verifier: a high-entropy random string kept only by the client. See Token for the length and character set it must use.code_challenge: the base64url-encoded SHA-256 hash of thecode_verifier.state: a high-entropy random string used to bind the callback to the request that started the flow.
code_verifier is sent only during the token exchange. The code_challenge is sent during authorization. The state is sent during authorization and must come back unchanged on the callback.
state and codeVerifier server-side before redirecting the user to us. For a local client, keep them in memory while the temporary callback server is running.
Client ID Metadata Documents
Instead of registering, host a JSON document that describes the client and pass its HTTPS URL as theclient_id:
client_id to store. The client hardcodes its own document URL, and sends the same value as client_id at /oauth/token and /oauth/revoke.
This follows the OAuth Client ID Metadata Document draft. The rules below are the parts specific to Resend.
Host the document on a site you control, whatever the client is. A hosted app serves it next to the app and lists its own https callback. A CLI or desktop app serves it on the vendor’s website and lists loopback callbacks, since the document only has to name the client, not run it.
The document
grant_types, a declaration Resend can’t honor fails the whole document rather than being dropped from it. A client that declares private_key_jwt, for instance, gets an error rather than being downgraded to an unauthenticated one.
The document URL
The URL is the client’s identity, and it’s compared as an exact string with no normalization. Serve the document at a URL that survives a parse-and-serialize round trip: lowercase host, no explicit:443, no . or .. segments. It must use https, have a path (https://example.com alone won’t do), and carry no fragment and no userinfo. Maximum 2048 characters.
Treat the URL as permanent. Moving the document makes a different client, and every user has to authorize again.
Redirect URIs in a document
A document proves that you control its host, and nothing else:- An
httpsredirect URI must be on the same host as the document URL. A document athttps://example.com/oauth/client.jsoncan’t name a callback onapp.example.net. - Loopback
httpURIs (127.0.0.1,localhost,[::1]) are exempt. A loopback address names the user’s own machine rather than a publisher, so there’s nothing to compare. Port variance works the same way it does for a registered client. - Private-use URI schemes (e.g.
cursor://) are exempt too, since they carry no web host at all.
Hosting and caching
Resend fetches the document when a browser hits/oauth/authorize.
- Redirects aren’t followed. Serve the document at the exact URL with a
2xx. - 5 KB maximum, 5 second timeout. A real document is a few hundred bytes.
- The document must be reachable. A fetch that fails, or a document that doesn’t validate, fails the authorization with
invalid_client. Resend won’t fall back to an older copy, since a stale document can name a redirect URI you no longer control. cache-control: max-ageis honored, clamped between 5 minutes and 24 hours. A document sent withno-storestill gets the 5-minute floor.
/oauth/authorize fetches. Token, refresh, and revoke requests use the copy Resend already holds, so a document that’s briefly unreachable doesn’t break clients that already have a grant.
Because of the 5-minute floor, allow time for an edit to take effect. If a CDN caches the file too, the two windows stack.
On the consent screen
Resend has no self-service verification yet, so a CIMD client is unverified. The consent screen headlines the document’s host rather thanclient_name, since the host is the part Resend can check. client_name is shown as the client’s own claim about itself.
Being unverified affects errors too. Once client_id and redirect_uri are validated, /oauth/authorize redirects an error back to the callback only when the target is a loopback address, a private-use scheme, or a verified client. An unverified https callback gets a JSON error instead, which stops the endpoint from being used as an open redirect. Handle both.
Pre-registered remote client
A remote client must use an HTTPS redirect URI owned by the app, for examplehttps://example.com/oauth/callback.
Because a remote client has a backend that can keep a secret, register it as confidential: pass token_endpoint_auth_method: "client_secret_basic" and store the client_secret Resend returns. That secret is shown only once at registration, so persist it securely and never expose it in browser or client-side code. It’s then presented on every token and revocation call, in addition to PKCE.
For remote apps, you can still use POST /oauth/register manually while we don’t have a central place in the app to create clients.
Load the pre-issued client_id and client_secret
Generate PKCE and state
code_verifier, code_challenge, and state. See
above.Store state and code_verifier server-side
Redirect the user's browser to /oauth/authorize
Handle the callback on the app's backend
Reject invalid callbacks
code, mismatched or missing state, or an error query
parameter should all be treated as failures.Exchange the code server-side
code_verifier, and present the client_secret via HTTP
Basic auth.Store the refresh token securely
Serialize refreshes
-u flag sends the client_id and client_secret as HTTP Basic auth, so client_id isn’t repeated in the body:
Local client
A local client must use a loopback redirect URI or a private-use URI scheme, per RFC 8252. Prefer127.0.0.1 with a random local port, for example http://127.0.0.1:49152/oauth/callback. A native app the operating system routes to can use its own scheme instead, such as cursor://oauth/callback.
Loopback redirect URIs allow port variance, whether they come from registration or from a metadata document. The host, path, and query string must still match, and localhost and 127.0.0.1 count as different hosts. For example, a client can declare http://127.0.0.1/oauth/callback and later authorize with http://127.0.0.1:49152/oauth/callback.
The sequence below registers dynamically. With a metadata document, skip the registration step: the client_id is the document’s URL, and the loopback redirect URI is listed in the document.
Bind a temporary callback server
127.0.0.1 or [::1], never
0.0.0.0.Generate PKCE and state
code_verifier, code_challenge, and state. See
above.Get a client_id
Open the authorize URL in the user's browser
/oauth/authorize from your process and follow the redirect
yourself. The user needs to see the consent screen.Let the browser follow the redirect to the dashboard consent screen
Handle exactly one callback, then close the server
Reject invalid callbacks
code, mismatched or missing state, or an error query
parameter should all be treated as failures.Exchange the code
code_verifier. See Token.Store the newest refresh token after every token response
Dynamic client registration
Passscope explicitly. If DCR omits scope, Resend registers the client with both scopes by default.
client_id:
Authorization request
Do not have a backend or CLI process prefetch/oauth/authorize and then open the returned dashboard URL. Instead, open the authorization URL in the user’s browser and let the browser follow the redirect to the dashboard.
redirect_uri used in the authorization request:
state matches the original state before exchanging the code.
Authorization code exchange
Theredirect_uri in the token request must match the redirect_uri from the authorization request.
Refresh token exchange
Refresh tokens rotate on every successful refresh. Serialize refresh operations for a grant, and store the new refresh token atomically with the rest of the response. If refresh succeeds but the new refresh token isn’t persisted, you’ll need to reauthorize. Retrying an old token from multiple workers can revoke the whole grant: a replay fails withinvalid_grant either way, and it also revokes the grant unless the rotation happened within the last minute. Don’t rely on that window. See Token for the reuse-detection details.
Each refresh resets the new token’s 60-day lifetime, so a client that refreshes regularly never has to reauthorize.
Revoking access
To disconnect a client, revoke the refresh token:client_secret with curl -u 'CLIENT_ID:CLIENT_SECRET' instead of sending client_id in the body.
Access tokens are JWTs and can’t be revoked individually. Revoking the refresh token revokes the grant. See Revoke Token.