# 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)

The destination policy lives in `src/lib/webhooks/url-policy.ts` and runs at **four** points: on register, on update, and again immediately before **each** test send and **each** queued delivery attempt. Re-checking before every send is the point — a name that was public when it was registered can be re-pointed afterwards.

The default policy rejects:

- Any scheme other than `https:` (matches Stripe, GitHub, and Slack webhook conventions)
- URLs carrying credentials (`https://user:pass@host/`)
- Loopback, private, and reserved **IPv4** literals: `0/8`, `10/8`, `100.64/10` (CGNAT), `127/8`, `169.254/16` (link-local + cloud metadata), `172.16/12`, `192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`, `198.51.100/24`, `203.0.113/24`, `224/4` (multicast), `240/4` (reserved), and the broadcast address — in any notation the URL parser accepts, including octal and decimal
- The equivalent **IPv6** literals: `::`, `::1`, unique-local `fc00::/7`, link-local `fe80::/10`, site-local `fec0::/10`, multicast `ff00::/8`, documentation `2001:db8::/32` and `3fff::/20`, `2001::/23` (Teredo), `2002::/16` (6to4), `64:ff9b::/96` (NAT64), `100::/64`, and IPv4-mapped forms such as `[::ffff:127.0.0.1]`
- Internal names: `localhost`, `metadata.goog`, anything ending in `.localhost`, `.local`, `.internal`, or `.home.arpa`, and any single-label host (`http://intranet/`)

> **Host names are resolved, not just pattern-matched.** For any name that survives the checks above, the runtime resolves both **A and AAAA** records and rejects the destination if **any** answer is a non-public address. One private record among several public ones is enough to refuse the whole name — that is the case a literal-only check misses.


Resolution is fail-closed. If the resolver errors, times out, returns a malformed answer, or is unavailable, the destination is **not** contacted:

| Outcome | Register / update | Queued delivery |
|---------|-------------------|-----------------|
| All answers public | Accepted | Delivered |
| Any answer non-public | `400` | Terminal failure, no retry |
| No A/AAAA records (NXDOMAIN) | `400` | Terminal failure, no retry |
| Resolver error, timeout, or malformed answer | `503` | Failed **attempt**, retried with backoff |
| Answer larger than 32 records | `503` | Failed **attempt**, retried with backoff |

DNS is always resolved through a fixed trusted resolver — an endpoint never gets to nominate who answers for it. An answer of more than 32 records is refused rather than truncated: silently cutting the list would let a zone owner pad it with public records and hide a private one past the cut.

> **A resolver outage fails deliveries permanently, and it fails them all at once.** The three retries above elapse in seconds, so a DNS outage lasting longer than that exhausts them and marks every in-flight delivery `failed` — across every endpoint and every project on the same resolver. There is no outbound replay endpoint (`/webhooks/v1/inbound/events/:id/retry` covers inbound events only), so those deliveries are not recoverable; the source event has to be re-emitted.
>
> This is the deliberate cost of fail-closed: the runtime will not contact a destination it could not verify. It also means outbound webhook failures are **correlated** — a shared dependency, not independent per-endpoint faults. Alert on a spike in `failed` deliveries rather than on any single endpoint.


> **This validates the DNS answer, not the socket.** It is not connection pinning, so a deployment that attaches private egress (a VPC, a tunnel, a service binding) must **also** enforce a network-layer egress policy. Application-level DNS validation cannot close the gap between the answer it checked and the address the runtime later connects to.


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
    },
  },
}
```

`webhookAllowInsecure` relaxes the **scheme and nothing else**. Every address rule above — including DNS resolution and the fail-closed behaviour — still applies when the flag is on. There is no configuration that permits a private destination.

### 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.

### On Neon (Postgres)

Webhooks are supported on the `neon` provider too. `webhooksBinding` acts as a
pure enable flag — there is no separate webhooks database. The webhook tables
live in a dedicated `webhooks` Postgres schema of your single Neon database,
created by the standard drizzle migration set, and are locked down by an RLS
policy so only the Worker's internal service-role handle can touch them:

```typescript
database: defineDatabase("neon", {
  connectionMode: "http",       // required for webhooks
  webhooksBinding: "WEBHOOKS_DB", // enable flag; no D1 binding is emitted
  // queue names stay configurable on any provider:
  webhooksQueueName: "my-app-webhooks-queue",
  webhooksDlqName: "my-app-webhooks-dlq",
})
```

The D1-only keys `webhooksDatabaseId` / `webhooksDatabaseName` are rejected at
compile time on Neon, and webhooks require `connectionMode: 'http'` (the
Cloudflare default). Queue wiring (`WEBHOOKS_QUEUE`, the consumer, and the
dead letter queue) is identical across providers.

## See Also

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

## Standard Webhooks signing

Outbound deliveries are signed in the [Standard Webhooks](https://www.standardwebhooks.com)
format:

| Header | Value |
|---|---|
| `webhook-id` | the delivery id (unique per delivery) |
| `webhook-timestamp` | unix seconds |
| `webhook-signature` | `v1,<base64 HMAC-SHA256 over id.timestamp.body>` |

These are the **only** signature headers sent. The legacy
`X-Webhook-Signature` / `X-Webhook-Event` / `X-Webhook-Delivery` set, and its
Stripe-format signature, are gone — a receiver still reading them sees no
signature at all, so move verification onto `webhook-signature` first.

Receiver verification (any Standard Webhooks library works):

```ts
import { Webhook } from "standardwebhooks";

const wh = new Webhook(endpointSecret); // whsec_…
const payload = wh.verify(rawBody, {
  "webhook-id": req.headers["webhook-id"],
  "webhook-timestamp": req.headers["webhook-timestamp"],
  "webhook-signature": req.headers["webhook-signature"],
});
```

The timestamp is inside the MAC, so the library's tolerance window is a real
replay bound.
