postkit
Guides

Webhooks

Receive real-time delivery events via webhooks

Webhooks notify your application in real time when events happen -- emails delivered, bounced, opened, clicked, and more. Instead of polling the API, you register an endpoint URL and Postkit sends HTTP POST requests with event data as they occur.

Create a webhook endpoint

Register a URL to receive events. You can subscribe to specific event types or use * to receive all events.

curl -X POST https://api.postkit.eu/v1/webhooks \
  -H "Authorization: Bearer pk_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/postkit",
    "events": ["email.delivered", "email.bounced", "email.complained"]
  }'
const response = await fetch('https://api.postkit.eu/v1/webhooks', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pk_live_abc123...',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://example.com/webhooks/postkit',
    events: ['email.delivered', 'email.bounced', 'email.complained'],
  }),
});
const webhook = await response.json();
import requests

response = requests.post(
    'https://api.postkit.eu/v1/webhooks',
    headers={'Authorization': 'Bearer pk_live_abc123...'},
    json={
        'url': 'https://example.com/webhooks/postkit',
        'events': ['email.delivered', 'email.bounced', 'email.complained'],
    },
)
webhook = response.json()

The response includes a signing_secret starting with whsec_. Save it -- you'll need it to verify webhook signatures.

The signing secret is only returned when you create the webhook or rotate the secret. Store it securely alongside your API key.

Event types

Postkit sends webhooks for the following event types:

EventDescription
email.sentEmail accepted and sent to the mail server
email.deliveredEmail successfully delivered to the recipient's inbox
email.delivery_delayedDelivery is being retried (temporary failure)
email.bouncedEmail permanently or temporarily bounced
email.complainedRecipient marked email as spam
email.openedRecipient opened the email (tracking pixel)
email.clickedRecipient clicked a link in the email
email.canceledScheduled email was canceled before sending
email.receivedInbound email received on your domain
contact.createdNew contact was created
contact.updatedContact details were updated
contact.deletedContact was deleted
contact.unsubscribedContact unsubscribed from a topic
domain.verifiedDomain DNS records verified successfully
domain.dns_changedDomain DNS records changed (re-verification needed)

Use wildcard patterns like email.* to subscribe to all email events, or * for everything.

Payload format

Every webhook delivery is an HTTP POST with a JSON body following this envelope structure:

{
  "type": "email.delivered",
  "id": "whl_abc123def456",
  "created_at": "2026-03-31T12:00:00Z",
  "data": {
    "email_id": "em_a1b2c3d4e5",
    "from": "Acme <noreply@acme.eu>",
    "to": ["user@example.com"],
    "subject": "Welcome to Acme",
    "tags": ["welcome"],
    "timestamp": "2026-03-31T12:00:00Z"
  }
}
  • type -- the event type (matches your subscription)
  • id -- unique delivery ID (prefixed whl_)
  • created_at -- when the event occurred (ISO 8601)
  • data -- event-specific payload (varies by event type)

Verify webhook signatures

Postkit signs every webhook using the Standard Webhooks specification. Always verify signatures before processing webhook data to ensure the request came from Postkit.

How signing works

Each webhook request includes three headers:

HeaderExamplePurpose
webhook-idwhl_abc123def456Unique delivery identifier
webhook-timestamp1711900000Unix timestamp (seconds)
webhook-signaturev1,K7rMz...HMAC-SHA256 signature

The signature is computed over the string {webhook-id}.{webhook-timestamp}.{body} using your signing secret as the HMAC key.

Verification steps

  1. Extract the webhook-id, webhook-timestamp, and webhook-signature headers
  2. Reconstruct the signed content: "{webhook-id}.{webhook-timestamp}.{raw JSON body}"
  3. Strip the whsec_ prefix from your signing secret and base64-decode it to get the key bytes
  4. Compute HMAC-SHA256 using the decoded key bytes and the signed content
  5. Base64-encode the result and prepend v1,
  6. Compare your computed signature with the webhook-signature header
  7. Optionally reject requests where webhook-timestamp is older than 5 minutes

Always base64-decode the secret after stripping the whsec_ prefix. Using the secret string directly as HMAC key bytes will produce incorrect signatures.

Verification code examples

# Verify a webhook signature with openssl
WEBHOOK_ID="whl_abc123def456"
WEBHOOK_TIMESTAMP="1711900000"
WEBHOOK_SECRET="whsec_dGVzdHNlY3JldGtleXRlc3Rz"

# 1. Reconstruct signed content
SIGNED_CONTENT="${WEBHOOK_ID}.${WEBHOOK_TIMESTAMP}.$(cat /tmp/body.json)"

# 2. Decode secret (strip whsec_ prefix, base64 decode)
SECRET_BYTES=$(echo -n "${WEBHOOK_SECRET#whsec_}" | base64 -d)

# 3. Compute HMAC-SHA256 and base64 encode
EXPECTED="v1,$(echo -n "$SIGNED_CONTENT" | openssl dgst -sha256 -hmac "$SECRET_BYTES" -binary | base64)"

echo "Expected: $EXPECTED"
# Compare with webhook-signature header
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyWebhook(
  body: string,
  headers: Record<string, string>,
  secret: string
): boolean {
  const msgId = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];

  // Reject old timestamps (5 min tolerance)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    throw new Error('Timestamp too old');
  }

  // Decode the signing secret
  const secretBytes = Buffer.from(
    secret.replace('whsec_', ''),
    'base64'
  );

  // Reconstruct signed content
  const signedContent = `${msgId}.${timestamp}.${body}`;

  // Compute expected signature
  const hmac = createHmac('sha256', secretBytes);
  hmac.update(signedContent);
  const expected = `v1,${hmac.digest('base64')}`;

  // Check against all provided signatures (key rotation support)
  return signatures.split(' ').some((sig) => {
    try {
      return timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(sig)
      );
    } catch {
      return false;
    }
  });
}
import base64
import hashlib
import hmac
import time

def verify_webhook(body: str, headers: dict, secret: str) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signatures = headers["webhook-signature"]

    # Reject old timestamps (5 min tolerance)
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp too old")

    # Decode the signing secret
    secret_bytes = base64.b64decode(secret.removeprefix("whsec_"))

    # Reconstruct signed content
    signed_content = f"{msg_id}.{timestamp}.{body}"

    # Compute expected signature
    mac = hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256)
    expected = f"v1,{base64.b64encode(mac.digest()).decode()}"

    # Check against all provided signatures (key rotation support)
    return any(
        hmac.compare_digest(expected, sig)
        for sig in signatures.split(" ")
    )

Retry behavior

If your endpoint returns a non-2xx status code or doesn't respond within 30 seconds, Postkit retries the delivery with exponential backoff.

AttemptDelayCumulative
1 (initial)Immediate0
2~5 seconds~5s
3~5 minutes~5min
4~30 minutes~35min
5~2 hours~2.5h
6~5 hours~7.5h
7~10 hours~17.5h
8~10 hours~27.5h

Each delay includes +/- 20% jitter to prevent thundering herds. After 8 failed attempts (~28 hours), the delivery is marked as exhausted.

Best practices

  • Return 200 quickly. Process webhook data asynchronously (e.g., queue it) and return a 200 immediately. Postkit waits up to 30 seconds before timing out.
  • Handle duplicates. Use the id field (e.g., whl_abc123def456) as an idempotency key. The same event may be delivered more than once during retries.
  • Verify signatures. Always verify the webhook-signature header before trusting the payload. This prevents spoofed requests.
  • Monitor failures. If your endpoint fails repeatedly, Postkit tracks consecutive failures. After too many failures, the webhook may be automatically disabled.

What's next?