1
29import { z } from "zod";
30
31
32export interface WebhookEventCatalogEntry {
33
34 name: string;
35
36 description: string;
37
38 payloadSchema: z.ZodTypeAny;
39
40 example: Record<string, unknown>;
41
42 firedBy: string;
43}
44
45
46export const WEBHOOK_EVENT_CATALOG: readonly WebhookEventCatalogEntry[] = [
47 {
48 name: "page.created",
49 description: "A page was created (including duplicated and templated pages).",
50 firedBy: "pages service create/duplicate/template",
51 payloadSchema: z.object({
52 workspaceId: z.string(),
53 pageId: z.string(),
54 title: z.string(),
55 parentId: z.string().nullable().optional(),
56 databaseId: z.string().optional(),
57 fromTemplate: z.string().optional()
58 }),
59 example: {
60 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
61 pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8",
62 title: "Q2 Launch Checklist",
63 parentId: null,
64 databaseId: undefined
65 }
66 },
67 {
68 name: "page.updated",
69 description:
70 "Page metadata/move/archive changed (block edits also arrive as page.updated).",
71 firedBy: "pages service update/move/archive/restore; block ops",
72 payloadSchema: z
73 .object({
74 workspaceId: z.string(),
75 pageId: z.string(),
76 fields: z.array(z.string()).optional(),
77 blockIds: z.array(z.string()).optional(),
78 changed: z.string().optional(),
79 restoredVersion: z.string().optional()
80 })
81 .passthrough(),
82 example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8", fields: ["title"] }
83 },
84 {
85 name: "page.deleted",
86 description: "A page was moved to the trash (or permanently purged).",
87 firedBy: "pages service delete/trash",
88 payloadSchema: z.object({
89 workspaceId: z.string(),
90 pageId: z.string(),
91 permanent: z.boolean(),
92 pageIds: z.array(z.string())
93 }),
94 example: {
95 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
96 pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8",
97 permanent: false,
98 pageIds: ["pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8"]
99 }
100 },
101 {
102 name: "page.shared",
103 description:
104 "The effective visibility of a page changed (share link created/changed, team move).",
105 firedBy: "share service create/update share link",
106 payloadSchema: z.object({
107 workspaceId: z.string(),
108 shareId: z.string(),
109 targetType: z.string(),
110 targetId: z.string(),
111 scope: z.string()
112 }),
113 example: {
114 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
115 shareId: "shr_01J9ZKQA2C4D6E8F0A2B4C6D8E",
116 targetType: "page",
117 targetId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8",
118 scope: "PUBLIC_LINK_VIEW"
119 }
120 },
121 {
122 name: "database.created",
123 description: "A database was created.",
124 firedBy: "pages service create (type: database)",
125 payloadSchema: z.object({
126 workspaceId: z.string(),
127 databaseId: z.string(),
128 pageId: z.string(),
129 propertyCount: z.number()
130 }),
131 example: {
132 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
133 databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0",
134 pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8",
135 propertyCount: 2
136 }
137 },
138 {
139 name: "database.updated",
140 description: "Database metadata or schema (property/view) changed.",
141 firedBy: "databases service update/property/view ops",
142 payloadSchema: z
143 .object({
144 workspaceId: z.string(),
145 databaseId: z.string(),
146 fields: z.array(z.string()).optional(),
147 deleted: z.boolean().optional(),
148 propertyId: z.string().optional(),
149 viewId: z.string().optional(),
150 reordered: z.boolean().optional()
151 })
152 .passthrough(),
153 example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0", propertyId: "prop_01J9ZKQ8F5G7H9J1K3L5M7N9P1" }
154 },
155 {
156 name: "record.created",
157 description: "A record was created (including via a form submission).",
158 firedBy: "databases query service create / form submission",
159 payloadSchema: z.object({
160 workspaceId: z.string(),
161 databaseId: z.string(),
162 recordId: z.string(),
163 changedProperties: z.array(z.string())
164 }),
165 example: {
166 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
167 databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0",
168 recordId: "rpt_01J9ZKR3F4A6B8C0D2E4F6A8B0",
169 changedProperties: ["*"]
170 }
171 },
172 {
173 name: "record.updated",
174 description: "A record's properties changed.",
175 firedBy: "databases query service update / bulk ops",
176 payloadSchema: z.object({
177 workspaceId: z.string(),
178 databaseId: z.string(),
179 recordId: z.string(),
180 changedProperties: z.array(z.string())
181 }),
182 example: {
183 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
184 databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0",
185 recordId: "rpt_01J9ZKR3F4A6B8C0D2E4F6A8B0",
186 changedProperties: ["Title"]
187 }
188 },
189 {
190 name: "comment.created",
191 description: "A new top-level comment (or reply) was added.",
192 firedBy: "comments service create",
193 payloadSchema: z.object({
194 workspaceId: z.string(),
195 commentId: z.string(),
196 replyId: z.string().optional(),
197 pageId: z.string().optional(),
198 blockId: z.string().nullable().optional(),
199 recordId: z.string().nullable().optional()
200 }),
201 example: {
202 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
203 commentId: "cmt_01J9ZKQA1B3C5D7E9F1A3B5C7D",
204 pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8",
205 blockId: null,
206 recordId: null
207 }
208 },
209 {
210 name: "form.submitted",
211 description: "A public or in-workspace form was submitted.",
212 firedBy: "public-forms service submit",
213 payloadSchema: z.object({
214 workspaceId: z.string(),
215 formId: z.string(),
216 slug: z.string(),
217 submissionId: z.string(),
218 recordId: z.string().optional(),
219 databaseId: z.string().optional(),
220 questionCount: z.number(),
221 anonymous: z.boolean(),
222 ip: z.string().nullable()
223 }),
224 example: {
225 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
226 formId: "frm_01J9ZKQA7C9D1E3F5A7B9C1D3E5",
227 slug: "feedback",
228 submissionId: "sub_01J9ZKQA8D0E2F4G6B8C0D2E4F6",
229 recordId: "rpt_01J9ZKQF5E6F7A8B9C0D1E2F3A",
230 databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0",
231 questionCount: 3,
232 anonymous: false,
233 ip: "203.0.113.0/24"
234 }
235 },
236 {
237 name: "automation.executed",
238 description: "An automation run finished (success or failure).",
239 firedBy: "worker automations processor (run finished)",
240 payloadSchema: z
241 .object({
242 workspaceId: z.string(),
243 automationId: z.string(),
244 runId: z.string(),
245 status: z.string()
246 })
247 .passthrough(),
248 example: {
249 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
250 automationId: "auto_01J9ZKPE4G6H8J0L2M4N6P8Q0",
251 runId: "run_01J9ZKPF5H7I9L1M3N5P7Q9R1",
252 status: "SUCCEEDED"
253 }
254 },
255 {
256 name: "file.uploaded",
257 description: "A file reached the READY state in the upload pipeline.",
258 firedBy: "files pipeline (READY finalization)",
259 payloadSchema: z
260 .object({
261 workspaceId: z.string(),
262 fileId: z.string(),
263 name: z.string().optional(),
264 size: z.number().optional(),
265 mimeType: z.string().optional()
266 })
267 .passthrough(),
268 example: {
269 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
270 fileId: "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4",
271 name: "spec.pdf",
272 size: 18244,
273 mimeType: "application/pdf"
274 }
275 },
276 {
277 name: "api_key.created",
278 description: "An API key was created (never includes the raw key).",
279 firedBy: "api-keys service create",
280 payloadSchema: z.object({
281 workspaceId: z.string(),
282 apiKeyId: z.string(),
283 name: z.string(),
284 scopes: z.array(z.string())
285 }),
286 example: {
287 workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
288 apiKeyId: "apk_01J9ZKPS9J2L4M6N8P0Q2R4S6",
289 name: "CI pipeline",
290 scopes: ["READ", "WRITE"]
291 }
292 }
293];
294
295
296export const WEBHOOK_EVENT_NAMES: readonly string[] = WEBHOOK_EVENT_CATALOG.map((e) => e.name);
297
298
299export function webhookEventCatalogEntry(name: string): WebhookEventCatalogEntry | null {
300 return WEBHOOK_EVENT_CATALOG.find((e) => e.name === name) ?? null;
301}
302
303
304export function isWebhookEvent(name: string): boolean {
305 return name === "*" || WEBHOOK_EVENT_NAMES.includes(name);
306}
307
308
313export function catalogSelfTestFailures(): string[] {
314 const bad: string[] = [];
315 for (const e of WEBHOOK_EVENT_CATALOG) {
316 const res = e.payloadSchema.safeParse(e.example);
317 if (!res.success) bad.push(e.name);
318 }
319 return bad;
320}
321
322
323export function validateWebhookEvents(events: string[]): { valid: boolean; unknown: string[] } {
324 const unknown = events.filter((e) => !isWebhookEvent(e));
325 return { valid: unknown.length === 0, unknown };
326}
327