Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions src/commands/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,14 @@ export const webhooksCommand = (program: Command) => {
streamType: 'webhook_event',
})

if (!rootOpts.json) {
const whsMessage = session.webhook_secret
? ` Your webhook signing secret is ${session.webhook_secret}`
: ''
const whsMessage = session.webhook_secret
? ` Your webhook signing secret is ${session.webhook_secret}`
: ''

info(`Listening for webhooks.${whsMessage}`)
info('Press Ctrl+C to stop.')
info(`Listening for webhooks.${whsMessage}`)
info('Press Ctrl+C to stop.')

hint('')
}
hint('')

await listenToRelay({
websocketUrl: session.websocket_url,
Expand Down
9 changes: 5 additions & 4 deletions src/lib/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ export const _resetColorCache = () => {
_colorEnabled = undefined
}

const green = (text: string) => (supportsColor() ? `\x1B[32m${text}\x1B[0m` : text)
const yellow = (text: string) => (supportsColor() ? `\x1B[33m${text}\x1B[0m` : text)
const red = (text: string) => (supportsColor() ? `\x1B[31m${text}\x1B[0m` : text)
const dim = (text: string) => (supportsColor() ? `\x1B[2m${text}\x1B[0m` : text)
export const green = (text: string) => (supportsColor() ? `\x1B[32m${text}\x1B[0m` : text)
export const yellow = (text: string) => (supportsColor() ? `\x1B[33m${text}\x1B[0m` : text)
export const red = (text: string) => (supportsColor() ? `\x1B[31m${text}\x1B[0m` : text)
export const dim = (text: string) => (supportsColor() ? `\x1B[2m${text}\x1B[0m` : text)
export const bold = (text: string) => (supportsColor() ? `\x1B[1m${text}\x1B[0m` : text)

const STATUS_COLORS = {
succeeded: green,
Expand Down
66 changes: 35 additions & 31 deletions src/lib/webhooks/__tests__/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { printJson } from '../../output.js'
import { log, printJson } from '../../output.js'
import { createWebhookRelayHandlers } from '../handlers.js'

vi.mock('../../output.js', () => ({
bold: vi.fn((text: string) => text),
dim: vi.fn((text: string) => text),
green: vi.fn((text: string) => text),
log: vi.fn(),
printJson: vi.fn(),
red: vi.fn((text: string) => text),
yellow: vi.fn((text: string) => text),
}))

const webhookEvent = {
id: 'evt_123',
type: 'payment.succeeded',
created_at: '2026-05-11T14:52:12Z',
}

describe('webhook relay handlers', () => {
beforeEach(() => {
vi.clearAllMocks()
})

afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})

test('pretty-prints the full event for webhook messages', async () => {
const event = JSON.stringify({ id: 'evt_123' })
test('prints a compact line for webhook messages', async () => {
const event = JSON.stringify(webhookEvent)
const message = {
type: 'webhook_event',
event,
Expand All @@ -27,29 +40,32 @@ describe('webhook relay handlers', () => {

await createWebhookRelayHandlers({}).webhook_event!(message)

expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' })
expect(log).toHaveBeenCalledWith('2026-05-11 14:52:12 --> payment.succeeded [evt_123]')
})

test('prints the full relay message in JSON mode', async () => {
test('prints the full event in JSON mode', async () => {
const message = {
type: 'webhook_event',
id: 'wem_123',
status: 'pending',
event: JSON.stringify({ id: 'evt_123' }),
event: JSON.stringify(webhookEvent),
signature: 'test_signature',
event_type: 'payment_intent.succeeded',
timestamp: 1_234,
} as const

await createWebhookRelayHandlers({ json: true }).webhook_event!(message)

expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' })
expect(printJson).toHaveBeenCalledWith(webhookEvent)
})

test('forwards the raw event with the webhook signature', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-11T14:52:13Z'))
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
const event = JSON.stringify({ id: 'evt_123' })
const event = JSON.stringify(webhookEvent)
const forwardTo = 'https://webhook.site/828b463d-c4a4-4f6b-a450-7c2cdb575d63'
const message = {
type: 'webhook_event',
event,
Expand All @@ -59,22 +75,25 @@ describe('webhook relay handlers', () => {
} as const

await createWebhookRelayHandlers({
forwardTo: 'https://example.test/webhooks',
forwardTo,
}).webhook_event!(message)

expect(fetchMock).toHaveBeenCalledWith('https://example.test/webhooks', {
expect(fetchMock).toHaveBeenCalledWith(forwardTo, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Fintoc-Signature': 'test_signature',
},
body: event,
})
expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' })
expect(log).toHaveBeenCalledWith('2026-05-11 14:52:12 --> payment.succeeded [evt_123]')
expect(log).toHaveBeenCalledWith(
'2026-05-11 14:52:13 <-- [204] POST https://webhook.site/828b463d-c4a4-4f6b-a450-7c2cdb575d63 [evt_123]',
)
})

test('handles events included in the event filter', async () => {
const event = JSON.stringify({ id: 'evt_123', type: 'payment.succeeded' })
const event = JSON.stringify(webhookEvent)
const message = {
type: 'webhook_event',
event,
Expand All @@ -85,18 +104,15 @@ describe('webhook relay handlers', () => {

await createWebhookRelayHandlers({ events: ['payment.succeeded'] }).webhook_event!(message)

expect(printJson).toHaveBeenCalledWith({
id: 'evt_123',
type: 'payment.succeeded',
})
expect(log).toHaveBeenCalledWith('2026-05-11 14:52:12 --> payment.succeeded [evt_123]')
})

test('skips events not included in the event filter', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
const message = {
type: 'webhook_event',
event: JSON.stringify({ id: 'evt_123', type: 'payment.failed' }),
event: JSON.stringify({ ...webhookEvent, type: 'payment.failed' }),
signature: 'test_signature',
event_type: 'payment.failed',
timestamp: 1_234,
Expand All @@ -107,7 +123,7 @@ describe('webhook relay handlers', () => {
forwardTo: 'https://example.test/webhooks',
}).webhook_event!(message)

expect(printJson).not.toHaveBeenCalled()
expect(log).not.toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
})

Expand All @@ -122,19 +138,7 @@ describe('webhook relay handlers', () => {

await createWebhookRelayHandlers({ events: ['payment.succeeded'] }).webhook_event!(message)

expect(printJson).not.toHaveBeenCalled()
})

test('rejects webhook event payloads that are not records', async () => {
await expect(
createWebhookRelayHandlers({}).webhook_event!({
type: 'webhook_event',
event: JSON.stringify('evt_123'),
signature: 'test_signature',
event_type: 'payment_intent.succeeded',
timestamp: 1_234,
}),
).rejects.toThrow('Invalid webhook event payload')
expect(log).not.toHaveBeenCalled()
})

test('rejects malformed webhook messages', async () => {
Expand Down
83 changes: 68 additions & 15 deletions src/lib/webhooks/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { HandledRelayMessage, RelayMessageHandlers } from '../action-cable.js'
import * as z from 'zod/mini'
import { printJson } from '../output.js'
import { bold, dim, green, log, printJson, red, yellow } from '../output.js'

const webhookEventMessageSchema = z.object({
type: z.literal('webhook_event'),
Expand All @@ -21,6 +21,8 @@ type WebhookRelayOptions = {
events?: string[]
}

const reformatDate = (isoDate: string) => isoDate.replace('T', ' ').slice(0, 19)

const forwardWebhookEvent = async (
url: string,
data: z.infer<typeof webhookEventMessageSchema>,
Expand All @@ -34,18 +36,64 @@ const forwardWebhookEvent = async (
body: data.event,
})

if (!response.ok) {
throw new Error(`Failed to forward webhook event: ${response.status} ${response.statusText}`)
return {
statusCode: response.status,
timestamp: new Date().toISOString(),
}
}

const WebhookEventSchema = z.looseObject({
id: z.string(),
type: z.string(),
created_at: z.string(),
})

type WebhookEvent = z.infer<typeof WebhookEventSchema>

const parseWebhookEvent = (event: string) => {
const eventResult = z.record(z.string(), z.unknown()).safeParse(JSON.parse(event))
if (!eventResult.success) {
throw new Error(`Invalid webhook event payload: ${z.prettifyError(eventResult.error)}`)
const eventResult = WebhookEventSchema.safeParse(JSON.parse(event))

return eventResult.success ? eventResult.data : undefined
}

const displayWebhookEvent = (event: WebhookEvent, options: { json: boolean | undefined }) => {
if (options.json) {
printJson(event)
log('\n')
return
}

return eventResult.data
log(`${dim(reformatDate(event.created_at))} --> ${bold(event.type)} [${event.id}]`)
}

const colorStatusCode = (statusCode: number) => {
if (statusCode >= 200 && statusCode < 300) {
return green(String(statusCode))
}
if (statusCode >= 400) {
return red(String(statusCode))
}
return yellow(String(statusCode))
}

const displayForwardResult = (
result: {
timestamp: string
statusCode: number
method: string
url: string
event: string
},
options: { json: boolean | undefined },
) => {
const timestamp = dim(reformatDate(result.timestamp))
const code = bold(colorStatusCode(result.statusCode))

log(`${timestamp} <-- [${code}] ${result.method} ${result.url} [${result.event}]`)

if (options.json) {
log('\n')
}
}

export const handleWebhookEvent: WebhookRelayHandler = async (message, options) => {
Expand All @@ -56,19 +104,24 @@ export const handleWebhookEvent: WebhookRelayHandler = async (message, options)

const parsed = parseWebhookEvent(result.data.event)

if (
options.events &&
(!('type' in parsed) ||
typeof parsed.type !== 'string' ||
!options.events.includes(parsed.type))
) {
if (!parsed || (options.events && !options.events.includes(parsed.type))) {
return
}

printJson(parsed)
displayWebhookEvent(parsed, { json: options.json })

if (options.forwardTo) {
await forwardWebhookEvent(options.forwardTo, result.data)
const { statusCode, timestamp } = await forwardWebhookEvent(options.forwardTo, result.data)
displayForwardResult(
{
timestamp,
statusCode,
method: 'POST',
url: options.forwardTo,
event: parsed.id,
},
{ json: options.json },
)
}
}

Expand Down
Loading