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.bounceTypeto 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 formatt={unix_timestamp},v1={hex_digest} and a separateX-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_deliveriesand visible in the dashboard - Permanent failures stop retrying after the configured max attempts
- Test endpoints with
POST /v1/webhooks/:id/testto 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.
/v1/webhooksList all webhook endpoints for the authenticated workspace.
Authorizations
Bearer authentication header of the form Bearer <token>, where <token> is your API Key.
curl https://api.posthawk.dev/v1/webhooks \
-H "Authorization: Bearer your_api_key"[
{
"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"
}
]/v1/webhooksCreate a new webhook endpoint. A signing secret is auto-generated and returned.
Authorizations
Bearer authentication header of the form Bearer <token>, where <token> is your API Key.
Body
urlstringrequiredThe URL to receive webhook POST requests
eventsstring[]requiredArray 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
descriptionstringoptionalHuman-readable label for this endpoint
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"
}'{
"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"
}/v1/webhooks/:idUpdate a webhook endpoint — change the URL, subscribed events, enabled state, or description.
Authorizations
Bearer authentication header of the form Bearer <token>, where <token> is your API Key.
Path Parameters
idstringrequiredWebhook endpoint UUID
Body
urlstringoptionalNew webhook URL
eventsstring[]optionalNew event subscriptions
enabledbooleanoptionalEnable or disable the endpoint
descriptionstringoptionalUpdated description
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"
}'{
"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"
}/v1/webhooks/:id/testSend a test event to the webhook endpoint to verify it is reachable and correctly configured.
Authorizations
Bearer authentication header of the form Bearer <token>, where <token> is your API Key.
Path Parameters
idstringrequiredWebhook endpoint UUID
curl -X POST https://api.posthawk.dev/v1/webhooks/{id}/test \
-H "Authorization: Bearer your_api_key"{
"success": true,
"status": 200,
"body": "OK"
}/v1/webhooks/:idDelete a webhook endpoint. It will immediately stop receiving events.
Authorizations
Bearer authentication header of the form Bearer <token>, where <token> is your API Key.
Path Parameters
idstringrequiredWebhook endpoint UUID
curl -X DELETE https://api.posthawk.dev/v1/webhooks/{id} \
-H "Authorization: Bearer your_api_key"{
"success": true
}