Outbound Webhooks
Send webhooks when data changes in your Quickback API.
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:
- Users register endpoints via the REST API
- Your code emits events using
emitWebhookEvent() - Quickback queues deliveries for each matching endpoint
- The queue consumer delivers with retry and exponential backoff
- Payloads are signed with HMAC-SHA256 (Stripe-compatible format)
Emitting Events
Call emitWebhookEvent() in your actions or custom routes:
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:
{
"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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdSigned 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)
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{
"name": "My Integration",
"url": "https://example.com/webhook",
"events": ["user.created", "subscription.*"]
}Response (201):
{
"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-localfc00::/7, link-localfe80::/10, site-localfec0::/10, multicastff00::/8, documentation2001:db8::/32and3fff::/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:
// 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:
# 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:
[[queues.consumers]]
queue = "my-app-webhooks-dlq"
max_batch_size = 1Enabling Webhooks
Set webhooksBinding in your database provider config:
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:
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 — Receive webhooks from external services
- Queues — Background processing infrastructure
Standard Webhooks signing
Outbound deliveries are signed in the Standard Webhooks 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):
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.