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. Verify signature (HMAC-SHA256)
├─ 2. Parse event (provider-specific)
├─ 3. Deduplicate via externalId
├─ 4. Store in webhook_events table
├─ 5. Enqueue for async processing
└─ 6. Return 200 OK immediately
Queue Consumer
│
├─ 1. Update status → 'processing'
├─ 2. Call registered handlers
└─ 3. Update status → 'processed' or 'failed'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