Skip to content
Docs

Guide

Let users connect your app to their Emailit workspace with OAuth 2.1 and PKCE, including client registration, token refresh, revocation and errors.

Updated Oct 1, 2026

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

  1. Register a client with dynamic client registration, or host a client ID metadata document.

  2. Send the user to the authorization URL with a PKCE code challenge.

  3. The user signs in to Emailit, chooses the workspaces your app can use and approves the requested scope.

  4. Emailit redirects back to your redirect URI with a single-use authorization code.

  5. Exchange the code for a 15-minute access token and a refresh token.

  6. 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

Terminal
curl https://api.emailit.com/.well-known/oauth-authorization-server
JSON
{
  "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:

JSON
{
  "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.

Terminal
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:

JSON
{
  "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:

https://crm.acme.com/oauth/client.json
{
  "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_id in the document must equal the document’s URL exactly.
  • token_endpoint_auth_method must be none. 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 of client_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, localhost and [::1]. For loopback URIs the port may differ at authorization time, but the host, path and query must match. localhost and 127.0.0.1 are different hosts.
  • Private-use schemes such as cursor://, vscode:// or com.acme.crm:// are allowed for native apps.
  • file, ftp, data, javascript, blob, about and vbscript URIs, and any URI with a #fragment, are rejected.
  • Apart from loopback ports, the redirect_uri you 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:

JavaScript
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:

Text
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

  1. 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.
  2. 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.
  3. 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:

Text
https://crm.acme.com/oauth/emailit/callback?code=XvRKyG5Pkplrd3xkRNaEayKkpkebEMdF&state=Jq3k9V0n2xR7bLm1&iss=https%3A%2F%2Fapi.emailit.com

Check 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:

Terminal
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.

JSON
{
  "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:

Terminal
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:

Terminal
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_token that’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 scope to 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:

Terminal
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:

JSON
{
  "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:

Terminal
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:

JSON
{
  "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_secret and 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 state and code verifier for every authorization, and check state and iss on 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_grant by asking the user to connect again.
  • Request sending if you only send email.
Grants, token lifetimes and revocation.
The endpoints your tokens can call.
For your own scripts, keys are simpler.
The OAuth flow in practice.

Was this page helpful?

Thanks for the feedback.

Thanks, we read every message.