Outbound webhooks and signature verification

Subscribing to events, the delivery and retry contract, and verifying the HMAC signature with a working code sample.

Outbound webhooks push events to your endpoint as they happen, so your CRM or data warehouse stays in sync without polling. Configure them under Settings → Webhooks: an HTTPS URL, the events you want, and the signing secret SendCanyon generates for the endpoint.

Events

EventFires when
message.sentA campaign or warmup message was handed to the provider
email.openedA tracked open was recorded
email.clickedA tracked link was clicked
email.repliedA reply was detected and classified as human
email.bouncedA hard or soft bounce was recorded
contact.unsubscribedAn unsubscribe was processed and suppressed
Example payload — email.replied
{
  "id": "evt_01j9x7v2m8",
  "type": "email.replied",
  "created_at": "2026-08-04T14:22:07Z",
  "workspace_id": "b1f4...",
  "data": {
    "message_id": "a2c8...",
    "campaign_id": "6b9d...",
    "contact_id": "e91f...",
    "mailbox_id": "77aa..."
  }
}

Delivery and retries

Deliveries are POST requests with a JSON body. Your endpoint should return a 2xx within 10 seconds — do real processing async and respond fast. Non-2xx responses and timeouts are retried with exponential backoff — 8 attempts at 30s, 1m, 2m, 4m, 8m, 16m, 32m, so roughly an hour — before the delivery is marked failed. After 10 failed deliveries in a rolling 7 days the endpoint is switched off, and turning it back on is a manual step in Settings → Webhooks. Deliveries can arrive out of order and, rarely, more than once — dedupe on the event id.

Verifying the signature

Every delivery is signed with your endpoint's secret so you can prove it came from SendCanyon and wasn't tampered with. Two headers matter: X-SendCanyon-Timestamp (unix seconds) and X-SendCanyon-Signature (hex HMAC-SHA256 of timestamp + "." + rawBody). Verify like this:

Node / TypeScript verification
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(
  rawBody: string,
  timestamp: string,
  signature: string,
  secret: string
): boolean {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) 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);
}
  • Compute the HMAC over the raw request body exactly as received — parse-then-restringify changes whitespace and breaks the signature. In most frameworks that means reading the body before any JSON middleware touches it.
  • The timestamp check (5-minute window) defeats replay of captured deliveries.
  • Use a constant-time comparison (timingSafeEqual above), never === on the hex strings.
  • Rotating the secret from Settings → Webhooks invalidates the old one after a 24-hour overlap window in which deliveries are signed with both (two signatures, comma-separated) so you can roll without downtime.