Quickstart with an API key
From a new key to a verified domain, a sent email and signed webhooks. Every request below is checked against the service's OpenAPI document.
export REACH_API_URL="https://api.tryreach.email"
export REACH_API_KEY="rk_..." # step 1Step 1: Create an API key
Sign in to the console, find the API keys section of the dashboard and create a key for your project. The full key (
rk_…) is shown exactly once: copy it into your secret store immediately. Afterwards the console only shows a prefix and the last 4 characters, and a lost key can only be replaced, never retrieved. A key holds every scope and is scoped to one organisation and project.Send it as
Authorization: Bearer $REACH_API_KEYon every/v1call. Never put it in a URL, a log line or client-side code.Step 2: Create a tenant for your customer
One tenant per customer of yours.
externalIdis your own id for that customer, and it makes the call idempotent: posting the sameexternalIdagain returns 200 and the existing tenant (a new tenant is 201), never a duplicate or an error.POST /v1/tenants
Create a tenant (curl)curl -X POST "https://api.tryreach.email/v1/tenants" \ -H "Authorization: Bearer $REACH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "externalId": "cust_8f2e1a" }'Response// 201 Created. 200 with the same tenant if "cust_8f2e1a" already has one. { "id": "tn_9f8c2a1d", "name": "Acme Corp", "externalId": "cust_8f2e1a", "status": "provisioning", "limits": { "perDay": 200, "perHour": 20, "burstPerSecond": 5 }, "provider": { "mapped": false, "syncedAt": null, "syncError": null }, "createdAt": "2026-09-29T12:00:00.000Z", "updatedAt": "2026-09-29T12:00:00.000Z" }A new tenant starts as
provisioningwhile its provider-side twin is created.provisioningandprovision_failedtenants cannot send; onlyactivecan. Read it back until it is active.GET /v1/tenants/{id}
Read the tenant until it is active (curl)curl -X GET "https://api.tryreach.email/v1/tenants/tn_9f8c2a1d" \ -H "Authorization: Bearer $REACH_API_KEY"Response// status is "provisioning", "active", "suspended" or "provision_failed". // Only "active" can send. { "id": "tn_9f8c2a1d", "status": "active", "...": "..." }Step 3: Add and verify the sending domain
A tenant sends from its own domain, and only
verifieddomains can send. Add the domain, publish the DNS records it returns, then poll.POST /v1/domains
Add a sending domain (curl)curl -X POST "https://api.tryreach.email/v1/domains" \ -H "Authorization: Bearer $REACH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tenant": "tn_9f8c2a1d", "domain": "acme.com" }'Response// 201 Created (200 if the tenant already has this domain). { "id": "dom_41c7a9e2", "tenant": "tn_9f8c2a1d", "domain": "acme.com", "status": "pending", "records": [ { "purpose": "dkim", "type": "CNAME", "name": "abc123._domainkey.acme.com", "nameHostOnly": "abc123._domainkey", "value": "abc123.dkim.example-ses.com", "required": true, "status": "missing" } ], "...": "..." }Each entry in
recordsis shaped for direct copy-paste:DNS record fields Field Meaning purposedkim(CNAME),mail_from_mx(MX),mail_from_spf(TXT),dmarc(TXT).nameFully qualified. Paste as-is into most registrars. nameHostOnlyThe same name without your domain suffix. Use this if your registrar appends the domain for you; confusing the two is the most common cause of a record that never verifies. valueCopy exactly. For MX records priorityis included.requiredFalse only for the recommended DMARC record, which is never needed for verified.statusPer-record diagnosis: missing(not found in DNS),mismatch(found but different),ok. Compareexpectedwithfound.GET /v1/domains/{id}
Poll the domain until it is verified (curl)curl -X GET "https://api.tryreach.email/v1/domains/dom_41c7a9e2" \ -H "Authorization: Bearer $REACH_API_KEY"Response// status: "pending" -> "verified" (or "failed"). // records[].status is "missing", "mismatch" or "ok" for each record. // verificationDeadline is when a still-pending domain becomes "failed": // read it from the response, it is configurable and not a constant. { "id": "dom_41c7a9e2", "status": "pending", "verificationDeadline": "2026-10-02T12:00:00.000Z", "lastCheckedAt": "2026-09-29T12:05:00.000Z", "...": "..." }A domain that is not verified is a named state, not a silent oneApendingdomain is checked on a schedule: it becomesverifiedshortly after (within about 5 minutes of) the records resolving, andfailedif it is still unverified at its deadline. The deadline is a configurable default (72 hours today), so readverificationDeadlinefrom the domain response rather than hardcoding a number. A verified domain that later loses its records also becomesfailed. Until it isverified, sending from it returns422 domain_not_verifiedwith the domain indetails.Step 4: Send an email
tenant,from,toandsubjectare required, plushtmland/ortext.tois a single recipient in this version;cc,bccandattachmentsare rejected withunsupported_field. Thefromdomain must be one of the tenant's verified domains.POST /v1/emails
Send an email (curl)curl -X POST "https://api.tryreach.email/v1/emails" \ -H "Authorization: Bearer $REACH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tenant": "tn_9f8c2a1d", "from": "billing@acme.com", "to": "customer@example.com", "subject": "Your invoice is ready", "html": "<p>Hi, your invoice for September is ready.</p>", "idempotencyKey": "invoice-2026-09-cust_8f2e1a" }'Response// 202 Accepted. Delivery happens afterwards, in a worker. // Repeating the request with the same idempotencyKey returns this same id // and the response header x-reach-idempotent-replay: true. // A brand-new organisation's first sends come back as "status": "held" // instead of "queued": expected, not an error (see "Held sends" below). { "id": "em_5b1e7c40", "status": "queued", "tenant": "tn_9f8c2a1d", "acceptedAt": "2026-09-29T12:00:00.000Z" }Send behaviour Situation What you get idempotencyKey(8-255 chars)Scoped to (tenant, key). A repeat returns the original idand sends nothing, with the response headerx-reach-idempotent-replay: true. Always set it on anything you might retry.Recipient is on the suppression list Still 202, with status: "suppressed"and no provider call. Release it withDELETE /v1/suppressions.Domain not verified 422 domain_not_verifiedTenant not active 409 tenant_not_sendableor409 tenant_suspendedYour organisation's first send has not been approved yet 202 with status: "held". Expected, not an error; see below.A send limit was hit (per tenant, or your organisation's plan ceiling) 429 rate_limitedwithRetry-After;details[0].reasonnames the window and tells you which of the two limits it was. Never blocks a retry of an already-accepted idempotency key. See rate limits.New organisation: sends are held until Reach approves itUntil an operator approves your organisation's first send,POST /v1/emailsstill answers 202, but the email'sstatusisheldinstead ofqueued. This is expected, not an error, and nothing is lost: held emails are delivered once the organisation is approved. There is nothing to fix in your request; contact Reach to get approved, or wait for approval. Treatheldas a normal status in your own code (it appears wherever a status is listed or filterable, e.g.GET /v1/emails?status=held), and do not retry or resend held emails: you would queue duplicates. The console shows the state and how many emails are waiting (it readsGET /account/organizations/:id/sending-policy, a console-session endpoint that API keys and OAuth tokens cannot call). See Held sends.Step 5: Read the status
A 202 means the email is recorded and queued; delivery happens afterwards. Poll it, list the log, or (better) use webhooks.
heldmeans accepted but waiting for your organisation's first-send approval.sentmeans the provider accepted it, which is notdelivered; a bounce or complaint arrives later and adds the address to the suppression list.GET /v1/emails/{id}
Read the email's status (curl)curl -X GET "https://api.tryreach.email/v1/emails/em_5b1e7c40" \ -H "Authorization: Bearer $REACH_API_KEY"Response// status: queued, held, dispatching, sent, dispatch_unconfirmed, delivered, // bounced, complained, failed, suppressed or canceled. // "held" = accepted, waiting for Reach to approve your organisation's first send. // "sent" means the provider accepted it, not that it was delivered. { "id": "em_5b1e7c40", "status": "delivered", "events": [ "..." ], "...": "..." }GET /v1/emails
List emails (curl)curl -X GET "https://api.tryreach.email/v1/emails?tenant=tn_9f8c2a1d&status=bounced&limit=20" \ -H "Authorization: Bearer $REACH_API_KEY"Response{ "rows": [ "..." ], "total": 1 }Step 6: Receive signed webhooks
A subscription receives every email and domain event for its tenant; there is no per-event filter in this version. The URL must be
httpsand must not point at a private address.POST /v1/webhook-subscriptions
Register a webhook subscription (curl)curl -X POST "https://api.tryreach.email/v1/webhook-subscriptions" \ -H "Authorization: Bearer $REACH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tenant": "tn_9f8c2a1d", "url": "https://your-app.example/webhooks/reach" }'Response// 201 Created. "secret" is shown exactly once: store it now. { "id": "whs_2a9d0c11", "tenant": "tn_9f8c2a1d", "url": "https://your-app.example/webhooks/reach", "status": "active", "secret": "rsec_...", "...": "..." }The signing secret is shown onceStoresecret(rsec_…) as soon as you get it. No endpoint returns it again; the only way to see a new one isPOST /v1/webhook-subscriptions/:id/rotate-secret, which replaces the old one.What a delivery looks like
Webhook request headers Header Value x-reach-signatureLowercase hex HMAC-SHA256 of the signed string below. x-reach-timestampUnix seconds when the request was sent. Part of the signed string. x-reach-event-typeThe event type, e.g. email.delivered,domain.verified. Treat unknown types as ignorable.x-reach-delivery-idThis delivery's id. Use it to de-duplicate. Request body{ "id": "whd_abc", // this delivery "eventId": "evt_xyz", // the event; identical across subscribers "type": "email.delivered", "tenant": "tn_9f8c2a1d", "occurredAt": "2026-09-29T12:00:05.000Z", "data": { } // event detail; shape depends on type }Verifying the signature
The signed string is
`${timestamp}.${rawBody}`, whererawBodyis the exact bytes you received, not a re-serialisation of parsed JSON. The signature ishex(HMAC-SHA256(secret, signedString)). Compare in constant time, and reject any timestamp more than 5 minutes from your own clock, in the past or the future, even when the digest is valid.Verify a webhook (Verifier)import { createHmac, timingSafeEqual } from "node:crypto"; const REPLAY_WINDOW_SECONDS = 300; /** * rawBody must be the exact bytes received (a string/Buffer from the raw * request), never JSON.stringify(parsedBody): a re-serialisation can reorder * keys or change whitespace and produce a different digest. */ export function verifyReachWebhook({ secret, rawBody, headers, now = Date.now() }) { const timestamp = headers["x-reach-timestamp"]; const signature = headers["x-reach-signature"]; if (typeof timestamp !== "string" || typeof signature !== "string") return false; // Reject stale AND future timestamps, independently of the digest. const ts = Number.parseInt(timestamp, 10); if (String(ts) !== timestamp.trim()) return false; if (Math.abs(Math.floor(now / 1000) - ts) > REPLAY_WINDOW_SECONDS) return false; const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(signature, "hex"); return a.length === b.length && timingSafeEqual(a, b); // constant time, never === }Respond with any
2xxto mark a deliverysucceeded. Otherwise it is attempted 6 times in total (the first attempt plus 5 retries, after roughly 5 minutes, 30 minutes, 2 hours, 6 hours and 14 hours, each with about ±20% jitter), then becomesfailed; it is never leftpending. The same event can reach you more than once, so handle it idempotently. Inspect and debug with:GET /v1/webhook-deliveries
List webhook deliveries (curl)curl -X GET "https://api.tryreach.email/v1/webhook-deliveries?subscription_id=whs_2a9d0c11&status=failed" \ -H "Authorization: Bearer $REACH_API_KEY"POST /v1/webhook-subscriptions/{id}/rotate-secret
Rotate a webhook signing secret (curl)curl -X POST "https://api.tryreach.email/v1/webhook-subscriptions/whs_2a9d0c11/rotate-secret" \ -H "Authorization: Bearer $REACH_API_KEY"