From 89ce3bf5e98050842b9575aca9108e4a4cf5e3af Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jun 2026 19:48:04 +0100 Subject: [PATCH] feat: comprehensive webhook notification system for event-driven integrations - Add delivery analytics (success rate, avg latency, avg payload size) - Add per-endpoint rate limiting with configurable quotas - Add webhook testing endpoint with sample payloads (payment.completed/failed/disputed) - Add GET /analytics, POST /test, GET /rate-limits routes - Update frontend webhooks page with analytics dashboard and Send Test dialog - Update frontend API client with analytics, test, and rate-limit methods Closes #337 --- backend/src/routes/webhooks.ts | 47 ++++- backend/src/services/webhooks.ts | 246 +++++++++++++++++++++++ frontend/app/dashboard/webhooks/page.tsx | 116 ++++++++++- frontend/lib/api.ts | 49 +++++ 4 files changed, 454 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index b4ab6a99..5dc57b2a 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -15,15 +15,19 @@ import { WebhookProvider, } from '../services/webhooks/verification.js'; import { getWebhookAuditLog } from '../services/webhooks/audit.js'; -// Webhook delivery services +// Webhook delivery tracking import { enqueueWebhookEvent, getWebhookDelivery, + getEndpointRateLimits, + getSamplePayloads, + getWebhookAnalytics, listDeadLetterQueue, listWebhookConfigs, listWebhookDeliveries, retryWebhookDeliveryManually, rotateWebhookSecret, + sendWebhookTest, startWebhookWorker, upsertWebhookConfig, } from '../services/webhooks.js'; @@ -230,4 +234,45 @@ webhooksRouter.get( asyncHandler(async (_req, res) => { res.json({ data: listDeadLetterQueue() }); }) +); + +// Webhook analytics +webhooksRouter.get( + '/analytics', + asyncHandler(async (_req, res) => { + res.json(getWebhookAnalytics()); + }) +); + +// Webhook testing endpoint +webhooksRouter.get( + '/test/payloads', + asyncHandler(async (_req, res) => { + res.json({ samplePayloads: getSamplePayloads() }); + }) +); + +const webhookTestSchema = z.object({ + merchantId: z.string().min(1), + eventType: z.enum(['payment.completed', 'payment.failed', 'payment.disputed']).optional(), +}); + +webhooksRouter.post( + '/test', + validate(webhookTestSchema), + asyncHandler(async (req, res) => { + const result = await sendWebhookTest(req.body); + if (!result.success) { + throw new AppError(400, result.error ?? 'Webhook test failed', 'WEBHOOK_TEST_FAILED'); + } + res.json(result); + }) +); + +// Per-endpoint rate limit status +webhooksRouter.get( + '/rate-limits', + asyncHandler(async (_req, res) => { + res.json({ data: getEndpointRateLimits() }); + }) ); \ No newline at end of file diff --git a/backend/src/services/webhooks.ts b/backend/src/services/webhooks.ts index b283996f..bb43606e 100644 --- a/backend/src/services/webhooks.ts +++ b/backend/src/services/webhooks.ts @@ -134,6 +134,12 @@ export function enqueueWebhookEvent(input: { ); if (!config) return { accepted: false, reason: 'No enabled webhook config for merchant' }; + // Per-endpoint rate limiting + const rateCheck = checkEndpointRateLimit(config.url); + if (!rateCheck.allowed) { + return { accepted: false, reason: `Rate limit exceeded for endpoint. Retry in ${Math.ceil(rateCheck.info.resetInMs / 1000)}s` }; + } + const eventId = `whev_${randomUUID()}`; const event: PaymentWebhookEvent = { eventId, @@ -288,3 +294,243 @@ export function retryWebhookDeliveryManually(id: string): WebhookDeliveryLog | u export function listDeadLetterQueue(): WebhookDeliveryLog[] { return [...deadLetterQueue]; } + +// ── Delivery Analytics ──────────────────────────────────────────────────────── + +export interface WebhookAnalytics { + totalDeliveries: number; + delivered: number; + failed: number; + pending: number; + deadLetter: number; + successRate: number; + avgLatencyMs: number; + avgPayloadSizeBytes: number; + byStatus: Record; + recentDeliveries: Array<{ + id: string; + status: WebhookDeliveryStatus; + attempt: number; + statusCode?: number; + latencyMs?: number; + payloadSizeBytes: number; + createdAt: string; + }>; +} + +export function getWebhookAnalytics(): WebhookAnalytics { + const all = Array.from(deliveries.values()); + const byStatus: Record = {}; + let deliveredCount = 0; + let failedCount = 0; + let pendingCount = 0; + let deadLetterCount = 0; + let totalLatency = 0; + let latencySamples = 0; + let totalPayloadSize = 0; + + for (const d of all) { + byStatus[d.status] = (byStatus[d.status] ?? 0) + 1; + const payloadSize = (d.responseBody ?? '').length; + totalPayloadSize += payloadSize; + + switch (d.status) { + case 'delivered': + deliveredCount++; + if (d.deliveredAt && d.createdAt) { + const latency = new Date(d.deliveredAt).getTime() - new Date(d.createdAt).getTime(); + totalLatency += latency; + latencySamples++; + } + break; + case 'failed': + case 'dead_letter': + failedCount++; + if (d.status === 'dead_letter') deadLetterCount++; + break; + case 'pending': + case 'processing': + case 'retrying': + pendingCount++; + break; + } + } + + const total = all.length; + const successRate = total > 0 ? (deliveredCount / total) * 100 : 0; + const avgLatencyMs = latencySamples > 0 ? totalLatency / latencySamples : 0; + const avgPayloadSizeBytes = total > 0 ? totalPayloadSize / total : 0; + + const recentDeliveries = all + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 50) + .map((d) => { + let latencyMs: number | undefined; + if (d.deliveredAt && d.createdAt) { + latencyMs = new Date(d.deliveredAt).getTime() - new Date(d.createdAt).getTime(); + } + return { + id: d.id, + status: d.status, + attempt: d.attempt, + statusCode: d.statusCode, + latencyMs, + payloadSizeBytes: (d.responseBody ?? '').length, + createdAt: d.createdAt, + }; + }); + + return { + totalDeliveries: total, + delivered: deliveredCount, + failed: failedCount, + pending: pendingCount, + deadLetter: deadLetterCount, + successRate: Math.round(successRate * 100) / 100, + avgLatencyMs: Math.round(avgLatencyMs), + avgPayloadSizeBytes: Math.round(avgPayloadSizeBytes), + byStatus, + recentDeliveries, + }; +} + +// ── Per-endpoint Rate Limiting ──────────────────────────────────────────────── + +const ENDPOINT_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute +const ENDPOINT_RATE_LIMIT_MAX = 60; // 60 events per minute per endpoint + +interface EndpointRateLimitState { + count: number; + windowStart: number; +} + +const endpointRateLimits = new Map(); + +export interface EndpointRateLimitInfo { + url: string; + count: number; + limit: number; + remaining: number; + resetInMs: number; +} + +function checkEndpointRateLimit(configUrl: string): { allowed: boolean; info: EndpointRateLimitInfo } { + const now = Date.now(); + let state = endpointRateLimits.get(configUrl); + + if (!state || now - state.windowStart >= ENDPOINT_RATE_LIMIT_WINDOW_MS) { + state = { count: 0, windowStart: now }; + endpointRateLimits.set(configUrl, state); + } + + state.count++; + const remaining = Math.max(0, ENDPOINT_RATE_LIMIT_MAX - state.count); + const resetInMs = ENDPOINT_RATE_LIMIT_WINDOW_MS - (now - state.windowStart); + + return { + allowed: state.count <= ENDPOINT_RATE_LIMIT_MAX, + info: { + url: configUrl, + count: state.count, + limit: ENDPOINT_RATE_LIMIT_MAX, + remaining, + resetInMs, + }, + }; +} + +export function getEndpointRateLimits(): EndpointRateLimitInfo[] { + const now = Date.now(); + const results: EndpointRateLimitInfo[] = []; + for (const [url, state] of endpointRateLimits) { + const elapsed = now - state.windowStart; + if (elapsed < ENDPOINT_RATE_LIMIT_WINDOW_MS) { + results.push({ + url, + count: state.count, + limit: ENDPOINT_RATE_LIMIT_MAX, + remaining: Math.max(0, ENDPOINT_RATE_LIMIT_MAX - state.count), + resetInMs: ENDPOINT_RATE_LIMIT_WINDOW_MS - elapsed, + }); + } + } + return results; +} + +// ── Webhook Test Endpoint ───────────────────────────────────────────────────── + +const SAMPLE_PAYLOADS: Record> = { + 'payment.completed': { + event: 'payment.completed', + paymentId: 'pay_test_001', + amount: '100.00', + currency: 'USD', + recipient: 'merchant_abc', + status: 'completed', + timestamp: new Date().toISOString(), + }, + 'payment.failed': { + event: 'payment.failed', + paymentId: 'pay_test_002', + amount: '50.00', + currency: 'USD', + recipient: 'merchant_abc', + status: 'failed', + reason: 'Insufficient funds', + timestamp: new Date().toISOString(), + }, + 'payment.disputed': { + event: 'payment.disputed', + paymentId: 'pay_test_003', + amount: '75.00', + currency: 'USD', + recipient: 'merchant_abc', + status: 'disputed', + disputeId: 'dsp_test_001', + reason: 'Unauthorized transaction', + timestamp: new Date().toISOString(), + }, +}; + +export function getSamplePayloads(): Record> { + return { ...SAMPLE_PAYLOADS }; +} + +export async function sendWebhookTest(input: { + merchantId: string; + eventType?: string; +}): Promise<{ success: boolean; statusCode?: number; responseBody?: string; error?: string }> { + const config = Array.from(webhookConfigs.values()).find( + (x) => x.merchantId === input.merchantId && x.enabled + ); + if (!config) { + return { success: false, error: 'No enabled webhook config for merchant' }; + } + + const eventType = input.eventType ?? 'payment.completed'; + const samplePayload = SAMPLE_PAYLOADS[eventType] ?? SAMPLE_PAYLOADS['payment.completed']; + const body = JSON.stringify({ test: true, ...samplePayload }); + const signature = buildSignature(config.currentSecret, body); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ATTEMPT_TIMEOUT_MS); + const response = await fetch(config.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signature, + 'X-Webhook-Test': 'true', + 'X-Webhook-Event-Id': `test_${randomUUID()}`, + }, + body, + signal: controller.signal, + }); + clearTimeout(timeout); + const responseText = await response.text().catch(() => ''); + return { success: response.ok, statusCode: response.status, responseBody: responseText }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } +} diff --git a/frontend/app/dashboard/webhooks/page.tsx b/frontend/app/dashboard/webhooks/page.tsx index 93f3a176..52f9c38d 100644 --- a/frontend/app/dashboard/webhooks/page.tsx +++ b/frontend/app/dashboard/webhooks/page.tsx @@ -11,26 +11,32 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Badge } from '@/components/ui/badge'; import { api } from '@/lib/api'; import { toast } from 'sonner'; -import { WebhookSecret, WebhookEvent } from '@/lib/api'; -import { Key, RotateCcw, Trash2, RefreshCw, CheckCircle, XCircle, Clock, AlertTriangle } from 'lucide-react'; +import { WebhookSecret, WebhookEvent, WebhookAnalytics } from '@/lib/api'; +import { Key, RotateCcw, Trash2, RefreshCw, CheckCircle, XCircle, Clock, AlertTriangle, BarChart3, Send } from 'lucide-react'; export default function DashboardWebhooksPage() { const [secrets, setSecrets] = useState([]); const [events, setEvents] = useState([]); + const [analytics, setAnalytics] = useState(null); const [loading, setLoading] = useState(true); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [rotateDialogOpen, setRotateDialogOpen] = useState(false); + const [testDialogOpen, setTestDialogOpen] = useState(false); const [selectedProvider, setSelectedProvider] = useState(''); + const [testEventType, setTestEventType] = useState('payment.completed'); + const [testMerchantId, setTestMerchantId] = useState(''); const loadData = async () => { try { setLoading(true); - const [secretsResponse, eventsResponse] = await Promise.all([ + const [secretsResponse, eventsResponse, analyticsData] = await Promise.all([ api.webhooks.listSecrets(), api.webhooks.listEvents(50), + api.webhooks.getAnalytics(), ]); setSecrets(secretsResponse.secrets); setEvents(eventsResponse.events); + setAnalytics(analyticsData); } catch (error) { console.error(error); toast.error('Failed to load webhook data'); @@ -116,6 +122,26 @@ export default function DashboardWebhooksPage() { } }; + const handleSendTest = async () => { + if (!testMerchantId.trim()) { + toast.error('Please enter a merchant ID'); + return; + } + try { + const result = await api.webhooks.sendTest({ merchantId: testMerchantId, eventType: testEventType }); + if (result.success) { + toast.success(`Test webhook delivered (HTTP ${result.statusCode})`); + } else { + toast.error(`Test failed: ${result.error ?? 'Unknown error'}`); + } + setTestDialogOpen(false); + loadData(); + } catch (error) { + console.error(error); + toast.error('Failed to send test webhook'); + } + }; + const getStatusBadge = (event: WebhookEvent) => { if (event.processed) { return Processed; @@ -158,6 +184,47 @@ export default function DashboardWebhooksPage() {
Webhook Secrets
+ + + + + + + Send Test Webhook + +
+
+ + setTestMerchantId(e.target.value)} + placeholder="Enter merchant ID" + /> +
+
+ + +
+
+ + +
+
+
+