Inbound Webhooks
Receive and process webhooks from external services in your Quickback API.
Inbound webhooks allow external services (Stripe, Paddle, GitHub, etc.) to notify your API of events. Quickback generates endpoints that verify signatures, deduplicate events, and dispatch to your handler functions via a Cloudflare Queue.
Endpoint
POST /webhooks/v1/inbound/:providerThe :provider parameter identifies which service sent the webhook (e.g., stripe, paddle, github). Each provider has its own signature verification logic.
How It Works
External Service → POST /webhooks/v1/inbound/stripe
│
├─ 1. Read body, capped at 1 MiB (413 if exceeded)
├─ 2. Verify signature (HMAC-SHA256)
├─ 3. Parse event (provider-specific)
├─ 4. Deduplicate via externalId
├─ 5. Store in webhook_events table
├─ 6. Enqueue for async processing
└─ 7. Return 200 OK immediately
Queue Consumer
│
├─ 1. Update status → 'processing'
├─ 2. Call registered handlers
└─ 3. Update status → 'processed' or 'failed'The body cap comes before signature verification, deliberately. The
signature is the authentication on this route, and it cannot be checked
until the body has been read — so the read itself carries the limit.
Without it, an unsigned caller would choose how much the Worker buffers,
hashes, stores and enqueues before being turned away. Bodies over 1 MiB get
413, distinct from the 401 an invalid signature returns.
The same cap applies to actions declaring webhook: verification.
The API returns 200 OK immediately after enqueuing. Your handler logic runs asynchronously via the queue consumer, so external services don't time out waiting for your processing to complete.
Registering Handlers
Use onWebhookEvent() in your action code to register handlers for specific events:
onWebhookEvent("stripe:checkout.session.completed", async (ctx) => {
const { type, data, event, provider, env } = ctx;
// ctx.event is the full Stripe envelope; ctx.data is event.data.object
await createSubscription(data, env);
});
// Wildcard — handle all events from a provider
onWebhookEvent("stripe:*", async (ctx) => {
console.log(`Received ${ctx.type} from ${ctx.provider}`);
});Handler context:
| Field | Type | Description |
|---|---|---|
type | string | Event type (e.g., checkout.session.completed) |
data | unknown | The convenience slice of the payload. For Stripe this is event.data.object. |
event | unknown | The full event envelope, exactly as the provider sent it — nothing dropped. For Stripe this carries account (Connect connected-account id), id, livemode, api_version, request, etc. |
provider | string | Provider name (e.g., stripe) |
env | CloudflareBindings | Cloudflare environment bindings |
For stripe: patterns the context is typed automatically: ctx.event is a
StripeWebhookEvent and ctx.data is its data.object, so ctx.event.account,
ctx.event.id, and ctx.event.livemode are all typed with no cast.
onWebhookEvent("stripe:invoice.paid", async (ctx) => {
ctx.event.account; // string | undefined — the acct_… (Connect)
ctx.event.id; // string — evt_…
ctx.event.livemode; // boolean
ctx.data; // event.data.object (Record<string, unknown>)
});data.object is an open record. Cast it when you need inner-field typing:
const session = ctx.data as import("stripe").Stripe.Checkout.Session;Execution order:
- Specific handlers run first (exact match on event type)
- Wildcard handlers run after
- All matching handlers run in parallel
- If any handler throws, the message is retried
Signature Verification
Each provider verifies signatures differently. The secret is read from an environment variable.
Stripe
Environment variable: STRIPE_WEBHOOK_SECRET
Stripe uses HMAC-SHA256 with a timestamp-based signature:
Stripe-Signature: t=1614556800,v1=abc123...The signed payload is <timestamp>.<raw_body>. Verification checks:
- HMAC matches
- Timestamp is within 5 minutes (prevents replay attacks)
Stripe Connect
The single-secret model is exactly what a Connect platform needs. With Standard connected accounts and direct charges, each of your customers connects their own Stripe account, and one webhook endpoint on your platform receives events from all connected accounts — every delivery signed with your one platform signing secret.
- In the Stripe Dashboard, add a webhook endpoint pointing at
https://<your-api>/webhooks/v1/inbound/stripeand enable "Listen to events on Connected accounts." - Set that endpoint's signing secret as
STRIPE_WEBHOOK_SECRET(one secret covers every connected account — no per-account configuration).
To know which connected account an event belongs to, read the top-level
event.account (an acct_… id) off the envelope via ctx.event.account. It is
not inside event.data.object.
onWebhookEvent("stripe:checkout.session.completed", async (ctx) => {
const connectedAccount = ctx.event.account; // acct_… — which organizer
if (!connectedAccount) return; // platform-account event, ignore
// Route to the org that owns this connected account, and validate the match.
const org = await findOrgByStripeAccount(connectedAccount, ctx.env);
if (!org) throw new Error(`Unknown connected account ${connectedAccount}`);
await fulfillOrder(ctx.data, org, ctx.env);
});ctx.data remains event.data.object (the Checkout Session) for convenience;
ctx.event gives you the rest of the envelope — account, id, livemode,
api_version, request — so nothing about the delivery is lost.
Custom Providers
Providers implement this interface:
interface InboundProvider {
name: string;
verifySignature(payload: string, signature: string, secret: string): Promise<boolean>;
parseEvent(payload: string): {
type: string;
data: unknown; // convenience slice (e.g. Stripe's data.object)
externalId?: string; // provider event id, for idempotency
envelope?: unknown; // full event envelope, when available
};
}Idempotency
Events are deduplicated on the tuple (provider, externalId) (e.g., Stripe's event.id). The webhook_events table carries a unique index on this pair, and inbound delivery uses INSERT … ON CONFLICT DO NOTHING so concurrent deliveries collapse deterministically:
- The first delivery to win the race inserts the row, enqueues processing, and returns
{ received: true, eventId }. - Any concurrent delivery with the same
(provider, externalId)short-circuits and returns{ received: true, duplicate: true }— the queue is never sent the duplicate, so handlers run exactly once.
Events without an externalId (NULL) are not deduplicated. SQLite treats NULLs as distinct in unique indexes, so providers that don't supply a stable event ID continue to insert one row per delivery.
Migrating an existing deployment
The unique index was added in v0.10.x. Existing deployments may have duplicate rows from before the constraint existed; drizzle-kit's migration will fail until they are cleaned up. Run this once before applying the migration:
DELETE FROM webhook_events
WHERE rowid NOT IN (
SELECT MIN(rowid) FROM webhook_events
WHERE external_id IS NOT NULL
GROUP BY provider, external_id
);This keeps the earliest row for each (provider, external_id) pair and discards the duplicates.
Database Schema
Inbound events are stored in the webhook_events table:
| Column | Type | Description |
|---|---|---|
id | text | Primary key (whe_<uuid>) |
provider | text | Provider name |
eventType | text | Event type from provider |
externalId | text | Provider's event ID (for deduplication) |
payload | text | Raw JSON payload |
status | text | received, processing, processed, failed |
attempts | integer | Processing attempt count |
processedAt | timestamp | When successfully processed |
error | text | Error message if failed |
createdAt | timestamp | When received |
Admin Routes
Two admin-only routes are generated for monitoring inbound webhooks:
List Events
GET /webhooks/v1/inbound/eventsReturns recent inbound events with their status. Requires admin role.
Retry Failed Event
POST /webhooks/v1/inbound/events/:id/retryRe-queues a failed event for processing. Requires admin role.
Error Handling
- Invalid signature — Returns
401 Unauthorized, event not stored - Handler failure — Event marked as
failed, message retried (up to 3 times via queue) - All retries exhausted — Event remains in
failedstatus; use the admin retry endpoint to re-process manually
Configuration
Webhooks are enabled by setting webhooksBinding in your database config:
database: defineDatabase("cloudflare-d1", {
binding: "DB",
webhooksBinding: "WEBHOOKS_DB",
})Set the provider secret as a Wrangler secret:
wrangler secret put STRIPE_WEBHOOK_SECRETSee Also
- Outbound Webhooks — Send webhooks when data changes
- Queues — Background processing infrastructure
Verified webhook actions (webhook: on defineAction)
For gateways that should hit a custom action rather than the queued
/webhooks/v1/inbound/:provider pipeline, declare verification on the action
itself. Verification runs before JSON parse, input validation, and access
evaluation, over the raw request bytes — and it is additive to the access
gate (a PUBLIC webhook action keeps its public-audit block).
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Receive SES delivery notifications and record the message outcome.",
path: "/comms/hooks/delivery",
method: "POST",
access: { roles: ["PUBLIC"] },
webhook: {
verify: "standard-webhooks", // webhook-id / webhook-timestamp / webhook-signature
secretEnv: "SES_WEBHOOK_SECRET", // env binding, validated at compile time
tolerance: 300, // seconds (default 300)
},
input: z.object({ messageId: z.string(), event: z.string() }),
execute: async ({ input }) => { /* ... */ },
});For gateways speaking sha256=-hex HMAC instead of Standard Webhooks:
webhook: {
verify: {
scheme: "hmac-sha256",
header: "X-Signature",
prefix: "sha256=",
timestampHeader: "X-Webhook-Timestamp",
signedContent: "timestamp.body", // MAC covers `${timestamp}.${rawBody}`
tolerance: 300,
},
secretEnv: "WEBHOOK_SIGNING_SECRET",
}Replay semantics — read this before choosing signedContent: a tolerance
window only closes replay when the timestamp is inside the signed content.
With signedContent: "body" the MAC covers raw bytes only, so a captured
request can be replayed forever with a fresh timestamp header — which is why
the compiler rejects tolerance/timestampHeader on body-only schemes
instead of pretending they help. Pair body-only receivers with
Idempotency-Key or gateway-event-id dedupe.
For AWS SNS (SES/SMS event topics) — asymmetric X.509 verification, no shared secret:
webhook: {
verify: {
scheme: "aws-sns",
topicArnsEnv: "AWS_SES_SNS_TOPIC_ARNS", // comma-separated allowlist (REQUIRED)
// autoConfirmSubscription: true (default) — verified SubscriptionConfirmation
// handshakes are completed and short-circuited; your action never runs for them.
},
}topicArnsEnv is compile-required: without a TopicArn allowlist the
signature check proves only "AWS signed it" — any AWS account could create
its own topic, subscribe your endpoint (auto-confirmed by the handshake),
and deliver arbitrary messages that pass verification. The compiler refuses
the declaration rather than defaulting to accept-any-topic.
The signing certificate URL and SubscribeURL are validated against the
sns.<region>.amazonaws.com allowlist before any fetch; a topic allowlist
that resolves empty at runtime fails closed (500). The signed Timestamp
is bounded to a 1-hour tolerance window — it sits inside the signed
canonical string, so this is a real replay bound (a captured envelope stops
replaying), while staying generous enough for SNS's own delivery retries,
which re-send the original signed envelope.
Fail-closed runtime posture for every scheme: an unset secret env is a 500 rejection — never a verify-skip; any signature failure is a 401 Problem before the body is ever parsed.