# Inbound Webhooks

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

The `: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:

```typescript
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.

```typescript
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 — and
route the cast **through `unknown`**. `ctx.data` is a `Record<string, unknown>`,
which does not sufficiently overlap a Stripe resource interface, so the direct
one-step form is a `TS2352` error:

```typescript
const session = ctx.data as unknown as import("stripe").Stripe.Checkout.Session;
```

**Execution order:**
1. Specific handlers run first (exact match on event type)
2. Wildcard handlers run after
3. All matching handlers run in parallel
4. 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.

1. In the Stripe Dashboard, add a webhook endpoint pointing at
   `https://<your-api>/webhooks/v1/inbound/stripe` and enable **"Listen to
   events on Connected accounts."**
2. 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`.

```typescript
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:

```typescript
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:

```sql
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/events
```

Returns recent inbound events with their status. Requires admin role.

### Retry Failed Event

```
POST /webhooks/v1/inbound/events/:id/retry
```

Re-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 `failed` status; use the admin retry endpoint to re-process manually

## Configuration

Webhooks are enabled by setting `webhooksBinding` in your database config:

```typescript
database: defineDatabase("cloudflare-d1", {
  binding: "DB",
  webhooksBinding: "WEBHOOKS_DB",
})
```

Set the provider secret as a Wrangler secret:

```bash
wrangler secret put STRIPE_WEBHOOK_SECRET
```

## See Also

- [Outbound Webhooks](/platform/webhooks/outbound) — Send webhooks when data changes
- [Queues](/platform/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).

```ts title="quickback/features/comms/actions/sesDeliveryWebhook.ts"
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:

```ts
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`](/platform/api-contract) or gateway-event-id dedupe.

For AWS SNS (SES/SMS event topics) — asymmetric X.509 verification, no shared
secret:

```ts
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.
