# 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)

```javascript
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)

```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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/hail-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/hail-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/hail-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/hail-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/hail-webhooks/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

```bash
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](https://github.com/hookdeck/webhook-skills/blob/main/skills/hail-webhooks/references/setup.md).

## Local Development

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

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/hail-webhooks/references/overview.md) - Hail webhook concepts, events, payload, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/hail-webhooks/references/setup.md) - Creating the subscription through the API and saving the secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/hail-webhooks/references/verification.md) - Signature details, the timestamp window, and debugging