Skip to content

Commit c469a7c

Browse files
tonyxiaoclaude
andcommitted
feat(service): verify Stripe webhook signature at HTTP boundary
Previously the webhook handler accepted any POST, fired a Temporal signal, and returned 200 — with signature verification only happening deep in the engine activity after the response was already sent. Now verification happens at the service HTTP boundary using stripe.webhooks.constructEvent() before the event is enqueued. - Move stripe to runtime dependencies (was devDependencies) - Query pipeline config for webhook_secret before accepting the event - Return 404 if pipeline not found, 400 if no secret or invalid signature - Await the Temporal signal (was fire-and-forget) - Update OpenAPI spec with 400/404 responses on the webhook route Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude
1 parent 19e1432 commit c469a7c

5 files changed

Lines changed: 94 additions & 26 deletions

File tree

apps/service/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"openapi-fetch": "^0.13",
4848
"pino": "^10",
4949
"pino-pretty": "^13",
50+
"stripe": "^21.0.1",
5051
"zod": "^4.3.6"
5152
},
5253
"devDependencies": {
@@ -55,7 +56,6 @@
5556
"@types/node": "^24.10.1",
5657
"openapi-typescript": "^7",
5758
"pg": "^8",
58-
"stripe": "^21.0.1",
5959
"vitest": "^3.2.4"
6060
},
6161
"repository": {

apps/service/src/__generated__/openapi.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/service/src/api/app.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { OpenAPIHono, createRoute } from '@stripe/sync-hono-zod-openapi'
22
import { z } from 'zod'
33
import { apiReference } from '@scalar/hono-api-reference'
4+
import Stripe from 'stripe'
45
import type { WorkflowClient } from '@temporalio/client'
56
import type { ConnectorResolver } from '@stripe/sync-engine'
67
import { endpointTable, addDiscriminators } from '@stripe/sync-engine/api/openapi-utils'
@@ -38,6 +39,9 @@ export interface AppOptions {
3839
resolver: ConnectorResolver
3940
}
4041

42+
// Shared Stripe instance used only for webhook signature verification (no API calls made).
43+
const stripe = new Stripe('placeholder')
44+
4145
export function createApp(options: AppOptions) {
4246
const { client: temporal, taskQueue } = options.temporal
4347
const {
@@ -415,16 +419,51 @@ export function createApp(options: AppOptions) {
415419
content: { 'text/plain': { schema: z.literal('ok') } },
416420
description: 'Event accepted',
417421
},
422+
400: {
423+
content: { 'application/json': { schema: ErrorSchema } },
424+
description: 'Missing or invalid signature, or pipeline not configured for webhooks',
425+
},
426+
404: {
427+
content: { 'application/json': { schema: ErrorSchema } },
428+
description: 'Pipeline not found',
429+
},
418430
},
419431
}),
420432
async (c) => {
421433
const { pipeline_id } = c.req.valid('param')
422434
const body = await c.req.text()
435+
436+
// Look up the pipeline config to get the webhook_secret
437+
let webhookSecret: string | undefined
438+
try {
439+
const handle = temporal.getHandle(pipeline_id)
440+
const pipeline = await handle.query<Pipeline>('config')
441+
webhookSecret = (pipeline.source as Record<string, unknown>).webhook_secret as
442+
| string
443+
| undefined
444+
} catch {
445+
return c.json({ error: `Pipeline ${pipeline_id} not found` }, 404)
446+
}
447+
if (!webhookSecret) {
448+
return c.json({ error: 'Pipeline has no webhook_secret configured' }, 400)
449+
}
450+
451+
// Verify Stripe signature
452+
const sig = c.req.header('stripe-signature') ?? ''
453+
try {
454+
stripe.webhooks.constructEvent(body, sig, webhookSecret)
455+
} catch (err) {
456+
return c.json(
457+
{
458+
error: `Webhook signature verification failed: ${err instanceof Error ? err.message : String(err)}`,
459+
},
460+
400
461+
)
462+
}
463+
464+
// Enqueue the verified event
423465
const headers = Object.fromEntries(c.req.raw.headers.entries())
424-
temporal
425-
.getHandle(pipeline_id)
426-
.signal('stripe_event', { body, headers })
427-
.catch(() => {})
466+
await temporal.getHandle(pipeline_id).signal('stripe_event', { body, headers })
428467
return c.text('ok', 200)
429468
}
430469
)

apps/service/src/cli.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,14 @@ const workerCmd = defineCommand({
9999
const engineUrl = args['engine-url'] || 'http://localhost:4010'
100100
const temporalAddress = args['temporal-address']
101101

102-
// import.meta.url is the URL of cli.ts/cli.js, NOT the bin entry point:
103-
// tsx: file:///.../apps/service/src/cli.ts → ./temporal/workflows.ts
104-
// compiled: file:///.../apps/service/dist/cli.js → ./temporal/workflows.js
102+
// tsx strips rootDir:"src" from import.meta.url, so paths differ by context:
103+
// tsx: file:///.../apps/service/bin/sync-service.ts → ../src/temporal/workflows.ts
104+
// compiled: file:///.../apps/service/dist/bin/sync-service.js → ../temporal/workflows.js
105105
const { fileURLToPath } = await import('node:url')
106-
const ext = import.meta.url.endsWith('.ts') ? '.ts' : '.js'
107-
const workflowsPath = fileURLToPath(new URL(`./temporal/workflows${ext}`, import.meta.url))
106+
const isTsx = import.meta.url.endsWith('.ts')
107+
const workflowsPath = fileURLToPath(
108+
new URL(isTsx ? '../src/temporal/workflows.ts' : '../temporal/workflows.js', import.meta.url)
109+
)
108110

109111
const worker = await createWorker({
110112
temporalAddress,

pnpm-lock.yaml

Lines changed: 13 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)