Webhooks

Subscribe to real-time email event notifications. Posthawk sends signed HTTP POST requests to your endpoint when email events occur.

Webhook endpoints receive POST requests with a JSON payload for each event. Each delivery is signed with HMAC SHA-256 so you can verify authenticity.

Supported event types:

Email delivery events:

  • send — email queued for sending via SES
  • delivery — successfully delivered
  • bounce — bounced (use event_data.bounce.bounceType to distinguish Permanent/Transient/Undetermined)
  • complaint — recipient marked as spam
  • reject — SES rejected at submission time
  • delivery_delay — temporarily delayed but not yet a bounce
  • open — recipient opened the email (tracking pixel hit)
  • click — recipient clicked a link in the email

Newsletter events (cloud + self-hosted):

  • newsletter.subscriber.created — pending subscriber created (DOI confirmation email sent)
  • newsletter.subscriber.confirmed — subscriber clicked the confirm link, or signed up with DOI off
  • newsletter.subscriber.unsubscribed — subscriber unsubscribed (manually, via auto-suppress, or via the public unsubscribe link)
  • newsletter.issue.sent — issue finished fanning out to subscribers
  • newsletter.issue.failed — issue send aborted with an error

Signature verification:

Each request includes an X-Posthawk-Signature header in the format
t={unix_timestamp},v1={hex_digest} and a separate
X-Posthawk-Timestamp header. Compute HMAC-SHA-256 of {timestamp}.{body}
using your webhook secret and compare against the v1= portion.
Receivers should also verify the timestamp is within ±5 minutes of
current time to reject replays.

> Migrating from the older `sha256={hex}` format: if you had a receiver
> written against the legacy header that was HMAC over body only, switch
> to the new format above. The legacy alias was removed because the
> sha256= value drifted to mean "HMAC over {t}.{body}" — receivers
> verifying it against body-only would silently fail. The new t=,v1=
> format is unambiguous.

Headers sent with each webhook delivery:

  • Content-Type: application/json
  • X-Posthawk-Signature: t={unix},v1={hmac_hex}
  • X-Posthawk-Timestamp: {unix}
  • X-Posthawk-Event: {event_type}
  • User-Agent: Posthawk-Webhook/1.0

Verification snippets

Node.js:

const crypto = require('crypto');

function verifyWebhook(req, secret) {
  const sigHeader = req.headers['x-posthawk-signature'] || '';
  const parts = Object.fromEntries(
    sigHeader.split(',').map(p => p.split('=').map(s => s.trim())),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;

  // Reject replays > 5 min old.
  const skewSec = Math.abs(Math.floor(Date.now() / 1000) - parseInt(t, 10));
  if (skewSec > 300) return false;

  const body = req.rawBody.toString(); // use a raw-body middleware
  const expected = crypto.createHmac('sha256', secret)
    .update(`${t}.${body}`)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
}

Python:

import hmac, hashlib, time

def verify_webhook(body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in signature_header.split(','))
    t = parts.get('t'); v1 = parts.get('v1')
    if not t or not v1:
        return False
    if abs(int(time.time()) - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.{body.decode()}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, expected)

Go:

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strconv"
    "strings"
    "time"
)

func VerifyWebhook(body []byte, signature, secret string) bool {
    var t, v1 string
    for _, p := range strings.Split(signature, ",") {
        kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
        if len(kv) != 2 { continue }
        switch kv[0] {
        case "t":  t = kv[1]
        case "v1": v1 = kv[1]
        }
    }
    if t == "" || v1 == "" { return false }
    ts, _ := strconv.ParseInt(t, 10, 64)
    if d := time.Now().Unix() - ts; d > 300 || d < -300 { return false }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(t + "." + string(body)))
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(v1), []byte(expected))
}

Retry behavior:

  • Failed deliveries (non-2xx response or timeout) are retried with exponential backoff
  • Each delivery attempt is logged in webhook_deliveries and visible in the dashboard
  • Permanent failures stop retrying after the configured max attempts
  • Test endpoints with POST /v1/webhooks/:id/test to verify before going live

Webhook secrets are auto-generated when you create an endpoint and returned in the response. Store the secret securely — it is used to verify that requests are genuinely from Posthawk.

GET/v1/webhooks

List all webhook endpoints for the authenticated workspace.

Authorizations

Authorizationstring · headerrequired

Bearer authentication header of the form Bearer <token>, where <token> is your API Key.

GET /v1/webhooks
curl https://api.posthawk.dev/v1/webhooks \
  -H "Authorization: Bearer your_api_key"
Response
[
  {
    "id": "uuid",
    "url": "https://yourapp.com/api/webhooks/posthawk",
    "secret": "whsec_...",
    "events": ["delivery", "bounce", "complaint"],
    "enabled": true,
    "description": "Production webhook",
    "created_at": "2026-01-15T10:00:00Z",
    "updated_at": "2026-01-15T10:00:00Z"
  }
]
POST/v1/webhooks

Create a new webhook endpoint. A signing secret is auto-generated and returned.

Authorizations

Authorizationstring · headerrequired

Bearer authentication header of the form Bearer <token>, where <token> is your API Key.

Body

urlstringrequired

The URL to receive webhook POST requests

eventsstring[]required

Array of event types to subscribe to. Email events: send, delivery, bounce, complaint, reject, delivery_delay, open, click. Newsletter events: newsletter.subscriber.created, newsletter.subscriber.confirmed, newsletter.subscriber.unsubscribed, newsletter.issue.sent, newsletter.issue.failed

descriptionstringoptional

Human-readable label for this endpoint

POST /v1/webhooks
curl -X POST https://api.posthawk.dev/v1/webhooks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your_api_key" \
  -d '{
    "url": "https://yourapp.com/api/webhooks/posthawk",
    "events": ["delivery", "bounce", "complaint"],
    "description": "Production webhook"
  }'
Response
{
  "id": "uuid",
  "url": "https://yourapp.com/api/webhooks/posthawk",
  "secret": "whsec_...",
  "events": ["delivery", "bounce", "complaint"],
  "enabled": true,
  "description": "Production webhook",
  "created_at": "2026-01-15T10:00:00Z",
  "updated_at": "2026-01-15T10:00:00Z"
}
PATCH/v1/webhooks/:id

Update a webhook endpoint — change the URL, subscribed events, enabled state, or description.

Authorizations

Authorizationstring · headerrequired

Bearer authentication header of the form Bearer <token>, where <token> is your API Key.

Path Parameters

idstringrequired

Webhook endpoint UUID

Body

urlstringoptional

New webhook URL

eventsstring[]optional

New event subscriptions

enabledbooleanoptional

Enable or disable the endpoint

descriptionstringoptional

Updated description

PATCH /v1/webhooks/:id
curl -X PATCH https://api.posthawk.dev/v1/webhooks/uuid \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your_api_key" \
  -d '{
    "events": ["delivery", "bounce", "complaint", "open", "click"],
    "description": "Updated production webhook"
  }'
Response
{
  "id": "uuid",
  "url": "https://yourapp.com/api/webhooks/posthawk",
  "secret": "whsec_...",
  "events": ["delivery", "bounce", "complaint", "open", "click"],
  "enabled": true,
  "description": "Updated production webhook",
  "created_at": "2026-01-15T10:00:00Z",
  "updated_at": "2026-03-15T12:00:00Z"
}
POST/v1/webhooks/:id/test

Send a test event to the webhook endpoint to verify it is reachable and correctly configured.

Authorizations

Authorizationstring · headerrequired

Bearer authentication header of the form Bearer <token>, where <token> is your API Key.

Path Parameters

idstringrequired

Webhook endpoint UUID

POST /v1/webhooks/:id/test
curl -X POST https://api.posthawk.dev/v1/webhooks/{id}/test \
  -H "Authorization: Bearer your_api_key"
Response
{
  "success": true,
  "status": 200,
  "body": "OK"
}
DELETE/v1/webhooks/:id

Delete a webhook endpoint. It will immediately stop receiving events.

Authorizations

Authorizationstring · headerrequired

Bearer authentication header of the form Bearer <token>, where <token> is your API Key.

Path Parameters

idstringrequired

Webhook endpoint UUID

DELETE /v1/webhooks/:id
curl -X DELETE https://api.posthawk.dev/v1/webhooks/{id} \
  -H "Authorization: Bearer your_api_key"
Response
{
  "success": true
}