# Guide to Hail Webhooks: Features and Best Practices

Hail gives agents and teams their own email addresses, phone numbers and voice lines through an API. Webhooks are how your application hears about what happens on them: an email or SMS arrives, an outbound message is delivered, bounces or gets opened, or a call is answered, transferred or ends.

This guide covers how Hail webhooks are subscribed and signed, the events and their payloads, the retry ladder, and the practices that keep a Hail receiver reliable.

## What are Hail webhooks?

A Hail webhook is a subscription: a `target_url` and a list of `event_types`, created through the API. When a subscribed event occurs, Hail POSTs a signed JSON event to that URL. Any `2xx` acknowledges it, and anything else, or a timeout, is retried on a fixed ladder that reaches 24 hours.

Every delivery carries `X-Hail-Signature: t=<unix>,v1=<hex>`. The `v1` value is an HMAC-SHA256 of the timestamp, a literal `.` and the raw request body. The key is the signing secret Hail returns when you create the subscription.

That secret looks like `whsec_...`, the prefix used by Standard Webhooks and Svix. Hail is neither. It uses the whole string, prefix included, as a plain HMAC key, so Standard Webhooks libraries won't verify Hail deliveries.

## Hail webhook features

| Feature | Details |
| --- | --- |
| Configuration | Hail API: `POST /v1/webhooks` with `target_url` and `event_types` |
| Event catalogue | Public, no API key needed: `GET https://api.hail.so/v1/webhooks/event-types` |
| Payload | `{ id, type, api_version, created_at, organization_id, data }` |
| Signature header | `X-Hail-Signature: t=<unix>,v1=<hex>` |
| Scheme | HMAC-SHA256, hex |
| Signed content | `{t}.{raw body}` |
| Secret | Returned once at create; the whole string, `whsec_` included, is the key |
| Other headers | `X-Hail-Event`, `X-Hail-Delivery`, `X-Hail-Subscription`, `X-Hail-Email-Domain` |
| Success | Any `2xx` |
| Retries | 0s, 30s, 2m, 10m, 1h, 6h, 24h, then the delivery is marked dead |
| Auto-disable | After 50 consecutive dead deliveries on an org-wide subscription |
| Replay window | None stated by Hail |

Hail doesn't document a response timeout or source IPs for webhooks.

## Common events

Hail's public catalogue lists 20 event types. The same value is in the body's `type` field and the `X-Hail-Event` header.

| Channel | Events | Fires when |
| --- | --- | --- |
| Inbound email | `email.received`, `email.received.suppressed` | A message arrives, or arrives but forwarding is held back |
| Outbound email | `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.send_failed` | SES accepts, delays or bounces a message, a recipient marks it as spam, or the send fails |
| Engagement | `email.opened`, `email.clicked` | A recipient opens a message or clicks a tracked link (HTML emails only) |
| SMS | `sms.received`, `sms.delivered`, `sms.undelivered`, `sms.failed` | An SMS arrives, or the carrier confirms or rejects an outbound one |
| Calls | `call.received`, `call.answered`, `call.completed`, `call.failed`, `call.busy`, `call.no_answer`, `call.transferred` | A call reaches your number, connects, ends, fails, gets no answer or is handed to a person |

A few details matter when you route these:

* `email.received.suppressed` fires once per reason (`forward_loop`, `forward_rate_limit`, `inbound_rate_limit` or `insufficient_funds`), so one message can produce more than one.
* `email.opened` and `email.clicked` only fire for emails with an HTML body. Plain-text emails never produce them.
* `sms.delivered` requires Twilio delivery receipts.
* There is no `call.canceled` event.

Here is Hail's `email.bounced` example, with its shortened IDs:

```json
{
  "id": "9f2c…",
  "type": "email.bounced",
  "api_version": "2026-06-06",
  "created_at": "2026-07-02T12:00:05+00:00",
  "organization_id": "org-uuid",
  "data": {
    "id": "em-uuid",
    "kind": "bounced",
    "occurred_at": "2026-07-01T12:00:05+00:00",
    "from_address": "noreply@acme.com",
    "to_addresses": ["bob@example.com"],
    "subject": "Welcome",
    "detail": {
      "hard": true,
      "bounce_type": "Permanent",
      "bounce_sub_type": "General",
      "recipients": ["bob@example.com"],
      "diagnostic_code": "smtp; 550 5.1.1 user unknown"
    }
  }
}

```

* `type` is the event. Route on it.
* `id` identifies the delivery and matches `X-Hail-Delivery`, which stays the same across retries.
* `api_version` is `2026-06-06` in every example. Hail doesn't describe how versions change.
* `data` depends on the event. Inbound email carries addresses, subject, spam, virus, SPF, DKIM and DMARC verdicts, a `raw_url` and attachment URLs. Every `call.*` event carries `id`, `status`, `direction`, `from`, `to`, `agent_id` and, once the call has ended, `end_reason`.
* For bounces, `detail.hard` tells hard and soft bounces apart.

`raw_url` and attachment `url` values are Hail API endpoints that redirect to presigned S3 URLs, so fetch them with your API key rather than storing them as permanent links.

## Setting up Hail webhooks

1. Pick your event types from the catalogue at `https://api.hail.so/v1/webhooks/event-types`.
2. Create a subscription with your API key:
  
  
  ```bash
  curl -X POST "https://api.hail.so/v1/webhooks" \
    -H "Authorization: Bearer $HAIL_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"target_url": "https://example.com/webhooks/hail",
         "event_types": ["email.received", "email.bounced", "sms.received"]}'
  
  ```
  
  
3. Copy the `secret` from the response straight away and store it server-side, for example as `HAIL_WEBHOOK_SECRET`. Later reads of the subscription return `null` for it. If you lose it, call `POST /v1/webhooks/{sub_id}/rotate-secret` for a new one.

**Paste the whole signing secret into Hookdeck, including any `whsec_` prefix.** Hail uses the full string as a plain HMAC key. Don't strip the prefix or base64-decode the rest, as you would for a Standard Webhooks secret.

If you receive Hail webhooks through Hookdeck, use the Hookdeck source URL as the subscription's `target_url` and paste the secret into the Hail source.

## Securing Hail webhooks

Split `X-Hail-Signature` on commas and each part on its first `=`. Build `{t}.{raw body}` with `t` exactly as sent, compute the hex HMAC-SHA256 with the whole secret as the key, and compare it with `v1` in constant time.

Hail states no timestamp tolerance. A freshness check is optional in the code below and off by default, and the Hail skill also accepts retries and replays by default, with the window behind an opt-in `HAIL_TIMESTAMP_TOLERANCE_SECONDS` setting. If you turn the window on, note that Hail signs each of its own retries with a new `t`, but a delivery that Hookdeck retries or replays to your endpoint keeps the `t` from Hail's original request, so late ones fail the window even though their signatures are valid.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyHailSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !secret) return false;
  let t;
  const signatures = [];
  for (const part of signatureHeader.split(',')) {
    const i = part.indexOf('=');
    if (i <= 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') t = value;
    if (key === 'v1') signatures.push(value.toLowerCase());
  }
  if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) {
    return false;
  }

  // The whole secret, whsec_ prefix included, is the key.
  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'),
    'utf8'
  );
  return signatures.some((signature) => {
    const received = Buffer.from(signature, 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/hail', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.HAIL_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyHailSignature(req.body, req.get('x-hail-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on X-Hail-Delivery, route on event.type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import time

def verify_hail_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
    if not signature_header or not secret:
        return False
    t, signatures = None, []
    for part in signature_header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t":
            t = value
        elif key == "v1":
            signatures.append(value.lower())
    if not t or not (t.isascii() and t.isdigit()) or not signatures:
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0 and abs(time.time() - int(t)) > tolerance_seconds:
        return False

    # The whole secret, whsec_ prefix included, is the key.
    expected = hmac.new(secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s.encode("utf-8"), expected.encode("utf-8")) for s in signatures)

# In FastAPI: raw_body = await request.body()
# verify_hail_signature(raw_body, request.headers.get("x-hail-signature"),
#                       os.environ["HAIL_WEBHOOK_SECRET"])

```

Hail's docs include a worked example you can test against. With the secret `whsec_example` and this exact body:

```
{"id":"evt_123","type":"email.received","data":{"id":"em_1"}}

```

the header is:

```
X-Hail-Signature: t=1700000000,v1=72f7940d13ca8528cf655a04e82dabfe90a531e239241fda9bdff72980d33a4a

```

If your verifier produces a different value, check whether it strips `whsec_` or base64-decodes the secret. Both are wrong for Hail.

> **Make Hail webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Hail-Signature` header on every delivery, acknowledges Hail straight away, and holds each email, SMS and call event until your service has processed it.

## Hail webhook limitations and pain points

### A secret that looks like Standard Webhooks

**The Problem:** Verification fails on every delivery even though the secret is correct.

**Why It Happens:** The `whsec_` prefix suggests Standard Webhooks, whose libraries strip the prefix, base64-decode the rest and expect `webhook-id`, `webhook-timestamp` and `webhook-signature` headers. Hail sends none of those and uses the secret as-is.

**Workarounds:**

* Use plain HMAC-SHA256, as shown above, with the full secret string.
* Test your code against Hail's worked example before going live.

**How Hookdeck Can Help:** Hookdeck's Hail source type implements Hail's scheme, so verification works once you paste the full secret.

### The secret is shown once, and rotation is immediate

**The Problem:** A lost secret, or a rotation, means a gap in verified deliveries.

**Why It Happens:** Hail returns the secret only in the create and rotate-secret responses. Rotating invalidates the old secret straight away, with no overlap period.

**Workarounds:**

* Save the secret from the create response into your secrets manager in the same script that creates the subscription.
* When you rotate, update every verifier immediately. Deliveries that fail verification during the switch can be redelivered through the API once the new secret is in place.

**How Hookdeck Can Help:** With Hookdeck in front, only the Hail source holds the secret, so a rotation means updating one field. Deliveries that arrive during the switch stay in Hookdeck's logs, marked as failed verification.

### Subscriptions disable themselves

**The Problem:** A long outage silently stops all deliveries.

**Why It Happens:** A delivery is marked dead after seven failed attempts, on a ladder whose final step is 24 hours. Once an org-wide subscription reaches 50 consecutive dead deliveries, Hail disables it.

**Workarounds:**

* Monitor the subscription's `status` and alert when it isn't `active`.
* After an outage, set it back to `active` and redeliver dead deliveries with `POST /v1/webhooks/{subscription-id}/deliveries/{delivery-id}/redeliver`.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives, so Hail's attempts succeed even while your service is down. Hookdeck then delivers to your service with [retries](/docs/retries) you configure, and failed events can be retried in bulk.

### Sample payloads are abbreviated

**The Problem:** Fixtures built from the docs don't match real deliveries.

**Why It Happens:** Every example in Hail's docs shortens IDs to values like `"9f2c…"`, and the `detail` field changes shape by event.

**Workarounds:**

* Capture the first real delivery of each event type you handle and build fixtures from those.
* Parse `data` defensively and branch on `type` before reading event-specific fields.

**How Hookdeck Can Help:** Hookdeck stores every request it receives, so you can inspect real Hail payloads and replay them against your endpoint while you build.

## Best practices

### Verify against the raw body

The signature covers the exact bytes. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI, and parse only after verification.

### Deduplicate on `X-Hail-Delivery`

Hail keeps `X-Hail-Delivery`, and the body's `id`, the same across retries. Record it when you process an event and skip any delivery you have already handled.

### Route by `X-Hail-Event` or `type`

Both carry the event name. Use `X-Hail-Email-Domain` to route inbound email per domain when you run several, and expect it to be absent for SMS.

### Acknowledge fast and process asynchronously

Verify, store, return `200`, and do the work afterwards. Inbound email with attachments can take time to process, and anything slow risks a timeout and a retry. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Hail signs every webhook with `X-Hail-Signature`, a hex HMAC-SHA256 of `{t}.{raw body}` keyed with the full secret, `whsec_` and all. Save the secret when you create the subscription, verify with plain HMAC rather than a Standard Webhooks library, deduplicate on `X-Hail-Delivery`, and watch for subscriptions that disable themselves after long outages.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Hail signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Hail webhooks reliably in minutes.