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)

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:

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

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.

See Also

  • Inbound Webhooks — Receive webhooks from external services
  • Queues — Background processing infrastructure

On this page