Agent skill

Hail Webhooks Skill

Receive and verify Hail webhooks. Use when setting up Hail email, SMS and voice webhook handlers, debugging X-Hail-Signature verification, or handling events such as email.received, email.delivered, email.bounced, email.opened, email.clicked, sms.received, sms.delivered, and call.completed.

Install this skill

npx skills add hookdeck/webhook-skills --skill hail-webhooks


When to Use This Skill

  • How do I receive Hail webhooks?
  • How do I verify the X-Hail-Signature header?
  • How do I handle email.received, email.bounced, sms.received, or call.completed events?
  • Why does the whsec_ secret not work with the Standard Webhooks or Svix libraries?
  • Why is my Hail webhook signature verification failing?

Essential Code (USE THIS)

Every Hail delivery carries X-Hail-Signature: t=<unix>,v1=<hex>. v1 is the HMAC-SHA256 (hex) of "<t>.<raw body>". The key is the whole signing secret string, including whsec_, as UTF-8. Hail returns the secret only when you create the subscription, so save it then.

This is not Standard Webhooks. The secret looks like whsec_..., but do not use the standardwebhooks or svix libraries, and do not strip or base64-decode the whsec_ prefix.

Hail's signer emits a single v1, and rotating the secret (POST /webhooks/{id}/rotate-secret) invalidates the old one immediately, so there is no overlap period to cover. The code below still accepts any matching v1, which costs nothing. Reject a header with no t or no v1.

Verification (Node.js)

const crypto = require('crypto');

// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyHail(rawBody, signatureHeader, secret) {
  if (!secret || !signatureHeader) return false;
  let t = null; const v1s = [];
  for (const part of signatureHeader.split(',')) {
    const i = part.indexOf('=');
    if (i <= 0) continue;
    const k = part.slice(0, i).trim(), v = part.slice(i + 1).trim();
    if (k === 't') t = v; else if (k === 'v1') v1s.push(v);
  }
  if (!t || v1s.length === 0) return false;
  // Optional replay window, off by default: Hookdeck retries and replays keep the original timestamp
  const expected = Buffer.from(crypto.createHmac('sha256', secret) // whole secret, as-is
    .update(`${t}.`).update(rawBody).digest('hex'));
  // exactly 64 hex chars first, so a bad value can't truncate or throw
  return v1s.some((v) => /^[0-9a-f]{64}$/i.test(v) &&
    crypto.timingSafeEqual(Buffer.from(v.toLowerCase()), expected));
}

Verification (Python)

import hashlib, hmac

def verify_hail(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not secret or not signature_header:
        return False
    t, v1s = None, []
    for part in signature_header.split(","):
        key, sep, value = part.partition("=")
        if sep and key.strip() == "t":
            t = value.strip()
        elif sep and key.strip() == "v1":
            v1s.append(value.strip())
    if not t or not v1s:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
    return any(hmac.compare_digest(v.lower().encode(), expected) for v in v1s)

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Timestamp window: off by default

A 5-minute freshness window is common advice, but Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries. (Hail itself re-signs each of its own attempts with the current time.) The examples therefore skip the freshness check unless you set HAIL_TIMESTAMP_TOLERANCE_SECONDS to a positive number of seconds (default 0 = off). Dedupe on X-Hail-Delivery or the event id instead. references/verification.md shows exactly where the window can be dropped or widened.

Common Event Types

The event name is the body's type field (also the X-Hail-Event header).

EventTriggered When
email.receivedAn inbound message arrived and Hail accepted it
email.received.suppressedAn inbound message arrived but fan-out/forwarding was held back
email.deliveredSES accepted the message for delivery
email.delivery_delayedSES reports a transient delay
email.bouncedThe recipient server rejected the message
email.complainedThe recipient marked the message as spam
email.openedThe recipient opened the message (HTML emails only)
email.clickedThe recipient clicked a tracked link (HTML emails only)
email.send_failedAn outbound email failed to send
sms.receivedAn inbound SMS arrived
sms.deliveredThe carrier confirmed delivery
sms.undeliveredThe carrier reported non-delivery
sms.failedThe SMS send failed
call.receivedAn inbound call reached one of your numbers
call.answeredThe other side is on the line
call.completedThe call ended normally
call.failedThe call failed or an inbound call was refused
call.busyThe callee was busy or rejected the call
call.no_answerThe callee did not answer
call.transferredThe agent handed the live call to a person

See references/overview.md for the payload.

Important Headers

HeaderDescription
X-Hail-Signaturet=<unix>,v1=<hex HMAC-SHA256>
X-Hail-EventEvent type, e.g. email.received
X-Hail-DeliveryUnique delivery id, stable across retries (dedupe on it)
X-Hail-SubscriptionSubscription that produced the delivery

Environment Variables

HAIL_WEBHOOK_SECRET=whsec_your_signing_secret   # returned once by POST /webhooks; use the whole string
HAIL_TIMESTAMP_TOLERANCE_SECONDS=0              # optional; a positive value opts in to the replay window

Hookdeck Setup

Paste the whole signing secret, including any whsec_ prefix, into the Hookdeck source's webhook secret. Hail uses it as a plain HMAC key. See references/setup.md.

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 hail --path /webhooks/hail

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 9, 2026

View on GitHub →