Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/stripe

src/webhooks.ts

282 lines · 8.4 KB · TypeScript
import {
	DASHBOARD_WEBHOOKS_URL,
	DEFAULT_WEBHOOK_SECRET,
	parseAction,
	resolveWebhookSecretName,
	stripeDate,
	stripeList,
	stripeRequest,
	webhookSecretSetupUrl,
	type StripeAuthOptions,
	type StripeObject,
} from './stripe-core.ts'

export const DEFAULT_SIGNATURE_TOLERANCE_SECONDS = 300

export class StripeSignatureError extends Error {
	code: string
	setupUrl: string

	constructor(message: string, code: string, setupUrl: string) {
		super(message)
		this.name = 'StripeSignatureError'
		this.code = code
		this.setupUrl = setupUrl
	}
}

function timingSafeEqual(left: string, right: string): boolean {
	if (left.length !== right.length) return false
	let diff = 0
	for (let i = 0; i < left.length; i += 1) {
		diff |= left.charCodeAt(i) ^ right.charCodeAt(i)
	}
	return diff === 0
}

export async function hmacSha256Hex(secret: string, payload: string): Promise<string> {
	const key = await crypto.subtle.importKey(
		'raw',
		new TextEncoder().encode(secret),
		{ name: 'HMAC', hash: 'SHA-256' },
		false,
		['sign'],
	)
	const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
	return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}

export function parseStripeSignatureHeader(header: string): { timestamp: number; signatures: string[] } {
	const timestampPart = header
		.split(',')
		.map((part) => part.trim())
		.find((part) => part.startsWith('t='))
	const signatures = header
		.split(',')
		.map((part) => part.trim())
		.filter((part) => part.startsWith('v1='))
		.map((part) => part.slice(3))
	const timestamp = timestampPart ? Number(timestampPart.slice(2)) : NaN
	if (!Number.isFinite(timestamp) || signatures.length === 0) {
		throw new StripeSignatureError(
			'Stripe-Signature header must include t= and at least one v1= value.',
			'invalid_header',
			WEBHOOK_DOCS_NEXT_STEP,
		)
	}
	return { timestamp, signatures }
}

const WEBHOOK_DOCS_NEXT_STEP =
	'Save the endpoint signing secret (whsec_...) at ' +
	webhookSecretSetupUrl() +
	' (from ' +
	DASHBOARD_WEBHOOKS_URL +
	').'

/**
 * Build a Stripe-Signature header for tests. Do not use live secrets in chat.
 */
export async function createTestSignatureHeader(input: {
	payload: string
	webhookSecret: string
	timestamp?: number
}): Promise<string> {
	const timestamp = input.timestamp ?? Math.floor(Date.now() / 1000)
	const digest = await hmacSha256Hex(input.webhookSecret, timestamp + '.' + input.payload)
	return 't=' + timestamp + ',v1=' + digest
}

export type VerifyWebhookSignatureInput = StripeAuthOptions & {
	/** Raw request body string. Must match the bytes Stripe signed. */
	payload: string
	/** Value of the Stripe-Signature header. */
	signatureHeader: string
	/**
	 * Endpoint signing secret (`whsec_...`). Required for HMAC verify.
	 * Do not paste live secrets in chat; use this from a trusted webhook
	 * handler, or call `loadVerifiedEvent` instead.
	 */
	webhookSecret: string
	/** Reject timestamps older/newer than this many seconds. Default 300. */
	toleranceSeconds?: number
	/** Override "now" for tests (unix seconds). */
	nowSeconds?: number
}

/**
 * Verify a Stripe webhook HMAC signature (t=, v1=) without calling Stripe.
 *
 * This is Stripe's documented scheme: HMAC-SHA256 of `${timestamp}.${payload}`
 * with the endpoint signing secret. It is not Kody's generic body HMAC.
 */
export async function verifyWebhookSignature(input: VerifyWebhookSignatureInput) {
	const secret = input.webhookSecret?.trim()
	if (!secret) {
		const name = resolveWebhookSecretName(input)
		throw new StripeSignatureError(
			'webhookSecret is required for HMAC verification. Save `' +
				name +
				'` at ' +
				webhookSecretSetupUrl(name) +
				', or call loadVerifiedEvent({ eventId }) to re-fetch the event through stripeApiKey instead.',
			'missing_webhook_secret',
			webhookSecretSetupUrl(name),
		)
	}
	if (typeof input.payload !== 'string' || input.payload.length === 0) {
		throw new StripeSignatureError(
			'payload must be the raw webhook body string Stripe signed.',
			'invalid_payload',
			webhookSecretSetupUrl(),
		)
	}
	if (typeof input.signatureHeader !== 'string' || input.signatureHeader.length === 0) {
		throw new StripeSignatureError(
			'signatureHeader must be the Stripe-Signature request header.',
			'invalid_header',
			webhookSecretSetupUrl(),
		)
	}

	const { timestamp, signatures } = parseStripeSignatureHeader(input.signatureHeader)
	const tolerance = input.toleranceSeconds ?? DEFAULT_SIGNATURE_TOLERANCE_SECONDS
	const now = input.nowSeconds ?? Math.floor(Date.now() / 1000)
	if (Math.abs(now - timestamp) > tolerance) {
		throw new StripeSignatureError(
			'Stripe webhook timestamp is outside the ' +
				tolerance +
				's tolerance. Replay the event or check clock skew.',
			'timestamp_out_of_tolerance',
			DASHBOARD_WEBHOOKS_URL,
		)
	}

	const expected = await hmacSha256Hex(secret, timestamp + '.' + input.payload)
	const matched = signatures.some((candidate) => timingSafeEqual(candidate, expected))
	if (!matched) {
		throw new StripeSignatureError(
			'Stripe webhook signature did not match. Confirm the signing secret is the endpoint `whsec_...` from ' +
				DASHBOARD_WEBHOOKS_URL +
				' and that payload is the raw body. Save it as `' +
				DEFAULT_WEBHOOK_SECRET +
				'` at ' +
				webhookSecretSetupUrl() +
				'.',
			'signature_mismatch',
			webhookSecretSetupUrl(),
		)
	}

	let event: StripeObject
	try {
		event = JSON.parse(input.payload) as StripeObject
	} catch {
		throw new StripeSignatureError(
			'Stripe webhook payload is not valid JSON after a matching signature.',
			'invalid_json',
			DASHBOARD_WEBHOOKS_URL,
		)
	}

	return {
		ok: true as const,
		verifiedVia: 'signature' as const,
		timestamp,
		eventId: typeof event.id === 'string' ? event.id : null,
		type: typeof event.type === 'string' ? event.type : null,
		livemode: Boolean(event.livemode),
		event,
	}
}

export type LoadVerifiedEventInput = StripeAuthOptions & {
	eventId: string
}

/**
 * Re-fetch a Stripe event by id with stripeApiKey. Use this when the inbound
 * handler should not hold `stripeWebhookSecret` — Stripe is the source of truth.
 */
export async function loadVerifiedEvent(input: LoadVerifiedEventInput) {
	const event = await stripeRequest({
		path: 'events/' + input.eventId,
		account: input.account,
		secretName: input.secretName,
	})
	return {
		ok: true as const,
		verifiedVia: 'api' as const,
		eventId: event.id ?? input.eventId,
		type: event.type ?? null,
		created: stripeDate(event.created),
		livemode: Boolean(event.livemode),
		objectId: event.data?.object?.id ?? null,
		objectType: event.data?.object?.object ?? null,
		event,
	}
}

export type ListWebhookEndpointsInput = StripeAuthOptions & {
	maxItems?: number
}

/** List Stripe webhook endpoints configured on the account. */
export async function listWebhookEndpoints(input: ListWebhookEndpointsInput = {}) {
	const { items, hasMore } = await stripeList('webhook_endpoints', {
		account: input.account,
		secretName: input.secretName,
		maxItems: input.maxItems ?? 25,
	})
	return {
		hasMore,
		endpoints: items.map((endpoint) => ({
			id: endpoint.id ?? null,
			url: endpoint.url ?? null,
			status: endpoint.status ?? null,
			enabledEvents: endpoint.enabled_events ?? [],
			livemode: Boolean(endpoint.livemode),
			created: stripeDate(endpoint.created),
		})),
		setup: {
			dashboard: DASHBOARD_WEBHOOKS_URL,
			signingSecret: webhookSecretSetupUrl(resolveWebhookSecretName(input)),
		},
	}
}

export async function getWebhookEndpoint(input: StripeAuthOptions & { webhookEndpointId: string }) {
	return await stripeRequest({
		path: 'webhook_endpoints/' + input.webhookEndpointId,
		account: input.account,
		secretName: input.secretName,
	})
}

const webhookActions = [
	'verify-signature',
	'load-event',
	'list-endpoints',
	'get-endpoint',
] as const

/**
 * Webhooks dispatcher. Defaults to list-endpoints.
 */
export default async function webhooks(input: Record<string, unknown> = {}) {
	const action = parseAction(input.action, webhookActions, 'list-endpoints', 'webhooks')
	switch (action) {
		case 'verify-signature':
			return await verifyWebhookSignature(input as never)
		case 'load-event':
			return await loadVerifiedEvent(input as never)
		case 'list-endpoints':
			return await listWebhookEndpoints(input as ListWebhookEndpointsInput)
		case 'get-endpoint':
			return await getWebhookEndpoint(input as never)
		default: {
			const exhaustive: never = action
			throw new Error('Unhandled webhooks action: ' + String(exhaustive))
		}
	}
}