Webhooks

Overview

Webhooks deliver real-time notifications when events happen in your Pigeons instance. When an event fires, Pigeons sends a POST request to each registered webhook that subscribes to that event type.

Event Types

EventFires when
email.sentAn email is accepted by SES
email.deliveredSES confirms delivery to the recipient
email.bouncedSES reports a bounce
email.complainedSES reports a spam complaint
email.openedA recipient opens an email (requires open tracking)
email.failedAn email fails to send
inbox.message.receivedAn inbound email arrives at an inbox
inbox.message.sentAn outbound email is sent from an inbox
contact.createdA new contact is created
contact.updatedA contact is updated
contact.unsubscribedA contact unsubscribes
campaign.sentA campaign email is sent
campaign.completedA campaign finishes sending to all recipients
sequence.step.sentA sequence step email is sent

Create a Webhook

curl -X POST http://localhost:3891/v1/webhooks \
  -H "x-api-key: pgn_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/pigeons",
    "events": ["email.delivered", "email.bounced", "inbox.message.received"]
  }'
const webhook = await pigeons.webhooks.create({
  url: 'https://your-app.com/webhooks/pigeons',
  events: ['email.delivered', 'email.bounced', 'inbox.message.received'],
})

console.log(webhook.secret) // "whsec_..."  -  only returned on creation

A signing secret is auto-generated if you don't provide one (minimum 16 characters). The secret is only returned in the create response. Store it securely.

Payload Format

Every webhook delivery is a POST request with a JSON body:

{
  "event": "email.delivered",
  "data": {
    "email_id": "msg_abc123...",
    "delivered_at": "2024-01-15T10:30:05.000Z"
  }
}

Headers

HeaderDescription
Content-Typeapplication/json
X-Pigeons-SignatureHMAC-SHA256 signature of the request body
X-Pigeons-EventEvent type (e.g. email.delivered)
X-Pigeons-DeliveryUnique delivery ID (wdl_...)

Example payloads

email.delivered:

{
  "event": "email.delivered",
  "data": {
    "email_id": "msg_abc123...",
    "delivered_at": "2024-01-15T10:30:05.000Z"
  }
}

email.bounced:

{
  "event": "email.bounced",
  "data": {
    "email_id": "msg_abc123...",
    "bounce_type": "Permanent",
    "bounced_at": "2024-01-15T10:30:05.000Z"
  }
}

inbox.message.received:

{
  "event": "inbox.message.received",
  "data": {
    "inbox_id": "inb_abc123...",
    "message_id": "imsg_xyz789...",
    "thread_id": "thr_def456...",
    "from": "customer@example.com",
    "to": ["support@yourdomain.com"],
    "subject": "Help with my order",
    "text": "I need help with order #123...",
    "direction": "inbound"
  }
}

email.opened:

{
  "event": "email.opened",
  "data": {
    "email_id": "msg_abc123...",
    "opened_at": "2024-01-15T14:22:00.000Z"
  }
}

Signature Verification

Every delivery is signed with your webhook's secret using HMAC-SHA256. Verify signatures to ensure requests come from your Pigeons instance.

Node.js

import { createHmac } from 'crypto'

function verifySignature(body: string, signature: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(body).digest('hex')
  return expected === signature
}

// In your webhook handler:
app.post('/webhooks/pigeons', (req, res) => {
  const signature = req.headers['x-pigeons-signature'] as string
  const rawBody = JSON.stringify(req.body)

  if (!verifySignature(rawBody, signature, 'whsec_your_secret')) {
    return res.status(401).send('Invalid signature')
  }

  // Process the event
  const { event, data } = req.body
  console.log(`Received ${event}:`, data)

  res.status(200).send('ok')
})

Python

import hmac
import hashlib

def verify_signature(body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

Retry Policy

Failed deliveries are retried with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
4 (final)15 minutes

A delivery is considered failed if:

  • The endpoint returns a non-2xx status code
  • The request times out (10 second timeout)
  • The connection fails

After 3 retry attempts, the delivery is abandoned.

Auto-disable

After 10 consecutive failures across any deliveries, the webhook is automatically disabled (status set to disabled). Re-enable it by updating the webhook:

curl -X PATCH http://localhost:3891/v1/webhooks/whk_abc123... \
  -H "x-api-key: pgn_..." \
  -H "Content-Type: application/json" \
  -d '{"status": "active"}'

Manage Webhooks

List

curl http://localhost:3891/v1/webhooks -H "x-api-key: pgn_..."

The list response does not include secrets.

Update

curl -X PATCH http://localhost:3891/v1/webhooks/whk_abc123... \
  -H "x-api-key: pgn_..." \
  -H "Content-Type: application/json" \
  -d '{"events": ["email.delivered", "email.bounced", "email.complained"]}'

Delete

curl -X DELETE http://localhost:3891/v1/webhooks/whk_abc123... \
  -H "x-api-key: pgn_..."

SSRF Protection

Webhook URLs are validated before every delivery to prevent SSRF attacks. URLs pointing to private IP ranges, localhost, or link-local addresses are rejected.