# Outbound Webhooks

Outbound webhooks notify external services when events happen in your API. Users register webhook endpoints, subscribe to event types, and receive signed HTTP POST requests when events are emitted.

## Overview

Outbound webhooks are a multi-step system:

1. **Users register endpoints** via the REST API
2. **Your code emits events** using `emitWebhookEvent()`
3. **Quickback queues deliveries** for each matching endpoint
4. **The queue consumer delivers** with retry and exponential backoff
5. **Payloads are signed** with HMAC-SHA256 (Stripe-compatible format)

## Emitting Events

Call `emitWebhookEvent()` in your actions or custom routes:

```typescript
import { emitWebhookEvent } from '../lib/webhooks/emit';

// After creating a user
const newUser = await db.insert(users).values(data).returning();

await emitWebhookEvent(
  "user.created",
  newUser[0],
  { organizationId: ctx.activeOrgId },
  env
);
```

**Parameters:**

| Parameter | Type | Description |
|-----------|------|-------------|
| `eventType` | `string` | Event name (e.g., `user.created`) |
| `payload` | `unknown` | Event data (auto-wrapped with metadata) |
| `options` | `object` | `{ organizationId?, userId? }` — scope for endpoint matching |
| `env` | `CloudflareBindings` | Cloudflare environment bindings |

**Note:** The compiler does NOT auto-emit events after CRUD operations. You must call `emitWebhookEvent()` explicitly where you want webhooks triggered.

## Event Types

Quickback provides recommended event naming conventions:

| Category | Events |
|----------|--------|
| User | `user.created`, `user.updated`, `user.deleted` |
| Subscription | `subscription.created`, `subscription.updated`, `subscription.cancelled`, `subscription.renewed` |
| Organization | `organization.created`, `organization.updated`, `organization.deleted`, `organization.member_added`, `organization.member_removed` |
| File | `file.uploaded`, `file.deleted` |

You can emit any custom event type — these are conventions, not restrictions.

## Payload Format

The emitted payload is wrapped automatically:

```json
{
  "type": "user.created",
  "data": {
    "id": "usr_abc123",
    "name": "Jane Doe",
    "email": "jane@example.com"
  },
  "createdAt": "2025-01-15T10:00:00.000Z"
}
```

## Payload Signing

Every delivery is signed with HMAC-SHA256 using the endpoint's secret. The signature format is Stripe-compatible:

```
X-Webhook-Signature: t=1705312800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

**Signed payload:** `<timestamp>.<json_body>`

**Additional headers:**

| Header | Description |
|--------|-------------|
| `X-Webhook-Signature` | `t=<timestamp>,v1=<hmac>` |
| `X-Webhook-Event` | Event type (e.g., `user.created`) |
| `X-Webhook-Delivery` | Delivery ID for tracking |
| `Content-Type` | `application/json` |

### Verifying Signatures (Consumer Side)

```typescript
import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhook(payload: string, signature: string, secret: string): boolean {
  const [tPart, v1Part] = signature.split(',');
  const timestamp = tPart.replace('t=', '');
  const expectedSig = v1Part.replace('v1=', '');

  // Check timestamp (5 minute tolerance)
  const age = Date.now() / 1000 - parseInt(timestamp);
  if (age > 300) return false;

  const signed = `${timestamp}.${payload}`;
  const computed = createHmac('sha256', secret).update(signed).digest('hex');
  return timingSafeEqual(Buffer.from(computed), Buffer.from(expectedSig));
}
```

## Endpoint Management API

### Register an Endpoint

```
POST /webhooks/v1/endpoints
```

```json
{
  "name": "My Integration",
  "url": "https://example.com/webhook",
  "events": ["user.created", "subscription.*"]
}
```

**Response (201):**

```json
{
  "id": "wep_abc123",
  "name": "My Integration",
  "url": "https://example.com/webhook",
  "secret": "whsec_a1b2c3d4...",
  "events": ["user.created", "subscription.*"],
  "enabled": true,
  "createdAt": "2025-01-15T10:00:00.000Z"
}
```

The `secret` is only returned on creation. Store it securely.

### Event Pattern Matching

Endpoints subscribe to events using patterns:

| Pattern | Matches |
|---------|---------|
| `user.created` | Exact match only |
| `user.*` | All user events |
| `*` | All events |

### Other Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/webhooks/v1/endpoints` | List your endpoints |
| `GET` | `/webhooks/v1/endpoints/:id` | Get endpoint details (secret masked) |
| `PATCH` | `/webhooks/v1/endpoints/:id` | Update endpoint (name, url, events, enabled) |
| `DELETE` | `/webhooks/v1/endpoints/:id` | Delete endpoint |
| `GET` | `/webhooks/v1/endpoints/:id/deliveries` | List delivery attempts |
| `POST` | `/webhooks/v1/endpoints/:id/test` | Send a test event |
| `POST` | `/webhooks/v1/endpoints/:id/rotate-secret` | Generate a new signing secret |
| `GET` | `/webhooks/v1/events` | List available event types |

### Access Control

Endpoints are scoped by `userId` or `organizationId`. Users can only manage their own endpoints. When emitting events, only endpoints matching the event's scope receive deliveries.

### URL Validation (anti-SSRF)

Registered URLs are validated at registration and again before each delivery attempt. The default policy rejects:

- Any scheme other than `https:` (matches Stripe, GitHub, and Slack webhook conventions)
- Loopback hosts (`localhost`, `127.0.0.1`, `::1`)
- Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`)
- RFC 1918 private ranges (`10/8`, `172.16/12`, `192.168/16`), `169.254/16`, and `0.0.0.0/8`
- Hosts ending in `.internal` or `.local`

Plaintext `http:` is rejected by default. To allow `http:` (e.g. for a self-hosted dev environment without TLS), set `webhookAllowInsecure: true` on the database provider config:

```ts
// quickback.config.ts
providers: {
  database: {
    name: 'cloudflare-d1',
    config: {
      webhooksBinding: 'WEBHOOKS_DB',
      webhookAllowInsecure: true, // dev/self-host only
    },
  },
}
```

The other validation rules (private/loopback/metadata blocking) still apply when this flag is on.

### Redirect Handling

Outbound delivery uses `redirect: "manual"` — the runtime refuses to follow 3xx responses. A redirect is treated as a **terminal** delivery failure (no retry), and the redirect target is recorded in the `responseBody`. This protects signed payloads from being bounced to a different host than the one the user registered.

If your endpoint legitimately needs to relocate, update the `url` on the endpoint record instead of relying on a 301/302.

## Delivery and Retry

Deliveries are processed asynchronously via a Cloudflare Queue.

**Success:** HTTP 2xx response marks the delivery as `delivered`.

**Failure:** HTTP 4xx/5xx or network errors trigger retries with exponential backoff:

| Attempt | Delay |
|---------|-------|
| 1st retry | ~2 seconds |
| 2nd retry | ~4 seconds |
| 3rd retry (final) | ~8 seconds |

Maximum backoff is capped at 1 hour. After 3 failed attempts, the delivery is marked as `failed`.

**Disabled endpoints:** If an endpoint is disabled or deleted between emission and delivery, the delivery is marked as failed immediately.

### Delivery Record

Each delivery attempt is tracked in the `webhook_deliveries` table:

| Field | Description |
|-------|-------------|
| `status` | `pending`, `delivered`, or `failed` |
| `attempts` | Number of delivery attempts |
| `responseStatus` | HTTP status from last attempt |
| `responseBody` | Response body (truncated to 1000 chars) |
| `lastAttemptAt` | Timestamp of last attempt |
| `nextRetryAt` | When the next retry will occur |

## Infrastructure

### Queue Configuration

Outbound webhooks use a dedicated Cloudflare Queue:

```toml
# Auto-generated in wrangler.toml
[[queues.producers]]
queue = "my-app-webhooks-queue"
binding = "WEBHOOKS_QUEUE"

[[queues.consumers]]
queue = "my-app-webhooks-queue"
max_batch_size = 10
max_batch_timeout = 30
max_retries = 3
dead_letter_queue = "my-app-webhooks-dlq"
```

### Dead Letter Queue

Failed webhook deliveries (after all retries) are sent to a dead letter queue for investigation:

```toml
[[queues.consumers]]
queue = "my-app-webhooks-dlq"
max_batch_size = 1
```

## Enabling Webhooks

Set `webhooksBinding` in your database provider config:

```typescript
database: defineDatabase("cloudflare-d1", {
  binding: "DB",
  webhooksBinding: "WEBHOOKS_DB",
})
```

This generates the webhook database schema, routes, queue consumer, and all supporting code.

## See Also

- [Inbound Webhooks](/stack/webhooks/inbound) — Receive webhooks from external services
- [Queues](/stack/queues) — Background processing infrastructure
