Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion backend/src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() });
})
);
246 changes: 246 additions & 0 deletions backend/src/services/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, number>;
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<string, number> = {};
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<string, EndpointRateLimitState>();

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<string, Record<string, unknown>> = {
'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<string, Record<string, unknown>> {
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 };
}
}
Loading