# 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](/docs/mcp/) 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

```bash
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.

```bash
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](#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`:

```json title="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

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:

**Confidential client**

```bash
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.

**Public client**

```bash
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.

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

```bash
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:

```bash
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:

```bash
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](/docs/account/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:

```bash
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](/docs/api-reference/errors/):

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

## Related

  - [Workspaces and permissions](/docs/mcp/workspaces-and-permissions/): Grants, token lifetimes and revocation.
  - [API reference](/docs/api-reference/): The endpoints your tokens can call.
  - [API keys](/docs/developers/api-keys/): For your own scripts, keys are simpler.
  - [MCP server](/docs/mcp/): The OAuth flow in practice.

---
Source: https://emailit.com/docs/developers/oauth-apps/
