Webhooks
Mailman delivers real-time event notifications to registered HTTPS endpoints.
Event Types
| Event | Trigger |
|---|---|
message.received | Inbound message successfully processed and stored |
message.delivered | SES confirmed outbound delivery |
message.bounced | Outbound message bounced (hard or soft) |
message.complained | Recipient marked message as spam |
thread.merged | Two 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:
{
"url": "https://app.example.com/webhook",
"secret": "minimum-32-character-signing-secret",
"events": ["message.received", "message.delivered"],
"inbox_ids": ["uuid-1", "uuid-2"]
}| Field | Required | Description |
|---|---|---|
url | Yes | Must be HTTPS |
secret | Yes | Minimum 32 characters, used for HMAC signing |
events | Yes | At least one event type |
inbox_ids | No | Scope 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:
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:
{
"challenge": "random-challenge-string"
}Payload Format
All webhooks share a common envelope:
{
"id": "evt_uuid",
"type": "message.received",
"timestamp": "2024-01-15T10:30:00Z",
"data": { ... }
}message.received
{
"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
{
"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
{
"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
{
"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)
{
"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_abc123Signature Computation
signature = HMAC-SHA256(
key: webhook_secret,
message: timestamp + "." + request_body
)Verification Example (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)
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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 | 24 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.rsfor 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:
| State | Failure Count | Behavior |
|---|---|---|
| Active | 0-4 | Normal delivery |
| Warning | 5-9 | Still delivers, flagged for attention |
| Disabled | 10+ | 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.rsfor 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:
- Store
event_idof processed events - Skip duplicates based on
event_id - Use idempotent operations when possible
-- 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 attemptswebhook_delivery_latency_seconds{endpoint}— Histogram of delivery timeswebhook_endpoints_unhealthy— Gauge of disabled/warning endpoints
Alerts
- Endpoint failure rate > 50% over 1 hour
- Delivery queue depth > 1000
- Endpoint disabled due to failures