diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index c4b7818f..9edff1c7 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/subscriptions/src/channels/webhook.test.ts b/packages/subscriptions/src/channels/webhook.test.ts
index acb94676..640e933d 100644
--- a/packages/subscriptions/src/channels/webhook.test.ts
+++ b/packages/subscriptions/src/channels/webhook.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
+import { webhookPayloadSchema } from "../payload";
import type { PageUpdate, Subscription } from "../types";
import {
buildGenericPayload,
@@ -306,15 +307,24 @@ describe("buildGenericPayload", () => {
message: "Looking into it.",
date: "2026-04-21T09:59:58Z",
updateId: 42,
- pageComponentsWithId: [{ id: 7, name: "API" }],
+ componentsWithImpact: [
+ { id: 7, name: "API", impact: "major_outage" },
+ {
+ id: 8,
+ name: "Dashboard",
+ impact: "degraded_performance",
+ },
+ ],
});
const payload = buildGenericPayload(update, sub, links) as {
+ version: string;
type: string;
data: { status_report: Record };
subscription: { manage_url: string; unsubscribe_url: string };
};
+ expect(payload.version).toBe("1");
expect(payload.type).toBe("status_report");
expect(payload.data.status_report).toMatchObject({
id: 12,
@@ -325,8 +335,20 @@ describe("buildGenericPayload", () => {
message: "Looking into it.",
created_at: "2026-04-21T09:59:58Z",
},
- page: { id: 42, name: "Acme", slug: "acme" },
- components: [{ id: 7, name: "API" }],
+ page: {
+ id: 42,
+ name: "Acme",
+ slug: "acme",
+ url: "https://acme.openstatus.dev",
+ },
+ components: [
+ { id: 7, name: "API", impact: "major_outage" },
+ {
+ id: 8,
+ name: "Dashboard",
+ impact: "degraded_performance",
+ },
+ ],
});
expect(payload.subscription).toEqual({
manage_url: links.manageUrl,
@@ -347,10 +369,12 @@ describe("buildGenericPayload", () => {
});
const payload = buildGenericPayload(update, sub, links) as {
+ version: string;
type: string;
data: { maintenance: Record };
};
+ expect(payload.version).toBe("1");
expect(payload.type).toBe("maintenance");
expect(payload.data.maintenance).toMatchObject({
id: 17,
@@ -358,11 +382,47 @@ describe("buildGenericPayload", () => {
message: "Rolling primary.",
starts_at: "2026-04-22T02:00:00Z",
ends_at: "2026-04-22T03:00:00Z",
- page: { id: 42, name: "Acme", slug: "acme" },
- components: [{ id: 7, name: "API" }],
+ page: {
+ id: 42,
+ name: "Acme",
+ slug: "acme",
+ url: "https://acme.openstatus.dev",
+ },
+ // maintenance has no per-component impact: falls back to operational
+ components: [{ id: 7, name: "API", impact: "operational" }],
});
expect(payload.data.maintenance).not.toHaveProperty("status");
});
+
+ test("output satisfies the canonical webhookPayloadSchema contract", () => {
+ const sub = makeSub({ pageId: 42, pageName: "Acme", pageSlug: "acme" });
+ const report = buildGenericPayload(
+ makeUpdate({
+ id: 12,
+ status: "investigating",
+ updateId: 42,
+ componentsWithImpact: [
+ { id: 7, name: "API", impact: "partial_outage" },
+ ],
+ }),
+ sub,
+ links,
+ );
+ const maintenance = buildGenericPayload(
+ makeUpdate({
+ id: 17,
+ status: "maintenance",
+ startsAt: "2026-04-22T02:00:00Z",
+ endsAt: "2026-04-22T03:00:00Z",
+ pageComponentsWithId: [{ id: 7, name: "API" }],
+ }),
+ sub,
+ links,
+ );
+
+ expect(webhookPayloadSchema.safeParse(report).success).toBe(true);
+ expect(webhookPayloadSchema.safeParse(maintenance).success).toBe(true);
+ });
});
// ─── buildTestPayload ─────────────────────────────────────────────────────────
diff --git a/packages/subscriptions/src/channels/webhook.ts b/packages/subscriptions/src/channels/webhook.ts
index 25b09e1c..94d0402f 100644
--- a/packages/subscriptions/src/channels/webhook.ts
+++ b/packages/subscriptions/src/channels/webhook.ts
@@ -2,12 +2,14 @@ import { COLORS, COLOR_DECIMALS } from "@openstatus/notification-base";
import { assertSafeUrl } from "@openstatus/utils";
import { z } from "zod";
+import { WEBHOOK_PAYLOAD_VERSION } from "../payload";
import type { PageUpdate, Subscription } from "../types";
export type WebhookFlavor = "slack" | "discord" | "generic";
const SLACK_PREFIX = "https://hooks.slack.com/services/";
const DISCORD_PREFIX = "https://discord.com/api/webhooks/";
+const TIMEOUT_MS = 5000; // 5 seconds
/**
* Classify a webhook URL by its incoming-webhook origin so we can emit
@@ -60,19 +62,20 @@ function statusColor(status: PageUpdate["status"]): StatusColor {
}
}
+const webhookConfigSchema = z.object({
+ headers: z
+ .array(
+ z.object({
+ key: z.string().min(1),
+ value: z.string(),
+ }),
+ )
+ .optional(),
+ secret: z.string().optional(),
+});
+
export async function validateWebhookConfig(config: unknown) {
- const schema = z.object({
- headers: z
- .array(
- z.object({
- key: z.string().min(1),
- value: z.string(),
- }),
- )
- .optional(),
- secret: z.string().optional(),
- });
- const result = schema.safeParse(config);
+ const result = webhookConfigSchema.safeParse(config);
return { valid: result.success, error: result.error?.message };
}
@@ -99,7 +102,7 @@ export async function sendWebhookVerification(
token: subscription.token,
verifyUrl,
}),
- signal: AbortSignal.timeout(10000),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!response.ok) {
@@ -255,10 +258,19 @@ export function buildGenericPayload(
id: subscription.pageId,
name: subscription.pageName,
slug: subscription.pageSlug,
+ url: resolveStatusPageOrigin(subscription),
};
const components =
- pageUpdate.pageComponentsWithId ??
- pageUpdate.pageComponents.map((name, i) => ({ id: i, name }));
+ pageUpdate.componentsWithImpact ??
+ pageUpdate.pageComponentsWithId?.map((c) => ({
+ ...c,
+ impact: "operational" as const,
+ })) ??
+ pageUpdate.pageComponents.map((name, i) => ({
+ id: i, // synthetic id: legacy bare-names path has no real pageComponentId
+ name,
+ impact: "operational" as const,
+ }));
const subscriptionBlock = {
manage_url: links.manageUrl,
unsubscribe_url: links.unsubscribeUrl,
@@ -266,6 +278,7 @@ export function buildGenericPayload(
if (pageUpdate.status === "maintenance") {
return {
+ version: WEBHOOK_PAYLOAD_VERSION,
type: "maintenance" as const,
data: {
maintenance: {
@@ -282,7 +295,12 @@ export function buildGenericPayload(
};
}
+ if (pageUpdate.updateId == null) {
+ throw new Error("status_report webhook payload requires updateId");
+ }
+
return {
+ version: WEBHOOK_PAYLOAD_VERSION,
type: "status_report" as const,
data: {
status_report: {
@@ -360,11 +378,9 @@ export async function sendWebhookNotifications(
"User-Agent": "OpenStatus-Webhooks/1.0",
};
- if (config.headers) {
- for (const header of config.headers as {
- key: string;
- value: string;
- }[]) {
+ const parsedConfig = webhookConfigSchema.safeParse(config);
+ if (parsedConfig.success) {
+ for (const header of parsedConfig.data.headers ?? []) {
headers[header.key] = header.value;
}
}
@@ -375,7 +391,7 @@ export async function sendWebhookNotifications(
method: "POST",
headers,
body: JSON.stringify(payload),
- signal: AbortSignal.timeout(10000),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!response.ok) {
@@ -468,7 +484,7 @@ export async function sendTestWebhookRequest(input: {
method: "POST",
headers,
body: JSON.stringify(buildTestPayload(flavor)),
- signal: AbortSignal.timeout(10000),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!response.ok) {
diff --git a/packages/subscriptions/src/dispatcher.ts b/packages/subscriptions/src/dispatcher.ts
index f06aab70..08e51301 100644
--- a/packages/subscriptions/src/dispatcher.ts
+++ b/packages/subscriptions/src/dispatcher.ts
@@ -5,6 +5,7 @@ import {
pageSubscriber,
statusReportUpdate,
} from "@openstatus/db/src/schema";
+import { currentImpactsFromUpdates } from "@openstatus/db/src/schema/page_components/constants";
import { getChannel } from "./channels";
import type { PageUpdate, Subscription } from "./types";
@@ -18,9 +19,15 @@ export async function dispatchStatusReportUpdate(statusReportUpdateId: number) {
with: {
statusReport: {
with: {
+ // Membership: the full set of components on the report (id + name).
statusReportsToPageComponents: {
with: { pageComponent: true },
},
+ // All updates' impact rows — current state is reconstructed from the
+ // delta history, not from any single update's rows.
+ statusReportUpdates: {
+ with: { statusReportUpdateToPageComponents: true },
+ },
},
},
},
@@ -40,6 +47,19 @@ export async function dispatchStatusReportUpdate(statusReportUpdateId: number) {
(i) => i.pageComponent,
);
+ const currentImpacts = currentImpactsFromUpdates(
+ update.statusReport.statusReportUpdates.map((u) => ({
+ id: u.id,
+ date: u.date,
+ componentImpacts: u.statusReportUpdateToPageComponents,
+ })),
+ );
+ const componentsWithImpact = pageComponents.map((c) => ({
+ id: c.id,
+ name: c.name,
+ impact: currentImpacts.get(c.id) ?? "operational",
+ }));
+
await dispatchPageUpdate({
id: update.statusReport.id,
pageId: update.statusReport.pageId,
@@ -54,6 +74,7 @@ export async function dispatchStatusReportUpdate(statusReportUpdateId: number) {
id: c.id,
name: c.name,
})),
+ componentsWithImpact,
});
}
diff --git a/packages/subscriptions/src/payload.ts b/packages/subscriptions/src/payload.ts
new file mode 100644
index 00000000..aeba254e
--- /dev/null
+++ b/packages/subscriptions/src/payload.ts
@@ -0,0 +1,68 @@
+import { pageComponentImpactSchema } from "@openstatus/db/src/schema/page_components/validation";
+import { statusReportStatusSchema } from "@openstatus/db/src/schema/status_reports/validation";
+import { z } from "zod";
+
+/** Bump on any breaking change — external consumers (statuspage-socials-notifier) pin to this version. */
+export const WEBHOOK_PAYLOAD_VERSION = "1" as const;
+
+const componentSchema = z.object({
+ id: z.number().int(),
+ name: z.string(),
+ impact: pageComponentImpactSchema,
+});
+
+const pageSchema = z.object({
+ id: z.number().int(),
+ name: z.string(),
+ slug: z.string(),
+ url: z.url(),
+});
+
+const subscriptionSchema = z.object({
+ manage_url: z.string().nullable(),
+ unsubscribe_url: z.string().nullable(),
+});
+
+export const statusReportWebhookSchema = z.object({
+ version: z.literal(WEBHOOK_PAYLOAD_VERSION),
+ type: z.literal("status_report"),
+ data: z.object({
+ status_report: z.object({
+ id: z.number().int(),
+ title: z.string(),
+ update: z.object({
+ id: z.number().int(),
+ status: statusReportStatusSchema,
+ message: z.string(),
+ created_at: z.string(),
+ }),
+ page: pageSchema,
+ components: z.array(componentSchema),
+ }),
+ }),
+ subscription: subscriptionSchema,
+});
+
+export const maintenanceWebhookSchema = z.object({
+ version: z.literal(WEBHOOK_PAYLOAD_VERSION),
+ type: z.literal("maintenance"),
+ data: z.object({
+ maintenance: z.object({
+ id: z.number().int(),
+ title: z.string(),
+ message: z.string(),
+ starts_at: z.string().optional(),
+ ends_at: z.string().optional(),
+ page: pageSchema,
+ components: z.array(componentSchema),
+ }),
+ }),
+ subscription: subscriptionSchema,
+});
+
+export const webhookPayloadSchema = z.discriminatedUnion("type", [
+ statusReportWebhookSchema,
+ maintenanceWebhookSchema,
+]);
+
+export type WebhookPayload = z.infer;
diff --git a/packages/subscriptions/src/types.ts b/packages/subscriptions/src/types.ts
index 908f0fca..f0c95d7c 100644
--- a/packages/subscriptions/src/types.ts
+++ b/packages/subscriptions/src/types.ts
@@ -1,17 +1,19 @@
// Core types for the subscription system
+import type { PageComponentImpact } from "@openstatus/db/src/schema/page_components/constants";
+
export interface Subscription {
id: number;
pageId: number;
- pageName: string; // For templates
- pageSlug: string; // For management URLs
- customDomain?: string | null; // For custom domain URLs
+ pageName: string;
+ pageSlug: string;
+ customDomain?: string | null;
componentIds: number[]; // Empty = entire page
- // Channel (only ONE identifier populated based on channelType)
+ // Only ONE identifier populated based on channelType
channelType: "email" | "webhook";
- email?: string; // For email channel
- webhookUrl?: string; // For webhook channel
+ email?: string;
+ webhookUrl?: string;
channelConfig?: string; // JSON string for headers, secrets, etc.
token?: string;
@@ -34,8 +36,8 @@ export interface PageUpdate {
| "resolved"
| "maintenance";
message: string;
- pageComponentIds: number[]; // For subscription matching
- pageComponents: string[]; // Component names for display
+ pageComponentIds: number[];
+ pageComponents: string[];
date: string; // can be single string or "from - to"
// Optional fields consumed by the prepared (staged) generic webhook payload.
@@ -43,6 +45,12 @@ export interface PageUpdate {
// builders, which key off the fields above.
updateId?: number; // statusReportUpdate.id (status reports only)
pageComponentsWithId?: { id: number; name: string }[];
+ // Current impact per component as of this update (not the raw delta).
+ componentsWithImpact?: {
+ id: number;
+ name: string;
+ impact: PageComponentImpact;
+ }[];
startsAt?: string; // maintenance only, ISO
endsAt?: string; // maintenance only, ISO
}