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