Files
GibSend/references/webhook-architecture.md
T
2026-01-18 20:50:54 +11:00

437 lines
24 KiB
Markdown

# Webhook Architecture
This document explains the webhook system architecture, including how events are emitted, queued, delivered, and displayed.
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ EVENT SOURCES │
├─────────────────────┬─────────────────────┬─────────────────────────────────────────┤
│ Email Service │ Contact Service │ Domain Service │
│ (SES callbacks) │ (CRUD operations) │ (verification, etc.) │
└─────────┬───────────┴──────────┬──────────┴──────────────────┬──────────────────────┘
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
└────────►│ WebhookService.emit │◄─────────────────┘
│ (teamId, type, │
│ payload) │
└───────────┬───────────┘
┌───────────▼───────────┐
│ Find Active Webhooks │
│ matching event type │
└───────────┬───────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Webhook A │ │ Webhook B │ │ Webhook C │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL Database │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ WebhookCall (one per matching webhook) │ │
│ │ ├── status: PENDING │ │
│ │ ├── payload: { event data only } │ │
│ │ └── attempt: 0 │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ Redis + BullMQ │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ WEBHOOK_DISPATCH_QUEUE │ │
│ │ ├── Job: { callId: "call_abc", teamId: 123 } │ │
│ │ ├── Job: { callId: "call_def", teamId: 123 } │ │
│ │ └── Job: { callId: "call_ghi", teamId: 456 } │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
│ BullMQ Worker (concurrency: 25)
┌───────────────────────┐
│ processWebhookCall │
└───────────┬───────────┘
┌───────────▼───────────┐
│ Acquire Redis Lock │──────┐
│ (per webhook ID) │ │ Lock failed
└───────────┬───────────┘ │
│ Lock acquired ▼
│ ┌─────────────┐
┌───────────▼──────┐ │ Retry later │
│ Check webhook │ └─────────────┘
│ status = ACTIVE? │
└───────────┬──────┘
Yes │ No
┌───────────┘ └──────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ buildPayload │ │ Mark call as │
│ (wrap event │ │ DISCARDED │
│ data) │ └─────────────────┘
└────────┬────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ HTTP POST Request │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ Headers: │ │
│ │ ├── X-UseSend-Signature: v1=<hmac-sha256> │ │
│ │ ├── X-UseSend-Timestamp: 1705312200000 │ │
│ │ ├── X-UseSend-Event: email.delivered │ │
│ │ └── X-UseSend-Call: call_abc123 │ │
│ │ │ │
│ │ Body: { │ │
│ │ "id": "call_abc123", │ │
│ │ "type": "email.delivered", │ │
│ │ "version": "2026-01-18", │ │
│ │ "createdAt": "...", │ │
│ │ "teamId": 123, │ │
│ │ "data": { ... event payload ... }, │ │
│ │ "attempt": 1 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌───────────┴───────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ 2xx OK │ │ Non-2xx / │
│ │ │ Timeout │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────────┐
│ Mark DELIVERED │ │ Increment failures │
│ Reset failures │ │ attempt < 6? │
│ to 0 │ └──────────┬──────────┘
└─────────────────┘ Yes │ No
┌──────────┘ └──────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Mark PENDING │ │ Mark FAILED │
│ Schedule retry │ │ │
│ (exp. backoff) │ │ failures >= 30? │
└─────────────────┘ └────────┬────────┘
Yes │ No
┌───────────┘ └────┐
▼ ▼
┌─────────────────┐ ┌──────────┐
│ AUTO_DISABLE │ │ Done │
│ webhook │ └──────────┘
└─────────────────┘
```
## Call Status State Machine
```
┌──────────────────────────────────────┐
│ │
▼ │
┌─────────┐ enqueue ┌───────────┐ worker picks up ┌─────────────────┐
│ (start) │ ──────────►│ PENDING │ ─────────────────►│ IN_PROGRESS │
└─────────┘ └───────────┘ └────────┬────────┘
▲ │
│ ┌────────────┼────────────┐
│ │ │ │
│ retry (attempt<6) │ │ │
│ ▼ ▼ ▼
│ ┌───────────┐ ┌───────────┐ ┌───────────┐
└────────────│ (fail) │ │ SUCCESS │ │ WEBHOOK │
└─────┬─────┘ └─────┬─────┘ │ INACTIVE │
│ │ └─────┬─────┘
│ │ │
attempt >= 6 │ │
│ ▼ ▼
│ ┌───────────┐ ┌───────────┐
└─────►│ FAILED │ │ DISCARDED │
└───────────┘ └───────────┘
```
## Overview
The webhook system allows users to receive real-time HTTP notifications when events occur (emails sent, contacts created, domains verified, etc.). The system is built with reliability in mind, featuring:
- Asynchronous delivery via BullMQ
- Exponential backoff with jitter for retries
- Automatic webhook disabling after consecutive failures
- Per-webhook locking to ensure ordered delivery
- HMAC signature verification for security
## Core Components
### 1. Database Models
Located in `apps/web/prisma/schema.prisma`:
```
Webhook
├── id (cuid)
├── teamId (FK → Team)
├── url (endpoint URL)
├── secret (signing key, prefixed with "whsec_")
├── status (ACTIVE | PAUSED | AUTO_DISABLED)
├── eventTypes (string[] - empty means all events)
├── apiVersion (optional version string)
├── consecutiveFailures (counter for auto-disable)
├── lastFailureAt / lastSuccessAt (timestamps)
└── createdByUserId (FK → User)
WebhookCall
├── id (cuid)
├── webhookId (FK → Webhook)
├── teamId (FK → Team)
├── type (event type, e.g., "email.delivered")
├── payload (JSON string - event data only)
├── status (PENDING | IN_PROGRESS | DELIVERED | FAILED | DISCARDED)
├── attempt (current attempt number)
├── nextAttemptAt (scheduled retry time)
├── lastError (error message if failed)
├── responseStatus / responseTimeMs / responseText
└── createdAt / updatedAt
```
### 2. Service Layer
Located in `apps/web/src/server/service/webhook-service.ts`:
- **WebhookService**: CRUD operations for webhooks and webhook calls
- **WebhookQueueService**: BullMQ queue management for async delivery
### 3. Event Types
Defined in `packages/lib/src/webhook/webhook-events.ts`:
```typescript
// Contact events
"contact.created" | "contact.updated" | "contact.deleted";
// Domain events
"domain.created" | "domain.verified" | "domain.updated" | "domain.deleted";
// Email events
"email.queued" |
"email.sent" |
"email.delivery_delayed" |
"email.delivered" |
"email.bounced" |
"email.rejected" |
"email.rendering_failure" |
"email.complained" |
"email.failed" |
"email.cancelled" |
"email.suppressed" |
"email.opened" |
"email.clicked";
// Test events
("webhook.test");
```
## Webhook Flow
### Step 1: Event Emission
When an event occurs in the system, `WebhookService.emit()` is called:
```typescript
// Example: emitting an email.delivered event
await WebhookService.emit(teamId, "email.delivered", {
id: email.id,
status: "DELIVERED",
from: email.from,
to: email.to,
occurredAt: new Date().toISOString(),
// ... other fields
});
```
### Step 2: Webhook Matching & Call Creation
`emit()` performs the following:
1. Finds all ACTIVE webhooks for the team that subscribe to the event type
2. Creates a `WebhookCall` record for each matching webhook (stores event data as `payload`)
3. Enqueues the call ID to BullMQ for async processing
```typescript
// Webhook matching logic
const activeWebhooks = await db.webhook.findMany({
where: {
teamId,
status: WebhookStatus.ACTIVE,
OR: [
{ eventTypes: { has: type } }, // Subscribed to this event
{ eventTypes: { isEmpty: true } }, // Subscribed to ALL events
],
},
});
```
### Step 3: Queue Processing
The BullMQ worker (`processWebhookCall`) handles delivery:
1. **Lock Acquisition**: Acquires a Redis lock per webhook to ensure ordered delivery
2. **Status Check**: Skips if webhook is no longer ACTIVE (marks call as DISCARDED)
3. **Payload Building**: Wraps the stored event data in the full payload structure
4. **HTTP POST**: Sends signed request to the webhook URL
5. **Result Handling**: Updates call status and webhook metrics
### Step 4: Payload Structure
**Important**: The stored `WebhookCall.payload` contains only the event data. The actual HTTP request body is built at delivery time by `buildPayload()`:
```typescript
// Stored in WebhookCall.payload (event data only):
{
"id": "email_123",
"status": "DELIVERED",
"from": "sender@example.com",
"to": ["recipient@example.com"],
"occurredAt": "2024-01-15T10:30:00Z"
}
// Actual payload sent to webhook endpoint:
{
"id": "call_abc123", // WebhookCall ID
"type": "email.delivered", // Event type
"version": "2026-01-18", // API version
"createdAt": "2024-01-15T10:30:00Z",
"teamId": 123,
"data": { // Original event data nested here
"id": "email_123",
"status": "DELIVERED",
"from": "sender@example.com",
"to": ["recipient@example.com"],
"occurredAt": "2024-01-15T10:30:00Z"
},
"attempt": 1
}
```
### Step 5: Request Signing
Each request includes security headers for verification:
```
Content-Type: application/json
User-Agent: UseSend-Webhook/1.0
X-UseSend-Event: email.delivered
X-UseSend-Call: call_abc123
X-UseSend-Timestamp: 1705312200000
X-UseSend-Signature: v1=<hmac-sha256-hex>
X-UseSend-Retry: false
```
Signature computation:
```typescript
const signature = HMAC - SHA256(secret, `${timestamp}.${JSON.stringify(body)}`);
// Format: "v1=" + hex(signature)
```
## Retry & Failure Handling
### Retry Configuration
```typescript
const WEBHOOK_MAX_ATTEMPTS = 6;
const WEBHOOK_BASE_BACKOFF_MS = 5_000; // 5 seconds
const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30;
```
### Backoff Schedule (approximate)
| Attempt | Delay (base) | With Jitter |
| ------- | ------------ | ----------- |
| 1 | 5s | 5-6.5s |
| 2 | 10s | 10-13s |
| 3 | 20s | 20-26s |
| 4 | 40s | 40-52s |
| 5 | 80s | 80-104s |
| 6 | 160s | 160-208s |
### Auto-Disable
After 30 consecutive failures across any calls, the webhook is automatically set to `AUTO_DISABLED` status. This prevents continued delivery attempts to consistently failing endpoints.
### Call Status Flow
```
PENDING → IN_PROGRESS → DELIVERED (success)
→ PENDING (retry on failure, attempts < 6)
→ FAILED (max attempts reached)
→ DISCARDED (webhook disabled/paused)
```
## SDK Webhook Verification
Located in `packages/sdk/src/webhooks.ts`:
```typescript
import { UseSend } from "usesend";
const usesend = new UseSend("us_api_key");
const webhooks = usesend.webhooks("whsec_your_secret");
// Option 1: Verify only (returns boolean)
const isValid = webhooks.verify(rawBody, { headers: request.headers });
// Option 2: Verify and parse (throws on invalid)
const event = webhooks.constructEvent(rawBody, { headers: request.headers });
if (event.type === "email.delivered") {
console.log(event.data.to); // Type-safe access
}
```
## UI Payload Display
The webhook call details UI (`apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx`) reconstructs the full payload for display, matching what was actually sent to the endpoint. This uses the same structure as `buildPayload()` in the service layer.
## Important Files
| File | Purpose |
| ------------------------------------------------ | --------------------------- |
| `apps/web/prisma/schema.prisma` | Database models |
| `apps/web/src/server/service/webhook-service.ts` | Core service & queue worker |
| `apps/web/src/server/api/routers/webhook.ts` | TRPC API routes |
| `apps/web/src/lib/constants/plans.ts` | Webhook limits per plan |
| `packages/lib/src/webhook/webhook-events.ts` | Event type definitions |
| `packages/sdk/src/webhooks.ts` | SDK verification utilities |
| `apps/web/src/app/(dashboard)/webhooks/` | UI components |
## Configuration Constants
```typescript
// apps/web/src/server/service/webhook-service.ts
const WEBHOOK_DISPATCH_CONCURRENCY = 25; // Parallel workers
const WEBHOOK_MAX_ATTEMPTS = 6; // Max delivery attempts
const WEBHOOK_BASE_BACKOFF_MS = 5_000; // Initial retry delay
const WEBHOOK_LOCK_TTL_MS = 15_000; // Redis lock TTL
const WEBHOOK_LOCK_RETRY_DELAY_MS = 2_000; // Lock retry delay
const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30; // Failures before disable
const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000; // HTTP timeout
const WEBHOOK_RESPONSE_TEXT_LIMIT = 4_096; // Max response body stored
const WEBHOOK_EVENT_VERSION = "2026-01-18"; // Default API version
```
## Plan Limits
```typescript
// apps/web/src/lib/constants/plans.ts
FREE: {
webhooks: 1;
}
BASIC: {
webhooks: -1;
} // unlimited
```