From ea07c839fc64c1910e33e1b40842ab0ce69aba61 Mon Sep 17 00:00:00 2001 From: dannyy2000 Date: Mon, 27 Jul 2026 13:44:51 +0100 Subject: [PATCH] feat(backend): webhook subscription create/list/delete with signed payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a WebhookSubscription model (url, per-subscription HMAC-SHA256 secret, events, owner, soft-delete) and repoints WebhookDelivery at it — it previously referenced the unrelated billing Subscription model. - POST /webhooks/subscriptions — register a subscription, returns the signing secret once - GET /webhooks/subscriptions — list the caller's subscriptions (secret omitted) - DELETE /webhooks/subscriptions/:id — soft-delete, owner only - webhooks.signing.ts — generateWebhookSecret / signWebhookPayload / verifyWebhookSignature (HMAC-SHA256, constant-time verification) Closes #997 --- .../migration.sql | 35 +++++ backend/prisma/schema.prisma | 38 ++++- .../modules/webhooks/webhooks.controller.ts | 77 ++++++++- .../src/modules/webhooks/webhooks.routes.ts | 7 + .../src/modules/webhooks/webhooks.schema.ts | 32 ++++ .../src/modules/webhooks/webhooks.service.ts | 96 +++++++++++- .../modules/webhooks/webhooks.signing.test.ts | 76 +++++++++ .../src/modules/webhooks/webhooks.signing.ts | 31 ++++ backend/src/modules/webhooks/webhooks.test.ts | 148 +++++++++++++++++- .../src/modules/webhooks/webhooks.types.ts | 26 +++ 10 files changed, 556 insertions(+), 10 deletions(-) create mode 100644 backend/prisma/migrations/20260727123500_add_webhook_subscription/migration.sql create mode 100644 backend/src/modules/webhooks/webhooks.signing.test.ts create mode 100644 backend/src/modules/webhooks/webhooks.signing.ts diff --git a/backend/prisma/migrations/20260727123500_add_webhook_subscription/migration.sql b/backend/prisma/migrations/20260727123500_add_webhook_subscription/migration.sql new file mode 100644 index 00000000..d19da029 --- /dev/null +++ b/backend/prisma/migrations/20260727123500_add_webhook_subscription/migration.sql @@ -0,0 +1,35 @@ +-- Add the WebhookSubscription model (issue #997) and repoint WebhookDelivery +-- at it. WebhookDelivery.subscriptionId previously referenced the billing +-- Subscription model, which was a mistake carried over from an earlier PR — +-- webhook deliveries belong to a webhook subscription, not a tip billing +-- subscription. + +-- CreateEnum +CREATE TYPE "WebhookSubscriptionStatus" AS ENUM ('ACTIVE', 'DISABLED'); + +-- DropForeignKey +ALTER TABLE "WebhookDelivery" DROP CONSTRAINT "WebhookDelivery_subscriptionId_fkey"; + +-- CreateTable +CREATE TABLE "WebhookSubscription" ( + "id" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "events" TEXT[], + "status" "WebhookSubscriptionStatus" NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "WebhookSubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "WebhookSubscription_ownerId_idx" ON "WebhookSubscription"("ownerId"); + +-- AddForeignKey +ALTER TABLE "WebhookSubscription" ADD CONSTRAINT "WebhookSubscription_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 12505168..7f22337f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { notificationPreference NotificationPreference? leaderboardSnapshots LeaderboardSnapshot[] streak Streak? + webhookSubscriptions WebhookSubscription[] @@index([createdAt]) } @@ -328,13 +329,40 @@ model Subscription { /// Soft-delete marker: non-null means the record is logically deleted. deletedAt DateTime? - webhookDeliveries WebhookDelivery[] - @@index([tipperId]) @@index([creatorId]) @@index([nextChargeAt]) } +/// Lifecycle status of a webhook subscription. +enum WebhookSubscriptionStatus { + ACTIVE + DISABLED +} + +/// A consumer-registered HTTP endpoint that receives signed event payloads +/// (issue #997). The `secret` is generated on creation and used to compute an +/// HMAC-SHA256 signature for every outgoing delivery so consumers can verify +/// payload authenticity. +model WebhookSubscription { + id String @id @default(cuid()) + ownerId String + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + url String + secret String + /// Event types this subscription receives, e.g. "tip.received". + events String[] + status WebhookSubscriptionStatus @default(ACTIVE) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + /// Soft-delete marker: non-null means the record is logically deleted. + deletedAt DateTime? + + deliveries WebhookDelivery[] + + @@index([ownerId]) +} + /// Delivery state of a single webhook notification attempt. enum WebhookDeliveryStatus { PENDING @@ -342,12 +370,12 @@ enum WebhookDeliveryStatus { FAILED } -/// A single delivery attempt of a subscription billing event to a webhook -/// consumer (e.g. notifying an integrator that a charge succeeded or failed). +/// A single delivery attempt of an event payload to a webhook subscription's +/// URL (e.g. notifying an integrator that a tip was received). model WebhookDelivery { id String @id @default(cuid()) subscriptionId String - subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) status WebhookDeliveryStatus @default(PENDING) responseCode Int? attempts Int @default(0) diff --git a/backend/src/modules/webhooks/webhooks.controller.ts b/backend/src/modules/webhooks/webhooks.controller.ts index 9e0dca92..e9ddbc3b 100644 --- a/backend/src/modules/webhooks/webhooks.controller.ts +++ b/backend/src/modules/webhooks/webhooks.controller.ts @@ -1,8 +1,81 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { BadRequestError } from "../../common/errors/AppError.js"; -import { listDeliveries, getDelivery } from "./webhooks.service.js"; -import { deliveryQuerySchema, deliveryIdParamSchema } from "./webhooks.schema.js"; +import type { AuthPayload } from "../auth/auth.types.js"; +import { + listDeliveries, + getDelivery, + createSubscription, + listSubscriptions, + deleteSubscription, +} from "./webhooks.service.js"; +import { + deliveryQuerySchema, + deliveryIdParamSchema, + createWebhookSubscriptionSchema, + listWebhookSubscriptionsQuerySchema, + webhookSubscriptionIdParamSchema, +} from "./webhooks.schema.js"; + +/** POST /webhooks/subscriptions — registers a new webhook subscription. */ +export async function createSubscriptionController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const auth = req.auth as AuthPayload; + const data = createWebhookSubscriptionSchema.parse(req.body); + const result = await createSubscription(auth.userId, data); + res.status(201).json({ data: result }); + } catch (error) { + if (error instanceof z.ZodError) { + next(new BadRequestError("Invalid webhook subscription data", error.issues)); + } else { + next(error); + } + } +} + +/** GET /webhooks/subscriptions — lists the authenticated user's webhook subscriptions. */ +export async function listSubscriptionsController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const auth = req.auth as AuthPayload; + const query = listWebhookSubscriptionsQuerySchema.parse(req.query); + const result = await listSubscriptions(auth.userId, query.page, query.limit); + res.json({ data: result }); + } catch (error) { + if (error instanceof z.ZodError) { + next(new BadRequestError("Invalid query parameters", error.issues)); + } else { + next(error); + } + } +} + +/** DELETE /webhooks/subscriptions/:id — removes a webhook subscription (owner only). */ +export async function deleteSubscriptionController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const auth = req.auth as AuthPayload; + const { id } = webhookSubscriptionIdParamSchema.parse(req.params); + await deleteSubscription(auth.userId, id); + res.status(204).send(); + } catch (error) { + if (error instanceof z.ZodError) { + next(new BadRequestError("Invalid webhook subscription ID", error.issues)); + } else { + next(error); + } + } +} export async function listDeliveriesController( req: Request, diff --git a/backend/src/modules/webhooks/webhooks.routes.ts b/backend/src/modules/webhooks/webhooks.routes.ts index cf2d88e6..587a5cf7 100644 --- a/backend/src/modules/webhooks/webhooks.routes.ts +++ b/backend/src/modules/webhooks/webhooks.routes.ts @@ -3,9 +3,16 @@ import { requireAuth } from "../auth/auth.middleware.js"; import { listDeliveriesController, getDeliveryController, + createSubscriptionController, + listSubscriptionsController, + deleteSubscriptionController, } from "./webhooks.controller.js"; export const webhooksRouter = Router(); +webhooksRouter.post("/subscriptions", requireAuth, createSubscriptionController); +webhooksRouter.get("/subscriptions", requireAuth, listSubscriptionsController); +webhooksRouter.delete("/subscriptions/:id", requireAuth, deleteSubscriptionController); + webhooksRouter.get("/deliveries", requireAuth, listDeliveriesController); webhooksRouter.get("/deliveries/:id", requireAuth, getDeliveryController); diff --git a/backend/src/modules/webhooks/webhooks.schema.ts b/backend/src/modules/webhooks/webhooks.schema.ts index f5da2636..afeae7bc 100644 --- a/backend/src/modules/webhooks/webhooks.schema.ts +++ b/backend/src/modules/webhooks/webhooks.schema.ts @@ -1,5 +1,37 @@ import { z } from "zod"; +/** Event types a webhook subscription can be registered for. */ +export const WEBHOOK_EVENT_TYPES = [ + "tip.received", + "tip.sent", + "goal.completed", + "withdrawal.completed", + "credit_score.updated", +] as const; + +export const createWebhookSubscriptionSchema = z.object({ + url: z + .string() + .url("Must be a valid URL") + .startsWith("https://", "Webhook URL must use https"), + events: z + .array(z.enum(WEBHOOK_EVENT_TYPES)) + .min(1, "At least one event is required"), +}); + +export const listWebhookSubscriptionsQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const webhookSubscriptionIdParamSchema = z.object({ + id: z.string().min(1, "Webhook subscription ID is required"), +}); + +export type CreateWebhookSubscriptionInput = z.infer; +export type ListWebhookSubscriptionsQuery = z.infer; +export type WebhookSubscriptionIdParam = z.infer; + export const deliveryQuerySchema = z.object({ subscriptionId: z.string().optional(), status: z.enum(["PENDING", "SUCCESS", "FAILED"]).optional(), diff --git a/backend/src/modules/webhooks/webhooks.service.ts b/backend/src/modules/webhooks/webhooks.service.ts index 32fa6a17..7c85ee92 100644 --- a/backend/src/modules/webhooks/webhooks.service.ts +++ b/backend/src/modules/webhooks/webhooks.service.ts @@ -1,7 +1,99 @@ import { prisma } from "../../db/prisma.js"; import { logger } from "../../common/utils/logger.js"; -import { NotFoundError } from "../../common/errors/AppError.js"; -import type { WebhookDeliveryResponse, WebhookDeliveryListResponse } from "./webhooks.types.js"; +import { ForbiddenError, NotFoundError } from "../../common/errors/AppError.js"; +import { generateWebhookSecret } from "./webhooks.signing.js"; +import type { + WebhookDeliveryResponse, + WebhookDeliveryListResponse, + WebhookSubscriptionCreateResponse, + WebhookSubscriptionListResponse, + WebhookSubscriptionResponse, +} from "./webhooks.types.js"; +import type { CreateWebhookSubscriptionInput } from "./webhooks.schema.js"; + +type WebhookSubscriptionRow = { + id: string; + ownerId: string; + url: string; + events: string[]; + status: string; + createdAt: Date; + updatedAt: Date; +}; + +function toSubscription(row: WebhookSubscriptionRow): WebhookSubscriptionResponse { + return { + id: row.id, + ownerId: row.ownerId, + url: row.url, + events: row.events, + status: row.status as WebhookSubscriptionResponse["status"], + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +/** Creates a webhook subscription for `ownerId`. The secret is returned once. */ +export async function createSubscription( + ownerId: string, + data: CreateWebhookSubscriptionInput, +): Promise { + logger.info({ ownerId, url: data.url, events: data.events }, "Creating webhook subscription"); + + const secret = generateWebhookSecret(); + const row = await prisma.webhookSubscription.create({ + data: { + ownerId, + url: data.url, + events: data.events, + secret, + }, + }); + + return { ...toSubscription(row), secret }; +} + +/** Lists webhook subscriptions owned by `ownerId`. */ +export async function listSubscriptions( + ownerId: string, + page: number, + limit: number, +): Promise { + logger.info({ ownerId, page, limit }, "Listing webhook subscriptions"); + + const where = { ownerId, deletedAt: null }; + const skip = (page - 1) * limit; + + const [rows, total] = await Promise.all([ + prisma.webhookSubscription.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + prisma.webhookSubscription.count({ where }), + ]); + + return { entries: rows.map(toSubscription), total, page, limit }; +} + +/** Soft-deletes a webhook subscription. Only the owner may delete it. */ +export async function deleteSubscription(ownerId: string, id: string): Promise { + const existing = await prisma.webhookSubscription.findUnique({ where: { id } }); + if (!existing || existing.deletedAt) { + throw new NotFoundError(`Webhook subscription ${id} not found`); + } + if (existing.ownerId !== ownerId) { + throw new ForbiddenError("You can only delete your own webhook subscriptions"); + } + + await prisma.webhookSubscription.update({ + where: { id }, + data: { deletedAt: new Date(), status: "DISABLED" }, + }); + + logger.info({ ownerId, subscriptionId: id }, "Webhook subscription deleted"); +} export async function listDeliveries( page: number, diff --git a/backend/src/modules/webhooks/webhooks.signing.test.ts b/backend/src/modules/webhooks/webhooks.signing.test.ts new file mode 100644 index 00000000..199510aa --- /dev/null +++ b/backend/src/modules/webhooks/webhooks.signing.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { + generateWebhookSecret, + signWebhookPayload, + verifyWebhookSignature, +} from "./webhooks.signing.js"; + +describe("generateWebhookSecret", () => { + it("returns a 64-char hex string", () => { + const secret = generateWebhookSecret(); + expect(secret).toMatch(/^[0-9a-f]{64}$/); + }); + + it("returns a different secret on each call", () => { + expect(generateWebhookSecret()).not.toBe(generateWebhookSecret()); + }); +}); + +describe("signWebhookPayload", () => { + it("produces a deterministic hex-encoded HMAC-SHA256 signature", () => { + const secret = "test-secret"; + const payload = JSON.stringify({ event: "tip.received", tipId: "tip_1" }); + + const signature = signWebhookPayload(secret, payload); + + expect(signature).toMatch(/^[0-9a-f]{64}$/); + expect(signature).toBe(signWebhookPayload(secret, payload)); + }); + + it("produces different signatures for different payloads", () => { + const secret = "test-secret"; + const sigA = signWebhookPayload(secret, JSON.stringify({ a: 1 })); + const sigB = signWebhookPayload(secret, JSON.stringify({ a: 2 })); + expect(sigA).not.toBe(sigB); + }); + + it("produces different signatures for different secrets", () => { + const payload = JSON.stringify({ a: 1 }); + expect(signWebhookPayload("secret-a", payload)).not.toBe( + signWebhookPayload("secret-b", payload), + ); + }); +}); + +describe("verifyWebhookSignature", () => { + it("accepts a valid signature", () => { + const secret = "test-secret"; + const payload = JSON.stringify({ event: "tip.received" }); + const signature = signWebhookPayload(secret, payload); + + expect(verifyWebhookSignature(secret, payload, signature)).toBe(true); + }); + + it("rejects a signature computed with the wrong secret", () => { + const payload = JSON.stringify({ event: "tip.received" }); + const signature = signWebhookPayload("wrong-secret", payload); + + expect(verifyWebhookSignature("test-secret", payload, signature)).toBe(false); + }); + + it("rejects a signature for a tampered payload", () => { + const secret = "test-secret"; + const signature = signWebhookPayload(secret, JSON.stringify({ amount: 100 })); + + expect( + verifyWebhookSignature(secret, JSON.stringify({ amount: 999 }), signature), + ).toBe(false); + }); + + it("rejects a malformed (non-hex) signature without throwing", () => { + const secret = "test-secret"; + const payload = JSON.stringify({ event: "tip.received" }); + + expect(verifyWebhookSignature(secret, payload, "not-hex-!!")).toBe(false); + }); +}); diff --git a/backend/src/modules/webhooks/webhooks.signing.ts b/backend/src/modules/webhooks/webhooks.signing.ts new file mode 100644 index 00000000..9e89f18e --- /dev/null +++ b/backend/src/modules/webhooks/webhooks.signing.ts @@ -0,0 +1,31 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** Generates a new random signing secret for a webhook subscription. */ +export function generateWebhookSecret(): string { + return randomBytes(32).toString("hex"); +} + +/** Computes the HMAC-SHA256 signature of `payload` using `secret`, hex-encoded. */ +export function signWebhookPayload(secret: string, payload: string): string { + return createHmac("sha256", secret).update(payload).digest("hex"); +} + +/** + * Verifies a webhook signature using a constant-time comparison to avoid + * leaking the expected signature via timing side-channels. + */ +export function verifyWebhookSignature( + secret: string, + payload: string, + signature: string, +): boolean { + const expected = Buffer.from(signWebhookPayload(secret, payload), "hex"); + let given: Buffer; + try { + given = Buffer.from(signature, "hex"); + } catch { + return false; + } + if (expected.length !== given.length) return false; + return timingSafeEqual(expected, given); +} diff --git a/backend/src/modules/webhooks/webhooks.test.ts b/backend/src/modules/webhooks/webhooks.test.ts index dcce330e..43443473 100644 --- a/backend/src/modules/webhooks/webhooks.test.ts +++ b/backend/src/modules/webhooks/webhooks.test.ts @@ -7,10 +7,23 @@ vi.mock("../../common/utils/logger.js", () => ({ vi.mock("../../db/prisma.js", () => ({ prisma: { webhookDelivery: { findMany: vi.fn(), count: vi.fn(), findUnique: vi.fn() }, + webhookSubscription: { + create: vi.fn(), + findMany: vi.fn(), + count: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, }, })); -import { listDeliveries, getDelivery } from "./webhooks.service.js"; +import { + listDeliveries, + getDelivery, + createSubscription, + listSubscriptions, + deleteSubscription, +} from "./webhooks.service.js"; import { prisma } from "../../db/prisma.js"; const fakeDeliveries = [ @@ -134,3 +147,136 @@ describe("getDelivery (issue #1001)", () => { }); }); }); + +const fakeSubscription = { + id: "wh_sub_01", + ownerId: "user_01", + url: "https://example.com/webhook", + secret: "abc123", + events: ["tip.received"], + status: "ACTIVE", + createdAt: new Date("2026-07-01T00:00:00Z"), + updatedAt: new Date("2026-07-01T00:00:00Z"), + deletedAt: null, +}; + +describe("createSubscription (issue #997)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("creates a subscription and returns the generated secret once", async () => { + vi.mocked(prisma.webhookSubscription.create).mockImplementation( + (async ({ data }: { data: { secret: string } }) => ({ + ...fakeSubscription, + ...data, + })) as never, + ); + + const result = await createSubscription("user_01", { + url: "https://example.com/webhook", + events: ["tip.received"], + }); + + expect(prisma.webhookSubscription.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + ownerId: "user_01", + url: "https://example.com/webhook", + events: ["tip.received"], + secret: expect.any(String), + }), + }); + expect(result.secret).toMatch(/^[0-9a-f]{64}$/); + expect(result.id).toBe("wh_sub_01"); + expect(result.status).toBe("ACTIVE"); + }); + + it("generates a fresh random secret per subscription", async () => { + vi.mocked(prisma.webhookSubscription.create).mockImplementation( + (async ({ data }: { data: { secret: string } }) => ({ + ...fakeSubscription, + secret: data.secret, + })) as never, + ); + + const a = await createSubscription("user_01", { + url: "https://example.com/a", + events: ["tip.received"], + }); + const b = await createSubscription("user_01", { + url: "https://example.com/b", + events: ["tip.received"], + }); + + expect(a.secret).not.toBe(b.secret); + }); +}); + +describe("listSubscriptions (issue #997)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns paginated subscriptions scoped to the owner, without secrets", async () => { + vi.mocked(prisma.webhookSubscription.findMany).mockResolvedValueOnce([ + fakeSubscription, + ] as never); + vi.mocked(prisma.webhookSubscription.count).mockResolvedValueOnce(1 as never); + + const result = await listSubscriptions("user_01", 1, 20); + + expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { ownerId: "user_01", deletedAt: null }, + }), + ); + expect(result.total).toBe(1); + expect(result.entries[0].id).toBe("wh_sub_01"); + expect(result.entries[0]).not.toHaveProperty("secret"); + }); +}); + +describe("deleteSubscription (issue #997)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("soft-deletes a subscription owned by the caller", async () => { + vi.mocked(prisma.webhookSubscription.findUnique).mockResolvedValueOnce( + fakeSubscription as never, + ); + vi.mocked(prisma.webhookSubscription.update).mockResolvedValueOnce( + { ...fakeSubscription, deletedAt: new Date(), status: "DISABLED" } as never, + ); + + await deleteSubscription("user_01", "wh_sub_01"); + + expect(prisma.webhookSubscription.update).toHaveBeenCalledWith({ + where: { id: "wh_sub_01" }, + data: { deletedAt: expect.any(Date), status: "DISABLED" }, + }); + }); + + it("throws NotFoundError when the subscription does not exist", async () => { + vi.mocked(prisma.webhookSubscription.findUnique).mockResolvedValueOnce(null); + + await expect(deleteSubscription("user_01", "ghost")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("throws NotFoundError when the subscription was already deleted", async () => { + vi.mocked(prisma.webhookSubscription.findUnique).mockResolvedValueOnce({ + ...fakeSubscription, + deletedAt: new Date(), + } as never); + + await expect(deleteSubscription("user_01", "wh_sub_01")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("throws ForbiddenError when the caller does not own the subscription", async () => { + vi.mocked(prisma.webhookSubscription.findUnique).mockResolvedValueOnce( + fakeSubscription as never, + ); + + await expect(deleteSubscription("someone_else", "wh_sub_01")).rejects.toMatchObject({ + statusCode: 403, + }); + }); +}); diff --git a/backend/src/modules/webhooks/webhooks.types.ts b/backend/src/modules/webhooks/webhooks.types.ts index 949021f1..1ecb50d8 100644 --- a/backend/src/modules/webhooks/webhooks.types.ts +++ b/backend/src/modules/webhooks/webhooks.types.ts @@ -1,3 +1,29 @@ +/** Lifecycle status of a webhook subscription. Mirrors the Prisma enum. */ +export type WebhookSubscriptionStatus = "ACTIVE" | "DISABLED"; + +/** A registered webhook subscription (secret omitted — only returned on creation). */ +export interface WebhookSubscriptionResponse { + id: string; + ownerId: string; + url: string; + events: string[]; + status: WebhookSubscriptionStatus; + createdAt: string; + updatedAt: string; +} + +/** Creation response — includes the signing secret, shown once. */ +export interface WebhookSubscriptionCreateResponse extends WebhookSubscriptionResponse { + secret: string; +} + +export interface WebhookSubscriptionListResponse { + entries: WebhookSubscriptionResponse[]; + total: number; + page: number; + limit: number; +} + export interface WebhookDeliveryResponse { id: string; subscriptionId: string;