Webhooks

Webhooks

Subscribe an HTTPS endpoint and ReachHQ will POST a signed event the moment something happens on a campaign. Deliveries are HMAC-signed, retried with backoff, and dead-lettered if your endpoint stays down.

Subscribe

Register a webhook from the API (scope webhooks:write) or in the app. Pass the destination url and the events you want; an empty array subscribes to all event types. The response includes a signingSecret shown only once.

bash
curl -X POST https://api.reachhq.io/api/v1/webhooks \
  -H "Authorization: Bearer rhq_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/hooks/reachhq", "events": ["REPLIED", "BOUNCED"] }'

# { "id": "wh_...", "url": "...", "events": ["REPLIED","BOUNCED"],
#   "enabled": true, "signingSecret": "..." }   <- secret is returned ONCE

Event types

EventFires whendata fields
SENTAn email was sent to a recipient.recipientId, email, campaignId
OPENA recipient opened a tracked email.recipientId
CLICKA recipient clicked a tracked link.recipientId, url
REPLIEDA reply was received and attributed to the campaign.recipientId, email
BOUNCEDA bounce was detected for a recipient.recipientId, email
UNSUBSCRIBEA recipient unsubscribed.recipientId, email

You can also fire a one-off TEST delivery (data: { "ok": true }) from a subscription to check your endpoint end to end.

Payload & headers

Every delivery is a JSON POST with the same envelope, { event, data, timestamp }, and two headers: X-ReachHQ-Event (the event type) and X-ReachHQ-Signature (sha256=<hex>, an HMAC of the body).

http
POST /hooks/reachhq HTTP/1.1
Content-Type: application/json
X-ReachHQ-Event: REPLIED
X-ReachHQ-Signature: sha256=4f1a...c7

{
  "event": "REPLIED",
  "data": { "recipientId": "rcp_123", "email": "ada@example.com" },
  "timestamp": "2026-06-19T12:00:00.000Z"
}

Verify the signature

Compute an HMAC-SHA256 of the raw request body using your signingSecret and compare it to the X-ReachHQ-Signature header with a constant-time comparison. Reject anything that does not match.

javascript
import { createHmac, timingSafeEqual } from "node:crypto";

// The signing secret returned when you created the webhook.
const SECRET = process.env.REACHHQ_WEBHOOK_SECRET;

// Verify against the EXACT raw request body (do not re-serialize the parsed JSON).
export function isValidSignature(rawBody, header) {
  const expected = "sha256=" + createHmac("sha256", SECRET).update(rawBody).digest("hex");
  const got = Buffer.from(header ?? "");
  const want = Buffer.from(expected);
  return got.length === want.length && timingSafeEqual(got, want);
}

Delivery & retries

  • A delivery succeeds on any 2xx response; each attempt times out after 15 seconds.
  • Failures retry with exponential backoff (1m, 2m, 4m, capped at 6 hours) for up to 10 attempts.
  • After the last attempt the delivery moves to a dead-letter queue. List it with GET /api/v1/webhooks/deliveries/dlq and retry one with POST /api/v1/webhooks/deliveries/:id/requeue.
  • Make your handler idempotent: the same event can arrive more than once.