# 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. 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:

```typescript
onWebhookEvent("stripe:checkout.session.completed", async (ctx) => {
  const { type, data, provider, env } = ctx;
  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` | Full event type (e.g., `stripe:checkout.session.completed`) |
| `data` | `unknown` | Parsed event payload |
| `provider` | `string` | Provider name (e.g., `stripe`) |
| `env` | `CloudflareBindings` | Cloudflare environment bindings |

**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)

### 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;
    externalId?: string;
  };
}
```

## 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](/stack/webhooks/outbound) — Send webhooks when data changes
- [Queues](/stack/queues) — Background processing infrastructure
