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.

Download Postman collectionThe collection's OAuth folder runs this whole flow, PKCE included.

0. Registering a client (operator, one time)

There is no self-serve registration. The Reach operator registers your client, run once per client:

Operator command
npm run oauth:client -- create --name <name> --redirect-uri <uri>... --scope <scope>...
  • It prints client_id (rc_…) and client_secret once. Only a hash is stored; if the secret is lost the operator runs rotate-secret.
  • Every client is confidential. There are no public clients, no client_credentials grant.
  • Redirect URIs must be https. http is allowed only for localhost, 127.0.0.1 or [::1], and never in production. Wildcards, fragments and userinfo are rejected.
  • The redirect_uri is matched byte for byte, with no normalisation.
  • allowed_scopes is a ceiling: consent can only approve a subset of it.

1. Sending the admin to consent

Redirect the admin's browser to the consent page:

Authorize URL
https://tryreach.email/oauth/authorize
  ?response_type=code
  &client_id=rc_...
  &redirect_uri=<your registered redirect URI>
  &scope=emails:send%20tenants:read      (space-delimited)
  &state=<random, yours>
  &code_challenge=<base64url(SHA-256(verifier))>
  &code_challenge_method=S256
  • PKCE is mandatory, S256 only. The challenge is base64url(SHA-256(verifier)) (43 chars). The verifier is 43-128 characters of [A-Za-z0-9._~-]. Keep the verifier server-side, per attempt.
  • The admin signs in if needed, chooses the organisation and project, and approves or denies. An unknown scope or one above your client's ceiling is a 422.
  • state comes back untouched. It is your CSRF defence: verify it before doing anything with the code.
  • The code is single use and valid for 60 seconds: verify state, then exchange it immediately.
Where the browser lands
# Approved
<redirect_uri>?code=<authorization code>&state=<your state>

# Denied
<redirect_uri>?error=access_denied&state=<your state>
PKCE and the authorize URL (Node.js)
import { createHash, randomBytes } from "node:crypto";

// 1. PKCE: S256 only. The verifier is 43-128 chars of [A-Za-z0-9._~-].
export function createPkcePair() {
  const verifier = randomBytes(32).toString("base64url"); // 43 chars
  const challenge = createHash("sha256").update(verifier).digest("base64url");
  return { verifier, challenge };
}

// 2. Build the consent URL. The console URL is https://tryreach.email
// (keep it in configuration rather than scattering it through your code).
export function buildAuthorizeUrl(opts) {
  const { consoleUrl, clientId, redirectUri, scopes, state, challenge } = opts;
  const url = new URL("/oauth/authorize", consoleUrl);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri, // matched byte for byte against the registered URI
    scope: scopes.join(" "),
    state, // your CSRF defence: returned untouched, verify it on the way back
    code_challenge: challenge,
    code_challenge_method: "S256",
  }).toString();
  return url.toString();
}

// Per connection attempt: store { verifier, state } server-side (session or DB),
// redirect the admin to the URL, and on the callback check state === stored.state
// before exchanging the code with code_verifier = stored.verifier.
const state = randomBytes(16).toString("base64url");
const { verifier, challenge } = createPkcePair();
// buildAuthorizeUrl({ consoleUrl: "https://tryreach.email", clientId, redirectUri, scopes, state, challenge })

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.

Client authentication methods
MethodHow
client_secret_basicAuthorization: Basic base64(urlencode(id) + ":" + urlencode(secret))
client_secret_postclient_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.

POST /oauth/token

Exchange the authorization code (curl)
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>'
Response
// 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_..."
}
200 response (Cache-Control: no-store, Pragma: no-cache)
{
  "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.

Lifetimes
ThingDefaultRange
Authorization code60 s10-600 s
Access token3600 s60-86400 s
Refresh token90 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.

Scopes and routes
ScopeRoutes
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
Missing scope
// 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.

Errors on /v1 specific to tokens
HTTPcodeMeaning
401unauthorizedThe token is malformed or unknown, or a refresh token was sent where an access token belongs.
401invalid_tokenThe 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.
403insufficient_scopeThe token lacks the route's scope. Checked before the body is validated.
429rate_limitedA per-tenant or organisation-plan send limit was hit (details[0].reason names the window). Honour Retry-After.
404not_foundA resource that belongs to another project is 404, never 403.

5. Provisioning, in this order

  1. Tenant: POST /v1/tenants {name, externalId}. New is 201; repeating the same externalId is 200 and the same tenant. A tenant in provisioning or provision_failed cannot send.
  2. Webhook: POST /v1/webhook-subscriptions {tenant, url} returns 201 with a secret (rsec_…) shown once. The URL must be https and not a private address.
  3. Domain: POST /v1/domains {tenant, domain} returns the DNS records with status pending. GET /v1/domains?tenant= lets the admin choose a sender. Only verified domains can send.
  4. Send: POST /v1/emails returns 202 with {id, status, tenant, acceptedAt}. Same idempotencyKey returns the original id with x-reach-idempotent-replay: true. A suppressed recipient is still 202 with status: "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

Refresh (rotates the refresh token) (curl)
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'
Response
// Same shape as the exchange. The refresh token you sent is now dead:
// persist the new one before doing anything else.
Serialised refresh (sketch, Node.js)
// 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 scope reports them.
  • Refresh token: keeps the full ceiling. A later refresh without scope gets the full grant back.
  • A scope outside the grant or the ceiling, or an empty scope, is 400 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

Revoke (disconnect) (curl)
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>'
Response
// 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.

OAuth errors
HTTPerrorWhen
400invalid_requestA 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.
401invalid_clientThe 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".
400invalid_grantOne message for every cause: unknown, expired, replayed, wrong client, wrong redirect_uri, wrong verifier, revoked, rotated. Not retryable: re-run consent.
400invalid_scopeThe scope is outside the grant or the ceiling, or empty.
400unsupported_grant_typeAnything other than authorization_code or refresh_token.
429slow_downA rate limit (below) was hit; Retry-After is in seconds.
500server_errorSomething unexpected.

10. Rate limits on /oauth/token and /oauth/revoke

OAuth rate limits
LimitValueNotes
Per client60 requests per client_id per minuteShared by both endpoints, counted after authentication.
Failed authentication per IP30 per minuteOnce 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.