OAuth integration
For platforms that connect their customers' Reach organisations. The admin approves scopes on a Reach consent page; you get tokens bound to exactly one organisation and project.
https://api.tryreach.email serves /oauth/token, /oauth/revoke and /v1. https://tryreach.email serves the consent page.0. Registering a client (operator, one time)
There is no self-serve registration. The Reach operator registers your client, run once per client:
npm run oauth:client -- create --name <name> --redirect-uri <uri>... --scope <scope>...- It prints
client_id(rc_…) andclient_secretonce. Only a hash is stored; if the secret is lost the operator runsrotate-secret. - Every client is confidential. There are no public clients, no
client_credentialsgrant. - Redirect URIs must be
https.httpis allowed only forlocalhost,127.0.0.1or[::1], and never in production. Wildcards, fragments and userinfo are rejected. - The
redirect_uriis matched byte for byte, with no normalisation. allowed_scopesis a ceiling: consent can only approve a subset of it.
2. Exchanging the code
POST https://api.tryreach.email/oauth/token with an application/x-www-form-urlencoded body. A JSON body, a query string, or duplicate parameters give 400 invalid_request.
| Method | How |
|---|---|
client_secret_basic | Authorization: Basic base64(urlencode(id) + ":" + urlencode(secret)) |
client_secret_post | client_id and client_secret in the body. |
Use exactly one method; both gives 400 invalid_request. A Bearer token, an rk_ key, a session header, or two Authorization headers give 401 invalid_client.
curl -u sends them as-is, so the curl samples build the Authorization header explicitly. Ids and secrets made only of unreserved characters are unaffected, but do not rely on it.POST /oauth/token
curl -X POST "https://api.tryreach.email/oauth/token" \
-H "Authorization: Basic $(printf '%s:%s' "$(jq -rn --arg v "$REACH_CLIENT_ID" '$v|@uri')" "$(jq -rn --arg v "$REACH_CLIENT_SECRET" '$v|@uri')" | base64 | tr -d '\n')" \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'code=<code from the redirect>' \
--data-urlencode 'redirect_uri=https://platform.example/reach/callback' \
--data-urlencode 'code_verifier=<the PKCE verifier>'// 200, Cache-Control: no-store
{
"access_token": "ro_at_...",
"refresh_token": "ro_rt_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "emails:send tenants:read",
"organization_id": "org_...",
"project_id": "proj_..."
}{
"access_token": "ro_at_...",
"refresh_token": "ro_rt_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "emails:send tenants:read",
"organization_id": "org_...",
"project_id": "proj_..."
}Tokens are opaque strings of up to 512 characters; do not parse them. organization_id and project_id bind the connection to one organisation and project; you never send them back. Store both tokens encrypted, per connection.
| Thing | Default | Range |
|---|---|---|
| Authorization code | 60 s | 10-600 s |
| Access token | 3600 s | 60-86400 s |
| Refresh token | 90 days, sliding (each rotation renews it) | 1-365 days |
Lifetimes are operator configuration; always trust expires_in from the response.
3. Scopes and the routes they unlock
Every /v1 route requires exactly one scope. An API key holds all of them; an OAuth token holds the scopes the admin approved.
| Scope | Routes |
|---|---|
emails:send | POST /v1/emails |
emails:read | GET /v1/emailsGET /v1/emails/{id} |
tenants:write | POST /v1/tenantsPATCH /v1/tenants/{id}DELETE /v1/tenants/{id} |
tenants:read | GET /v1/tenantsGET /v1/tenants/{id} |
domains:write | POST /v1/domains |
domains:read | GET /v1/domainsGET /v1/domains/{id} |
webhooks:write | POST /v1/webhook-subscriptionsPATCH /v1/webhook-subscriptions/{id}POST /v1/webhook-subscriptions/{id}/rotate-secret |
webhooks:read | GET /v1/webhook-subscriptionsGET /v1/webhook-subscriptions/{id}GET /v1/webhook-deliveriesGET /v1/webhook-deliveries/{id} |
suppressions:read | GET /v1/suppressions |
suppressions:write | DELETE /v1/suppressions |
// 403 insufficient_scope. The scope check runs before body validation.
{
"error": {
"code": "insufficient_scope",
"message": "...",
"details": [{ "field": "scope", "reason": "emails:send" }],
"requestId": "req_..."
}
}Tokens are for /v1 only. The console's /account/* endpoints are internal to the console and are not on https://api.tryreach.email; only a human session administers an account.
4. Calling /v1
Send Authorization: Bearer <access_token> and nothing else; a second credential is a 401. Error bodies are { error: { code, message, details?, requestId } }; branch on code.
| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | The token is malformed or unknown, or a refresh token was sent where an access token belongs. |
| 401 | invalid_token | The token was real but is now expired or revoked (the token, its grant, or its client). Takes effect on the next request; nothing is cached. |
| 403 | insufficient_scope | The token lacks the route's scope. Checked before the body is validated. |
| 429 | rate_limited | A per-tenant or organisation-plan send limit was hit (details[0].reason names the window). Honour Retry-After. |
| 404 | not_found | A resource that belongs to another project is 404, never 403. |
5. Provisioning, in this order
- Tenant:
POST /v1/tenants {name, externalId}. New is 201; repeating the sameexternalIdis 200 and the same tenant. A tenant inprovisioningorprovision_failedcannot send. - Webhook:
POST /v1/webhook-subscriptions {tenant, url}returns 201 with asecret(rsec_…) shown once. The URL must be https and not a private address. - Domain:
POST /v1/domains {tenant, domain}returns the DNS records with statuspending.GET /v1/domains?tenant=lets the admin choose a sender. Onlyverifieddomains can send. - Send:
POST /v1/emailsreturns 202 with{id, status, tenant, acceptedAt}. SameidempotencyKeyreturns the original id withx-reach-idempotent-replay: true. A suppressed recipient is still 202 withstatus: "suppressed".
Each call is shown with curl and Node in the API key quickstart; only the credential differs.
6. Refresh
POST https://api.tryreach.email/oauth/token with grant_type=refresh_token, refresh_token, an optional scope, and the same client authentication. The response has the same shape as the exchange. Refresh a little before expires_in runs out; if /v1 answers 401, refresh once and retry once.
POST /oauth/token
curl -X POST "https://api.tryreach.email/oauth/token" \
-H "Authorization: Basic $(printf '%s:%s' "$(jq -rn --arg v "$REACH_CLIENT_ID" '$v|@uri')" "$(jq -rn --arg v "$REACH_CLIENT_SECRET" '$v|@uri')" | base64 | tr -d '\n')" \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode 'refresh_token=<current refresh token>' \
--data-urlencode 'scope=emails:send'// Same shape as the exchange. The refresh token you sent is now dead:
// persist the new one before doing anything else.400 invalid_grant. Treat it as a security event, not something to retry. If more than one process can refresh the same connection, serialise refreshes per connection: two refreshes racing each other look exactly like reuse.// One refresh at a time per connection. Two refreshes racing each other look
// exactly like refresh-token reuse, which revokes the whole grant.
const inflight = new Map(); // connectionId -> Promise (single process only)
export async function getAccessToken(connectionId) {
const conn = await db.connections.get(connectionId);
if (conn.expiresAt - Date.now() > 60_000) return conn.accessToken; // refresh a little early
return refreshOnce(connectionId);
}
function refreshOnce(connectionId) {
let p = inflight.get(connectionId);
if (!p) {
p = doRefresh(connectionId).finally(() => inflight.delete(connectionId));
inflight.set(connectionId, p);
}
return p;
}
async function doRefresh(connectionId) {
// With several processes, take a per-connection lock first instead
// (SELECT ... FOR UPDATE, a Redis lock, an advisory lock...), and re-read
// the row after acquiring it: another process may already have refreshed.
const conn = await db.connections.get(connectionId);
const res = await fetch("https://api.tryreach.email/oauth/token", { /* see "Refresh" */ });
if (res.status === 400) {
// invalid_grant: a revoked or reused grant. Do NOT retry. Mark the
// connection disconnected and send the admin through authorize again.
await db.connections.markDisconnected(connectionId);
throw new Error("Reach connection needs to be re-authorised");
}
const t = await res.json();
// Persist the NEW refresh token atomically with the new access token.
await db.connections.update(connectionId, {
accessToken: t.access_token,
refreshToken: t.refresh_token,
expiresAt: Date.now() + t.expires_in * 1000,
});
return t.access_token;
}7. Narrowing a refresh
scope on a refresh may only repeat or narrow the grant, within the client's current ceiling. Use it to hand a worker a token that can do less.
- Access token: carries exactly the scopes you asked for; the response's
scopereports them. - Refresh token: keeps the full ceiling. A later refresh without
scopegets the full grant back. - A scope outside the grant or the ceiling, or an empty
scope, is400 invalid_scope; the refresh token is not consumed. - If the operator lowers the ceiling until nothing of the grant is left, the refresh gives
invalid_scope.
8. Revocation
POST https://api.tryreach.email/oauth/revoke with token (and an optional token_type_hint, which is ignored), using the same client authentication.
POST /oauth/revoke
curl -X POST "https://api.tryreach.email/oauth/revoke" \
-H "Authorization: Basic $(printf '%s:%s' "$(jq -rn --arg v "$REACH_CLIENT_ID" '$v|@uri')" "$(jq -rn --arg v "$REACH_CLIENT_SECRET" '$v|@uri')" | base64 | tr -d '\n')" \
--data-urlencode 'token=<refresh token>'// Always 200 with an empty body for a well-formed request.- A well-formed request always gets 200 with an empty body. An unknown token, one already revoked, or another client's token is a silent no-op.
- Revoking a refresh token ends the whole grant; use this on disconnect. Revoking an access token ends only that token.
- Other ways a grant ends: an admin disconnects it in the console; refresh-token reuse is detected; an authorization code is replayed; a new consent for the same client and project replaces it; the operator disables the client.
- Re-consent catch: re-running authorize for the same project replaces the previous grant when the new code is redeemed, and the old refresh token dies at that moment.
- After any revocation the refresh token gives
invalid_grant. Send the admin through step 1 again. Revocation is effective within 60 seconds, uncached.
9. Errors on /oauth/*
Bodies follow RFC 6749 §5.2: { "error": "...", "error_description": "..." }, with no-store. Branch on error.
| HTTP | error | When |
|---|---|---|
| 400 | invalid_request | A parameter is missing or duplicated, the body isn't form-encoded, there is a query string, both auth methods were used, or client_id doesn't match Basic. |
| 401 | invalid_client | The client is unknown or disabled, the secret is wrong, or the credential type is forbidden. Basic failures also get WWW-Authenticate: Basic realm="oauth", charset="UTF-8". |
| 400 | invalid_grant | One message for every cause: unknown, expired, replayed, wrong client, wrong redirect_uri, wrong verifier, revoked, rotated. Not retryable: re-run consent. |
| 400 | invalid_scope | The scope is outside the grant or the ceiling, or empty. |
| 400 | unsupported_grant_type | Anything other than authorization_code or refresh_token. |
| 429 | slow_down | A rate limit (below) was hit; Retry-After is in seconds. |
| 500 | server_error | Something unexpected. |
10. Rate limits on /oauth/token and /oauth/revoke
| Limit | Value | Notes |
|---|---|---|
| Per client | 60 requests per client_id per minute | Shared by both endpoints, counted after authentication. |
| Failed authentication per IP | 30 per minute | Once an IP hits the cap, every request from it gets 429 until the window ends, including correct ones. |
Both use fixed one-minute windows. The response is 429 slow_down with Retry-After; honour it and do not retry in a loop. These are the defaults; the operator can change them. Until the service trusts its proxy, clients behind the same proxy may share one IP for the per-IP limit.