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.

Download Postman collectionPrefer clicking? Every step is in the collection.
Setup
export REACH_API_URL="https://api.tryreach.email"
export REACH_API_KEY="rk_..."   # step 1
  1. Step 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_KEY on every /v1 call. Never put it in a URL, a log line or client-side code.

  2. Step 2: Create a tenant for your customer

    One tenant per customer of yours. externalId is your own id for that customer, and it makes the call idempotent: posting the same externalId again 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 provisioning while its provider-side twin is created. provisioning and provision_failed tenants cannot send; only active can. 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", "...": "..." }
  3. Step 3: Add and verify the sending domain

    A tenant sends from its own domain, and only verified domains 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 records is shaped for direct copy-paste:

    DNS record fields
    FieldMeaning
    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 priority is 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. Compare expected with found.

    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",
      "...": "..."
    }
  4. Step 4: Send an email

    tenant, from, to and subject are required, plus html and/or text. to is a single recipient in this version; cc, bcc and attachments are rejected with unsupported_field. The from domain 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
    SituationWhat you get
    idempotencyKey (8-255 chars)Scoped to (tenant, key). A repeat returns the original id and sends nothing, with the response header x-reach-idempotent-replay: true. Always set it on anything you might retry.
    Recipient is on the suppression listStill 202, with status: "suppressed" and no provider call. Release it with DELETE /v1/suppressions.
    Domain not verified422 domain_not_verified
    Tenant not active409 tenant_not_sendable or 409 tenant_suspended
    Your organisation's first send has not been approved yet202 with status: "held". Expected, not an error; see below.
    A send limit was hit (per tenant, or your organisation's plan ceiling)429 rate_limited with Retry-After; details[0].reason names 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.
  5. 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. held means accepted but waiting for your organisation's first-send approval. sent means the provider accepted it, which is not delivered; 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 }
  6. 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 https and 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_...",
      "...": "..."
    }

    What a delivery looks like

    Webhook request headers
    HeaderValue
    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}`, where rawBody is the exact bytes you received, not a re-serialisation of parsed JSON. The signature is hex(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 2xx to mark a delivery succeeded. 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 becomes failed; it is never left pending. 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"