import crypto from 'crypto' import { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' /** * Dispatch a webhook event to all active webhooks subscribed to this event. */ export async function dispatchWebhookEvent( event: string, payload: Record ): Promise { const webhooks = await prisma.webhook.findMany({ where: { isActive: true, events: { has: event }, }, }) if (webhooks.length === 0) return 0 let deliveryCount = 0 for (const webhook of webhooks) { try { const delivery = await prisma.webhookDelivery.create({ data: { webhookId: webhook.id, event, payload: payload as Prisma.InputJsonValue, status: 'PENDING', attempts: 0, }, }) // Attempt delivery asynchronously (don't block the caller) deliverWebhook(delivery.id).catch((err) => { console.error(`[Webhook] Background delivery failed for ${delivery.id}:`, err) }) deliveryCount++ } catch (error) { console.error(`[Webhook] Failed to create delivery for webhook ${webhook.id}:`, error) } } return deliveryCount } /** * Attempt to deliver a single webhook. */ export async function deliverWebhook(deliveryId: string): Promise { const delivery = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId }, include: { webhook: true }, }) if (!delivery || !delivery.webhook) { console.error(`[Webhook] Delivery ${deliveryId} not found`) return } const { webhook } = delivery const payloadStr = JSON.stringify(delivery.payload) // Sign payload with HMAC-SHA256 const signature = crypto .createHmac('sha256', webhook.secret) .update(payloadStr) .digest('hex') // Build headers const headers: Record = { 'Content-Type': 'application/json', 'X-Webhook-Signature': `sha256=${signature}`, 'X-Webhook-Event': delivery.event, 'X-Webhook-Delivery': delivery.id, } // Merge custom headers from webhook config if (webhook.headers && typeof webhook.headers === 'object') { const customHeaders = webhook.headers as Record for (const [key, value] of Object.entries(customHeaders)) { if (typeof value === 'string') { headers[key] = value } } } try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 30000) // 30s timeout const response = await fetch(webhook.url, { method: 'POST', headers, body: payloadStr, signal: controller.signal, }) clearTimeout(timeout) const responseBody = await response.text().catch(() => '') await prisma.webhookDelivery.update({ where: { id: deliveryId }, data: { status: response.ok ? 'DELIVERED' : 'FAILED', responseStatus: response.status, responseBody: responseBody.slice(0, 4000), // Truncate long responses attempts: delivery.attempts + 1, lastAttemptAt: new Date(), }, }) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' await prisma.webhookDelivery.update({ where: { id: deliveryId }, data: { status: 'FAILED', responseBody: errorMessage.slice(0, 4000), attempts: delivery.attempts + 1, lastAttemptAt: new Date(), }, }) } } /** * Retry all failed webhook deliveries that haven't exceeded max retries. * Called by cron. */ export async function retryFailedDeliveries(): Promise<{ retried: number errors: number }> { let retried = 0 let errors = 0 const failedDeliveries = await prisma.webhookDelivery.findMany({ where: { status: 'FAILED', }, include: { webhook: { select: { maxRetries: true, isActive: true }, }, }, }) for (const delivery of failedDeliveries) { // Skip if webhook is inactive or max retries exceeded if (!delivery.webhook.isActive) continue if (delivery.attempts >= delivery.webhook.maxRetries) continue try { await deliverWebhook(delivery.id) retried++ } catch (error) { console.error(`[Webhook] Retry failed for delivery ${delivery.id}:`, error) errors++ } } return { retried, errors } } /** * Generate a random HMAC secret for webhook signing. */ export function generateWebhookSecret(): string { return crypto.randomBytes(32).toString('hex') }