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-Signatureheader? - How do I handle
email.received,email.bounced,sms.received, orcall.completedevents? - 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_ 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).
| Event | Triggered When |
|---|---|
email.received | An inbound message arrived and Hail accepted it |
email.received.suppressed | An inbound message arrived but fan-out/forwarding was held back |
email.delivered | SES accepted the message for delivery |
email.delivery_delayed | SES reports a transient delay |
email.bounced | The recipient server rejected the message |
email.complained | The recipient marked the message as spam |
email.opened | The recipient opened the message (HTML emails only) |
email.clicked | The recipient clicked a tracked link (HTML emails only) |
email.send_failed | An outbound email failed to send |
sms.received | An inbound SMS arrived |
sms.delivered | The carrier confirmed delivery |
sms.undelivered | The carrier reported non-delivery |
sms.failed | The SMS send failed |
call.received | An inbound call reached one of your numbers |
call.answered | The other side is on the line |
call.completed | The call ended normally |
call.failed | The call failed or an inbound call was refused |
call.busy | The callee was busy or rejected the call |
call.no_answer | The callee did not answer |
call.transferred | The agent handed the live call to a person |
See references/overview.md for the payload.
Important Headers
| Header | Description |
|---|---|
X-Hail-Signature | t=<unix>,v1=<hex HMAC-SHA256> |
X-Hail-Event | Event type, e.g. email.received |
X-Hail-Delivery | Unique delivery id, stable across retries (dedupe on it) |
X-Hail-Subscription | Subscription 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
- references/overview.md - Hail webhook concepts, events, payload, retries
- references/setup.md - Creating the subscription through the API and saving the secret
- references/verification.md - Signature details, the timestamp window, and debugging