Skip to content

Commit eadcc69

Browse files
authored
Merge pull request #5 from framer/feat/notion-automation
Add `notion-automations-sync` example
1 parent a4f7122 commit eadcc69

15 files changed

Lines changed: 2205 additions & 132 deletions

File tree

.github/workflows/pull_request.yml

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
pull_request:
77

88
jobs:
9-
lint:
9+
check:
1010
runs-on: ubuntu-latest
1111
steps:
1212
- uses: actions/checkout@v6
@@ -16,18 +16,12 @@ jobs:
1616
node-version: 24
1717
cache: npm
1818

19-
- run: npm ci
20-
- run: npm run check
21-
22-
typecheck:
23-
runs-on: ubuntu-latest
24-
steps:
25-
- uses: actions/checkout@v6
26-
27-
- uses: actions/setup-node@v6
28-
with:
29-
node-version: 24
30-
cache: npm
19+
- name: Setup env files for type generation
20+
run: |
21+
for dir in examples/*/; do
22+
[ -f "$dir/.env.example" ] && cp "$dir/.env.example" "$dir/.env"
23+
done
3124
3225
- run: npm ci
26+
- run: npm run lint:check
3327
- run: npm run typecheck

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ dist/
66

77
.env
88
.env.*
9+
.dev.vars
910
!.env.example
10-

examples/.gitignore

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Cloudflare Workers
2+
.wrangler/
3+
worker-configuration.d.ts
4+
5+
# Lock files
6+
package-lock.json
7+
bun.lock
8+
yarn.lock
9+
pnpm-lock.yaml
10+
11+
# Logs
12+
logs
13+
*.log
14+
15+
# Build output
16+
dist/
17+
build/
18+
.out/
19+
.cache/
20+
.next/
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
FRAMER_PROJECT_URL="https://framer.com/projects/YourProject--xxxxxxxxxxxxxxxx"
2+
FRAMER_API_KEY="your-framer-api-key-here"
3+
FRAMER_COLLECTION_NAME="Content Items"
4+
NOTION_DATABASE_ID="your-notion-database-id-here"
5+
WEBHOOK_TOKEN="your-webhook-secret-token-here"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Notion → Framer Sync
2+
3+
Cloudflare Worker that syncs a Notion database to a Framer CMS collection.
4+
5+
## How it works
6+
7+
Uses `framer-api` with the `using` keyword for automatic resource cleanup:
8+
9+
```ts
10+
using framer = await connect(projectUrl, apiKey);
11+
const collections = await framer.getManagedCollections();
12+
// connection automatically closed when scope exits
13+
```
14+
15+
## Setup
16+
17+
1. `npm install`
18+
2. `cp .env.example .env` and fill in your values
19+
3. `npm run setup` - creates Framer collection (one-time)
20+
21+
## Local Development
22+
23+
```bash
24+
npm run dev
25+
```
26+
27+
To test with Notion webhooks, expose your local server:
28+
29+
```bash
30+
cloudflared tunnel --url http://localhost:8787
31+
```
32+
33+
## Deploy
34+
35+
```bash
36+
wrangler secret bulk .env
37+
npm run deploy
38+
```
39+
40+
Your worker URL will be printed after deploy.
41+
42+
## Notion Automation Setup
43+
44+
1. Add "Deleted" checkbox property (for soft-deletes)
45+
2. Click ⚡ → New automation
46+
3. Trigger: "When page added" or "When property edited"
47+
4. Action: "Send webhook"
48+
- URL: your worker URL
49+
- Headers: `Authorization: <your WEBHOOK_TOKEN>`
50+
51+
Repeat for each trigger type you need.
52+
53+
## Config
54+
55+
Edit `src/config.ts`:
56+
57+
- `TOMBSTONE_PROPERTY` - checkbox property for soft-delete
58+
- `FIELD_MAPPING` - maps Notion properties to Framer fields
59+
60+
## Notion Automations vs REST API
61+
62+
This example uses Notion Automations (webhook actions), not the Notion API.
63+
64+
| | Automation Webhooks | REST API + Webhooks |
65+
|---|---|---|
66+
| Setup | UI-based, per-database | Programmatic integration |
67+
| Payload | Full page properties | Webhook sends IDs only, must fetch |
68+
| Auth | Custom header (optional) | OAuth / integration token |
69+
| Triggers | Page add, property edit, button | Subscribe to events programmatically |
70+
| Rate limits | Max 5 webhooks per automation | 3 req/sec |
71+
| Page content | Properties only | Full blocks access |
72+
| Bulk sync | Not supported | Query database endpoint |
73+
| Plan | Paid plans only | Free tier available |
74+
75+
When to use Automations:
76+
- Simple property sync
77+
- No initial bulk import needed
78+
- UI-based configuration preferred
79+
80+
When to use REST API:
81+
- Need page content (blocks)
82+
- Bulk/initial sync required
83+
- Free tier
84+
- Multiple databases from one integration
85+
86+
Note: Notion's webhook features are evolving. Verify current capabilities in the official docs.
87+
88+
Sources: [Notion Webhook Actions](https://www.notion.com/help/webhook-actions), [Notion API Webhooks](https://developers.notion.com/reference/webhooks)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "notion-framer-sync",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"deploy": "wrangler deploy",
8+
"dev": "wrangler dev",
9+
"start": "wrangler dev",
10+
"cf-typegen": "wrangler types",
11+
"postinstall": "wrangler types",
12+
"setup": "node --experimental-strip-types scripts/setup.ts",
13+
"typecheck": "tsc --noEmit -p scripts && tsc --noEmit"
14+
},
15+
"dependencies": {
16+
"@notionhq/client": "^5.6.0",
17+
"framer-api": "beta"
18+
},
19+
"devDependencies": {
20+
"@types/node": "^24.10.0",
21+
"typescript": "^5.5.2",
22+
"wrangler": "4.57.0"
23+
}
24+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env node --strip-types
2+
import assert from "node:assert";
3+
import { connect, type ManagedCollection } from "framer-api";
4+
import { config } from "../src/config";
5+
6+
if (process.loadEnvFile) {
7+
process.loadEnvFile(".env");
8+
}
9+
10+
const projectUrl = process.env.FRAMER_PROJECT_URL;
11+
const apiKey = process.env.FRAMER_API_KEY;
12+
const collectionName = process.env.FRAMER_COLLECTION_NAME;
13+
14+
assert(projectUrl, "FRAMER_PROJECT_URL required");
15+
assert(apiKey, "FRAMER_API_KEY required");
16+
assert(collectionName, "FRAMER_COLLECTION_NAME required");
17+
18+
using framer = await connect(projectUrl, apiKey);
19+
20+
async function findOrCreateCollection(name: string) {
21+
const existingCollections = await framer.getManagedCollections();
22+
const existing = existingCollections.find((c) => c.name === name);
23+
24+
if (existing) {
25+
console.log(`Found existing collection [id: ${existing.id}]`);
26+
return existing;
27+
}
28+
29+
const collection = await framer.createManagedCollection(name);
30+
console.log(`Created collection [id: ${collection.id}]`);
31+
return collection;
32+
}
33+
34+
async function setupFields(collection: ManagedCollection) {
35+
const fields = config.FIELD_MAPPING.map((mapping) => ({
36+
type: mapping.type,
37+
name: mapping.framerName,
38+
id: mapping.framerId,
39+
}));
40+
41+
await collection.setFields(fields);
42+
43+
const setFields = await collection.getFields();
44+
console.log(`Set ${setFields.length} fields`);
45+
}
46+
47+
async function logCollectionStatus(collection: ManagedCollection) {
48+
const itemIds = await collection.getItemIds();
49+
console.log(`Collection ready [existing items: ${itemIds.length}]`);
50+
}
51+
52+
const collection = await findOrCreateCollection(collectionName);
53+
await setupFields(collection);
54+
await logCollectionStatus(collection);
55+
56+
console.log("\n✅ Setup complete! Collection is ready for webhook integration.");
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "ES2022",
5+
"moduleResolution": "bundler",
6+
"lib": ["ES2022"],
7+
"strict": true,
8+
"skipLibCheck": true,
9+
"esModuleInterop": true,
10+
"resolveJsonModule": true,
11+
"allowSyntheticDefaultImports": true
12+
},
13+
"include": ["**/*.ts"]
14+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export type FieldMapping = {
2+
notionProperty: string;
3+
framerId: string;
4+
framerName: string;
5+
type: "string" | "number" | "boolean" | "date";
6+
};
7+
8+
type Config = {
9+
AUTO_PUBLISH: boolean;
10+
TOMBSTONE_PROPERTY: string;
11+
FIELD_MAPPING: FieldMapping[];
12+
};
13+
14+
export const config: Config = {
15+
AUTO_PUBLISH: true,
16+
TOMBSTONE_PROPERTY: "Deleted",
17+
18+
FIELD_MAPPING: [
19+
{
20+
notionProperty: "Title",
21+
framerId: "title",
22+
framerName: "Title",
23+
type: "string",
24+
},
25+
{
26+
notionProperty: "Description",
27+
framerId: "description",
28+
framerName: "Description",
29+
type: "string",
30+
},
31+
{
32+
notionProperty: "Status",
33+
framerId: "status",
34+
framerName: "Status",
35+
type: "string",
36+
},
37+
{
38+
notionProperty: "Created",
39+
framerId: "created",
40+
framerName: "Created",
41+
type: "date",
42+
},
43+
{
44+
notionProperty: "Priority",
45+
framerId: "priority",
46+
framerName: "Priority",
47+
type: "number",
48+
},
49+
],
50+
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { connect } from "framer-api";
2+
import { config } from "./config";
3+
import { extractFieldData, isDeleted, type NotionAutomationPayload } from "./notion";
4+
5+
async function handleWebhook(request: Request, env: Env): Promise<Response> {
6+
if (request.method !== "POST") {
7+
return new Response("Method Not Allowed", { status: 405 });
8+
}
9+
10+
const token = request.headers.get("Authorization");
11+
if (token !== env.WEBHOOK_TOKEN) {
12+
return new Response("Unauthorized", { status: 401 });
13+
}
14+
15+
const payload = await request.json<NotionAutomationPayload>();
16+
17+
try {
18+
const pageId = payload.data.id.replace(/-/gu, "");
19+
20+
if (payload.data.in_trash || payload.data.archived) {
21+
return json({ success: true, action: "skipped", reason: "trashed or archived" });
22+
}
23+
24+
const parent = payload.data.parent;
25+
if (env.NOTION_DATABASE_ID && "database_id" in parent && parent.database_id) {
26+
const normalize = (id: string) => id.replace(/-/gu, "");
27+
if (normalize(parent.database_id) !== normalize(env.NOTION_DATABASE_ID)) {
28+
return json({ skipped: true, reason: "Different database" });
29+
}
30+
}
31+
32+
using framer = await connect(env.FRAMER_PROJECT_URL, env.FRAMER_API_KEY);
33+
34+
const collections = await framer.getManagedCollections();
35+
const collection = collections.find((c) => c.name === env.FRAMER_COLLECTION_NAME);
36+
37+
if (!collection) {
38+
return json(
39+
{
40+
error: `${env.FRAMER_COLLECTION_NAME} collection not found`,
41+
available: collections.map((c) => c.name),
42+
},
43+
404,
44+
);
45+
}
46+
47+
if (isDeleted(payload.data.properties, config.TOMBSTONE_PROPERTY)) {
48+
await collection.removeItems([pageId]);
49+
await publishAndDeploy(framer);
50+
console.log(`Deleted page ${pageId}`);
51+
return json({ success: true, action: "deleted", id: pageId, published: config.AUTO_PUBLISH });
52+
}
53+
54+
const fieldData = extractFieldData(payload.data.properties, config.FIELD_MAPPING);
55+
const slug = `item-${pageId}`;
56+
57+
await collection.addItems([{ id: pageId, slug, fieldData }]);
58+
await publishAndDeploy(framer);
59+
console.log(`Upserted page ${pageId}${slug}`);
60+
61+
return json({ success: true, action: "upserted", id: pageId, slug, published: config.AUTO_PUBLISH });
62+
} catch (error) {
63+
console.error(`Error processing page:`, error);
64+
return json({ error: error instanceof Error ? error.message : "Unknown error" }, 500);
65+
}
66+
}
67+
68+
async function publishAndDeploy(framer: Awaited<ReturnType<typeof connect>>) {
69+
if (!config.AUTO_PUBLISH) return null;
70+
const { deployment } = await framer.publish();
71+
const hostnames = await framer.deploy(deployment.id);
72+
return { deploymentId: deployment.id, hostnames };
73+
}
74+
75+
const json = (data: unknown, status = 200) =>
76+
new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
77+
78+
export default { fetch: handleWebhook } satisfies ExportedHandler<Env>;

0 commit comments

Comments
 (0)