Webhooks

Outbound webhooks for events in your workspace, how to verify their signatures, and the inbound lead webhook for forms.

Outbound webhooks

A workspace can subscribe HTTPS endpoints to events. ZeroReach sends each event as a signed POST. Managing endpoints needs the admin role, or an API key with the admin scope.

EndpointDoes
POST /api/v1/webhook-endpointsCreate an endpoint with its event types (* for all). The answer shows the signing secret (whsec_...) once.
PATCH /api/v1/webhook-endpoints/{endpoint_id}Change it, or re-enable it after it was disabled.
POST .../{endpoint_id}/testSend a webhook.test ping.
POST .../{endpoint_id}/rotate-secretIssue a new secret (shown once).
GET .../{endpoint_id}/deliveriesThe delivery log.
POST .../deliveries/{delivery_id}/replaySend a delivery again.

A workspace can have up to 10 endpoints by default. Every event type is listed in Webhook event types. An event goes to the endpoints that existed when it happened.

The request

POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-ReachOut-Signature: t=1767225600,v1=5f2b...
X-ReachOut-Event: email.sent
X-ReachOut-Event-Id: 01930c3e-...
X-ReachOut-Delivery: 01930c3f-...

{"id": "...", "type": "email.sent", "created_at": "...", "workspace_id": "...", "data": {...}}

Header names

The headers keep the platform's original name, X-ReachOut-*.

Verify the signature

X-ReachOut-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256>. The HMAC is computed with your endpoint's whole secret (including the whsec_ prefix) as the key, over "<t>.<raw body>", as Stripe does. Verify against the raw request body, compare in constant time, and reject old timestamps to stop replays.

import hashlib
import hmac
import time


def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts = dict(item.split("=", 1) for item in header.split(","))
    t, sig = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, header: string, body: Buffer, tolerance = 300): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2) as [string, string]));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(body).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "utf8");
  return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected, "utf8"));
}

Delivery and retries

  • Any 2xx answer is a success. Each attempt times out after 10 seconds by default.
  • Anything else is retried with exponential backoff, honouring Retry-After. After the last attempt the delivery is dead; you can replay it.
  • After 20 failed attempts in a row (by default) the endpoint is disabled. Re-enable it with PATCH.
  • Events are collected every 30 seconds while any endpoint is enabled, so expect a short delay.
  • Deliveries are kept for 30 days.

Endpoint rules

The target must be a public address. Private, loopback, link-local, CGNAT, multicast, reserved and cloud-metadata addresses are refused, also when hidden in IPv6. The connection is pinned to the resolved address: there are no redirects and no proxies. In production only https:// URLs are accepted.

Inbound lead webhook

Forms and tools like Zapier can push leads in with POST /api/v1/hooks/leads. This is the speed-to-lead webhook.

  • Auth: Authorization: Bearer rowh_..., a workspace webhook key that an admin creates or rotates. The key is shown once, and it names the workspace, so no other header is needed.
  • What happens: the lead is created or updated. If the workspace has speed-to-lead turned on (it is off by default) and every compliance check passes, an AI call to the lead is queued right away. If the calling window is closed or a cap is reached, the call is scheduled for when it opens. Anything else is skipped, with the reasons on the lead's timeline.
  • Phone consent: a form can record the person's consent with the lead (phone_consent: the consent text they agreed to and the page URL), for the number they submitted. AI calls need prior express written consent.
  • Idempotency: send an Idempotency-Key header, or the body itself is used as the key. A replayed delivery returns the stored result (deduplicated: true) and never calls twice.
  • Limits: requests per minute per key, and a maximum body size.