Skip to content

Webhooks

Mailman delivers real-time event notifications to registered HTTPS endpoints.

Event Types

EventTrigger
message.receivedInbound message successfully processed and stored
message.deliveredSES confirmed outbound delivery
message.bouncedOutbound message bounced (hard or soft)
message.complainedRecipient marked message as spam
thread.mergedTwo or more threads merged (planned — not yet emitting events)

Event types are also listed in openapi.yaml — the webhook event enum.

Registration

Register a webhook via POST /webhooks:

json
{
  "url": "https://app.example.com/webhook",
  "secret": "minimum-32-character-signing-secret",
  "events": ["message.received", "message.delivered"],
  "inbox_ids": ["uuid-1", "uuid-2"]
}
FieldRequiredDescription
urlYesMust be HTTPS
secretYesMinimum 32 characters, used for HMAC signing
eventsYesAt least one event type
inbox_idsNoScope webhook to specific inboxes (all inboxes if omitted). Column exists in schema but filtering logic is not yet wired (TB-555).

Verification Challenge

On registration, Mailman sends a verification POST to the endpoint:

http
POST https://app.example.com/webhook
Content-Type: application/json
X-Mailman-Signature: sha256=...

{
  "type": "webhook.verification",
  "challenge": "random-challenge-string",
  "timestamp": "2024-01-15T10:00:00Z"
}

The endpoint must respond with 200 OK within 5 seconds, echoing the challenge:

json
{
  "challenge": "random-challenge-string"
}

Payload Format

All webhooks share a common envelope:

json
{
  "id": "evt_uuid",
  "type": "message.received",
  "timestamp": "2024-01-15T10:30:00Z",
  "data": { ... }
}

message.received

json
{
  "id": "evt_abc123",
  "type": "message.received",
  "timestamp": "2024-01-15T10:30:00Z",
  "data": {
    "message_id": "<abc@example.com>",
    "thread_id": "550e8400-e29b-41d4-a716-446655440000",
    "from": { "name": "John Doe", "address": "john@example.com" },
    "to": [{ "address": "support@example.com" }],
    "subject": "Help with my order",
    "received_at": "2024-01-15T10:30:00Z",
    "has_attachments": true,
    "attachment_count": 2,
    "preview": "Hi, I need help with order #12345..."
  }
}

message.delivered

json
{
  "id": "evt_def456",
  "type": "message.delivered",
  "timestamp": "2024-01-15T10:31:00Z",
  "data": {
    "message_id": "<outbound@example.com>",
    "thread_id": "550e8400-e29b-41d4-a716-446655440000",
    "recipient": "user@example.com",
    "delivered_at": "2024-01-15T10:31:00Z",
    "smtp_response": "250 OK"
  }
}

message.bounced

json
{
  "id": "evt_ghi789",
  "type": "message.bounced",
  "timestamp": "2024-01-15T10:31:00Z",
  "data": {
    "message_id": "<outbound@example.com>",
    "thread_id": "550e8400-e29b-41d4-a716-446655440000",
    "recipient": "invalid@example.com",
    "bounce_type": "permanent",
    "bounce_subtype": "general",
    "diagnostic_code": "550 5.1.1 User unknown",
    "bounced_at": "2024-01-15T10:31:00Z"
  }
}

message.complained

json
{
  "id": "evt_jkl012",
  "type": "message.complained",
  "timestamp": "2024-01-15T10:31:00Z",
  "data": {
    "message_id": "<outbound@example.com>",
    "thread_id": "550e8400-e29b-41d4-a716-446655440000",
    "recipient": "annoyed@example.com",
    "complaint_type": "abuse",
    "complained_at": "2024-01-15T10:31:00Z"
  }
}

thread.merged (planned)

json
{
  "id": "evt_mno345",
  "type": "thread.merged",
  "timestamp": "2024-01-15T10:32:00Z",
  "data": {
    "source_thread_id": "661e8400-e29b-41d4-a716-446655440000",
    "target_thread_id": "550e8400-e29b-41d4-a716-446655440000",
    "merged_at": "2024-01-15T10:32:00Z"
  }
}

source_thread_id is the thread that was absorbed. Consumers holding references to it should update to target_thread_id.

Signature Verification

All webhook requests include an HMAC-SHA256 signature.

Headers

X-Mailman-Signature: sha256=<hex-encoded-hmac>
X-Mailman-Timestamp: 1705315800
X-Mailman-Event-Id: evt_abc123

Signature Computation

signature = HMAC-SHA256(
    key: webhook_secret,
    message: timestamp + "." + request_body
)

Verification Example (Rust)

rust
use hmac::{Hmac, Mac};
use sha2::Sha256;

fn verify_signature(secret: &str, timestamp: &str, body: &[u8], signature: &str) -> bool {
    // Check timestamp freshness (within 5 minutes)
    let ts: i64 = timestamp.parse().unwrap_or(0);
    let now = Utc::now().timestamp();
    if (now - ts).abs() > 300 {
        return false;
    }

    // Compute expected signature
    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
    mac.update(timestamp.as_bytes());
    mac.update(b".");
    mac.update(body);

    let expected = mac.finalize().into_bytes();
    let provided = signature.strip_prefix("sha256=").unwrap_or(signature);
    let provided_bytes = hex::decode(provided).unwrap_or_default();

    // Constant-time comparison
    use subtle::ConstantTimeEq;
    expected.ct_eq(&provided_bytes).into()
}

Verification Example (Node.js)

javascript
const crypto = require('crypto');

function verifySignature(secret, timestamp, body, signature) {
  // Check timestamp freshness
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return false;
  }

  // Compute expected signature
  const payload = `${timestamp}.${body}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const provided = signature.replace('sha256=', '');

  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(provided)
  );
}

Retry Schedule

Failed deliveries retry with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours
68 hours
724 hours

Maximum 7 attempts. After 7 failures, the delivery is abandoned. The exponential curve balances fast retries for transient failures (network blips) with patience for longer outages (endpoint maintenance windows).

See crates/core/src/webhooks.rs for retry delay computation.

A delivery is successful if the response status is 2xx and the response is received within 30 seconds.

Endpoint Health

Webhook endpoints have three health states based on consecutive failure count:

StateFailure CountBehavior
Active0-4Normal delivery
Warning5-9Still delivers, flagged for attention
Disabled10+No longer receives events

Successful deliveries reset the failure counter. The thresholds give endpoints room for intermittent failures (5 consecutive before flagging) while protecting consumers from dead endpoints (disabled at 10).

See crates/core/src/webhooks.rs for endpoint health logic.

Delivery Infrastructure

Webhook events are published to a dedicated SQS FIFO queue (mailman-{env}-webhooks.fifo). The worker's webhook loop polls this queue and delivers events to registered endpoints.

Per-Thread Ordering

  • MessageGroupId: thread_id — ensures per-thread ordering
  • MessageDeduplicationId: event_id — prevents duplicate delivery from SQS
  • DLQ: mailman-{env}-webhooks-dlq.fifo (after 7 failed receives)

Webhooks are delivered in order per thread, but not globally ordered. Events for different threads are delivered concurrently across message groups. Failed deliveries block subsequent events for the same thread (FIFO guarantees this automatically via visibility timeout).

Idempotency

Events may be delivered more than once (at-least-once delivery). Consumers should:

  1. Store event_id of processed events
  2. Skip duplicates based on event_id
  3. Use idempotent operations when possible
sql
-- Consumer-side deduplication example
CREATE TABLE processed_webhook_events (
    event_id    TEXT PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Clean up old entries periodically
DELETE FROM processed_webhook_events
WHERE processed_at < NOW() - INTERVAL '7 days';

Monitoring

Metrics

  • webhook_deliveries_total{status,event_type} — Counter of delivery attempts
  • webhook_delivery_latency_seconds{endpoint} — Histogram of delivery times
  • webhook_endpoints_unhealthy — Gauge of disabled/warning endpoints

Alerts

  • Endpoint failure rate > 50% over 1 hour
  • Delivery queue depth > 1000
  • Endpoint disabled due to failures