Gareth Wilson Gareth Wilson

Guide to Hail Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationHail API: POST /v1/webhooks with target_url and event_types
Event cataloguePublic, no API key needed: GET https://api.hail.so/v1/webhooks/event-types
Payload{ id, type, api_version, created_at, organization_id, data }
Signature headerX-Hail-Signature: t=<unix>,v1=<hex>
SchemeHMAC-SHA256, hex
Signed content{t}.{raw body}
SecretReturned once at create; the whole string, whsec_ included, is the key
Other headersX-Hail-Event, X-Hail-Delivery, X-Hail-Subscription, X-Hail-Email-Domain
SuccessAny 2xx
Retries0s, 30s, 2m, 10m, 1h, 6h, 24h, then the delivery is marked dead
Auto-disableAfter 50 consecutive dead deliveries on an org-wide subscription
Replay windowNone 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.

ChannelEventsFires when
Inbound emailemail.received, email.received.suppressedA message arrives, or arrives but forwarding is held back
Outbound emailemail.delivered, email.delivery_delayed, email.bounced, email.complained, email.send_failedSES accepts, delays or bounces a message, a recipient marks it as spam, or the send fails
Engagementemail.opened, email.clickedA recipient opens a message or clicks a tracked link (HTML emails only)
SMSsms.received, sms.delivered, sms.undelivered, sms.failedAn SMS arrives, or the carrier confirms or rejects an outbound one
Callscall.received, call.answered, call.completed, call.failed, call.busy, call.no_answer, call.transferredA 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:

{
  "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:

    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.

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:

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 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 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 and our guide to 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 sits in front of your receiver, checks every Hail signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Hail webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.