Quickback Docs

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:

  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:

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:

ParameterTypeDescription
eventTypestringEvent name (e.g., user.created)
payloadunknownEvent data (auto-wrapped with metadata)
optionsobject{ organizationId?, userId? } — scope for endpoint matching
envCloudflareBindingsCloudflare 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:

CategoryEvents
Useruser.created, user.updated, user.deleted
Subscriptionsubscription.created, subscription.updated, subscription.cancelled, subscription.renewed
Organizationorganization.created, organization.updated, organization.deleted, organization.member_added, organization.member_removed
Filefile.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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Signed payload: <timestamp>.<json_body>

Additional headers:

HeaderDescription
X-Webhook-Signaturet=<timestamp>,v1=<hmac>
X-Webhook-EventEvent type (e.g., user.created)
X-Webhook-DeliveryDelivery ID for tracking
Content-Typeapplication/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:

PatternMatches
user.createdExact match only
user.*All user events
*All events

Other Endpoints

MethodPathDescription
GET/webhooks/v1/endpointsList your endpoints
GET/webhooks/v1/endpoints/:idGet endpoint details (secret masked)
PATCH/webhooks/v1/endpoints/:idUpdate endpoint (name, url, events, enabled)
DELETE/webhooks/v1/endpoints/:idDelete endpoint
GET/webhooks/v1/endpoints/:id/deliveriesList delivery attempts
POST/webhooks/v1/endpoints/:id/testSend a test event
POST/webhooks/v1/endpoints/:id/rotate-secretGenerate a new signing secret
GET/webhooks/v1/eventsList 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:

OutcomeRegister / updateQueued delivery
All answers publicAcceptedDelivered
Any answer non-public400Terminal failure, no retry
No A/AAAA records (NXDOMAIN)400Terminal failure, no retry
Resolver error, timeout, or malformed answer503Failed attempt, retried with backoff
Answer larger than 32 records503Failed 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:

AttemptDelay
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:

FieldDescription
statuspending, delivered, or failed
attemptsNumber of delivery attempts
responseStatusHTTP status from last attempt
responseBodyResponse body (truncated to 1000 chars)
lastAttemptAtTimestamp of last attempt
nextRetryAtWhen 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 = 1

Enabling 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:

HeaderValue
webhook-idthe delivery id (unique per delivery)
webhook-timestampunix seconds
webhook-signaturev1,<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.

On this page