1
42import { asJson } from "../lib/prisma.js";
43import { QUEUES, enqueue } from "../queues.js";
44import { getLogger } from "../logger.js";
45import type { WorkerEnv } from "../config.js";
46import { StreamConsumer, type StreamEntry } from "../lib/redis-stream.js";
47import { notifyWorkspaceAdmins } from "../lib/notify.js";
48import { tryParseEvent, WEBHOOK_EVENT_CATALOG } from "../events.js";
49import {
50 buildDeliveryEnvelope,
51 classifyDeliveryResult,
52 generateId,
53 newDeliveryId,
54 postWebhookDelivery,
55 resolveSigningSecret,
56 webhookBackoffMs,
57 type DeliveryEnvelopeInput
58} from "@factory/shared";
59import { Worker, type Job } from "bullmq";
60import { withJobOtel } from "../lib/job-otel.js";
61import type { Redis } from "ioredis";
62import { z } from "zod";
63import type { PrismaClient } from "@prisma/client";
64
65const log = getLogger("webhooks");
66
67
68export const WEBHOOK_MAX_ATTEMPTS = 8;
69
70const NOTIFY_THROTTLE_KEY = (webhookId: string) => `webhook:dlq-notified:${webhookId}`;
71const NOTIFY_THROTTLE_SECONDS = 24 * 3600;
72
73
74
75
76
77
78export const webhookJobSchema = z.object({
79 kind: z.literal("row"),
80 webhookId: z.string().min(1),
81 event: z.string().min(1),
82 payload: z.record(z.string(), z.unknown()).default({}),
83 deliveryId: z.string().min(1).optional(),
84
85 eventId: z.string().min(1).optional(),
86
87 occurredAt: z.string().min(1).optional(),
88 test: z.literal(true).optional(),
89
90 originDeliveryId: z.string().min(1).optional(),
91
92 attempt: z.number().int().min(1).optional()
93});
94export type WebhookJobData = z.infer<typeof webhookJobSchema>;
95
96
97
98
99
100export function isWebhookSubscribed(events: string[], event: string): boolean {
101 return events.includes("*") || events.includes(event);
102}
103
104
108function backoffFor(env: WorkerEnv, attempt: number): number {
109 return webhookBackoffMs(attempt, {
110 baseMs: env.WEBHOOK_BACKOFF_BASE_MS,
111 maxMs: env.WEBHOOK_BACKOFF_MAX_MS
112 });
113}
114
115
116
117
118
119export function createWebhooksWorker(opts: {
120 connection: Redis;
121 prisma: PrismaClient;
122 env: WorkerEnv;
123 concurrency?: number;
124 fetchImpl?: typeof fetch;
125}): Worker {
126 const { connection, prisma, env } = opts;
127 const doFetch = opts.fetchImpl ?? fetch;
128
129 return new Worker(
130 QUEUES.webhooks,
131 withJobOtel(QUEUES.webhooks, async (job: Job) => {
132 let data: WebhookJobData;
133 try {
134 data = webhookJobSchema.parse(job.data);
135 } catch (err) {
136 log.error({ jobId: job.id, err: (err as Error).message }, "webhook job unparseable — discarding");
137 job.discard();
138 return;
139 }
140 try {
141 await dispatchRow(doFetch, prisma, connection, env, job, data);
142 } catch (err) {
143
144
145
146
147 log.error(
148 { jobId: job.id, webhookId: data.webhookId, event: data.event, err: (err as Error).message },
149 "webhook dispatch crashed — dead-lettering"
150 );
151 if (data.deliveryId) {
152 await prisma.webhookDelivery
153 .update({
154 where: { id: data.deliveryId },
155 data: { status: "DEAD_LETTER", error: `crashed: ${(err as Error).message}`, nextRetryAt: null }
156 })
157 .catch(() => undefined);
158 }
159 job.discard();
160 }
161 }),
162 {
163 connection,
164 concurrency: opts.concurrency ?? env.WORKER_CONCURRENCY
165
166
167 }
168 );
169}
170
171
173async function dispatchRow(
174 doFetch: typeof fetch,
175 prisma: PrismaClient,
176 connection: Redis,
177 env: WorkerEnv,
178 job: Job,
179 data: WebhookJobData
180): Promise<void> {
181 const { webhookId, event } = data;
182 const isTest = Boolean(data.test);
183 const isResubmit = Boolean(data.originDeliveryId);
184 const attempt = data.attempt ?? 1;
185 const started = Date.now();
186 log.info(
187 { jobId: job.id, webhookId, event, test: isTest, resubmit: isResubmit, attempt },
188 "dispatching webhook"
189 );
190
191 const webhook = await prisma.webhook.findUnique({ where: { id: webhookId } });
192 if (!webhook) {
193 log.warn({ jobId: job.id, webhookId }, "webhook row missing — discarding");
194 await touchDelivery(prisma, data.deliveryId, { status: "REJECTED", error: "webhook deleted" });
195 job.discard();
196 return;
197 }
198 if (!webhook.active && !isTest) {
199 log.info({ jobId: job.id, webhookId }, "webhook inactive — discarding");
200 await touchDelivery(prisma, data.deliveryId, { status: "REJECTED", error: "webhook inactive" });
201 job.discard();
202 return;
203 }
204 if (!isTest && !isWebhookSubscribed(webhook.events, event)) {
205 log.info({ jobId: job.id, webhookId, event }, "not subscribed — discarding");
206 await touchDelivery(prisma, data.deliveryId, { status: "REJECTED", error: "not subscribed" });
207 job.discard();
208 return;
209 }
210
211 const resolved = resolveSigningSecret(env.MASTER_ENCRYPTION_KEY, webhook.secretEncrypted, webhook.secretHash);
212 if (!resolved) {
213 log.error({ jobId: job.id, webhookId }, "webhook has no resolvable signing secret — dead-lettering");
214 await touchDelivery(prisma, data.deliveryId, { status: "DEAD_LETTER", error: "signing secret unavailable" });
215 job.discard();
216 return;
217 }
218
219 const deliveryId = data.deliveryId ?? newDeliveryId();
220 const wireEvent = isTest ? "webhook.test" : event;
221
222
223 const ws = await prisma.workspace.findUnique({
224 where: { id: webhook.workspaceId },
225 select: { id: true, name: true, slug: true }
226 });
227 const input: DeliveryEnvelopeInput = {
228 type: wireEvent,
229 workspaceId: webhook.workspaceId,
230 data: data.payload
231 };
232 if (data.eventId) input.id = data.eventId;
233 if (ws) input.workspace = ws;
234 if (data.occurredAt) input.occurredAt = data.occurredAt;
235 if (isTest) input.test = true;
236 const envelope = buildDeliveryEnvelope(input);
237
238
239
240 await prisma.webhookDelivery.upsert({
241 where: { id: deliveryId },
242 create: {
243 id: deliveryId,
244 webhookId,
245 event: wireEvent,
246 payload: asJson(envelope),
247 status: "PENDING",
248 attempts: attempt,
249 originDeliveryId: data.originDeliveryId ?? null
250 },
251 update: { attempts: attempt, nextRetryAt: null, status: "PENDING" }
252 });
253
254 const result = await postWebhookDelivery(
255 {
256 url: webhook.url,
257 secret: resolved.secret,
258 event: wireEvent,
259 envelope,
260 deliveryId,
261 attempt
262 },
263 { allowPrivate: env.WEBHOOK_ALLOW_PRIVATE, fetchImpl: doFetch }
264 );
265 const outcome = classifyDeliveryResult(result);
266
267 if (outcome === "DELIVERED") {
268 await prisma.webhookDelivery.update({
269 where: { id: deliveryId },
270 data: {
271 status: "DELIVERED",
272 statusCode: result.status,
273 error: null,
274 deliveredAt: new Date(),
275 latencyMs: result.latencyMs
276 }
277 });
278 log.info(
279 {
280 jobId: job.id,
281 webhookId,
282 deliveryId,
283 status: result.status,
284 latencyMs: result.latencyMs,
285 attempt,
286 secretSource: resolved.source
287 },
288 "webhook delivered"
289 );
290 return;
291 }
292
293 if (outcome === "REJECTED") {
294
295 await prisma.webhookDelivery.update({
296 where: { id: deliveryId },
297 data: {
298 status: "REJECTED",
299 statusCode: result.status,
300 error: `rejected: HTTP ${result.status}`,
301 nextRetryAt: null,
302 latencyMs: result.latencyMs
303 }
304 });
305 log.warn({ jobId: job.id, webhookId, deliveryId, status: result.status, attempt }, "webhook 4xx — rejected (no retry)");
306 return;
307 }
308
309
310 if (attempt >= WEBHOOK_MAX_ATTEMPTS) {
311 await markDeadLetter(prisma, connection, env, webhook, deliveryId, result, attempt);
312 return;
313 }
314 const delay = backoffFor(env, attempt);
315 const nextRetryAt = new Date(Date.now() + delay);
316 await prisma.webhookDelivery.update({
317 where: { id: deliveryId },
318 data: {
319 status: "FAILED",
320 statusCode: result.status || null,
321 error: result.transportError ?? `non-2xx: HTTP ${result.status}`,
322 nextRetryAt,
323 latencyMs: result.latencyMs
324 }
325 });
326
327
328 await enqueue(connection, QUEUES.webhooks, {
329 kind: "row",
330 webhookId,
331 event,
332 payload: data.payload,
333 deliveryId,
334 eventId: data.eventId,
335 occurredAt: data.occurredAt,
336 attempt: attempt + 1,
337 originDeliveryId: data.originDeliveryId
338
339 }, { delay, jobId: `${deliveryId}-a${attempt + 1}` });
340 log.warn(
341 { jobId: job.id, webhookId, deliveryId, status: result.status, attempt, nextRetryMs: delay },
342 "webhook failed — retry scheduled"
343 );
344}
345
346
347async function markDeadLetter(
348 prisma: PrismaClient,
349 connection: Redis,
350 env: WorkerEnv,
351 webhook: { id: string; workspaceId: string; url?: string },
352 deliveryId: string,
353 result: { status: number; transportError?: string; latencyMs: number },
354 attempt: number
355): Promise<void> {
356 const error = result.transportError ?? `non-2xx: HTTP ${result.status}`;
357 log.error(
358 { webhookId: webhook.id, deliveryId, status: result.status, attempt, err: error },
359 "webhook dispatch failed after all retries → DEAD_LETTER"
360 );
361 await prisma.webhookDelivery.update({
362 where: { id: deliveryId },
363 data: {
364 status: "DEAD_LETTER",
365 statusCode: result.status || null,
366 error,
367 nextRetryAt: null,
368 latencyMs: result.latencyMs
369 }
370 });
371
372
373 try {
374 const key = NOTIFY_THROTTLE_KEY(webhook.id);
375 const notified = await connection.set(key, "1", "EX", NOTIFY_THROTTLE_SECONDS, "NX");
376 if (notified !== "OK") return;
377 await notifyWorkspaceAdmins(
378 prisma,
379 connection,
380 env.SMTP_HOST,
381 webhook.workspaceId,
382 "Webhook delivery failed",
383 `Webhook ${webhook.id} (${webhook.url}) could not be completed after ${attempt} attempts (${error}) and was moved to the dead letter queue. Resubmit it from the delivery log when the endpoint recovers.`,
384 { type: "webhook", id: webhook.id },
385 "INTEGRATION"
386 );
387 } catch (err) {
388 log.warn({ err: (err as Error).message }, "dead-letter notification failed (non-fatal)");
389 }
390}
391
392async function touchDelivery(
393 prisma: PrismaClient,
394 deliveryId: string | undefined,
395 patch: Record<string, unknown>
396): Promise<void> {
397 if (!deliveryId) return;
398 await prisma.webhookDelivery
399 .update({ where: { id: deliveryId }, data: patch })
400 .catch(() => undefined);
401}
402
403
404
405
406
407export function createWebhooksConsumer(opts: {
408 redis: Redis;
409 prisma: PrismaClient;
410 consumerId: string;
411}): StreamConsumer {
412 const { redis, prisma, consumerId } = opts;
413 return new StreamConsumer(
414 redis,
415 "factory:events",
416 "worker-webhooks",
417 consumerId,
418 async (entry: StreamEntry) => {
419 const parsed = tryParseEvent(entry);
420 if (!parsed) return;
421 const { envelope } = parsed;
422 if (
423 !WEBHOOK_EVENT_CATALOG.includes(
424 envelope.event as (typeof WEBHOOK_EVENT_CATALOG)[number]
425 )
426 ) {
427 return;
428 }
429 const p = envelope.payload as Record<string, unknown>;
430 const workspaceId = p.workspaceId ? String(p.workspaceId) : null;
431 const webhooks = await prisma.webhook.findMany({
432 where: {
433 active: true,
434 OR: [{ events: { has: "*" } }, { events: { has: envelope.event } }],
435 ...(workspaceId ? { workspaceId } : {})
436 },
437 select: { id: true, events: true }
438 });
439 for (const wh of webhooks) {
440
441
442 if (!isWebhookSubscribed(wh.events, envelope.event)) continue;
443
444
445
446 await enqueue(redis, QUEUES.webhooks, {
447 kind: "row",
448 webhookId: wh.id,
449 event: envelope.event,
450 payload: envelope.payload,
451 eventId: generateId("evt"),
452 occurredAt: new Date().toISOString()
453 });
454 }
455 if (webhooks.length) {
456 log.debug({ event: envelope.event, webhooks: webhooks.length }, "fan-out enqueued");
457 }
458 }
459 );
460}
461
462
463
464
465
466
467
468export function handleWebhookFinalFailure(
469 prisma: PrismaClient,
470 connection: Redis,
471 env: WorkerEnv
472): (job: Job, err: Error) => Promise<void> {
473 return async (job, err) => {
474 let data: WebhookJobData;
475 try {
476 data = webhookJobSchema.parse(job.data);
477 } catch {
478 log.error({ jobId: job.id, err: err.message }, "webhook job failed (unparseable data)");
479 return;
480 }
481 log.error(
482 {
483 jobId: job.id,
484 webhookId: data.webhookId,
485 event: data.event,
486 err: err.message
487 },
488 "webhook job crashed → dead-lettering (unexpected error)"
489 );
490 try {
491 await prisma.webhookDelivery
492 .update({
493 where: { id: data.deliveryId ?? "" },
494 data: {
495 status: "DEAD_LETTER",
496 error: `crashed: ${err.message}`,
497 nextRetryAt: null
498 }
499 })
500 .catch(() => undefined);
501 const webhook = await prisma.webhook.findUnique({
502 where: { id: data.webhookId },
503 select: { id: true, workspaceId: true }
504 });
505 if (webhook) {
506 const key = NOTIFY_THROTTLE_KEY(webhook.id);
507 const notified = await connection.set(key, "1", "EX", NOTIFY_THROTTLE_SECONDS, "NX");
508 if (notified === "OK") {
509 await notifyWorkspaceAdmins(
510 prisma,
511 connection,
512 env.SMTP_HOST,
513 webhook.workspaceId,
514 "Webhook delivery failed",
515 `A webhook delivery crashed unexpectedly (${err.message}) and was moved to the dead letter queue.`,
516 { type: "webhook", id: webhook.id },
517 "INTEGRATION"
518 );
519 }
520 }
521 } catch (inner) {
522 log.error({ err: (inner as Error).message }, "webhook crash bookkeeping failed");
523 }
524 };
525}
526