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
| Event | Fires when |
|---|---|
| message.sent | A campaign or warmup message was handed to the provider |
| email.opened | A tracked open was recorded |
| email.clicked | A tracked link was clicked |
| email.replied | A reply was detected and classified as human |
| email.bounced | A hard or soft bounce was recorded |
| contact.unsubscribed | An unsubscribe was processed and suppressed |
{
"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:
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 (
timingSafeEqualabove), 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.