Guide
Build an OAuth app
Let users connect your app to their Emailit workspace with OAuth 2.1 and PKCE, including client registration, token refresh, revocation and errors.
Emailit runs an OAuth 2.1 authorization server at https://api.emailit.com. Use it when you build an integration, such as a CRM, a no-code tool or an AI client, that acts on behalf of Emailit users. Your users sign in and approve access in the browser, and you get tokens for the workspaces they choose without ever handling their API keys. This is the same flow the MCP server uses.
How it works
-
Register a client with dynamic client registration, or host a client ID metadata document.
-
Send the user to the authorization URL with a PKCE code challenge.
-
The user signs in to Emailit, chooses the workspaces your app can use and approves the requested scope.
-
Emailit redirects back to your redirect URI with a single-use authorization code.
-
Exchange the code for a 15-minute access token and a refresh token.
-
Call the API with the access token, and refresh it when it expires.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/.well-known/oauth-authorization-server |
Authorization server metadata (RFC 8414) |
GET |
/.well-known/oauth-protected-resource |
Protected resource metadata for the REST API |
GET |
/.well-known/oauth-protected-resource/mcp |
Protected resource metadata for the MCP server |
POST |
/oauth/register |
Dynamic client registration (RFC 7591) |
GET |
/oauth/authorize |
Browser sign-in and consent |
POST |
/oauth/token |
Exchange a code or refresh token |
POST |
/oauth/revoke |
Revoke a grant with its refresh token (RFC 7009) |
GET |
/oauth/grants |
List grants (the signed-in user’s, or one workspace’s with an API key) |
POST |
/oauth/grants/:id/revoke |
Revoke a grant |
PUT |
/oauth/grants/:id/workspaces |
Change which workspaces a grant can use (the user who connected the app) |
All paths are on https://api.emailit.com.
Discover the server
curl https://api.emailit.com/.well-known/oauth-authorization-server{
"issuer": "https://api.emailit.com",
"authorization_endpoint": "https://api.emailit.com/oauth/authorize",
"token_endpoint": "https://api.emailit.com/oauth/token",
"registration_endpoint": "https://api.emailit.com/oauth/register",
"revocation_endpoint": "https://api.emailit.com/oauth/revoke",
"jwks_uri": "https://api.emailit.com/.well-known/jwks.json",
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"response_types_supported": ["code"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
"client_id_metadata_document_supported": true,
"authorization_response_iss_parameter_supported": true,
"scopes_supported": ["sending", "full"]
}MCP clients start from the protected resource metadata instead. An unauthenticated request to https://api.emailit.com/mcp returns 401 with WWW-Authenticate: Bearer resource_metadata="https://api.emailit.com/.well-known/oauth-protected-resource/mcp", and that document points to the authorization server:
{
"resource": "https://api.emailit.com/mcp",
"authorization_servers": ["https://api.emailit.com"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["sending", "full"]
}Scopes
| Scope | Grants |
|---|---|
sending |
Sending and forwarding email, and rescheduling, canceling and retrying sends. The same access as a Sending Only API key. |
full |
Every REST API endpoint and MCP tool. The same access as a Full Access API key. |
full already includes everything sending allows. Request sending if your app only sends and full otherwise; a space-separated sending full is accepted too. If you leave scope out, Emailit uses every scope the client registered.
Register your client
You can register a client in one of two ways. Both work with every flow on this page.
Dynamic client registration
Send a registration request. No authentication is needed, and each IP address can register up to 20 clients per hour.
curl https://api.emailit.com/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "Acme CRM",
"redirect_uris": ["https://crm.acme.com/oauth/emailit/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "full",
"token_endpoint_auth_method": "client_secret_basic",
"client_uri": "https://crm.acme.com",
"logo_uri": "https://crm.acme.com/logo.png"
}'| Field | Required | Description |
|---|---|---|
client_name |
Yes | Shown on the consent page. Up to 200 characters. |
redirect_uris |
Yes | 1 to 10 redirect URIs. See Redirect URI rules. |
grant_types |
No | Must include authorization_code; may include refresh_token. Defaults to both. |
response_types |
No | Only code. |
scope |
No | Space-separated scopes. Defaults to sending full. |
token_endpoint_auth_method |
No | none (default) for public clients such as desktop, mobile and browser apps; client_secret_basic or client_secret_post for server-side apps. |
client_uri |
No | Your app’s homepage. |
logo_uri |
No | Logo shown on the consent page. |
The 201 response echoes the metadata and adds a client_id. Confidential clients also get a client_secret, shown only once:
{
"client_id": "3e8a1f52-7c4b-4d29-8e6f-1a5b9c0d2e73",
"client_id_issued_at": 1790847000,
"client_name": "Acme CRM",
"redirect_uris": ["https://crm.acme.com/oauth/emailit/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "full",
"token_endpoint_auth_method": "client_secret_basic",
"client_uri": "https://crm.acme.com",
"logo_uri": "https://crm.acme.com/logo.png",
"client_secret": "Ky7WI2z5HxoT6q1z19tuiIev_U1zX0_FKhIIfh0OBco",
"client_secret_expires_at": 0
}client_secret_expires_at is always 0: secrets don’t expire. Every client, public or confidential, must use PKCE.
Client ID metadata document
If your app can host a static JSON file, you can skip registration. Publish a document at an HTTPS URL and use that URL as your client_id:
{
"client_id": "https://crm.acme.com/oauth/client.json",
"client_name": "Acme CRM",
"client_uri": "https://crm.acme.com",
"logo_uri": "https://crm.acme.com/logo.png",
"redirect_uris": ["https://crm.acme.com/oauth/emailit/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none",
"scope": "full"
}client_idin the document must equal the document’s URL exactly.token_endpoint_auth_methodmust benone. These are public clients that rely on PKCE.- HTTPS redirect URIs must be on the same host as the document.
- Emailit fetches the document during authorization, with a 5-second timeout and without following redirects, and caches it for 5 minutes.
- The consent page shows the document’s host, for example
crm.acme.com, instead ofclient_name.
AI clients use this method: ChatGPT, Claude and Grok identify themselves with their own client ID metadata documents, so they connect without registering first. For Grok, Emailit also accepts redirects to console.x.ai.
Redirect URI rules
https://URIs are allowed.http://is only allowed for loopback hosts:127.0.0.1,localhostand[::1]. For loopback URIs the port may differ at authorization time, but the host, path and query must match.localhostand127.0.0.1are different hosts.- Private-use schemes such as
cursor://,vscode://orcom.acme.crm://are allowed for native apps. file,ftp,data,javascript,blob,aboutandvbscriptURIs, and any URI with a#fragment, are rejected.- Apart from loopback ports, the
redirect_uriyou send must exactly match a registered one.
Send the user to Emailit
Create a PKCE verifier and challenge, and a random state, for each authorization:
import { createHash, randomBytes } from 'node:crypto';
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
const state = randomBytes(16).toString('base64url');
// Store codeVerifier and state in the user's session.Then redirect the user’s browser to the authorization endpoint:
https://api.emailit.com/oauth/authorize
?response_type=code
&client_id=3e8a1f52-7c4b-4d29-8e6f-1a5b9c0d2e73
&redirect_uri=https%3A%2F%2Fcrm.acme.com%2Foauth%2Femailit%2Fcallback
&scope=full
&state=Jq3k9V0n2xR7bLm1
&code_challenge=zsrvXVr2pbLDHbccAx_NY9osQ6nwqTnDZlIeFWKQOsg
&code_challenge_method=S256
&resource=https%3A%2F%2Fapi.emailit.com| Parameter | Required | Description |
|---|---|---|
response_type |
Yes | code |
client_id |
Yes | Your client ID, or your metadata document URL. |
redirect_uri |
Yes | One of your registered redirect URIs. |
code_challenge |
Yes | Base64url-encoded SHA-256 of the code verifier. |
code_challenge_method |
Recommended | S256. plain isn’t supported. |
scope |
Recommended | sending or full. Must be a scope the client registered. |
state |
Recommended | A random value you check on the callback. |
resource |
No | The resource you want to call (RFC 8707): https://api.emailit.com for the REST API or https://api.emailit.com/mcp for MCP. Tokens work on both either way. |
Open this URL in the user’s browser. Don’t fetch it from your backend.
What the user sees
- Sign in to Emailit. The user enters their email and password. If they use an authenticator app for two-factor authentication, they enter its code or a recovery code next.
- Choose workspaces. The page shows your logo, your client name (or your metadata document’s host) and the scopes you requested. The user picks All my workspaces, which includes workspaces they create or join later, or Only these workspaces with the ones they tick, and chooses where your app starts.
- Allow access. They select Allow access or Deny.
One grant can cover several workspaces. The user can change the list later under Account > Connected apps in the dashboard, and your app sees the change on its next request.
On approval, Emailit redirects to your redirect URI:
https://crm.acme.com/oauth/emailit/callback?code=XvRKyG5Pkplrd3xkRNaEayKkpkebEMdF&state=Jq3k9V0n2xR7bLm1&iss=https%3A%2F%2Fapi.emailit.comCheck that state matches the value you stored and that iss is https://api.emailit.com. The code is valid for 10 minutes and can be used once. If the user selects Deny, the redirect carries error=access_denied instead.
Exchange the code for tokens
Post to the token endpoint as application/x-www-form-urlencoded (JSON is also accepted). Send the same redirect_uri and the original code verifier:
curl https://api.emailit.com/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://crm.acme.com/oauth/emailit/callback \
-d code_verifier="$CODE_VERIFIER"With client_secret_basic, send the client ID and secret in the Authorization: Basic header. With client_secret_post, send client_id and client_secret as form fields instead. Don’t send the secret both ways.
curl https://api.emailit.com/oauth/token \
-d grant_type=authorization_code \
-d client_id="$CLIENT_ID" \
-d code="$CODE" \
-d redirect_uri=http://127.0.0.1:53682/callback \
-d code_verifier="$CODE_VERIFIER"Public clients send client_id and no secret. PKCE proves that the same client started the flow.
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im9hdXRoLWhzMjU2In0…",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "XvRKyG5Pkplrd3xkRNaEayKkpkebEMdFn0h1VIuqixAOXybMtJfK8w",
"scope": "full"
}The access token lasts 15 minutes (expires_in is in seconds). Treat it as an opaque string: don’t parse it or rely on its contents.
Call the API
Use the access token as a bearer token on the REST API and the MCP server, exactly like an API key:
curl https://api.emailit.com/v2/domains \
-H "Authorization: Bearer $ACCESS_TOKEN"REST requests run in the grant’s default workspace: the one the user chose to start in, or changed to later. MCP tools can also target another allowed workspace with their workspace argument. Every request uses the granted scope and the user’s role in the workspace, so a request outside the scope, or an Admin-only route called by a workspace Member, returns 403. If the user leaves a workspace, the grant loses it too.
Refresh tokens
Before the access token expires, or when a request returns 401, get a new one:
curl https://api.emailit.com/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"- Rotation. Every refresh returns a new
refresh_tokenthat’s valid for 60 days. Store it and discard the old one. As long as you refresh within 60 days, the connection doesn’t expire. - Reuse detection. If an old refresh token is used again more than a minute after it was replaced, Emailit returns
invalid_grant(Refresh token reuse detected) and revokes the whole grant. The user then has to authorize again. Serialize refreshes so two workers never use the same refresh token. - Narrower scope. You can pass
scopeto get an access token with fewer scopes than the grant. You can’t ask for more.
Revoke a grant
When a user disconnects Emailit from your app, revoke the grant with its refresh token:
curl https://api.emailit.com/oauth/revoke \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$REFRESH_TOKEN"The endpoint authenticates your client the same way as the token endpoint and always returns 200 with an empty body. Revoking the refresh token revokes the whole grant: its access tokens stop working on the next request. Access tokens can’t be revoked on their own; token_type_hint=access_token returns invalid_request.
Users can also revoke your app’s access from their side under Connected apps in the dashboard, or with the grants API below.
Manage grants
The grants API lists and changes grants from the user’s side. It accepts the user’s dashboard session or a Full Access API key:
| Caller | GET /oauth/grants |
POST /oauth/grants/:id/revoke |
PUT /oauth/grants/:id/workspaces |
|---|---|---|---|
| The user who connected the app | Their grants across all workspaces | Revokes the whole grant | Changes the grant’s workspaces |
| Full Access API key | Grants that include the key’s workspace | Removes the key’s workspace from the grant; the grant is revoked when no workspace is left | Not allowed (403) |
Each grant in the list has this shape:
{
"id": "9b1f6c2e-4d3a-4f8e-9a7b-2c5d8e1f0a3b",
"client_id": "https://chatgpt.com/oauth/client.json",
"client": { "name": "ChatGPT", "uri": "https://chatgpt.com", "logo_uri": null },
"default_workspace": { "id": "w3f9a1c2e", "name": "Acme" },
"workspace_access": "selected",
"workspaces": [{ "id": "w3f9a1c2e", "name": "Acme" }],
"scopes": ["sending", "full"],
"resource": "https://api.emailit.com/mcp",
"created_at": "2026-10-01T09:30:12Z",
"revoked_at": null,
"revoked_reason": null
}To change a grant’s workspaces, send access (all or selected), workspace_ids for selected, and an optional default_workspace_id that must be one of the allowed workspaces:
curl -X PUT https://api.emailit.com/oauth/grants/9b1f6c2e-4d3a-4f8e-9a7b-2c5d8e1f0a3b/workspaces \
-H "Authorization: Bearer $DASHBOARD_SESSION" \
-H "Content-Type: application/json" \
-d '{ "access": "selected", "workspace_ids": ["w3f9a1c2e"], "default_workspace_id": "w3f9a1c2e" }'The user must be a member of every workspace they select. A revoked grant can’t be edited (409); the app has to connect again.
Errors
OAuth endpoints return errors in the OAuth format, not the REST API error format:
{
"error": "invalid_grant",
"error_description": "Authorization code has expired"
}| Error | Status | When |
|---|---|---|
invalid_request |
400 | A parameter is missing or invalid, the client is unknown at authorization, the redirect_uri isn’t registered, code_challenge_method isn’t S256, or a client secret was sent in both the header and the body. |
invalid_client |
401 | The token or revocation endpoint can’t authenticate the client: unknown client, missing or wrong secret. |
invalid_grant |
400 | The code is invalid, used or expired; the redirect_uri or PKCE verifier doesn’t match; or the refresh token is invalid, expired, revoked or reused. |
invalid_scope |
400 | The scope isn’t supported, wasn’t registered for the client, or exceeds the grant on refresh. |
unsupported_response_type |
400 | response_type isn’t code. |
unsupported_grant_type |
400 | grant_type isn’t authorization_code or refresh_token. |
access_denied |
Redirect | The user selected Deny. |
too_many_requests |
429 | More than 20 registrations per hour from one IP address. |
server_error |
500 | Something went wrong on Emailit’s side. Try again. |
Until the client_id and redirect_uri are validated, authorization errors are shown as JSON in the browser and never redirected. After that, errors are redirected to your callback (with error, error_description, state and iss) only for loopback and private-use redirect URIs and for metadata-document clients; other clients get a JSON error page. A user’s Deny is always redirected.
Security checklist
- Keep
client_secretand refresh tokens on your server, encrypted at rest. Never ship a client secret in a mobile, desktop or browser app; use a public client with PKCE instead. - Generate a new
stateand code verifier for every authorization, and checkstateandisson the callback. - Register exact redirect URIs. Don’t use open redirectors as callbacks.
- Store each grant with the workspace it belongs to, and handle
invalid_grantby asking the user to connect again. - Request
sendingif you only send email.