diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx
index 46d93092..62d01edd 100644
--- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx
+++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx
@@ -32,17 +32,17 @@ export default function Page() {
}),
);
const sendMaintenanceUpdateMutation = useMutation(
- trpc.emailRouter.sendMaintenance.mutationOptions(),
+ trpc.maintenance.notify.mutationOptions(),
);
const createMaintenanceMutation = useMutation(
trpc.maintenance.new.mutationOptions({
onSuccess: (maintenance) => {
- // TODO: move to server
+ refetch();
if (maintenance.notifySubscribers) {
- sendMaintenanceUpdateMutation.mutateAsync({ id: maintenance.id });
+ sendMaintenanceUpdateMutation.mutate({
+ id: maintenance.id,
+ });
}
- //
- refetch();
},
}),
);
diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx
index a9ba537d..8bd09525 100644
--- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx
+++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx
@@ -42,14 +42,14 @@ export default function Page() {
);
const sendStatusReportUpdateMutation = useMutation(
- trpc.emailRouter.sendStatusReport.mutationOptions(),
+ trpc.statusReport.notify.mutationOptions(),
);
const createStatusReportUpdateMutation = useMutation(
trpc.statusReport.createStatusReportUpdate.mutationOptions({
onSuccess: (update) => {
if (update?.notifySubscribers) {
- sendStatusReportUpdateMutation.mutateAsync({ id: update.id });
+ sendStatusReportUpdateMutation.mutate({ id: update.id });
}
refetch();
queryClient.invalidateQueries({
diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx
index 0cd1b7c5..b860305f 100644
--- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx
+++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx
@@ -34,18 +34,16 @@ export default function Page() {
trpc.statusReport.list.queryOptions({ pageId: Number.parseInt(id) }),
);
const sendStatusReportUpdateMutation = useMutation(
- trpc.emailRouter.sendStatusReport.mutationOptions(),
+ trpc.statusReport.notify.mutationOptions(),
);
const createStatusReportMutation = useMutation(
trpc.statusReport.create.mutationOptions({
- onSuccess: async (statusReport) => {
- // TODO: move to server
+ onSuccess: (statusReport) => {
if (statusReport.notifySubscribers) {
- await sendStatusReportUpdateMutation.mutateAsync({
+ sendStatusReportUpdateMutation.mutate({
id: statusReport.id,
});
}
- //
refetch();
queryClient.invalidateQueries({
queryKey: trpc.page.list.queryKey(),
diff --git a/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx b/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx
index 75a63056..1d452e02 100644
--- a/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx
+++ b/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx
@@ -49,16 +49,14 @@ export function DataTable({
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const sendStatusReportUpdateMutation = useMutation(
- trpc.emailRouter.sendStatusReport.mutationOptions(),
+ trpc.statusReport.notify.mutationOptions(),
);
const createStatusReportUpdateMutation = useMutation(
trpc.statusReport.createStatusReportUpdate.mutationOptions({
onSuccess: (update) => {
- // TODO: move to server
if (update?.notifySubscribers) {
- sendStatusReportUpdateMutation.mutateAsync({ id: update.id });
+ sendStatusReportUpdateMutation.mutate({ id: update.id });
}
- //
queryClient.invalidateQueries({
queryKey: trpc.statusReport.list.queryKey({
pageId: Number.parseInt(id),
diff --git a/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx
index 34f19eb9..50f586df 100644
--- a/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx
+++ b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx
@@ -55,7 +55,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const currentImpacts = currentImpactsFromUpdates(row.original.updates);
const nextStatus = getNextStatus(row.original.status);
const sendStatusReportUpdateMutation = useMutation(
- trpc.emailRouter.sendStatusReport.mutationOptions(),
+ trpc.statusReport.notify.mutationOptions(),
);
const updateStatusReportMutation = useMutation(
trpc.statusReport.updateStatus.mutationOptions({
@@ -79,11 +79,9 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const createStatusReportUpdateMutation = useMutation(
trpc.statusReport.createStatusReportUpdate.mutationOptions({
onSuccess: (update) => {
- // TODO: move to server
if (update?.notifySubscribers) {
- sendStatusReportUpdateMutation.mutateAsync({ id: update.id });
+ sendStatusReportUpdateMutation.mutate({ id: update.id });
}
- //
queryClient.invalidateQueries({
queryKey: trpc.statusReport.list.queryKey({
pageId: row.original.pageId ?? undefined,
diff --git a/apps/dashboard/src/components/forms/subscriber/form.tsx b/apps/dashboard/src/components/forms/subscriber/form.tsx
index a9af1623..4f923987 100644
--- a/apps/dashboard/src/components/forms/subscriber/form.tsx
+++ b/apps/dashboard/src/components/forms/subscriber/form.tsx
@@ -2,6 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { detectWebhookFlavor } from "@openstatus/subscriptions/client";
+import { Badge } from "@openstatus/ui/components/ui/badge";
import { Button } from "@openstatus/ui/components/ui/button";
import {
Form,
@@ -70,12 +71,6 @@ const formSchema = z
path: ["webhookUrl"],
message: "Please enter a valid URL",
});
- } else if (detectWebhookFlavor(data.webhookUrl) === "generic") {
- ctx.addIssue({
- code: "custom",
- path: ["webhookUrl"],
- message: "Only Slack and Discord webhook URLs are supported.",
- });
}
}
});
@@ -297,8 +292,15 @@ export function FormSubscriber({
/>
- Only Slack and Discord webhook URLs are supported for now
- - more channels to come.
+
+ Slack and Discord URLs receive channel-native messages;
+ any other URL receives a generic JSON payload.
+
+ {/^https?:\/\//.test(field.value) ? (
+
+ {detectWebhookFlavor(field.value)}
+
+ ) : null}
diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts
index c71a76a4..e28573eb 100644
--- a/packages/analytics/src/events.ts
+++ b/packages/analytics/src/events.ts
@@ -104,6 +104,10 @@ export const Events = {
name: "report_update_deleted",
channel: "report",
},
+ NotifyReport: {
+ name: "report_notified",
+ channel: "report",
+ },
CreateMaintenance: {
name: "maintenance_created",
channel: "maintenance",
@@ -116,6 +120,10 @@ export const Events = {
name: "maintenance_deleted",
channel: "maintenance",
},
+ NotifyMaintenance: {
+ name: "maintenance_notified",
+ channel: "maintenance",
+ },
CreateNotification: {
name: "notification_created",
channel: "notification",
diff --git a/packages/api/src/router/email/index.ts b/packages/api/src/router/email/index.ts
index 636626ba..3dea10a3 100644
--- a/packages/api/src/router/email/index.ts
+++ b/packages/api/src/router/email/index.ts
@@ -5,14 +5,11 @@ import {
selectWorkspaceSchema,
} from "@openstatus/db/src/schema";
import { EmailClient } from "@openstatus/emails";
-import { notifyMaintenance } from "@openstatus/services/maintenance";
-import { notifyStatusReport } from "@openstatus/services/status-report";
import { getChannel } from "@openstatus/subscriptions";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { env } from "../../env";
-import { toServiceCtx, toTRPCError } from "../../service-adapter";
import {
createTRPCRouter,
protectedProcedure,
@@ -122,40 +119,6 @@ export const emailRouter = createTRPCRouter({
return { success: true };
}),
- /**
- * PROTECTED: Send status report update notifications via dispatcher
- */
- sendStatusReport: protectedProcedure
- .input(z.object({ id: z.number() }))
- .mutation(async (opts) => {
- try {
- await notifyStatusReport({
- ctx: toServiceCtx(opts.ctx),
- input: { statusReportUpdateId: opts.input.id },
- });
- return { success: true };
- } catch (err) {
- toTRPCError(err);
- }
- }),
-
- /**
- * PROTECTED: Send maintenance notifications via dispatcher
- */
- sendMaintenance: protectedProcedure
- .input(z.object({ id: z.number() }))
- .mutation(async (opts) => {
- try {
- await notifyMaintenance({
- ctx: toServiceCtx(opts.ctx),
- input: { maintenanceId: opts.input.id },
- });
- return { success: true };
- } catch (err) {
- toTRPCError(err);
- }
- }),
-
sendTeamInvitation: protectedProcedure
.input(z.object({ id: z.number(), baseUrl: z.string().optional() }))
.mutation(async (opts) => {
diff --git a/packages/api/src/router/maintenance.ts b/packages/api/src/router/maintenance.ts
index 12b46da7..697d523f 100644
--- a/packages/api/src/router/maintenance.ts
+++ b/packages/api/src/router/maintenance.ts
@@ -4,6 +4,7 @@ import {
createMaintenance,
deleteMaintenance,
listMaintenances,
+ notifyMaintenance,
updateMaintenance,
} from "@openstatus/services/maintenance";
import { z } from "zod";
@@ -65,6 +66,21 @@ export const maintenanceRouter = createTRPCRouter({
}
}),
+ notify: protectedProcedure
+ .meta({ track: Events.NotifyMaintenance })
+ .input(z.object({ id: z.number() }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ await notifyMaintenance({
+ ctx: toServiceCtx(ctx),
+ input: { maintenanceId: input.id },
+ });
+ return { success: true };
+ } catch (err) {
+ toTRPCError(err);
+ }
+ }),
+
new: protectedProcedure
.meta({ track: Events.CreateMaintenance })
.input(
diff --git a/packages/api/src/router/pageSubscriber.ts b/packages/api/src/router/pageSubscriber.ts
index 9af4c3ca..1e8c4750 100644
--- a/packages/api/src/router/pageSubscriber.ts
+++ b/packages/api/src/router/pageSubscriber.ts
@@ -13,7 +13,6 @@ import {
upsertSelfSignupSubscriber,
verifySelfSignupSubscriber,
} from "@openstatus/services/page-subscriber";
-import { detectWebhookFlavor } from "@openstatus/subscriptions";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -30,11 +29,7 @@ const webhookHeadersSchema = z
.max(20)
.optional();
-const supportedWebhookUrlSchema = z
- .url()
- .refine((url) => detectWebhookFlavor(url) !== "generic", {
- message: "Only Slack and Discord webhook URLs are supported.",
- });
+const supportedWebhookUrlSchema = z.url();
// Public (status-page-facing) procedures use the same allow-list as
// the protected procedures going through `toTRPCError`. Single source
diff --git a/packages/api/src/router/statusReport.ts b/packages/api/src/router/statusReport.ts
index 156df7f4..8074282c 100644
--- a/packages/api/src/router/statusReport.ts
+++ b/packages/api/src/router/statusReport.ts
@@ -10,6 +10,7 @@ import {
deleteStatusReportUpdate,
getStatusReport,
listStatusReports,
+ notifyStatusReport,
updateStatusReport,
updateStatusReportUpdate,
} from "@openstatus/services/status-report";
@@ -66,8 +67,9 @@ export const statusReportRouter = createTRPCRouter({
.input(createStatusReportTRPCInput)
.mutation(async ({ ctx, input }) => {
try {
+ const serviceCtx = toServiceCtx(ctx);
const { initialUpdate } = await createStatusReport({
- ctx: toServiceCtx(ctx),
+ ctx: serviceCtx,
input: {
title: input.title,
status: input.status,
@@ -78,8 +80,8 @@ export const statusReportRouter = createTRPCRouter({
message: input.message,
},
});
- // Preserve the original "return the initial update row with
- // notifySubscribers merged in" shape the dashboard consumes.
+ // Notification is a separate, client-driven step (statusReport.notify)
+ // so creation and dispatch stay split.
return { ...initialUpdate, notifySubscribers: input.notifySubscribers };
} catch (err) {
toTRPCError(err);
@@ -91,8 +93,9 @@ export const statusReportRouter = createTRPCRouter({
.input(createStatusReportUpdateTRPCInput)
.mutation(async ({ ctx, input }) => {
try {
+ const serviceCtx = toServiceCtx(ctx);
const { statusReportUpdate } = await addStatusReportUpdate({
- ctx: toServiceCtx(ctx),
+ ctx: serviceCtx,
input: {
statusReportId: input.statusReportId,
status: input.status,
@@ -110,6 +113,21 @@ export const statusReportRouter = createTRPCRouter({
}
}),
+ notify: protectedProcedure
+ .meta({ track: Events.NotifyReport })
+ .input(z.object({ id: z.number() }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ await notifyStatusReport({
+ ctx: toServiceCtx(ctx),
+ input: { statusReportUpdateId: input.id },
+ });
+ return { success: true };
+ } catch (err) {
+ toTRPCError(err);
+ }
+ }),
+
updateStatusReportUpdate: protectedProcedure
.meta({ track: Events.UpdateReportUpdate })
.input(updateStatusReportUpdateTRPCInput)
diff --git a/packages/emails/src/client.test.ts b/packages/emails/src/client.test.ts
new file mode 100644
index 00000000..cde4f7bf
--- /dev/null
+++ b/packages/emails/src/client.test.ts
@@ -0,0 +1,115 @@
+import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
+
+import { EmailClient } from "./client";
+
+// sendStatusReportUpdate early-returns in development; force the real send path.
+process.env.NODE_ENV = "test";
+
+function makeSubscribers(n: number) {
+ return Array.from({ length: n }, (_, i) => ({
+ email: `user-${i}@example.com`,
+ token: `token-${i}`,
+ }));
+}
+
+function baseReq(
+ overrides: Partial[0]> = {},
+) {
+ return {
+ subscribers: makeSubscribers(1),
+ pageSlug: "demo",
+ pageTitle: "Demo",
+ reportTitle: "Outage",
+ status: "investigating" as const,
+ date: "2026-04-21T09:59:58Z",
+ message: "We are investigating.",
+ pageComponents: [] as string[],
+ ...overrides,
+ };
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: test doubles for the Resend batch result
+const ok = { data: { data: [] }, error: null } as any;
+// biome-ignore lint/suspicious/noExplicitAny: simulated Resend application error
+const fail = { data: null, error: { name: "application_error" } } as any;
+
+describe("EmailClient.sendStatusReportUpdate - idempotency & chunking", () => {
+ let client: EmailClient;
+ // biome-ignore lint/suspicious/noExplicitAny: bun spy handle
+ let batchSend: any;
+
+ beforeEach(() => {
+ // zero backoff so the retry test doesn't wait on the real exponential sleep
+ client = new EmailClient({
+ apiKey: "re_test_123",
+ retryBackoff: "0 millis",
+ });
+ batchSend = spyOn(client.client.batch, "send").mockResolvedValue(ok);
+ });
+
+ afterEach(() => {
+ batchSend.mockRestore();
+ });
+
+ test("passes the base idempotency key suffixed with the batch index", async () => {
+ await client.sendStatusReportUpdate(
+ baseReq({ idempotencyKey: "status-report-update:5" }),
+ );
+
+ expect(batchSend).toHaveBeenCalledTimes(1);
+ const [, options] = batchSend.mock.calls[0];
+ expect(options).toEqual({ idempotencyKey: "status-report-update:5:0" });
+ });
+
+ test("gives each 100-recipient chunk a distinct key and its own slice", async () => {
+ await client.sendStatusReportUpdate(
+ baseReq({
+ subscribers: makeSubscribers(250),
+ idempotencyKey: "status-report-update:9",
+ }),
+ );
+
+ expect(batchSend).toHaveBeenCalledTimes(3);
+ const keys = batchSend.mock.calls.map(
+ // biome-ignore lint/suspicious/noExplicitAny: positional spy args
+ ([, o]: [unknown, any]) => o?.idempotencyKey,
+ );
+ expect(keys).toEqual([
+ "status-report-update:9:0",
+ "status-report-update:9:1",
+ "status-report-update:9:2",
+ ]);
+ const sizes = batchSend.mock.calls.map(
+ // biome-ignore lint/suspicious/noExplicitAny: positional spy args
+ ([payload]: [any[]]) => payload.length,
+ );
+ expect(sizes).toEqual([100, 100, 50]);
+ });
+
+ test("omits the option entirely when no base key is provided", async () => {
+ await client.sendStatusReportUpdate(baseReq());
+
+ const [, options] = batchSend.mock.calls[0];
+ expect(options).toBeUndefined();
+ });
+
+ test("reuses the same key across a retry so Resend dedupes the resend", async () => {
+ batchSend.mockResolvedValueOnce(fail).mockResolvedValueOnce(ok);
+
+ await client.sendStatusReportUpdate(
+ baseReq({ idempotencyKey: "status-report-update:7" }),
+ );
+
+ // failure → retry: the second attempt must carry the identical key, or
+ // Resend would treat the retry as a fresh batch and double-send.
+ expect(batchSend).toHaveBeenCalledTimes(2);
+ const keys = batchSend.mock.calls.map(
+ // biome-ignore lint/suspicious/noExplicitAny: positional spy args
+ ([, o]: [unknown, any]) => o?.idempotencyKey,
+ );
+ expect(keys).toEqual([
+ "status-report-update:7:0",
+ "status-report-update:7:0",
+ ]);
+ });
+});
diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx
index ce7b7a68..275293ef 100644
--- a/packages/emails/src/client.tsx
+++ b/packages/emails/src/client.tsx
@@ -1,6 +1,6 @@
/** @jsxImportSource react */
-import { Effect, Schedule } from "effect";
+import { type Duration, Effect, Schedule } from "effect";
import { render } from "react-email";
import { Resend } from "resend";
@@ -37,9 +37,13 @@ function chunk(array: T[], size: number): T[][] {
export class EmailClient {
public readonly client: Resend;
+ // Base delay for the per-batch send retry. Overridable so tests can run the
+ // retry path without the real ~1s exponential sleep.
+ private readonly retryBackoff: Duration.DurationInput;
- constructor(opts: { apiKey: string }) {
+ constructor(opts: { apiKey: string; retryBackoff?: Duration.DurationInput }) {
this.client = new Resend(opts.apiKey);
+ this.retryBackoff = opts.retryBackoff ?? "1000 millis";
}
public async sendFollowUp(req: { to: string }) {
@@ -161,6 +165,10 @@ export class EmailClient {
subscribers: Array<{ email: string; token: string }>;
pageSlug: string;
customDomain?: string | null;
+ // Base key for Resend idempotency. The per-batch retry below would
+ // otherwise re-send the whole chunk if a request succeeds server-side
+ // but the response is lost. Must be stable across retries.
+ idempotencyKey?: string;
},
) {
const statusPageBaseUrl = req.customDomain
@@ -176,7 +184,14 @@ export class EmailClient {
return;
}
- for (const recipients of chunk(req.subscribers, 100)) {
+ const chunks = chunk(req.subscribers, 100);
+ for (let i = 0; i < chunks.length; i++) {
+ const recipients = chunks[i];
+ // suffix the chunk index so a multi-batch send doesn't collide its
+ // own chunks on a single shared key
+ const batchKey = req.idempotencyKey
+ ? `${req.idempotencyKey}:${i}`
+ : undefined;
const sendEmail = Effect.tryPromise({
try: () =>
this.client.batch.send(
@@ -196,6 +211,7 @@ export class EmailClient {
),
};
}),
+ batchKey ? { idempotencyKey: batchKey } : undefined,
),
catch: (_unknown) =>
new Error(
@@ -209,7 +225,7 @@ export class EmailClient {
),
Effect.retry({
times: 3,
- schedule: Schedule.exponential("1000 millis"),
+ schedule: Schedule.exponential(this.retryBackoff),
}),
);
await Effect.runPromise(sendEmail).catch(console.error);
@@ -345,6 +361,7 @@ export class EmailClient {
from: string;
to: string;
pageComponents: string[];
+ idempotencyKey?: string;
}) {
const statusPageBaseUrl = req.customDomain
? `https://${req.customDomain}`
@@ -359,7 +376,12 @@ export class EmailClient {
return;
}
- for (const recipients of chunk(req.subscribers, 100)) {
+ const chunks = chunk(req.subscribers, 100);
+ for (let i = 0; i < chunks.length; i++) {
+ const recipients = chunks[i];
+ const batchKey = req.idempotencyKey
+ ? `${req.idempotencyKey}:${i}`
+ : undefined;
const sendEmail = Effect.tryPromise({
try: () =>
this.client.batch.send(
@@ -384,6 +406,7 @@ export class EmailClient {
),
};
}),
+ batchKey ? { idempotencyKey: batchKey } : undefined,
),
catch: (_unknown) =>
new Error(
@@ -397,7 +420,7 @@ export class EmailClient {
),
Effect.retry({
times: 3,
- schedule: Schedule.exponential("1000 millis"),
+ schedule: Schedule.exponential(this.retryBackoff),
}),
);
await Effect.runPromise(sendEmail).catch(console.error);
diff --git a/packages/services/src/page-subscriber/create.ts b/packages/services/src/page-subscriber/create.ts
index f845ec42..6eb1d5b1 100644
--- a/packages/services/src/page-subscriber/create.ts
+++ b/packages/services/src/page-subscriber/create.ts
@@ -5,7 +5,6 @@ import {
pageSubscriberToPageComponent,
selectPageSubscriberSchema,
} from "@openstatus/db/src/schema";
-import { detectWebhookFlavor } from "@openstatus/subscriptions";
import { assertSafeUrl } from "@openstatus/utils";
import { emitAudit } from "../audit";
@@ -64,11 +63,6 @@ export async function createPageSubscriber(args: {
// outside avoids holding the SQLite write lock across a network call.
if (input.channelType === "webhook") {
await assertSafeUrl(input.webhookUrl);
- if (detectWebhookFlavor(input.webhookUrl) === "generic") {
- throw new ValidationError(
- "Only Slack and Discord webhook URLs are supported.",
- );
- }
}
return withTransaction(ctx, async (tx) => {
diff --git a/packages/services/src/page-subscriber/internal.ts b/packages/services/src/page-subscriber/internal.ts
index c98c6bd9..a2f9986a 100644
--- a/packages/services/src/page-subscriber/internal.ts
+++ b/packages/services/src/page-subscriber/internal.ts
@@ -134,7 +134,6 @@ export const SAFE_SUBSCRIPTION_MESSAGES = new Set([
"Some components do not belong to this page",
"A subscriber with this email already exists for this page.",
"A subscriber with this webhook URL already exists for this page.",
- "Only Slack and Discord webhook URLs are supported.",
"Subscriber not found",
"Subscriber is not a webhook channel",
"Self-signup subscribers manage their own subscription; use the unsubscribe action instead.",
diff --git a/packages/services/src/page-subscriber/update.ts b/packages/services/src/page-subscriber/update.ts
index 0f572abc..4beb9413 100644
--- a/packages/services/src/page-subscriber/update.ts
+++ b/packages/services/src/page-subscriber/update.ts
@@ -6,7 +6,6 @@ import {
pageSubscriberToPageComponent,
selectPageSubscriberSchema,
} from "@openstatus/db/src/schema";
-import { detectWebhookFlavor } from "@openstatus/subscriptions";
import { assertSafeUrl } from "@openstatus/utils";
import { emitAudit } from "../audit";
@@ -40,14 +39,9 @@ export async function updatePageSubscriberChannel(args: {
// `assertSafeUrl` does a DNS lookup to block private/internal targets;
// keep it outside the tx so we don't hold the SQLite write lock across
- // a network call. Flavor check is pure and stays here for symmetry.
+ // a network call.
if (input.webhookUrl !== undefined) {
await assertSafeUrl(input.webhookUrl);
- if (detectWebhookFlavor(input.webhookUrl) === "generic") {
- throw new ValidationError(
- "Only Slack and Discord webhook URLs are supported.",
- );
- }
}
await withTransaction(ctx, async (tx) => {
diff --git a/packages/subscriptions/package.json b/packages/subscriptions/package.json
index 64824e69..c50cc3b9 100644
--- a/packages/subscriptions/package.json
+++ b/packages/subscriptions/package.json
@@ -22,6 +22,7 @@
"@openstatus/emails": "workspace:*",
"@openstatus/notification-base": "workspace:*",
"@openstatus/utils": "workspace:*",
+ "effect": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
diff --git a/packages/subscriptions/src/channels/email.test.ts b/packages/subscriptions/src/channels/email.test.ts
index d289ade2..6772aceb 100644
--- a/packages/subscriptions/src/channels/email.test.ts
+++ b/packages/subscriptions/src/channels/email.test.ts
@@ -141,4 +141,23 @@ describe("sendEmailNotifications", () => {
const [args] = sendStatusReportUpdateMock.mock.calls[0];
expect(args.pageComponents).toEqual(["API", "Database"]);
});
+
+ test("derives the idempotency key from the status-report update id", async () => {
+ const sub = makeSub();
+ await sendEmailNotifications([sub], makeUpdate({ updateId: 77 }));
+
+ const [args] = sendStatusReportUpdateMock.mock.calls[0];
+ expect(args.idempotencyKey).toBe("status-report-update:77");
+ });
+
+ test("falls back to a page-update key when there is no update id (maintenance)", async () => {
+ const sub = makeSub();
+ await sendEmailNotifications(
+ [sub],
+ makeUpdate({ id: 17, updateId: undefined, status: "maintenance" }),
+ );
+
+ const [args] = sendStatusReportUpdateMock.mock.calls[0];
+ expect(args.idempotencyKey).toBe("page-update:17:maintenance");
+ });
});
diff --git a/packages/subscriptions/src/channels/email.ts b/packages/subscriptions/src/channels/email.ts
index b82397b6..6e2cc6ba 100644
--- a/packages/subscriptions/src/channels/email.ts
+++ b/packages/subscriptions/src/channels/email.ts
@@ -24,6 +24,15 @@ export async function validateEmailConfig(config: unknown) {
return { valid: email.success, error: email.error?.message };
}
+// Stable per status-report update / maintenance so Resend dedupes the email
+// retry path. Status reports key off the specific update; maintenance has no
+// update row, so fall back to its id + status.
+function idempotencyKeyFor(pageUpdate: PageUpdate): string {
+ return pageUpdate.updateId != null
+ ? `status-report-update:${pageUpdate.updateId}`
+ : `page-update:${pageUpdate.id}:${pageUpdate.status}`;
+}
+
function hasEmailAndToken(
sub: Subscription,
): sub is Subscription & { email: string; token: string } {
@@ -76,5 +85,6 @@ export async function sendEmailNotifications(
message: pageUpdate.message,
date: pageUpdate.date,
pageComponents: pageUpdate.pageComponents,
+ idempotencyKey: idempotencyKeyFor(pageUpdate),
});
}
diff --git a/packages/subscriptions/src/channels/retry.ts b/packages/subscriptions/src/channels/retry.ts
new file mode 100644
index 00000000..c720ea7f
--- /dev/null
+++ b/packages/subscriptions/src/channels/retry.ts
@@ -0,0 +1,67 @@
+import { Effect, Schedule } from "effect";
+
+export class WebhookSendError extends Error {
+ readonly httpStatus?: number;
+
+ constructor(
+ message: string,
+ opts?: { httpStatus?: number; cause?: unknown },
+ ) {
+ super(message, { cause: opts?.cause });
+ this.name = "WebhookSendError";
+ this.httpStatus = opts?.httpStatus;
+ }
+}
+
+// Network errors and timeouts (no status) are transient; 5xx and 429 may clear.
+// Other 4xx are client errors that won't succeed on retry.
+const isRetryable = (err: WebhookSendError): boolean =>
+ err.httpStatus === undefined ||
+ err.httpStatus >= 500 ||
+ err.httpStatus === 429;
+
+const retryPolicy = {
+ schedule: Schedule.exponential("200 millis").pipe(Schedule.jittered),
+ times: 3,
+ while: isRetryable,
+};
+
+/**
+ * POST to a webhook with exponential-backoff retries on transient failures.
+ * Rejects with `WebhookSendError` once retries are exhausted.
+ */
+export function postWebhookWithRetry(opts: {
+ url: string;
+ headers: Record;
+ body: string;
+ timeoutMs: number;
+}): Promise {
+ const send = Effect.tryPromise({
+ try: (signal) =>
+ fetch(opts.url, {
+ method: "POST",
+ headers: opts.headers,
+ body: opts.body,
+ signal,
+ }),
+ catch: (cause) => new WebhookSendError("Webhook request failed", { cause }),
+ }).pipe(
+ Effect.timeoutFail({
+ duration: `${opts.timeoutMs} millis`,
+ onTimeout: () =>
+ new WebhookSendError(`Webhook timed out after ${opts.timeoutMs}ms`),
+ }),
+ Effect.flatMap((response) =>
+ response.ok
+ ? Effect.void
+ : Effect.fail(
+ new WebhookSendError(`Webhook returned ${response.status}`, {
+ httpStatus: response.status,
+ }),
+ ),
+ ),
+ Effect.retry(retryPolicy),
+ );
+
+ return Effect.runPromise(send);
+}
diff --git a/packages/subscriptions/src/channels/webhook.test.ts b/packages/subscriptions/src/channels/webhook.test.ts
index 640e933d..a10a9b41 100644
--- a/packages/subscriptions/src/channels/webhook.test.ts
+++ b/packages/subscriptions/src/channels/webhook.test.ts
@@ -161,7 +161,7 @@ describe("sendWebhookNotifications", () => {
expect(urls).toContain("https://hooks.slack.com/services/T2/B2/bbb");
});
- test("drops subscriptions with non-Slack/Discord URLs", async () => {
+ test("sends a generic JSON payload to non-Slack/Discord URLs", async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
const generic = makeSub({
id: 101,
@@ -169,11 +169,61 @@ describe("sendWebhookNotifications", () => {
});
const slack = makeSub({ id: 102, webhookUrl: SLACK_URL });
- await sendWebhookNotifications([generic, slack], makeUpdate());
+ await sendWebhookNotifications(
+ [generic, slack],
+ makeUpdate({ updateId: 42 }),
+ );
- expect(fetchMock).toHaveBeenCalledTimes(1);
- const [url] = fetchMock.mock.calls[0];
- expect(url).toBe(SLACK_URL);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ const byUrl = new Map(
+ fetchMock.mock.calls.map(([url, init]: [string, RequestInit]) => [
+ url,
+ JSON.parse(init.body as string),
+ ]),
+ );
+ expect(byUrl.get("https://example.com/webhook").type).toBe("status_report");
+ expect(byUrl.get(SLACK_URL).attachments).toBeDefined();
+ });
+
+ test("the status_report body sent over the wire satisfies webhookPayloadSchema (v1)", async () => {
+ fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
+ const sub = makeSub({ webhookUrl: "https://example.com/webhook" });
+
+ await sendWebhookNotifications(
+ [sub],
+ makeUpdate({
+ status: "investigating",
+ updateId: 42,
+ componentsWithImpact: [
+ { id: 7, name: "API", impact: "partial_outage" },
+ ],
+ }),
+ );
+
+ const [, init] = fetchMock.mock.calls[0];
+ const sentBody = JSON.parse(init?.body as string);
+ const result = webhookPayloadSchema.safeParse(sentBody);
+ expect(result.success).toBe(true);
+ });
+
+ test("the maintenance body sent over the wire satisfies webhookPayloadSchema (v1)", async () => {
+ fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
+ const sub = makeSub({ webhookUrl: "https://example.com/webhook" });
+
+ await sendWebhookNotifications(
+ [sub],
+ makeUpdate({
+ status: "maintenance",
+ startsAt: "2026-04-22T02:00:00Z",
+ endsAt: "2026-04-22T03:00:00Z",
+ pageComponentsWithId: [{ id: 7, name: "API" }],
+ }),
+ );
+
+ const [, init] = fetchMock.mock.calls[0];
+ const sentBody = JSON.parse(init?.body as string);
+ const result = webhookPayloadSchema.safeParse(sentBody);
+ expect(result.success).toBe(true);
});
test("applies custom headers from channelConfig", async () => {
@@ -191,14 +241,15 @@ describe("sendWebhookNotifications", () => {
expect(headers["X-Custom-Header"]).toBe("my-value");
});
- test("continues sending to remaining webhooks when one fails", async () => {
- fetchMock
- .mockRejectedValueOnce(new Error("Network error"))
- .mockResolvedValueOnce(new Response(null, { status: 200 }));
+ test("continues sending to remaining webhooks when one keeps failing", async () => {
+ const failUrl = "https://hooks.slack.com/services/T1/B1/fail";
+ fetchMock.mockImplementation(async (url: string) =>
+ url === failUrl
+ ? Promise.reject(new Error("Network error"))
+ : new Response(null, { status: 200 }),
+ );
- const sub1 = makeSub({
- webhookUrl: "https://hooks.slack.com/services/T1/B1/fail",
- });
+ const sub1 = makeSub({ webhookUrl: failUrl });
const sub2 = makeSub({
webhookUrl: "https://hooks.slack.com/services/T2/B2/succeed",
});
@@ -207,7 +258,34 @@ describe("sendWebhookNotifications", () => {
sendWebhookNotifications([sub1, sub2], makeUpdate()),
).resolves.toBeUndefined();
- expect(fetchMock).toHaveBeenCalledTimes(2);
+ const calls = fetchMock.mock.calls.map(([url]: [string]) => url);
+ expect(calls).toContain("https://hooks.slack.com/services/T2/B2/succeed");
+ });
+
+ test("retries transient failures then succeeds", async () => {
+ let attempts = 0;
+ fetchMock.mockImplementation(async () => {
+ attempts += 1;
+ return attempts < 3
+ ? new Response(null, { status: 503, statusText: "Service Unavailable" })
+ : new Response(null, { status: 200 });
+ });
+
+ await expect(
+ sendWebhookNotifications([makeSub()], makeUpdate()),
+ ).resolves.toBeUndefined();
+
+ expect(attempts).toBe(3);
+ });
+
+ test("does not retry non-retryable 4xx responses", async () => {
+ fetchMock.mockResolvedValue(
+ new Response(null, { status: 400, statusText: "Bad Request" }),
+ );
+
+ await sendWebhookNotifications([makeSub()], makeUpdate());
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -288,6 +366,49 @@ describe("sendWebhookNotifications (flavor detection)", () => {
const stringified = init?.body as string;
expect(stringified).toContain("https://status.partner.com/manage/tok-123");
});
+
+ test("Slack payload links to the event details and the page origin", async () => {
+ fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
+ const sub = makeSub({ pageSlug: "demo", pageName: "Demo" });
+
+ await sendWebhookNotifications([sub], makeUpdate({ id: 99 }));
+
+ const [, init] = fetchMock.mock.calls[0];
+ const body = JSON.parse(init?.body as string);
+ const stringified = init?.body as string;
+ // title stays in a plain_text header block
+ expect(body.attachments[0].blocks[0]).toMatchObject({
+ type: "header",
+ text: { type: "plain_text", text: "Test Incident" },
+ });
+ expect(stringified).toContain(
+ "",
+ );
+ expect(stringified).toContain("");
+ });
+
+ test("Discord embed url points at the event; maintenance uses the maintenance path", async () => {
+ fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
+ const sub = makeSub({
+ webhookUrl: DISCORD_URL,
+ pageSlug: "demo",
+ pageName: "Demo",
+ });
+
+ await sendWebhookNotifications(
+ [sub],
+ makeUpdate({ id: 7, status: "maintenance" }),
+ );
+
+ const [, init] = fetchMock.mock.calls[0];
+ const body = JSON.parse(init?.body as string);
+ expect(body.embeds[0].url).toBe(
+ "https://demo.openstatus.dev/events/maintenance/7",
+ );
+ expect(body.embeds[0].fields[1].value).toBe(
+ "[Demo](https://demo.openstatus.dev)",
+ );
+ });
});
// ─── buildGenericPayload (staged, not yet reachable in production) ────────────
@@ -329,11 +450,12 @@ describe("buildGenericPayload", () => {
expect(payload.data.status_report).toMatchObject({
id: 12,
title: "API degraded",
+ url: "https://acme.openstatus.dev/events/report/12",
update: {
id: 42,
status: "investigating",
message: "Looking into it.",
- created_at: "2026-04-21T09:59:58Z",
+ occurred_at: "2026-04-21T09:59:58Z",
},
page: {
id: 42,
@@ -379,6 +501,7 @@ describe("buildGenericPayload", () => {
expect(payload.data.maintenance).toMatchObject({
id: 17,
title: "DB upgrade",
+ url: "https://acme.openstatus.dev/events/maintenance/17",
message: "Rolling primary.",
starts_at: "2026-04-22T02:00:00Z",
ends_at: "2026-04-22T03:00:00Z",
@@ -438,13 +561,22 @@ describe("buildTestPayload", () => {
expect(payload.embeds).toBeInstanceOf(Array);
});
- test("generic flavor returns type='test' JSON", () => {
+ test("generic flavor returns enveloped type='test' JSON", () => {
const payload = buildTestPayload("generic") as {
+ version: string;
type: string;
- message: string;
+ data: { test: { message: string; timestamp: string } };
};
+ expect(payload.version).toBe("1");
expect(payload.type).toBe("test");
- expect(typeof payload.message).toBe("string");
+ expect(typeof payload.data.test.message).toBe("string");
+ expect(typeof payload.data.test.timestamp).toBe("string");
+ });
+
+ test("generic output satisfies the canonical webhookPayloadSchema contract", () => {
+ expect(
+ webhookPayloadSchema.safeParse(buildTestPayload("generic")).success,
+ ).toBe(true);
});
});
diff --git a/packages/subscriptions/src/channels/webhook.ts b/packages/subscriptions/src/channels/webhook.ts
index 94d0402f..c0921277 100644
--- a/packages/subscriptions/src/channels/webhook.ts
+++ b/packages/subscriptions/src/channels/webhook.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
import { WEBHOOK_PAYLOAD_VERSION } from "../payload";
import type { PageUpdate, Subscription } from "../types";
+import { postWebhookWithRetry } from "./retry";
export type WebhookFlavor = "slack" | "discord" | "generic";
@@ -35,6 +36,16 @@ function resolveStatusPageOrigin(subscription: Subscription): string {
: `https://${subscription.pageSlug}.openstatus.dev`;
}
+// Deep link to the specific event on the status page, mirroring the path
+// shape used by the Slack app integration (apps/server slack/page-urls.ts).
+function resolveEventUrl(
+ pageUpdate: PageUpdate,
+ subscription: Subscription,
+): string {
+ const kind = pageUpdate.status === "maintenance" ? "maintenance" : "report";
+ return `${resolveStatusPageOrigin(subscription)}/events/${kind}/${pageUpdate.id}`;
+}
+
function buildManagementLinks(subscription: Subscription) {
if (!subscription.token) {
return { manageUrl: null, unsubscribeUrl: null };
@@ -120,6 +131,8 @@ function buildSlackPayload(
links: ManagementLinks,
) {
const color = statusColor(pageUpdate.status);
+ const eventUrl = resolveEventUrl(pageUpdate, subscription);
+ const pageOrigin = resolveStatusPageOrigin(subscription);
const blocks: Record[] = [
{
@@ -139,7 +152,7 @@ function buildSlackPayload(
},
{
type: "mrkdwn",
- text: `*Page*\n${subscription.pageName}`,
+ text: `*Page*\n<${pageOrigin}|${subscription.pageName}>`,
},
],
},
@@ -175,17 +188,17 @@ function buildSlackPayload(
],
});
+ const footerLinks = [`<${eventUrl}|View details>`];
if (links.manageUrl && links.unsubscribeUrl) {
- blocks.push({
- type: "context",
- elements: [
- {
- type: "mrkdwn",
- text: `<${links.manageUrl}|Manage> · <${links.unsubscribeUrl}|Unsubscribe>`,
- },
- ],
- });
+ footerLinks.push(
+ `<${links.manageUrl}|Manage>`,
+ `<${links.unsubscribeUrl}|Unsubscribe>`,
+ );
}
+ blocks.push({
+ type: "context",
+ elements: [{ type: "mrkdwn", text: footerLinks.join(" · ") }],
+ });
return {
attachments: [
@@ -203,6 +216,8 @@ function buildDiscordPayload(
links: ManagementLinks,
) {
const color = statusColor(pageUpdate.status);
+ const eventUrl = resolveEventUrl(pageUpdate, subscription);
+ const pageOrigin = resolveStatusPageOrigin(subscription);
const descriptionParts: string[] = [];
if (pageUpdate.message) descriptionParts.push(pageUpdate.message);
@@ -221,6 +236,7 @@ function buildDiscordPayload(
embeds: [
{
title: pageUpdate.title,
+ url: eventUrl,
description: descriptionParts.join("\n\n") || undefined,
color: COLOR_DECIMALS[color],
fields: [
@@ -231,7 +247,7 @@ function buildDiscordPayload(
},
{
name: "Page",
- value: subscription.pageName,
+ value: `[${subscription.pageName}](${pageOrigin})`,
inline: true,
},
],
@@ -244,21 +260,21 @@ function buildDiscordPayload(
}
/**
- * Prepared (staged) generic webhook payload. Currently unreachable in
- * production — the input gate rejects non-Slack/Discord URLs, and the
- * dispatcher filters the same at send-time. Exported for unit-test coverage
- * so the contract stays green while generic webhooks are held back.
+ * Generic JSON webhook payload, sent to any non-Slack/Discord URL.
+ * Shape is pinned by `webhookPayloadSchema` in `../payload`.
*/
export function buildGenericPayload(
pageUpdate: PageUpdate,
subscription: Subscription,
links: ManagementLinks,
) {
+ const origin = resolveStatusPageOrigin(subscription);
+ const eventUrl = resolveEventUrl(pageUpdate, subscription);
const page = {
id: subscription.pageId,
name: subscription.pageName,
slug: subscription.pageSlug,
- url: resolveStatusPageOrigin(subscription),
+ url: origin,
};
const components =
pageUpdate.componentsWithImpact ??
@@ -284,6 +300,7 @@ export function buildGenericPayload(
maintenance: {
id: pageUpdate.id,
title: pageUpdate.title,
+ url: eventUrl,
message: pageUpdate.message,
starts_at: pageUpdate.startsAt,
ends_at: pageUpdate.endsAt,
@@ -306,11 +323,12 @@ export function buildGenericPayload(
status_report: {
id: pageUpdate.id,
title: pageUpdate.title,
+ url: eventUrl,
update: {
id: pageUpdate.updateId,
status: pageUpdate.status,
message: pageUpdate.message,
- created_at: pageUpdate.date,
+ occurred_at: pageUpdate.date,
},
page,
components,
@@ -344,18 +362,7 @@ export async function sendWebhookNotifications(
subscriptions: Subscription[],
pageUpdate: PageUpdate,
) {
- // Defense-in-depth: the input gate (tRPC + service layer) already rejects
- // non-Slack/Discord URLs at save time. Drop any that slip through so the
- // unreachable generic payload never ships to an unapproved destination.
- const validSubscriptions = subscriptions
- .filter(hasWebhookUrl)
- .filter((sub) => {
- if (detectWebhookFlavor(sub.webhookUrl) !== "generic") return true;
- console.warn(
- `Dropping webhook subscription ${sub.id}: generic URLs are not currently supported`,
- );
- return false;
- });
+ const validSubscriptions = subscriptions.filter(hasWebhookUrl);
if (validSubscriptions.length === 0) return;
await Promise.allSettled(
@@ -387,19 +394,12 @@ export async function sendWebhookNotifications(
try {
await assertSafeUrl(subscription.webhookUrl);
- const response = await fetch(subscription.webhookUrl, {
- method: "POST",
+ await postWebhookWithRetry({
+ url: subscription.webhookUrl,
headers,
body: JSON.stringify(payload),
- signal: AbortSignal.timeout(TIMEOUT_MS),
+ timeoutMs: TIMEOUT_MS,
});
-
- if (!response.ok) {
- console.error(
- `Webhook notification failed for ${redactWebhookUrl(subscription.webhookUrl)}: ${response.status} ${response.statusText}`,
- );
- throw new Error(`Webhook returned ${response.status}`);
- }
} catch (error) {
console.error(
`Failed to send webhook notification to ${redactWebhookUrl(subscription.webhookUrl)}:`,
@@ -455,9 +455,14 @@ export function buildTestPayload(flavor: WebhookFlavor) {
};
case "generic":
return {
- type: "test",
- message: "Your openstatus webhook is configured correctly.",
- timestamp: new Date().toISOString(),
+ version: WEBHOOK_PAYLOAD_VERSION,
+ type: "test" as const,
+ data: {
+ test: {
+ message: "Your openstatus webhook is configured correctly.",
+ timestamp: new Date().toISOString(),
+ },
+ },
};
}
}
diff --git a/packages/subscriptions/src/payload.ts b/packages/subscriptions/src/payload.ts
index aeba254e..df509aee 100644
--- a/packages/subscriptions/src/payload.ts
+++ b/packages/subscriptions/src/payload.ts
@@ -30,11 +30,12 @@ export const statusReportWebhookSchema = z.object({
status_report: z.object({
id: z.number().int(),
title: z.string(),
+ url: z.url(),
update: z.object({
id: z.number().int(),
status: statusReportStatusSchema,
message: z.string(),
- created_at: z.string(),
+ occurred_at: z.string(),
}),
page: pageSchema,
components: z.array(componentSchema),
@@ -50,6 +51,7 @@ export const maintenanceWebhookSchema = z.object({
maintenance: z.object({
id: z.number().int(),
title: z.string(),
+ url: z.url(),
message: z.string(),
starts_at: z.string().optional(),
ends_at: z.string().optional(),
@@ -60,9 +62,21 @@ export const maintenanceWebhookSchema = z.object({
subscription: subscriptionSchema,
});
+export const testWebhookSchema = z.object({
+ version: z.literal(WEBHOOK_PAYLOAD_VERSION),
+ type: z.literal("test"),
+ data: z.object({
+ test: z.object({
+ message: z.string(),
+ timestamp: z.string(),
+ }),
+ }),
+});
+
export const webhookPayloadSchema = z.discriminatedUnion("type", [
statusReportWebhookSchema,
maintenanceWebhookSchema,
+ testWebhookSchema,
]);
export type WebhookPayload = z.infer;
diff --git a/packages/subscriptions/tsconfig.json b/packages/subscriptions/tsconfig.json
index 0b8670d6..868037cf 100644
--- a/packages/subscriptions/tsconfig.json
+++ b/packages/subscriptions/tsconfig.json
@@ -1,5 +1,13 @@
{
- "extends": "@openstatus/tsconfig/react-library.json",
+ "extends": "@openstatus/tsconfig/base.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "DOM"],
+ "module": "ESNext",
+ "target": "ES2022",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "types": ["bun"]
+ },
"include": [".", "src"],
"exclude": ["dist", "build", "node_modules"]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8c362b6d..4fcd3756 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1195,7 +1195,7 @@ importers:
version: 5.0.0-beta.31(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
next-intl:
specifier: 'catalog:'
- version: 4.12.0(next@16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ version: 4.12.0(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
next-plausible:
specifier: 'catalog:'
version: 3.12.5(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -2601,6 +2601,9 @@ importers:
'@openstatus/utils':
specifier: workspace:*
version: link:../utils
+ effect:
+ specifier: 'catalog:'
+ version: 3.21.2
zod:
specifier: 'catalog:'
version: 4.1.13
@@ -18056,7 +18059,7 @@ snapshots:
next-intl-swc-plugin-extractor@4.12.0: {}
- next-intl@4.12.0(next@16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
+ next-intl@4.12.0(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@formatjs/intl-localematcher': 0.8.1
'@parcel/watcher': 2.5.1