API keys and authentication

One header on every request, and how to scope, store, and rotate the key behind it.

Every request carries Authorization: Bearer esh_..., and nothing else. There is no signing, no client id, and no session. A key belongs to one workspace and can do anything that workspace can do unless you narrow it with scopes.

Any authenticated call
curl https://emails.sh/v1/domains \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

Making keys

Create them at https://emails.sh/dashboard/api-keys, or over the API when a deploy pipeline needs its own. The value is in the create response and nowhere else afterwards: we store a hash, so a lost key is replaced rather than recovered.

A key that can only send
curl -X POST https://emails.sh/v1/api-keys \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "production web", "scopes": ["mail:send"]}'

api_keys.list

Keys on the workspace, with the last time each was used. Values are never listed.

Takes no arguments.

Returns{ api_keys: ApiKey[] }

api_keys.create

Create a key. The value is returned once and never again.

Arguments

name string required
What it is for, so a later reader can revoke the right one.
scopes string[]
Defaults to full access. Give ["mail:send"] to a key that only sends.

Returns{ id, name, key }

api_keys.revoke

Revoke a key. It stops working on the next request, with no grace period.

Arguments

id string required

Returns{ deleted: id }

Scopes

ScopeWhat it allows
mail:sendPOST /v1/emails and /v1/emails/batch, and reading the status of what it sent.
mail:readReading received mail at /v1/messages, /v1/threads, and /v1/search.
workspaceDomains, webhooks, audiences, and other keys.

A key with no scopes listed gets all of them. Give the key in your web app mail:send only: an attacker with it can send mail as you, which is bad, but cannot read your inbound mail or repoint your webhooks, which is worse.

Storing the key

Server side only
A key in browser JavaScript is a key anybody can read. Send from your server, your API route, or your edge function, never from the client.
Environment, not source
Put it in .env, .env.local, or your platform's secret store, and confirm that file is gitignored before you write it.
One key per environment
Separate keys for local, staging, and production means revoking one does not take the others down, and the last-used column tells you which is which.

Rotating

Create the new key, deploy it, confirm traffic on the new key in the dashboard, then revoke the old one. Revocation takes effect on the next request with no grace period, so doing it in the other order causes an outage.

Rate limits

600 requests a minute per key, which is 10 a second, across every endpoint. The budget is per API key rather than per workspace, so a workspace with several keys gets more, never less. Over it you get 429 with retry-after in seconds. Responses carry x-ratelimit-limit and, where we can compute it cheaply, x-ratelimit-remaining and x-ratelimit-reset.