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.
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.
See Also
- Inbound Webhooks — Receive webhooks from external services
- Queues — Background processing infrastructure