create-ncblock 0.0.39 → 0.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/utils/templates.ts +37 -7
- package/sdk-version.json +1 -1
- package/templates/worker/.agents/INSTRUCTIONS.md +536 -0
- package/templates/worker/.agents/skills/auth-guide/SKILL.md +227 -0
- package/templates/worker/.agents/skills/sync/SKILL.md +368 -0
- package/templates/worker/.agents/skills/sync-debug/SKILL.md +101 -0
- package/templates/worker/.agents/skills/sync-guide/SKILL.md +253 -0
- package/templates/worker/.agents/skills/sync-guide/api-pagination-patterns.md +661 -0
- package/templates/worker/.agents/skills/sync-guide/examples/incremental-basic.ts +103 -0
- package/templates/worker/.agents/skills/sync-guide/examples/incremental-bimodal.ts +207 -0
- package/templates/worker/.agents/skills/sync-guide/examples/incremental-events.ts +132 -0
- package/templates/worker/.agents/skills/sync-guide/examples/replace-paginated.ts +79 -0
- package/templates/worker/.agents/skills/sync-guide/examples/replace-simple.ts +57 -0
- package/templates/worker/.agents/skills/sync-validate/SKILL.md +60 -0
- package/templates/worker/.claudeignore +2 -0
- package/templates/worker/.codexignore +2 -0
- package/templates/worker/.examples/automation-example.ts +60 -0
- package/templates/worker/.examples/oauth-example.ts +79 -0
- package/templates/worker/.examples/sync-example.ts +184 -0
- package/templates/worker/.examples/tool-example.ts +37 -0
- package/templates/worker/.examples/webhook-example.ts +66 -0
- package/templates/worker/README.md +765 -0
- package/templates/worker/_gitignore +6 -0
- package/templates/worker/docs/custom-tool.png +0 -0
- package/templates/worker/notionhq-workers-0.4.0.tgz +0 -0
- package/templates/worker/package.json +25 -0
- package/templates/worker/src/index.ts +8 -0
- package/templates/worker/tsconfig.json +16 -0
- package/templates/worker/views/empty/AGENTS.md +67 -0
- package/templates/worker/views/empty/README.md +10 -0
- package/templates/worker/views/empty/_gitignore +2 -0
- package/templates/worker/views/empty/custom_blocks.json +4 -0
- package/templates/worker/views/empty/index.html +15 -0
- package/templates/worker/views/empty/package.json +23 -0
- package/templates/worker/views/empty/src/index.css +33 -0
- package/templates/worker/views/empty/src/index.tsx +20 -0
- package/templates/worker/views/empty/tsconfig.json +17 -0
- package/templates/worker/views/empty/vite.config.ts +7 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
# Notion Workers [beta]
|
|
2
|
+
|
|
3
|
+
A worker is a small Node/TypeScript program hosted by Notion. Workers have three capability types:
|
|
4
|
+
|
|
5
|
+
- **Tools** — callable functions for Notion custom agents
|
|
6
|
+
- **Syncs** — sync external data sources into Notion
|
|
7
|
+
- **Webhooks** — receive HTTP events from external services
|
|
8
|
+
|
|
9
|
+
> [!NOTE]
|
|
10
|
+
>
|
|
11
|
+
> Notion Workers is currently in beta. APIs, CLI commands, templates, and
|
|
12
|
+
> hosting behavior may continue to evolve.
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
Install the `ntn` CLI and scaffold a new worker:
|
|
17
|
+
|
|
18
|
+
```shell
|
|
19
|
+
curl -fsSL https://ntn.dev | bash
|
|
20
|
+
ntn workers new my-worker
|
|
21
|
+
cd my-worker
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> [!TIP]
|
|
25
|
+
> The template ships with a `/sync` slash command in your coding agent (Claude Code, Codex, etc.) that scaffolds a new sync capability interactively — it asks about your data source, mode, and pagination, then generates working code.
|
|
26
|
+
|
|
27
|
+
## Tools quickstart
|
|
28
|
+
|
|
29
|
+
A tool gives your Notion agent a new ability. Here's a simple greeting tool in `src/index.ts`:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { Worker } from "@notionhq/workers";
|
|
33
|
+
import { j } from "@notionhq/workers/schema-builder";
|
|
34
|
+
|
|
35
|
+
const worker = new Worker();
|
|
36
|
+
export default worker;
|
|
37
|
+
|
|
38
|
+
worker.tool("sayHello", {
|
|
39
|
+
title: "Say Hello",
|
|
40
|
+
description: "Returns a friendly greeting for the given name.",
|
|
41
|
+
schema: j.object({
|
|
42
|
+
name: j.string().describe("The name to greet."),
|
|
43
|
+
}),
|
|
44
|
+
execute: ({ name }) => `Hello, ${name}!`,
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Deploy and add the tool to your agent:
|
|
49
|
+
|
|
50
|
+
```shell
|
|
51
|
+
ntn workers deploy
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+

|
|
55
|
+
|
|
56
|
+
## Syncs quickstart
|
|
57
|
+
|
|
58
|
+
A sync pulls data from an external source into a Notion database.
|
|
59
|
+
|
|
60
|
+
**Fast path:** run the `/sync` slash command in your coding agent (Claude Code, Codex, etc.) to scaffold a sync interactively. It walks through the data source, picks `replace` vs `incremental` + backfill, designs pagination, and writes the code into `src/index.ts`.
|
|
61
|
+
|
|
62
|
+
**Manual path:** here's a simple sync in `src/index.ts`:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { Worker } from "@notionhq/workers";
|
|
66
|
+
import * as Builder from "@notionhq/workers/builder";
|
|
67
|
+
import * as Schema from "@notionhq/workers/schema";
|
|
68
|
+
|
|
69
|
+
const worker = new Worker();
|
|
70
|
+
export default worker;
|
|
71
|
+
|
|
72
|
+
const issues = worker.database("issues", {
|
|
73
|
+
type: "managed",
|
|
74
|
+
initialTitle: "Issues",
|
|
75
|
+
primaryKeyProperty: "Issue ID",
|
|
76
|
+
schema: {
|
|
77
|
+
properties: {
|
|
78
|
+
Title: Schema.title(),
|
|
79
|
+
"Issue ID": Schema.richText(),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const issueTracker = worker.pacer("issueTracker", { allowedRequests: 10, intervalMs: 1000 });
|
|
85
|
+
|
|
86
|
+
worker.sync("issuesSync", {
|
|
87
|
+
database: issues,
|
|
88
|
+
execute: async () => {
|
|
89
|
+
await issueTracker.wait();
|
|
90
|
+
const items = await fetchIssues(); // your data source
|
|
91
|
+
return {
|
|
92
|
+
changes: items.map((issue) => ({
|
|
93
|
+
type: "upsert" as const,
|
|
94
|
+
key: issue.id,
|
|
95
|
+
properties: {
|
|
96
|
+
Title: Builder.title(issue.title),
|
|
97
|
+
"Issue ID": Builder.richText(issue.id),
|
|
98
|
+
},
|
|
99
|
+
})),
|
|
100
|
+
hasMore: false,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Deploy and your sync runs automatically on a schedule (default: every 30 minutes):
|
|
107
|
+
|
|
108
|
+
```shell
|
|
109
|
+
ntn workers deploy
|
|
110
|
+
ntn workers sync status
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Webhooks quickstart
|
|
114
|
+
|
|
115
|
+
A webhook exposes an HTTP endpoint that external services (GitHub, Stripe, etc.) can call to push events into your worker:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { Worker } from "@notionhq/workers";
|
|
119
|
+
|
|
120
|
+
const worker = new Worker();
|
|
121
|
+
export default worker;
|
|
122
|
+
|
|
123
|
+
worker.webhook("onExternalEvent", {
|
|
124
|
+
title: "External Event Handler",
|
|
125
|
+
description: "Processes incoming webhook requests",
|
|
126
|
+
execute: async (events) => {
|
|
127
|
+
for (const event of events) {
|
|
128
|
+
console.log("Delivery:", event.deliveryId);
|
|
129
|
+
console.log("Method:", event.method);
|
|
130
|
+
console.log("Body:", event.body);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Deploy and grab the webhook URL to give to the external service:
|
|
137
|
+
|
|
138
|
+
```shell
|
|
139
|
+
ntn workers deploy
|
|
140
|
+
ntn workers webhooks list
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Tools reference
|
|
144
|
+
|
|
145
|
+
### Schema builder
|
|
146
|
+
|
|
147
|
+
Use the schema builder (`j`) to define tool inputs. It auto-sets `required` and `additionalProperties`, and provides TypeScript type inference:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { j } from "@notionhq/workers/schema-builder";
|
|
151
|
+
|
|
152
|
+
schema: j.object({
|
|
153
|
+
query: j.string().describe("Search query"),
|
|
154
|
+
limit: j.number().describe("Max results").nullable(),
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Use `.nullable()` to mark a field as optional. Use `.describe()` to tell the agent what the field is for.
|
|
159
|
+
|
|
160
|
+
### Output schema
|
|
161
|
+
|
|
162
|
+
Optionally define the shape of what your tool returns:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
worker.tool("search", {
|
|
166
|
+
title: "Search",
|
|
167
|
+
description: "Search for items",
|
|
168
|
+
schema: j.object({
|
|
169
|
+
query: j.string().describe("Search query"),
|
|
170
|
+
}),
|
|
171
|
+
outputSchema: j.object({
|
|
172
|
+
results: j.array(j.string()),
|
|
173
|
+
}),
|
|
174
|
+
execute: async ({ query }) => {
|
|
175
|
+
return { results: [] };
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Execute function
|
|
181
|
+
|
|
182
|
+
The `execute` function receives the validated input and a context object:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
execute: async (input, context) => {
|
|
186
|
+
// context.notion — authenticated Notion SDK client
|
|
187
|
+
const { query, limit = 10 } = input;
|
|
188
|
+
return { results: [] };
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Syncs reference
|
|
193
|
+
|
|
194
|
+
### Databases and schema
|
|
195
|
+
|
|
196
|
+
Declare databases with `worker.database()` and define schemas with `Schema` helpers. Build property values with `Builder`:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import * as Schema from "@notionhq/workers/schema";
|
|
200
|
+
import * as Builder from "@notionhq/workers/builder";
|
|
201
|
+
|
|
202
|
+
const records = worker.database("records", {
|
|
203
|
+
type: "managed",
|
|
204
|
+
initialTitle: "My Data",
|
|
205
|
+
primaryKeyProperty: "ID",
|
|
206
|
+
schema: {
|
|
207
|
+
properties: {
|
|
208
|
+
Name: Schema.title(),
|
|
209
|
+
ID: Schema.richText(),
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// In execute, return changes with matching property values:
|
|
215
|
+
properties: {
|
|
216
|
+
Name: Builder.title("Item name"),
|
|
217
|
+
ID: Builder.richText("item-1"),
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`primaryKeyProperty` specifies which property to use as the unique key for each record.
|
|
222
|
+
|
|
223
|
+
### Sync modes
|
|
224
|
+
|
|
225
|
+
**Replace** (`mode: "replace"`) — each sync cycle returns the full dataset. After the final `hasMore: false`, any records not seen are deleted:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
worker.sync("teamsSync", {
|
|
229
|
+
database: teams,
|
|
230
|
+
mode: "replace",
|
|
231
|
+
execute: async (state) => {
|
|
232
|
+
const page = state?.page ?? 1;
|
|
233
|
+
await myApi.wait();
|
|
234
|
+
const { items, hasMore } = await fetchPage(page, 100);
|
|
235
|
+
return {
|
|
236
|
+
changes: items.map((item) => ({
|
|
237
|
+
type: "upsert" as const,
|
|
238
|
+
key: item.id,
|
|
239
|
+
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
240
|
+
})),
|
|
241
|
+
hasMore,
|
|
242
|
+
nextState: hasMore ? { page: page + 1 } : undefined,
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Incremental** (`mode: "incremental"`) — each cycle returns only what changed since the last run. Records not mentioned are left unchanged. Deletions must be explicit:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
worker.sync("eventsSync", {
|
|
252
|
+
database: events,
|
|
253
|
+
mode: "incremental",
|
|
254
|
+
execute: async (state) => {
|
|
255
|
+
await myApi.wait();
|
|
256
|
+
const { upserts, deletes, nextCursor } = await fetchChanges(state?.cursor);
|
|
257
|
+
return {
|
|
258
|
+
changes: [
|
|
259
|
+
...upserts.map((item) => ({
|
|
260
|
+
type: "upsert" as const,
|
|
261
|
+
key: item.id,
|
|
262
|
+
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
263
|
+
})),
|
|
264
|
+
...deletes.map((id) => ({ type: "delete" as const, key: id })),
|
|
265
|
+
],
|
|
266
|
+
hasMore: Boolean(nextCursor),
|
|
267
|
+
nextState: nextCursor ? { cursor: nextCursor } : undefined,
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Use `replace` for smaller datasets (<1k records). Use `incremental` for larger datasets or when the API supports change tracking.
|
|
274
|
+
|
|
275
|
+
### Pagination
|
|
276
|
+
|
|
277
|
+
Syncs run as a chain of `execute` calls within a sync cycle:
|
|
278
|
+
|
|
279
|
+
1. Return changes with `hasMore: true` and a `nextState` value
|
|
280
|
+
2. The runtime calls `execute` again with that state
|
|
281
|
+
3. Continue until you return `hasMore: false`
|
|
282
|
+
|
|
283
|
+
`nextState` can be any serializable value — a cursor string, page number, timestamp, or object. Start with batch sizes of ~100 changes.
|
|
284
|
+
|
|
285
|
+
### Schedule
|
|
286
|
+
|
|
287
|
+
By default syncs run every 30 minutes. Set `schedule` to an interval like `"15m"`, `"1h"`, `"1d"` (min `"1m"`, max `"7d"`), `"continuous"`, or `"manual"`:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
worker.sync("fastSync", {
|
|
291
|
+
database: myDb,
|
|
292
|
+
schedule: "5m",
|
|
293
|
+
// ...
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Relations
|
|
298
|
+
|
|
299
|
+
Two databases can relate to each other using `Schema.relation()` and `Builder.relation()`:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
const projects = worker.database("projects", {
|
|
303
|
+
type: "managed",
|
|
304
|
+
initialTitle: "Projects",
|
|
305
|
+
primaryKeyProperty: "Project ID",
|
|
306
|
+
schema: {
|
|
307
|
+
properties: {
|
|
308
|
+
Name: Schema.title(),
|
|
309
|
+
"Project ID": Schema.richText(),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const tasks = worker.database("tasks", {
|
|
315
|
+
type: "managed",
|
|
316
|
+
initialTitle: "Tasks",
|
|
317
|
+
primaryKeyProperty: "Task ID",
|
|
318
|
+
schema: {
|
|
319
|
+
properties: {
|
|
320
|
+
Name: Schema.title(),
|
|
321
|
+
"Task ID": Schema.richText(),
|
|
322
|
+
Project: Schema.relation("projectsSync", {
|
|
323
|
+
twoWay: true,
|
|
324
|
+
relatedPropertyName: "Tasks",
|
|
325
|
+
}),
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
worker.sync("projectsSync", {
|
|
331
|
+
database: projects,
|
|
332
|
+
execute: async () => { /* ... */ },
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
worker.sync("tasksSync", {
|
|
336
|
+
database: tasks,
|
|
337
|
+
execute: async () => {
|
|
338
|
+
const items = await fetchTasks();
|
|
339
|
+
return {
|
|
340
|
+
changes: items.map((task) => ({
|
|
341
|
+
type: "upsert" as const,
|
|
342
|
+
key: task.id,
|
|
343
|
+
properties: {
|
|
344
|
+
Name: Builder.title(task.name),
|
|
345
|
+
"Task ID": Builder.richText(task.id),
|
|
346
|
+
Project: [Builder.relation(task.projectId)],
|
|
347
|
+
},
|
|
348
|
+
})),
|
|
349
|
+
hasMore: false,
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Sync CLI commands
|
|
356
|
+
|
|
357
|
+
```shell
|
|
358
|
+
ntn workers sync status # live-updating status
|
|
359
|
+
ntn workers sync trigger <key> --preview # preview output without writing to the database
|
|
360
|
+
ntn workers sync trigger <key> # trigger a real sync immediately
|
|
361
|
+
ntn workers sync state reset <key> # restart from scratch
|
|
362
|
+
ntn workers capabilities disable <key> # pause a sync
|
|
363
|
+
ntn workers capabilities enable <key> # resume a sync
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
> [!NOTE]
|
|
367
|
+
> Deploying does **not** reset sync state — syncs resume from their last cursor position. Use `ntn workers sync state reset <key>` to restart from scratch.
|
|
368
|
+
|
|
369
|
+
## Webhooks reference
|
|
370
|
+
|
|
371
|
+
### The event object
|
|
372
|
+
|
|
373
|
+
The `execute` function receives an array of `WebhookEvent` objects:
|
|
374
|
+
|
|
375
|
+
| Property | Type | Description |
|
|
376
|
+
| :-- | :-- | :-- |
|
|
377
|
+
| `deliveryId` | `string` | Unique ID for this delivery, stable across retries. |
|
|
378
|
+
| `body` | `Record<string, unknown>` | Parsed JSON body (`{}` if not a JSON object). |
|
|
379
|
+
| `rawBody` | `string` | Original request body as a string. Use for signature verification. |
|
|
380
|
+
| `headers` | `Record<string, string>` | Request headers (lowercased names). |
|
|
381
|
+
| `method` | `string` | HTTP method (webhook URLs accept `POST`). |
|
|
382
|
+
|
|
383
|
+
### Verifying requests
|
|
384
|
+
|
|
385
|
+
Most webhook providers sign requests with a shared secret. Verify using `event.rawBody` and `event.headers`, and throw `WebhookVerificationError` when verification fails:
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import * as crypto from "node:crypto";
|
|
389
|
+
import { WebhookVerificationError, Worker } from "@notionhq/workers";
|
|
390
|
+
|
|
391
|
+
const worker = new Worker();
|
|
392
|
+
export default worker;
|
|
393
|
+
|
|
394
|
+
function verifyGitHubSignature(
|
|
395
|
+
rawBody: string,
|
|
396
|
+
headers: Record<string, string>,
|
|
397
|
+
): void {
|
|
398
|
+
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
|
399
|
+
if (!secret) {
|
|
400
|
+
throw new WebhookVerificationError("GITHUB_WEBHOOK_SECRET not configured");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const signature = headers["x-hub-signature-256"];
|
|
404
|
+
if (!signature?.startsWith("sha256=")) {
|
|
405
|
+
throw new WebhookVerificationError("Invalid GitHub signature");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const expected = `sha256=${crypto
|
|
409
|
+
.createHmac("sha256", secret)
|
|
410
|
+
.update(rawBody)
|
|
411
|
+
.digest("hex")}`;
|
|
412
|
+
|
|
413
|
+
if (
|
|
414
|
+
signature.length !== expected.length ||
|
|
415
|
+
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
|
416
|
+
) {
|
|
417
|
+
throw new WebhookVerificationError("Invalid GitHub signature");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
worker.webhook("onGithubPush", {
|
|
422
|
+
title: "GitHub Push Webhook",
|
|
423
|
+
description: "Handles push events from GitHub repositories",
|
|
424
|
+
execute: async (events) => {
|
|
425
|
+
for (const event of events) {
|
|
426
|
+
verifyGitHubSignature(event.rawBody, event.headers);
|
|
427
|
+
console.log("Verified GitHub event:", event.body);
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Store the signing secret before deploying:
|
|
434
|
+
|
|
435
|
+
```shell
|
|
436
|
+
ntn workers env set GITHUB_WEBHOOK_SECRET=your-secret
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
> [!WARNING]
|
|
440
|
+
> After 5 consecutive `WebhookVerificationError` failures, Notion blocks the webhook. Redeploy the worker to reset the counter.
|
|
441
|
+
|
|
442
|
+
### Webhook URLs
|
|
443
|
+
|
|
444
|
+
Each webhook gets a unique URL that acts as a shared secret:
|
|
445
|
+
|
|
446
|
+
```text
|
|
447
|
+
https://www.notion.so/webhooks/worker/{spaceId}/{workerId}/{uniqueWebhookId}/{webhookName}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Use the CLI to view URLs:
|
|
451
|
+
|
|
452
|
+
```shell
|
|
453
|
+
ntn workers webhooks list
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
> [!WARNING]
|
|
457
|
+
> Treat webhook URLs as secrets. Anyone with the full URL can send events unless you add signature verification.
|
|
458
|
+
|
|
459
|
+
### Execution and retries
|
|
460
|
+
|
|
461
|
+
Webhook requests are acknowledged with `202 Accepted` and your handler runs asynchronously. If your handler throws a non-verification error, Notion retries the run up to 3 times. `WebhookVerificationError` is never retried.
|
|
462
|
+
|
|
463
|
+
### Webhook CLI commands
|
|
464
|
+
|
|
465
|
+
```shell
|
|
466
|
+
ntn workers webhooks list # show webhook URLs
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
## Authentication & secrets
|
|
470
|
+
|
|
471
|
+
### Secrets
|
|
472
|
+
|
|
473
|
+
Store API keys and credentials:
|
|
474
|
+
|
|
475
|
+
```shell
|
|
476
|
+
ntn workers env set API_KEY=your-secret
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
For local development, pull secrets to a `.env` file:
|
|
480
|
+
|
|
481
|
+
```shell
|
|
482
|
+
ntn workers env pull
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Access them via `process.env`:
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
const apiKey = process.env.API_KEY;
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### OAuth
|
|
492
|
+
|
|
493
|
+
For services requiring user authorization (GitHub, Google, etc.):
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
const githubAuth = worker.oauth("githubAuth", {
|
|
497
|
+
name: "github-oauth",
|
|
498
|
+
authorizationEndpoint: "https://github.com/login/oauth/authorize",
|
|
499
|
+
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
|
500
|
+
scope: "repo user",
|
|
501
|
+
clientId: process.env.GITHUB_CLIENT_ID ?? "",
|
|
502
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
|
|
503
|
+
});
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
> [!NOTE]
|
|
507
|
+
> A Notion-managed OAuth shorthand (`{ provider: "google" }`) also exists but is in alpha and behind a feature flag. Most developers should use the user-managed approach shown above.
|
|
508
|
+
|
|
509
|
+
After deploying, configure your OAuth provider's redirect URL:
|
|
510
|
+
|
|
511
|
+
```shell
|
|
512
|
+
ntn workers oauth show-redirect-url
|
|
513
|
+
ntn workers oauth start githubAuth
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Use the token in your tools:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
worker.tool("getGitHubRepos", {
|
|
520
|
+
title: "Get GitHub Repos",
|
|
521
|
+
description: "Fetch user's GitHub repositories",
|
|
522
|
+
schema: j.object({}),
|
|
523
|
+
execute: async () => {
|
|
524
|
+
const token = await githubAuth.accessToken();
|
|
525
|
+
const response = await fetch("https://api.github.com/user/repos", {
|
|
526
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
527
|
+
});
|
|
528
|
+
return response.json();
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
## What you can build
|
|
534
|
+
|
|
535
|
+
<details open>
|
|
536
|
+
<summary><strong>Sync external data into Notion</strong></summary>
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
const customers = worker.database("customers", {
|
|
540
|
+
type: "managed",
|
|
541
|
+
initialTitle: "Customers",
|
|
542
|
+
primaryKeyProperty: "Customer ID",
|
|
543
|
+
schema: {
|
|
544
|
+
properties: {
|
|
545
|
+
Name: Schema.title(),
|
|
546
|
+
"Customer ID": Schema.richText(),
|
|
547
|
+
Email: Schema.richText(),
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const crm = worker.pacer("crm", { allowedRequests: 10, intervalMs: 1000 });
|
|
553
|
+
|
|
554
|
+
worker.sync("customersSync", {
|
|
555
|
+
database: customers,
|
|
556
|
+
execute: async (state) => {
|
|
557
|
+
const page = state?.page ?? 1;
|
|
558
|
+
await crm.wait();
|
|
559
|
+
const { customers: items, hasMore } = await fetchCustomers(page);
|
|
560
|
+
return {
|
|
561
|
+
changes: items.map((c) => ({
|
|
562
|
+
type: "upsert" as const,
|
|
563
|
+
key: c.id,
|
|
564
|
+
properties: {
|
|
565
|
+
Name: Builder.title(c.name),
|
|
566
|
+
"Customer ID": Builder.richText(c.id),
|
|
567
|
+
Email: Builder.richText(c.email),
|
|
568
|
+
},
|
|
569
|
+
})),
|
|
570
|
+
hasMore,
|
|
571
|
+
nextState: hasMore ? { page: page + 1 } : undefined,
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
</details>
|
|
578
|
+
|
|
579
|
+
<details>
|
|
580
|
+
<summary><strong>Give agents a phone with Twilio</strong></summary>
|
|
581
|
+
|
|
582
|
+
```ts
|
|
583
|
+
worker.tool("sendSMS", {
|
|
584
|
+
title: "Send SMS",
|
|
585
|
+
description: "Send a text message to a phone number",
|
|
586
|
+
schema: j.object({
|
|
587
|
+
to: j.string().describe("Phone number in E.164 format"),
|
|
588
|
+
message: j.string().describe("Message to send"),
|
|
589
|
+
}),
|
|
590
|
+
execute: async ({ to, message }) => {
|
|
591
|
+
const response = await fetch(
|
|
592
|
+
`https://api.twilio.com/2010-04-01/Accounts/${process.env.TWILIO_ACCOUNT_SID}/Messages.json`,
|
|
593
|
+
{
|
|
594
|
+
method: "POST",
|
|
595
|
+
headers: {
|
|
596
|
+
Authorization: `Basic ${Buffer.from(
|
|
597
|
+
`${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`,
|
|
598
|
+
).toString("base64")}`,
|
|
599
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
600
|
+
},
|
|
601
|
+
body: new URLSearchParams({
|
|
602
|
+
To: to,
|
|
603
|
+
From: process.env.TWILIO_PHONE_NUMBER ?? "",
|
|
604
|
+
Body: message,
|
|
605
|
+
}),
|
|
606
|
+
},
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
if (!response.ok) throw new Error(`Twilio API error: ${response.statusText}`);
|
|
610
|
+
return "Message sent successfully";
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
</details>
|
|
616
|
+
|
|
617
|
+
<details>
|
|
618
|
+
<summary><strong>Post to Discord, WhatsApp, and Teams</strong></summary>
|
|
619
|
+
|
|
620
|
+
```ts
|
|
621
|
+
worker.tool("postToDiscord", {
|
|
622
|
+
title: "Post to Discord",
|
|
623
|
+
description: "Send a message to a Discord channel",
|
|
624
|
+
schema: j.object({
|
|
625
|
+
message: j.string().describe("Message to post"),
|
|
626
|
+
}),
|
|
627
|
+
execute: async ({ message }) => {
|
|
628
|
+
const response = await fetch(process.env.DISCORD_WEBHOOK_URL ?? "", {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: { "Content-Type": "application/json" },
|
|
631
|
+
body: JSON.stringify({ content: message }),
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
if (!response.ok) throw new Error(`Discord API error: ${response.statusText}`);
|
|
635
|
+
return "Posted to Discord";
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
</details>
|
|
641
|
+
|
|
642
|
+
<details>
|
|
643
|
+
<summary><strong>Turn a Notion page into a podcast with ElevenLabs</strong></summary>
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
worker.tool("createPodcast", {
|
|
647
|
+
title: "Create Podcast from Page",
|
|
648
|
+
description: "Convert page content to audio using ElevenLabs",
|
|
649
|
+
schema: j.object({
|
|
650
|
+
content: j.string().describe("Page content to convert"),
|
|
651
|
+
voiceId: j.string().describe("ElevenLabs voice ID"),
|
|
652
|
+
}),
|
|
653
|
+
execute: async ({ content, voiceId }) => {
|
|
654
|
+
const response = await fetch(
|
|
655
|
+
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
|
|
656
|
+
{
|
|
657
|
+
method: "POST",
|
|
658
|
+
headers: {
|
|
659
|
+
"xi-api-key": process.env.ELEVENLABS_API_KEY ?? "",
|
|
660
|
+
"Content-Type": "application/json",
|
|
661
|
+
},
|
|
662
|
+
body: JSON.stringify({ text: content, model_id: "eleven_monolingual_v1" }),
|
|
663
|
+
},
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
if (!response.ok) throw new Error(`ElevenLabs API error: ${response.statusText}`);
|
|
667
|
+
const audioBuffer = await response.arrayBuffer();
|
|
668
|
+
return `Generated ${audioBuffer.byteLength} bytes of audio`;
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
</details>
|
|
674
|
+
|
|
675
|
+
<details>
|
|
676
|
+
<summary><strong>Get live stocks, weather, and traffic</strong></summary>
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
worker.tool("getWeather", {
|
|
680
|
+
title: "Get Weather",
|
|
681
|
+
description: "Get current weather for a location",
|
|
682
|
+
schema: j.object({
|
|
683
|
+
location: j.string().describe("City name or zip code"),
|
|
684
|
+
}),
|
|
685
|
+
execute: async ({ location }) => {
|
|
686
|
+
const response = await fetch(
|
|
687
|
+
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(location)}&appid=${process.env.OPENWEATHER_API_KEY}&units=metric`,
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
if (!response.ok) throw new Error(`Weather API error: ${response.statusText}`);
|
|
691
|
+
|
|
692
|
+
const data = await response.json();
|
|
693
|
+
return `${data.name}: ${data.main.temp}°C, ${data.weather[0].description}`;
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
```
|
|
697
|
+
</details>
|
|
698
|
+
|
|
699
|
+
## Helpful CLI commands
|
|
700
|
+
|
|
701
|
+
```shell
|
|
702
|
+
# Deploy your worker to Notion
|
|
703
|
+
ntn workers deploy
|
|
704
|
+
|
|
705
|
+
# Test a tool locally
|
|
706
|
+
ntn workers exec <toolName> --local -d '{"key": "value"}'
|
|
707
|
+
|
|
708
|
+
# Monitor sync status (live-updating)
|
|
709
|
+
ntn workers sync status
|
|
710
|
+
|
|
711
|
+
# Preview sync output without writing to the database
|
|
712
|
+
ntn workers sync trigger <syncKey> --preview
|
|
713
|
+
|
|
714
|
+
# Trigger a real sync immediately (writes to the database, bypasses schedule)
|
|
715
|
+
ntn workers sync trigger <syncKey>
|
|
716
|
+
|
|
717
|
+
# Reset sync state (restart from scratch)
|
|
718
|
+
ntn workers sync state reset <syncKey>
|
|
719
|
+
|
|
720
|
+
# List all capabilities
|
|
721
|
+
ntn workers capabilities list
|
|
722
|
+
|
|
723
|
+
# Pause / resume a sync
|
|
724
|
+
ntn workers capabilities disable <syncKey>
|
|
725
|
+
ntn workers capabilities enable <syncKey>
|
|
726
|
+
|
|
727
|
+
# List webhook URLs
|
|
728
|
+
ntn workers webhooks list
|
|
729
|
+
|
|
730
|
+
# Manage authentication
|
|
731
|
+
ntn login
|
|
732
|
+
ntn logout
|
|
733
|
+
|
|
734
|
+
# Store API keys and secrets
|
|
735
|
+
ntn workers env set API_KEY=your-secret
|
|
736
|
+
|
|
737
|
+
# View execution logs
|
|
738
|
+
ntn workers runs logs <runId>
|
|
739
|
+
|
|
740
|
+
# Start OAuth flow
|
|
741
|
+
ntn workers oauth start <oauthName>
|
|
742
|
+
|
|
743
|
+
# Show OAuth redirect URL (set this in your provider's app settings)
|
|
744
|
+
ntn workers oauth show-redirect-url
|
|
745
|
+
|
|
746
|
+
# Display help for all commands
|
|
747
|
+
ntn --help
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
## Local development
|
|
751
|
+
|
|
752
|
+
```shell
|
|
753
|
+
npm run check # type-check
|
|
754
|
+
npm run build # emit dist/
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
Store secrets in `.env` for local development:
|
|
758
|
+
|
|
759
|
+
```shell
|
|
760
|
+
ntn workers env pull
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
## Learn more
|
|
764
|
+
|
|
765
|
+
Read the full [Workers documentation](https://developers.notion.com/workers/get-started/overview) or join the [Notion Dev Slack](https://join.slack.com/t/notiondevs/shared_invite/zt-3u9oid9q8-HLUBmMVWYK~g9HFo4U4raA).
|