acme/billing#203
Description
Description
Emits billing events (invoice paid, refund issued) to tenant-registered webhook endpoints, with retry/backoff and an admin settings screen. The delivery module is extended (timeout + outcome recording, then a retry wrapper); schema, API, UI, tests, and docs are new.
Changed files
db/schema/webhookEvents.ts
1 / 9new18
db/schema/webhookEvents.ts
1 / 9@@ -0,0 +1,18 @@
import {pgTable, uuid, timestamp, integer, text} from 'drizzle-orm/pg-core'
export const webhookEndpoints = pgTable('webhook_endpoints', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
url: text('url').notNull(),
secret: text('secret').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const webhookEvents = pgTable('webhook_events', {
id: uuid('id').primaryKey().defaultRandom(),
endpointId: uuid('endpoint_id').notNull().references(() => webhookEndpoints.id),
morgan
morgan
Should
payloadbejsonbinstead oftextso we can query into it later?
payload: text('payload').notNull(),
status: text('status').notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
attemptedAt: timestamp('attempted_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
db/migrations/0009_webhook_events.sql
2 / 9new16
db/migrations/0009_webhook_events.sql
2 / 9@@ -0,0 +1,16 @@
CREATE TABLE webhook_endpoints (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
url text NOT NULL,
secret text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE webhook_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint_id uuid NOT NULL REFERENCES webhook_endpoints(id),
payload text NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
attempted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
lib/webhooks/deliver.ts
3 / 9355
lib/webhooks/deliver.ts
3 / 9@@ -26,15 +26,21 @@ import {eq} from 'drizzle-orm'
import {webhookEvents} from '@/db/schema/webhookEvents'
export async function deliverWebhook(event: WebhookEvent, endpoint: WebhookEndpoint) {
const res = await fetch(endpoint.url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(event.payload),
})
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), WEBHOOK_DELIVERY_TIMEOUT_MS)
let res: Response
try {
res = await fetch(endpoint.url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Webhook-Signature': sign(event)},
body: JSON.stringify(event.payload),
signal: controller.signal,
})
} finally {
clearTimeout(timeout)
}
const status = res.ok ? 'delivered' : 'failed'
await db.update(webhookEvents).set({status, attemptedAt: new Date()}).where(eq(webhookEvents.id, event.id))
}
@@ -108,0 +110,23 @@ import {eq} from 'drizzle-orm'
import {computeBackoffDelay} from './backoff'
const WEBHOOK_MAX_RETRIES = Number(process.env.WEBHOOK_MAX_RETRIES ?? '5')
export async function deliverWithRetries(event: WebhookEvent, endpoint: WebhookEndpoint) {
let attempt = 0
let lastError: unknown
while (attempt <= WEBHOOK_MAX_RETRIES) {
try {
await deliverWebhook(event, endpoint)
return
morgan
morgan
Worth a circuit breaker so we stop hammering an endpoint that is hard-failing across events, not just within one.
} catch (error) {
lastError = error
attempt += 1
if (attempt > WEBHOOK_MAX_RETRIES) break
await sleep(computeBackoffDelay(attempt))
}
}
await db.update(webhookEvents).set({status: 'failed', attempts: attempt}).where(eq(webhookEvents.id, event.id))
throw lastError
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
lib/webhooks/backoff.ts
4 / 9new12
lib/webhooks/backoff.ts
4 / 9@@ -0,0 +1,12 @@
export type BackoffOptions = {
baseMs: number
capMs: number
}
const DEFAULTS: BackoffOptions = {baseMs: 500, capMs: 30_000}
export function computeBackoffDelay(attempt: number, options: BackoffOptions = DEFAULTS): number {
const {baseMs, capMs} = options
const exp = baseMs * 2 ** (attempt - 1)
return Math.min(exp + Math.random() * baseMs, capMs)
}
app/api/webhooks/route.ts
5 / 9new22
app/api/webhooks/route.ts
5 / 9@@ -0,0 +1,22 @@
import {NextRequest} from 'next/server'
import {z} from 'zod'
import {db} from '@/db'
import {webhookEndpoints} from '@/db/schema/webhookEvents'
const RegisterInput = z.object({
url: z.string().url(),
secret: z.string().min(16),
})
export async function POST(request: NextRequest) {
const parsed = RegisterInput.safeParse(await request.json())
if (!parsed.success) {
return Response.json({error: parsed.error.flatten()}, {status: 400})
}
const [endpoint] = await db
.insert(webhookEndpoints)
.values({
tenantId: request.auth?.tenantId ?? '',
url: parsed.data.url,
secret: parsed.data.secret,
})
.returning()
return Response.json({id: endpoint.id}, {status: 201})
}
components/settings/WebhookSettings.tsx
6 / 9new26
components/settings/WebhookSettings.tsx
6 / 9@@ -0,0 +1,26 @@
'use client'
import {useState} from 'react'
import {Button} from '@/components/ui/button'
type Endpoint = {id: string; url: string}
export function WebhookSettings({endpoints}: {endpoints: Endpoint[]}) {
const [url, setUrl] = useState('')
const [secret, setSecret] = useState('')
return (
<section className="space-y-4">
<h2 className="text-lg font-semibold">Webhook endpoints</h2>
<ul className="space-y-1">
{endpoints.map((endpoint) => (
<li key={endpoint.id} className="font-mono text-sm">{endpoint.url}</li>
))}
</ul>
<form onSubmit={(e) => e.preventDefault()}>
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." />
<input value={secret} onChange={(e) => setSecret(e.target.value)} placeholder="shared secret" />
<Button type="submit">Add endpoint</Button>
</form>
</section>
)
}
lib/webhooks/deliver.test.ts
7 / 9new20
lib/webhooks/deliver.test.ts
7 / 9@@ -0,0 +1,20 @@
import {describe, it, expect, vi} from 'vitest'
import {deliverWithRetries} from './deliver'
vi.mock('./backoff', () => ({computeBackoffDelay: () => 0}))
describe('deliverWithRetries', () => {
it('retries up to the max, then marks the event failed', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
await expect(deliverWithRetries(event(), endpoint())).rejects.toThrow('network')
expect(fetch).toHaveBeenCalledTimes(6)
})
it('succeeds on the first attempt without retrying', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: true} as Response))
await deliverWithRetries(event(), endpoint())
expect(fetch).toHaveBeenCalledTimes(1)
})
})
.env.example
8 / 93
.env.example
8 / 9@@ -24,0 +25,3 @@
# Webhook delivery
WEBHOOK_DELIVERY_TIMEOUT_MS=5000
WEBHOOK_MAX_RETRIES=5
docs/webhooks.md
9 / 9new14
docs/webhooks.md
9 / 9@@ -0,0 +1,14 @@
# Webhooks
Register an HTTP endpoint to receive billing events (invoice paid, refund issued).
## Register an endpoint
POST /api/webhooks with a JSON body:
{"url": "https://example.com/hooks", "secret": "..."}
## Retry behavior
Failed deliveries are retried with exponential backoff up to WEBHOOK_MAX_RETRIES times.
Set WEBHOOK_DELIVERY_TIMEOUT_MS to cap the per-request timeout.
Discussion (1)
Discussion (1)
morgan
Nice decomposition. I reviewed the schema + delivery layers first and they read cleanly. One ask: a circuit breaker in the retry layer — commented inline.
Submit your review
Leave overall feedback on these changes.