From 6977c62e32b8dd812250d7ce3329e98edc3985af Mon Sep 17 00:00:00 2001 From: Heazzy500 Date: Sun, 21 Jun 2026 15:42:27 +0100 Subject: [PATCH] feat: add webhook endpoints for external trigger integrations - Add webhook service with file and database storage - Add HMAC signature verification for secure webhook delivery - Add endpoints: POST/GET/DELETE /api/webhooks, PATCH /api/webhooks/:id - Add /api/webhooks/events to list valid event types - Add /api/webhooks/:id/test to send test events - Add /api/webhooks/trigger for manual event triggering - Add 22 comprehensive tests for webhook functionality - Support 7 event types: action.login/interact/dm/post/error, schedule.completed/failed --- src/routes/api.ts | 172 +++++++++++++++ src/services/webhooks.test.ts | 283 ++++++++++++++++++++++++ src/services/webhooks.ts | 398 ++++++++++++++++++++++++++++++++++ 3 files changed, 853 insertions(+) create mode 100644 src/services/webhooks.test.ts create mode 100644 src/services/webhooks.ts diff --git a/src/routes/api.ts b/src/routes/api.ts index 50ec23c..db0c022 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -23,6 +23,16 @@ import fs from 'fs/promises'; import path from 'path'; import { getAccount, getAccountsMap } from '../config/accounts'; import { getActionSummary, listActionLogs, logAction } from '../services/actionLog'; +import { + createWebhook, + listWebhooks, + deleteWebhook, + getValidEvents, + isValidEvent, + triggerWebhooks, + updateWebhookStatus, + WebhookEvent, +} from '../services/webhooks'; import { loginLimiter, actionLimiter, @@ -742,6 +752,168 @@ router.get('/actions/summary', async (req: Request, res: Response) => { } }); +// Webhook endpoints - for external trigger integrations (ROADMAP: Webhook endpoints for external triggers) + +// Get available webhook events +router.get('/webhooks/events', (_req: Request, res: Response) => { + return res.json({ events: getValidEvents() }); +}); + +// Register a new webhook +router.post('/webhooks', async (req: Request, res: Response) => { + try { + const { url, events } = req.body; + if (!url || !events || !Array.isArray(events)) { + return res.status(400).json({ error: 'url and events array are required' }); + } + const account = (req as any).user.account || 'default'; + const webhook = await createWebhook({ url, events, account }); + await logAction({ + platform: 'system', + action: 'webhook-create', + status: 'success', + account, + username: (req as any).user.username, + details: { webhookId: webhook.id, url, events }, + }); + return res.status(201).json({ + id: webhook.id, + url: webhook.url, + events: webhook.events, + secret: webhook.secret, + status: webhook.status, + createdAt: webhook.createdAt, + message: 'Store the secret securely. It will not be shown again.', + }); + } catch (error) { + logger.error('Webhook create error:', error); + return res.status(400).json({ error: getErrorMessage(error) }); + } +}); + +// List webhooks for the current account +router.get('/webhooks', async (req: Request, res: Response) => { + try { + const account = (req as any).user.account || 'default'; + const showAll = req.query.all === '1' || req.query.all === 'true'; + const webhooks = await listWebhooks(showAll ? undefined : account); + return res.json({ webhooks }); + } catch (error) { + logger.error('Webhook list error:', error); + return res.status(500).json({ error: 'Failed to list webhooks' }); + } +}); + +// Delete a webhook +router.delete('/webhooks/:id', async (req: Request, res: Response) => { + try { + const account = (req as any).user.account || 'default'; + const id = String(req.params.id); + const deleted = await deleteWebhook(id, account); + if (!deleted) { + return res.status(404).json({ error: 'Webhook not found' }); + } + await logAction({ + platform: 'system', + action: 'webhook-delete', + status: 'success', + account, + username: (req as any).user.username, + details: { webhookId: id }, + }); + return res.json({ success: true, id }); + } catch (error) { + logger.error('Webhook delete error:', error); + return res.status(500).json({ error: 'Failed to delete webhook' }); + } +}); + +// Update webhook status (pause/resume) +router.patch('/webhooks/:id', async (req: Request, res: Response) => { + try { + const id = String(req.params.id); + const { status } = req.body; + if (!status || !['active', 'paused'].includes(status)) { + return res.status(400).json({ error: 'status must be "active" or "paused"' }); + } + const account = (req as any).user.account || 'default'; + const updated = await updateWebhookStatus(id, status); + if (!updated) { + return res.status(404).json({ error: 'Webhook not found' }); + } + await logAction({ + platform: 'system', + action: 'webhook-update', + status: 'success', + account, + username: (req as any).user.username, + details: { webhookId: id, newStatus: status }, + }); + return res.json({ success: true, id, status }); + } catch (error) { + logger.error('Webhook update error:', error); + return res.status(500).json({ error: 'Failed to update webhook' }); + } +}); + +// Test webhook by sending a test event +router.post('/webhooks/:id/test', async (req: Request, res: Response) => { + try { + const id = String(req.params.id); + const account = (req as any).user.account || 'default'; + const event: WebhookEvent = 'action.login'; + const result = await triggerWebhooks( + event, + { + test: true, + message: 'This is a test webhook event', + triggeredBy: (req as any).user.username, + webhookId: id, + }, + account, + ); + return res.json({ + success: true, + result, + message: `Test event sent. ${result.sent} webhook(s) received the event.`, + }); + } catch (error) { + logger.error('Webhook test error:', error); + return res.status(500).json({ error: 'Failed to test webhook' }); + } +}); + +// Manual trigger endpoint - allows external systems to trigger internal actions +router.post('/webhooks/trigger', async (req: Request, res: Response) => { + try { + const { event, data } = req.body; + if (!event || !isValidEvent(event)) { + return res.status(400).json({ + error: `Invalid event. Valid events: ${getValidEvents().join(', ')}`, + }); + } + const account = (req as any).user.account || 'default'; + const result = await triggerWebhooks(event as WebhookEvent, data || {}, account); + await logAction({ + platform: 'system', + action: 'webhook-trigger', + status: 'success', + account, + username: (req as any).user.username, + details: { event, sent: result.sent, failed: result.failed }, + }); + return res.json({ + success: true, + event, + sent: result.sent, + failed: result.failed, + }); + } catch (error) { + logger.error('Webhook trigger error:', error); + return res.status(500).json({ error: 'Failed to trigger webhooks' }); + } +}); + // Exit endpoint router.post('/exit-interactions', async (_req: Request, res: Response) => { const { setShouldExitInteractions } = await import('../api/agent'); diff --git a/src/services/webhooks.test.ts b/src/services/webhooks.test.ts new file mode 100644 index 0000000..530307e --- /dev/null +++ b/src/services/webhooks.test.ts @@ -0,0 +1,283 @@ +import crypto from 'crypto'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { closeDB } from '../config/db'; +import { + createWebhook, + listWebhooks, + deleteWebhook, + getWebhook, + updateWebhookStatus, + signPayload, + verifySignature, + isValidEvent, + validateWebhookUrl, + getValidEvents, +} from './webhooks'; + +describe('webhook service', () => { + const originalPath = process.env.WEBHOOKS_PATH; + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'riona-webhooks-')); + process.env.WEBHOOKS_PATH = path.join(tempDir, 'webhooks.json'); + }); + + afterEach(async () => { + process.env.WEBHOOKS_PATH = originalPath; + await closeDB(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('createWebhook', () => { + test('creates a webhook with valid input', async () => { + const webhook = await createWebhook({ + url: 'https://example.com/webhook', + events: ['action.login', 'action.error'], + account: 'test-account', + }); + + expect(webhook.id).toMatch(/^wh_/); + expect(webhook.url).toBe('https://example.com/webhook'); + expect(webhook.events).toEqual(['action.login', 'action.error']); + expect(webhook.secret).toMatch(/^whsec_/); + expect(webhook.status).toBe('active'); + expect(webhook.account).toBe('test-account'); + expect(webhook.failureCount).toBe(0); + }); + + test('rejects invalid URL', async () => { + await expect( + createWebhook({ + url: 'not-a-valid-url', + events: ['action.login'], + }), + ).rejects.toThrow('Invalid webhook URL'); + }); + + test('rejects invalid events', async () => { + await expect( + createWebhook({ + url: 'https://example.com/webhook', + events: ['invalid.event' as any], + }), + ).rejects.toThrow('Invalid events'); + }); + + test('rejects empty events array', async () => { + await expect( + createWebhook({ + url: 'https://example.com/webhook', + events: [], + }), + ).rejects.toThrow('At least one event must be specified'); + }); + + test('uses default account when not specified', async () => { + const webhook = await createWebhook({ + url: 'https://example.com/webhook', + events: ['action.login'], + }); + + expect(webhook.account).toBe('default'); + }); + }); + + describe('listWebhooks', () => { + test('lists all webhooks', async () => { + await createWebhook({ + url: 'https://example.com/webhook1', + events: ['action.login'], + account: 'account1', + }); + await createWebhook({ + url: 'https://example.com/webhook2', + events: ['action.error'], + account: 'account2', + }); + + const webhooks = await listWebhooks(); + expect(webhooks).toHaveLength(2); + }); + + test('filters by account', async () => { + await createWebhook({ + url: 'https://example.com/webhook1', + events: ['action.login'], + account: 'account1', + }); + await createWebhook({ + url: 'https://example.com/webhook2', + events: ['action.error'], + account: 'account2', + }); + + const webhooks = await listWebhooks('account1'); + expect(webhooks).toHaveLength(1); + expect(webhooks[0].account).toBe('account1'); + }); + + test('does not expose secrets', async () => { + await createWebhook({ + url: 'https://example.com/webhook', + events: ['action.login'], + }); + + const webhooks = await listWebhooks(); + expect(webhooks[0]).not.toHaveProperty('secret'); + }); + }); + + describe('getWebhook', () => { + test('retrieves webhook by id', async () => { + const created = await createWebhook({ + url: 'https://example.com/webhook', + events: ['action.login'], + }); + + const webhook = await getWebhook(created.id); + expect(webhook).not.toBeNull(); + expect(webhook?.id).toBe(created.id); + expect(webhook?.secret).toBe(created.secret); + }); + + test('returns null for non-existent id', async () => { + const webhook = await getWebhook('non-existent-id'); + expect(webhook).toBeNull(); + }); + }); + + describe('deleteWebhook', () => { + test('deletes webhook by id', async () => { + const webhook = await createWebhook({ + url: 'https://example.com/webhook', + events: ['action.login'], + }); + + const deleted = await deleteWebhook(webhook.id); + expect(deleted).toBe(true); + + const found = await getWebhook(webhook.id); + expect(found).toBeNull(); + }); + + test('returns false for non-existent id', async () => { + const deleted = await deleteWebhook('non-existent-id'); + expect(deleted).toBe(false); + }); + + test('respects account filter', async () => { + const webhook = await createWebhook({ + url: 'https://example.com/webhook', + events: ['action.login'], + account: 'account1', + }); + + const deleted = await deleteWebhook(webhook.id, 'account2'); + expect(deleted).toBe(false); + + const found = await getWebhook(webhook.id); + expect(found).not.toBeNull(); + }); + }); + + describe('updateWebhookStatus', () => { + test('updates webhook status', async () => { + const webhook = await createWebhook({ + url: 'https://example.com/webhook', + events: ['action.login'], + }); + + const updated = await updateWebhookStatus(webhook.id, 'paused'); + expect(updated).toBe(true); + + const found = await getWebhook(webhook.id); + expect(found?.status).toBe('paused'); + }); + + test('updates failure count', async () => { + const webhook = await createWebhook({ + url: 'https://example.com/webhook', + events: ['action.login'], + }); + + await updateWebhookStatus(webhook.id, 'active', 3); + const found = await getWebhook(webhook.id); + expect(found?.failureCount).toBe(3); + }); + }); + + describe('signature verification', () => { + test('signs and verifies payload correctly', () => { + const payload = JSON.stringify({ event: 'test', data: {} }); + const secret = 'whsec_testsecret123'; + + const signature = signPayload(payload, secret); + expect(signature).toMatch(/^t=\d+,v1=[a-f0-9]+$/); + + const isValid = verifySignature(payload, signature, secret); + expect(isValid).toBe(true); + }); + + test('rejects invalid signature', () => { + const payload = JSON.stringify({ event: 'test', data: {} }); + const secret = 'whsec_testsecret123'; + + const isValid = verifySignature(payload, 't=12345,v1=invalidsig', secret); + expect(isValid).toBe(false); + }); + + test('rejects expired timestamp', () => { + const payload = JSON.stringify({ event: 'test', data: {} }); + const secret = 'whsec_testsecret123'; + + // Create signature with old timestamp + const oldTimestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago + const signedPayload = `${oldTimestamp}.${payload}`; + const sig = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); + const signature = `t=${oldTimestamp},v1=${sig}`; + + const isValid = verifySignature(payload, signature, secret, 300); + expect(isValid).toBe(false); + }); + + test('rejects malformed signature', () => { + const payload = JSON.stringify({ event: 'test', data: {} }); + const secret = 'whsec_testsecret123'; + + expect(verifySignature(payload, 'malformed', secret)).toBe(false); + expect(verifySignature(payload, 't=12345', secret)).toBe(false); + expect(verifySignature(payload, 'v1=abc', secret)).toBe(false); + }); + }); + + describe('validation helpers', () => { + test('isValidEvent validates correctly', () => { + expect(isValidEvent('action.login')).toBe(true); + expect(isValidEvent('action.interact')).toBe(true); + expect(isValidEvent('action.dm')).toBe(true); + expect(isValidEvent('action.post')).toBe(true); + expect(isValidEvent('action.error')).toBe(true); + expect(isValidEvent('schedule.completed')).toBe(true); + expect(isValidEvent('schedule.failed')).toBe(true); + expect(isValidEvent('invalid.event')).toBe(false); + }); + + test('validateWebhookUrl validates correctly', () => { + expect(validateWebhookUrl('https://example.com')).toBe(true); + expect(validateWebhookUrl('http://localhost:3000')).toBe(true); + expect(validateWebhookUrl('ftp://example.com')).toBe(false); + expect(validateWebhookUrl('not-a-url')).toBe(false); + }); + + test('getValidEvents returns all events', () => { + const events = getValidEvents(); + expect(events).toContain('action.login'); + expect(events).toContain('action.error'); + expect(events).toContain('schedule.completed'); + expect(events.length).toBe(7); + }); + }); +}); diff --git a/src/services/webhooks.ts b/src/services/webhooks.ts new file mode 100644 index 0000000..54b8a5c --- /dev/null +++ b/src/services/webhooks.ts @@ -0,0 +1,398 @@ +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; +import logger from '../config/logger'; +import { getPool, isDbConnected } from '../config/db'; + +export type WebhookEvent = + | 'action.login' + | 'action.interact' + | 'action.dm' + | 'action.post' + | 'action.error' + | 'schedule.completed' + | 'schedule.failed'; + +export type WebhookStatus = 'active' | 'paused' | 'failed'; + +export type WebhookRegistration = { + id: string; + url: string; + events: WebhookEvent[]; + secret: string; + status: WebhookStatus; + account: string; + createdAt: string; + lastTriggeredAt?: string; + failureCount: number; +}; + +export type WebhookPayload = { + event: WebhookEvent; + timestamp: string; + data: Record; +}; + +export type WebhookCreateInput = { + url: string; + events: WebhookEvent[]; + account?: string; +}; + +const VALID_EVENTS: WebhookEvent[] = [ + 'action.login', + 'action.interact', + 'action.dm', + 'action.post', + 'action.error', + 'schedule.completed', + 'schedule.failed', +]; + +const getWebhooksPath = () => + process.env.WEBHOOKS_PATH || path.join(process.cwd(), 'data', 'webhooks.json'); + +const generateId = () => `wh_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`; + +const generateSecret = () => `whsec_${crypto.randomBytes(32).toString('hex')}`; + +export const isValidEvent = (event: string): event is WebhookEvent => + VALID_EVENTS.includes(event as WebhookEvent); + +export const validateWebhookUrl = (url: string): boolean => { + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' || parsed.protocol === 'http:'; + } catch { + return false; + } +}; + +const readFileWebhooks = async (): Promise => { + try { + const raw = await fs.readFile(getWebhooksPath(), 'utf-8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') return []; + logger.warn('Failed to read webhooks file.', error); + return []; + } +}; + +const writeFileWebhooks = async (webhooks: WebhookRegistration[]) => { + const filePath = getWebhooksPath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(webhooks, null, 2)); +}; + +let fileWebhookChain: Promise = Promise.resolve(); + +const withFileWebhookLock = async (fn: () => Promise): Promise => { + const run = fileWebhookChain.then(fn, fn); + fileWebhookChain = run.then( + () => undefined, + () => undefined, + ); + return run; +}; + +export const createWebhook = async (input: WebhookCreateInput): Promise => { + if (!validateWebhookUrl(input.url)) { + throw new Error('Invalid webhook URL. Must be a valid HTTP or HTTPS URL.'); + } + + const invalidEvents = input.events.filter((e) => !isValidEvent(e)); + if (invalidEvents.length > 0) { + throw new Error( + `Invalid events: ${invalidEvents.join(', ')}. Valid events: ${VALID_EVENTS.join(', ')}`, + ); + } + + if (input.events.length === 0) { + throw new Error('At least one event must be specified.'); + } + + const webhook: WebhookRegistration = { + id: generateId(), + url: input.url, + events: input.events, + secret: generateSecret(), + status: 'active', + account: input.account || 'default', + createdAt: new Date().toISOString(), + failureCount: 0, + }; + + if (isDbConnected()) { + const pool = getPool(); + if (pool) { + await pool.query( + `INSERT INTO webhooks (id, url, events, secret, status, account, created_at, failure_count) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + webhook.id, + webhook.url, + JSON.stringify(webhook.events), + webhook.secret, + webhook.status, + webhook.account, + webhook.createdAt, + webhook.failureCount, + ], + ); + return webhook; + } + } + + await withFileWebhookLock(async () => { + const webhooks = await readFileWebhooks(); + webhooks.push(webhook); + await writeFileWebhooks(webhooks); + }); + + return webhook; +}; + +export const listWebhooks = async ( + account?: string, +): Promise[]> => { + let webhooks: WebhookRegistration[]; + + if (isDbConnected()) { + const pool = getPool(); + if (pool) { + const result = await pool.query( + account + ? 'SELECT id, url, events, status, account, created_at, last_triggered_at, failure_count FROM webhooks WHERE account = $1 ORDER BY created_at DESC' + : 'SELECT id, url, events, status, account, created_at, last_triggered_at, failure_count FROM webhooks ORDER BY created_at DESC', + account ? [account] : [], + ); + return result.rows.map((row) => ({ + id: row.id, + url: row.url, + events: typeof row.events === 'string' ? JSON.parse(row.events) : row.events, + status: row.status, + account: row.account, + createdAt: row.created_at, + lastTriggeredAt: row.last_triggered_at || undefined, + failureCount: row.failure_count, + })); + } + } + + webhooks = await readFileWebhooks(); + if (account) { + webhooks = webhooks.filter((w) => w.account === account); + } + + return webhooks.map(({ secret: _secret, ...rest }) => rest); +}; + +export const getWebhook = async (id: string): Promise => { + if (isDbConnected()) { + const pool = getPool(); + if (pool) { + const result = await pool.query('SELECT * FROM webhooks WHERE id = $1', [id]); + if (result.rows.length === 0) return null; + const row = result.rows[0]; + return { + id: row.id, + url: row.url, + events: typeof row.events === 'string' ? JSON.parse(row.events) : row.events, + secret: row.secret, + status: row.status, + account: row.account, + createdAt: row.created_at, + lastTriggeredAt: row.last_triggered_at || undefined, + failureCount: row.failure_count, + }; + } + } + + const webhooks = await readFileWebhooks(); + return webhooks.find((w) => w.id === id) || null; +}; + +export const deleteWebhook = async (id: string, account?: string): Promise => { + if (isDbConnected()) { + const pool = getPool(); + if (pool) { + const result = await pool.query( + account + ? 'DELETE FROM webhooks WHERE id = $1 AND account = $2' + : 'DELETE FROM webhooks WHERE id = $1', + account ? [id, account] : [id], + ); + return (result.rowCount ?? 0) > 0; + } + } + + return withFileWebhookLock(async () => { + const webhooks = await readFileWebhooks(); + const index = webhooks.findIndex((w) => w.id === id && (!account || w.account === account)); + if (index === -1) return false; + webhooks.splice(index, 1); + await writeFileWebhooks(webhooks); + return true; + }); +}; + +export const updateWebhookStatus = async ( + id: string, + status: WebhookStatus, + failureCount?: number, +): Promise => { + if (isDbConnected()) { + const pool = getPool(); + if (pool) { + const result = await pool.query( + failureCount !== undefined + ? 'UPDATE webhooks SET status = $2, failure_count = $3 WHERE id = $1' + : 'UPDATE webhooks SET status = $2 WHERE id = $1', + failureCount !== undefined ? [id, status, failureCount] : [id, status], + ); + return (result.rowCount ?? 0) > 0; + } + } + + return withFileWebhookLock(async () => { + const webhooks = await readFileWebhooks(); + const webhook = webhooks.find((w) => w.id === id); + if (!webhook) return false; + webhook.status = status; + if (failureCount !== undefined) webhook.failureCount = failureCount; + await writeFileWebhooks(webhooks); + return true; + }); +}; + +export const signPayload = (payload: string, secret: string): string => { + const timestamp = Math.floor(Date.now() / 1000); + const signedPayload = `${timestamp}.${payload}`; + const signature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); + return `t=${timestamp},v1=${signature}`; +}; + +export const verifySignature = ( + payload: string, + signature: string, + secret: string, + tolerance = 300, +): boolean => { + const parts = signature.split(','); + const timestampPart = parts.find((p) => p.startsWith('t=')); + const signaturePart = parts.find((p) => p.startsWith('v1=')); + + if (!timestampPart || !signaturePart) return false; + + const timestamp = parseInt(timestampPart.slice(2), 10); + const providedSignature = signaturePart.slice(3); + + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestamp) > tolerance) return false; + + const signedPayload = `${timestamp}.${payload}`; + const expectedSignature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); + + return crypto.timingSafeEqual(Buffer.from(providedSignature), Buffer.from(expectedSignature)); +}; + +export const triggerWebhooks = async ( + event: WebhookEvent, + data: Record, + account?: string, +): Promise<{ sent: number; failed: number }> => { + let webhooks: WebhookRegistration[]; + + if (isDbConnected()) { + const pool = getPool(); + if (pool) { + const result = await pool.query( + account + ? "SELECT * FROM webhooks WHERE status = 'active' AND account = $1" + : "SELECT * FROM webhooks WHERE status = 'active'", + account ? [account] : [], + ); + webhooks = result.rows.map((row) => ({ + id: row.id, + url: row.url, + events: typeof row.events === 'string' ? JSON.parse(row.events) : row.events, + secret: row.secret, + status: row.status, + account: row.account, + createdAt: row.created_at, + lastTriggeredAt: row.last_triggered_at || undefined, + failureCount: row.failure_count, + })); + } else { + webhooks = []; + } + } else { + webhooks = await readFileWebhooks(); + webhooks = webhooks.filter((w) => w.status === 'active'); + if (account) { + webhooks = webhooks.filter((w) => w.account === account); + } + } + + const matching = webhooks.filter((w) => w.events.includes(event)); + let sent = 0; + let failed = 0; + + for (const webhook of matching) { + const payload: WebhookPayload = { + event, + timestamp: new Date().toISOString(), + data, + }; + + const payloadString = JSON.stringify(payload); + const signature = signPayload(payloadString, webhook.secret); + + try { + const response = await fetch(webhook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signature, + 'X-Webhook-Event': event, + 'X-Webhook-ID': webhook.id, + }, + body: payloadString, + signal: AbortSignal.timeout(10000), + }); + + if (response.ok) { + sent++; + await updateWebhookStatus(webhook.id, 'active', 0); + if (isDbConnected()) { + const pool = getPool(); + if (pool) { + await pool.query('UPDATE webhooks SET last_triggered_at = NOW() WHERE id = $1', [ + webhook.id, + ]); + } + } + } else { + failed++; + const newFailureCount = webhook.failureCount + 1; + const newStatus = newFailureCount >= 5 ? 'failed' : 'active'; + await updateWebhookStatus(webhook.id, newStatus, newFailureCount); + logger.warn(`Webhook ${webhook.id} failed with status ${response.status}`); + } + } catch (error) { + failed++; + const newFailureCount = webhook.failureCount + 1; + const newStatus = newFailureCount >= 5 ? 'failed' : 'active'; + await updateWebhookStatus(webhook.id, newStatus, newFailureCount); + logger.warn(`Webhook ${webhook.id} failed:`, error); + } + } + + return { sent, failed }; +}; + +export const getValidEvents = (): WebhookEvent[] => [...VALID_EVENTS];