Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Groundmail OAuth API
version: 0.16.0
description: 'Transactional email API for developers.
Auth: `Authorization: Bearer gm_…` with an API key, or `Bearer gm_oat_…` with an OAuth
access token from the device flow (`groundmail login`). Dashboard session cookies work
for integrations.
Azure managed identity (keyless): an Azure workload can send a Microsoft Entra access
token (a JWT) as the Bearer credential instead of a key. Register the identity''s tenant
ID, object ID and token audience in the dashboard (API keys → Azure managed identity);
it then has the same access options as an API key (scopes, domain limit, tenant).
App-only tokens only. Any authentication failure is the same 401 `unauthorized`.
See https://docs.groundmail.dev/authentication/azure-managed-identity.
Test mode: a test key (`gm_test_…`, `POST /api-keys` with `test: true`) behaves
like a live key, but its sends are never delivered: they are stored with
`test: true`, get simulated events and webhooks (`data.test: true`), and do not
count toward plan limits, the sending ramp, stats or billing. Simulator
addresses (`delivered@`, `bounce@`, `block@`, `complaint@`, `suppressed@`,
`delayed@simulator.groundmail.dev`, plus addressing allowed) produce one outcome
each with any key. See https://docs.groundmail.dev/testing.
Tenants: a platform that sends email for its own customers creates one tenant per
customer (`/tenants`), each with its own domains, suppression list, stats, sending
ramp and pause switch. Name the tenant with `tenant` on sends, domains and
suppressions, or send from a domain the tenant owns. API keys and webhook
endpoints can be limited to tenants. Included on pay as you go and the Dedicated plan;
elsewhere a request that names a tenant gets 403 `tenants_not_enabled`. See
https://docs.groundmail.dev/tenants.
Data retention: sent emails (content and events) and received emails are kept for
your plan''s window, then deleted: 7 days on Free, 30 on Pro, 90 on Scale, up to 365
on Dedicated. Past it, `GET /emails/{id}` and `GET /received-emails/{id}` return
404 `not_found` and lists no longer include them. Aggregate stats
(`GET /deliverability`) are kept. Store webhook events to keep history longer. See
https://docs.groundmail.dev/emails#data-retention.
Scopes: every credential carries scopes (`full`, `emails:send`, `emails:read`,
`domains:read`, `domains:write`, `webhooks:write`, `templates:write`,
`suppressions:write`, `account:read`). Each operation lists the scope it needs under
`x-required-scope` (space-separated: all of them; `a | b`: either); a credential
without it gets 403 `insufficient_scope` with `required_scope`. `full` grants everything; `domains:write` includes `domains:read`.
Legacy `sending` API keys have `emails:send` + `emails:read`.
Production base URL: https://api.groundmail.dev'
servers:
- url: https://api.groundmail.dev
description: Production
tags:
- name: OAuth
description: 'OAuth 2.0 Device Authorization Grant (RFC 8628) for the CLI and agents, the authorization code grant with PKCE for the hosted MCP server, dynamic client registration (RFC 7591), token refresh with rotating refresh tokens, revocation (RFC 7009), and a UserInfo endpoint for the `openid` and `email` scopes. These endpoints send CORS headers and answer OPTIONS preflights. Server metadata: https://app.groundmail.dev/.well-known/oauth-authorization-server'
paths:
/oauth/register:
post:
operationId: registerOAuthClient
summary: Register an OAuth client (RFC 7591)
tags:
- OAuth
security: []
description: 'Dynamic client registration for MCP clients and other apps. Redirect URIs must be `https://`, loopback `http://` (`localhost`, `127.0.0.1`, `[::1]`, any port), or a private-use scheme for a native app (RFC 8252), such as `cursor://anysphere.cursor-mcp/oauth/callback`. Fragments and schemes a browser handles itself (`javascript:`, `data:`, `file:`, …) are refused. With `token_endpoint_auth_method: client_secret_post` the response includes a `client_secret`, shown once, that the client must send to `/oauth/token`.'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthClientRegistrationRequest'
responses:
'201':
description: Client registered
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthClientRegistrationResponse'
'400':
description: '`invalid_client_metadata`'
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
/oauth/device_authorization:
post:
operationId: startDeviceAuthorization
summary: Start a device sign-in (RFC 8628)
tags:
- OAuth
security: []
description: Public and rate limited. Returns a secret `device_code` to poll `/oauth/token` with, and a short `user_code` the person approves at `verification_uri_complete`. The approval page shows the client, the device name and the scopes asked for, and the person picks the workspace. Form-encoded (RFC 8628) or JSON bodies are accepted.
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/DeviceAuthorizationRequest'
application/json:
schema:
$ref: '#/components/schemas/DeviceAuthorizationRequest'
responses:
'200':
description: Device sign-in started
content:
application/json:
schema:
$ref: '#/components/schemas/DeviceAuthorizationResponse'
'400':
description: '`invalid_request`, `invalid_scope` or `unauthorized_client`'
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
'401':
description: '`invalid_client`: unknown client_id'
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
'429':
description: Rate limited (`Retry-After`)
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
/oauth/token:
post:
operationId: oauthToken
summary: Exchange a device code, authorization code or refresh token for tokens
tags:
- OAuth
security: []
description: '`grant_type=urn:ietf:params:oauth:grant-type:device_code` polls a device sign-in: `authorization_pending` until approved, `slow_down` when polled faster than `interval` (add 5 seconds), `access_denied` when canceled, `expired_token` after ten minutes. `grant_type=refresh_token` rotates the refresh token: each one works once, and presenting a used one revokes the whole sign-in. Pass `scope` to narrow the new access token. Access tokens last 3600 seconds; refresh tokens expire after 90 days without use. `authorization_code` (PKCE S256 required) serves the hosted MCP server; the tokens carry the scopes approved on the consent page, and a code presented twice revokes what it was exchanged for. Clients registered with a secret authenticate with `client_secret` (body or HTTP Basic); public clients send only `client_id`. A `resource` (RFC 8707) must be the one that was authorized.'
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/OAuthTokenRequest'
application/json:
schema:
$ref: '#/components/schemas/OAuthTokenRequest'
responses:
'200':
description: Tokens
headers:
Cache-Control:
schema:
type: string
enum:
- no-store
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthTokenResponse'
'400':
description: '`authorization_pending`, `slow_down`, `access_denied`, `expired_token`, `invalid_grant`, `invalid_scope`, `invalid_target`, `invalid_request` or `unsupported_grant_type`
'
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
'401':
description: '`invalid_client`: unknown client, or a confidential client without a valid `client_secret`
'
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
'429':
description: Rate limited (`Retry-After`)
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
/oauth/revoke:
post:
operationId: oauthRevoke
summary: Revoke a token (RFC 7009)
tags:
- OAuth
security: []
description: 'Revoking a refresh token ends the sign-in: it and every access token it issued stop working. Revoking an access token revokes only that token. Always 200, whether or not the token was known.'
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/OAuthRevokeRequest'
application/json:
schema:
$ref: '#/components/schemas/OAuthRevokeRequest'
responses:
'200':
description: Revoked (or unknown)
'429':
description: Rate limited (`Retry-After`)
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
/oauth/userinfo:
get:
operationId: oauthUserInfo
summary: Signed-in user (OpenID Connect UserInfo)
tags:
- OAuth
security:
- bearerAuth: []
description: 'Who the access token''s user is. Send an OAuth access token (`gm_oat_…`) that was granted the `openid` and/or `email` scope; API keys and tokens without an identity scope are refused. `sub` is the stable user id. `email` and `email_verified` are returned with the `email` scope. Sign-in requires a verified email address, so `email_verified` is `true` for every account. The identity scopes grant no other access: a token with only `openid` and `email` is refused by every other endpoint.'
responses:
'200':
description: Claims for the signed-in user
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthUserInfo'
'401':
description: 'Missing, invalid or expired access token (`WWW-Authenticate: Bearer`)'
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
'403':
description: The token has neither the `openid` nor the `email` scope (`insufficient_scope`)
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
post:
operationId: oauthUserInfoPost
summary: Signed-in user (OpenID Connect UserInfo)
tags:
- OAuth
security:
- bearerAuth: []
description: 'Who the access token''s user is. Send an OAuth access token (`gm_oat_…`) that was granted the `openid` and/or `email` scope; API keys and tokens without an identity scope are refused. `sub` is the stable user id. `email` and `email_verified` are returned with the `email` scope. Sign-in requires a verified email address, so `email_verified` is `true` for every account. The identity scopes grant no other access: a token with only `openid` and `email` is refused by every other endpoint.'
responses:
'200':
description: Claims for the signed-in user
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthUserInfo'
'401':
description: 'Missing, invalid or expired access token (`WWW-Authenticate: Bearer`)'
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
'403':
description: The token has neither the `openid` nor the `email` scope (`insufficient_scope`)
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
components:
schemas:
DeviceAuthorizationResponse:
type: object
required:
- device_code
- user_code
- verification_uri
- verification_uri_complete
- expires_in
- interval
properties:
device_code:
type: string
user_code:
type: string
example: BCDF-GHJK
verification_uri:
type: string
verification_uri_complete:
type: string
expires_in:
type: integer
example: 600
interval:
type: integer
description: Minimum seconds between polls
OAuthUserInfo:
type: object
required:
- sub
properties:
sub:
type: string
description: Stable user id
email:
type: string
format: email
description: With the `email` scope
email_verified:
type: boolean
description: With the `email` scope
example: true
OAuthClientRegistrationResponse:
type: object
required:
- client_id
- client_id_issued_at
- client_name
- redirect_uris
- token_endpoint_auth_method
- grant_types
properties:
client_id:
type: string
example: gm_oauth_0123456789abcdef0123456789abcdef
client_secret:
type: string
description: Only for `client_secret_post` clients, shown once
client_id_issued_at:
type: integer
client_name:
type: string
redirect_uris:
type: array
items:
type: string
token_endpoint_auth_method:
type: string
enum:
- none
- client_secret_post
grant_types:
type: array
items:
type: string
response_types:
type: array
items:
type: string
DeviceAuthorizationRequest:
type: object
required:
- client_id
properties:
client_id:
type: string
description: '`groundmail-cli`, or a client registered with the device_code grant type'
scope:
type: string
description: Space-separated scopes; defaults to `full`
example: emails:send domains:read
device_name:
type: string
description: Shown on the approval page and in Settings → CLIs & agents (e.g. the hostname)
workspace:
type: string
description: Workspace name or id to preselect on the approval page (a hint, not a grant)
OAuthErrorResponse:
type: object
required:
- error
properties:
error:
type: string
example: authorization_pending
error_description:
type: string
OAuthRevokeRequest:
type: object
required:
- token
properties:
token:
type: string
token_type_hint:
type: string
enum:
- access_token
- refresh_token
client_id:
type: string
OAuthTokenRequest:
type: object
required:
- grant_type
properties:
grant_type:
type: string
enum:
- urn:ietf:params:oauth:grant-type:device_code
- refresh_token
- authorization_code
client_id:
type: string
device_code:
type: string
refresh_token:
type: string
client_secret:
type: string
description: Confidential clients only (`client_secret_post`); or send HTTP Basic
scope:
type: string
description: With refresh_token, narrows the new access token (never widens it)
resource:
type: string
description: 'RFC 8707 resource indicator. Optional; when sent it must be the resource that was authorized (`https://mcp.groundmail.dev/mcp`, or its origin), else `invalid_target`
'
code:
type: string
redirect_uri:
type: string
code_verifier:
type: string
OAuthTokenResponse:
type: object
required:
- access_token
- token_type
- expires_in
- refresh_token
- scope
properties:
access_token:
type: string
description: Bearer token for the API (`gm_oat_…`)
token_type:
type: string
enum:
- Bearer
expires_in:
type: integer
example: 3600
refresh_token:
type: string
description: Single-use; store the new one each time
scope:
type: string
workspace:
type: object
description: Device grant only
properties:
id:
type: string
name:
type: string
user:
type: object
description: Device grant only
properties:
id:
type: string
email:
type: string
OAuthClientRegistrationRequest:
type: object
required:
- client_name
properties:
client_name:
type: string
example: Cursor
redirect_uris:
type: array
description: Required with the authorization_code grant (the default)
items:
type: string
example:
- cursor://anysphere.cursor-mcp/oauth/callback
token_endpoint_auth_method:
type: string
enum:
- none
- client_secret_post
default: none
grant_types:
type: array
description: Default `authorization_code` and `refresh_token`
items:
type: string
enum:
- authorization_code
- refresh_token
- urn:ietf:params:oauth:grant-type:device_code
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: 'Groundmail API key (`gm_…`), OAuth access token (`gm_oat_…`), or a Microsoft Entra access token from an Azure managed identity registered in the dashboard (keyless; see https://docs.groundmail.dev/authentication/azure-managed-identity). All carry scopes; see the API description.
'