Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
120 commits
Select commit Hold shift + click to select a range
6756663
Rebuild the site shell on the Livepeer UI design system
adamsoffer Sep 6, 2026
b9d798f
Read templated content from Notion; keep the ecosystem in markdown
adamsoffer Sep 6, 2026
b507ee0
Add the roadmap, with organizations and people as records
adamsoffer Sep 6, 2026
1ec6adf
Move the blog to Notion
adamsoffer Sep 6, 2026
ed22d7f
Add /contribute: how to get involved, and how work gets funded
adamsoffer Sep 6, 2026
7f8c01a
Rebuild the pages on the registry; show the agent runtime mid-task
adamsoffer Sep 6, 2026
2e68392
Redesign /brand as objects on plates, not a spec sheet
adamsoffer Sep 7, 2026
eb473a8
Set the site in Inter and Geist Mono; drop Favorit
adamsoffer Sep 7, 2026
bce475a
Merge main: Next 16, framer-motion 13, sanitised ecosystem HTML
adamsoffer Sep 8, 2026
e961dcc
Merge main: flat lint config and the prettier pass
adamsoffer Sep 8, 2026
d4f3208
style: apply prettier across the redesign's files
adamsoffer Sep 8, 2026
3572396
Make the header's Agent item a plain link to /agent
adamsoffer Sep 8, 2026
aff1aab
Point the Agent console links at earlyaccess.livepeer.org
adamsoffer Sep 8, 2026
b5971e3
Take two of Copilot's review notes on #93
adamsoffer Sep 8, 2026
eb2901b
Lay the blog out as a rail and a grid; give categories routes
adamsoffer Sep 9, 2026
0326764
Blog index: a row of places under the heading, not a sidebar
adamsoffer Sep 9, 2026
29ae11b
Blog heading: a size up, a weight up, and closer to the header
adamsoffer Sep 9, 2026
05e1f4f
Blog heading: sit 48px under the header, as Vercel's does
adamsoffer Sep 9, 2026
67a9997
Blog heading: 64px under the header
adamsoffer Sep 9, 2026
ca33cef
Blog categories as ghost buttons; heading at 48px, sentence case
adamsoffer Sep 9, 2026
fb7273d
Add /changelog, read from Notion, in the blog's row
adamsoffer Sep 9, 2026
3bcb649
Link a changelog entry to the roadmap commitment it delivers
adamsoffer Sep 9, 2026
d2ff842
Changelog: an Atom feed, and the pressed place scrolled into view
adamsoffer Sep 9, 2026
0f1e195
Changelog fallback: the Discord route entry in place of retroactive g…
adamsoffer Sep 9, 2026
ac36e7d
Changelog fallback: pixverse-i2v on the Agent in place of the Discord…
adamsoffer Sep 9, 2026
1aebc9e
Changelog fallback: date the reward-call entry with the Live Runner, …
adamsoffer Sep 9, 2026
e2eb260
Fallback: pixverse-i2v entry credits two authors and delivers Network…
adamsoffer Sep 9, 2026
72077eb
Changelog entry: the delivered commitment as a plate, not a sentence
adamsoffer Sep 9, 2026
dca60b1
Changelog entry: the commitment reference as a chip
adamsoffer Sep 9, 2026
9253a6c
Changelog entry: the commitment plate, after the write-up
adamsoffer Sep 9, 2026
9d199cb
Changelog entry: hairline on the commitment plate
adamsoffer Sep 9, 2026
7b1fa07
Changelog entry: the write-up instead of the summary, not under it
adamsoffer Sep 9, 2026
efd41bd
Land route changes at the top; give the blog an Atom feed too
adamsoffer Sep 9, 2026
7d4e0eb
Changelog: month markers with anchors
adamsoffer Sep 9, 2026
2d27111
Revert "Changelog: month markers with anchors"
adamsoffer Sep 9, 2026
6c7c0c2
Mock: /reporting, a live board of which funded bodies are reporting
adamsoffer Sep 9, 2026
20bcb23
Mock: the monthly wrap-ups beneath the reporting board
adamsoffer Sep 9, 2026
7b91564
Mock: the reporting board in Linear's terms
adamsoffer Sep 9, 2026
cbe2dd7
Remove the reporting mock
adamsoffer Sep 9, 2026
a9c7b9b
Updates on commitments, and the changelog as a monthly roundup
adamsoffer Sep 9, 2026
12e71a4
Record: the latest update leads, the rest run beneath
adamsoffer Sep 9, 2026
65e30d0
Prettier
adamsoffer Sep 9, 2026
a65fb79
Record: outlined update cards with a header row
adamsoffer Sep 9, 2026
dc0d653
Record: an activity log instead of a stack of cards
adamsoffer Sep 9, 2026
44b4bc5
Record: the expanded update reads at the log's size
adamsoffer Sep 9, 2026
16aa44c
Record: health in colour, the Health row back, the write-up first
adamsoffer Sep 9, 2026
a7ea3a5
Health: Linear's icon
adamsoffer Sep 9, 2026
3abe37f
Health: one mix for At risk
adamsoffer Sep 9, 2026
50f03eb
Changelog: a digest that scans
adamsoffer Sep 9, 2026
84c6559
Roundup: the last word on shipped work, a JSON route, one icon per card
adamsoffer Sep 9, 2026
fa20ca6
Roadmap card: the state dot and the health mark, as before
adamsoffer Sep 9, 2026
59c1f78
Changelog: one flat list per month
adamsoffer Sep 9, 2026
e079510
Changelog: say when nothing shipped, not when everyone posted
adamsoffer Sep 9, 2026
0dcaa43
Changelog: a month with nothing shipped opens on the work under way
adamsoffer Sep 9, 2026
25c0d33
Changelog: a month is published when it ends
adamsoffer Sep 9, 2026
fb92587
Notion: the updates database is Roadmap updates
adamsoffer Sep 9, 2026
8fdb194
Fallback: two records ship in August
adamsoffer Sep 9, 2026
a679bbd
Fallback: the litepaper update is from July
adamsoffer Sep 9, 2026
b4c5602
Changelog: every row names its owner
adamsoffer Sep 9, 2026
c59d3c1
Changelog: the update's date, set apart from its words
adamsoffer Sep 9, 2026
3d38d4d
Changelog: no middot before the words
adamsoffer Sep 9, 2026
56a7812
Changelog: what has happened since
adamsoffer Sep 10, 2026
2aa1572
Revert "Changelog: what has happened since"
adamsoffer Sep 10, 2026
13d8f18
Roadmap: group by owner
adamsoffer Sep 10, 2026
3af78ba
Roadmap: owner grouping on Shipped too, closing line under quarters only
adamsoffer Sep 10, 2026
5339f8a
Roadmap: a health facet in the rail
adamsoffer Sep 10, 2026
bef5cdf
Roadmap: glyphs on the view tabs and the group-by pair
adamsoffer Sep 10, 2026
98a6469
Roadmap: the owner glyph is a person, as Linear marks a lead
adamsoffer Sep 10, 2026
01d18fe
Retrospectives: the closing post on shipped work
adamsoffer Sep 10, 2026
4d251a0
Roadmap: the retrospective mark in the health idiom
adamsoffer Sep 10, 2026
2d75dc3
Record: a post's one-liner is its title
adamsoffer Sep 10, 2026
c28db3f
Record: in the log, the one-liner is the lede
adamsoffer Sep 10, 2026
131007f
Record: the post's title is a size above its write-up
adamsoffer Sep 10, 2026
4f6c4c5
Record: air around the post's title in the log
adamsoffer Sep 10, 2026
da86625
Record: no gap under a post title with nothing beneath it
adamsoffer Sep 10, 2026
51c918c
Roadmap: the tabs and the group-by pair take the pointer
adamsoffer Sep 11, 2026
8192000
Roadmap: a list display, and a cut by health
adamsoffer Sep 11, 2026
3bd8395
Roadmap: keep the list display but stop offering it
adamsoffer Sep 11, 2026
398feb6
Roadmap: a target is a date and a precision, like Linear
adamsoffer Sep 11, 2026
8b161a5
Roadmap: Planned, not Committed
adamsoffer Sep 11, 2026
08ba797
Roadmap: read Planned only, now the Notion option is renamed
adamsoffer Sep 11, 2026
0842396
Changelog: the state word on the dated line
adamsoffer Sep 11, 2026
57d0dac
Record: a post's title at the log's size, its write-up muted
adamsoffer Sep 11, 2026
3e147b5
Roadmap: owner tallies, and past target as a count
adamsoffer Sep 12, 2026
482a1a1
Roadmap: no Past target checkbox
adamsoffer Sep 12, 2026
247417e
Roadmap: "No update since Jul 15", not "No update"
adamsoffer Sep 14, 2026
2dfedc2
Changelog: a headline per month, and the month as a visible permalink
adamsoffer Sep 14, 2026
8a45462
Changelog: the headline is a title, not the tally in prose
adamsoffer Sep 14, 2026
4dedfbf
Changelog: a model writes the headline, stored in Notion
adamsoffer Sep 14, 2026
bb67e45
Changelog: reach the model through Vercel's AI Gateway
adamsoffer Sep 14, 2026
eec1145
Changelog: a person writes the headline
adamsoffer Sep 14, 2026
f379fec
Changelog: an entry exists when someone publishes its row
adamsoffer Sep 14, 2026
ad5a270
Changelog: headlines for the three published entries, fallback included
adamsoffer Sep 14, 2026
898214d
Changelog: the skill page for publishing an entry is its own
adamsoffer Sep 14, 2026
3814bfb
CLAUDE.md: the changelog route is /[period]
adamsoffer Sep 14, 2026
e6ba02c
Changelog: what was said links to the post it was said in
adamsoffer Sep 14, 2026
a4eab72
Updates: an optional Link to where the full update was posted
adamsoffer Sep 14, 2026
2ee96c5
Roadmap: the quarter badge and count centred on the heading
adamsoffer Sep 14, 2026
e30f7af
Changelog: the entry row's page body as an optional intro
adamsoffer Sep 14, 2026
aa716e4
No Sanity: the two URL maps move out from under its name
adamsoffer Sep 14, 2026
6d50147
Docs: the skill page is "Posting a roadmap update"
adamsoffer Sep 14, 2026
fc266e6
Reporting on a commitment, and the path from an idea to the roadmap
adamsoffer Sep 14, 2026
64e02cc
Reporting page from Notion; Contribute's path compact, owners' block
adamsoffer Sep 14, 2026
8e3ae01
Reporting guide: the post is the obligation, the row follows from it
adamsoffer Sep 14, 2026
074faaa
Contribute: one rule between the path and the ladder; guide wording
adamsoffer Sep 14, 2026
881716e
Contribute: the middle as one document
adamsoffer Sep 14, 2026
7d36dd2
Contribute: the ladder as a table
adamsoffer Sep 14, 2026
3648f2e
Roadmap rail: the owners' link as one line, not a block
adamsoffer Sep 14, 2026
21d22d6
Revert "Roadmap rail: the owners' link as one line, not a block"
adamsoffer Sep 14, 2026
b6c6c3d
Roadmap rail: pin to the edge the reader scrolls towards
adamsoffer Sep 14, 2026
86f445c
Roadmap rail: the two prompts in one line each
adamsoffer Sep 14, 2026
0bb82a8
Roadmap rail: air under the last link when pinned to the bottom
adamsoffer Sep 14, 2026
f35108e
Roadmap rail: the owners' prompt as a sentence
adamsoffer Sep 14, 2026
b2e9bef
Roadmap rail: search level with the tabs, a Status heading
adamsoffer Sep 14, 2026
dd42557
Roadmap rail: a Status facet with In progress and Planned
adamsoffer Sep 14, 2026
a695ff4
Updates: a published retrospective must sit on a shipped commitment
adamsoffer Sep 15, 2026
bf91f1c
CLAUDE.md: the connector fires the Shipped-on automation
adamsoffer Sep 15, 2026
551eb83
Roadmap: the reporting guide opens in the record panel from the register
adamsoffer Sep 15, 2026
140a721
Fonts: Inter waits rather than swaps, and is subset to Latin
adamsoffer Sep 15, 2026
83ea539
Trigger a preview build
adamsoffer Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,18 @@
# The Graph (optional β€” falls back to hardcoded values)
THEGRAPH_API_KEY=

# Notion β€” the roadmap register (optional locally; without it /roadmap falls
# back to content/roadmap/*.md). Internal integration token, shared with both
# the "Roadmap commitments" and "Roadmap people" databases.
NOTION_TOKEN=

# Lets an agent that has just edited the roadmap in Notion push the change live
# immediately, instead of waiting out NOTION_REVALIDATE. Any long random string;
# the endpoint refuses to run when it is unset.
REVALIDATE_SECRET=

# Set by the Notion webhook handshake β€” POST the subscription from Notion's
# Webhooks tab, read verification_token out of the deployment logs, and put it
# here. It is the signing key for every event after it and is not recoverable
# later; re-subscribe to get a new one.
NOTION_WEBHOOK_SECRET=
15 changes: 11 additions & 4 deletions .github/workflows/check-ecosystem-urls.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
name: Check Ecosystem URLs

on:
# Run on PRs that touch ecosystem data
# Run on PRs that touch ecosystem data. The catalogue is markdown in
# content/ecosystem β€” data/ecosystem.json was its old shape and no longer
# exists, so this trigger had stopped firing on the files it is meant to guard.
pull_request:
paths:
- "data/ecosystem.json"
- "content/ecosystem/**"
- "public/ecosystem/**"

# Weekly check β€” Mondays at 9am UTC
Expand All @@ -23,13 +25,18 @@ jobs:
steps:
- uses: actions/checkout@v7

# pnpm, not npm: the repo pins it via package.json's packageManager field
# and ships pnpm-lock.yaml. This step must precede setup-node so that
# `cache: pnpm` can find the store.
- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v7
with:
node-version: 20
cache: npm
cache: pnpm

- name: Install dependencies
run: npm ci
run: pnpm install --frozen-lockfile

- name: Check ecosystem URLs
run: node scripts/check-ecosystem-urls.mjs
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ img/
.vscode/
*.code-workspace
.playwright-mcp/
.env*
!.env.example
333 changes: 116 additions & 217 deletions CLAUDE.md

Large diffs are not rendered by default.

57 changes: 39 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
# Livepeer Website

The official website for [Livepeer](https://livepeer.org) β€” open infrastructure for real-time AI video.
The official website for [Livepeer](https://livepeer.org) β€” the open inference network for AI video and image workloads.

## Tech Stack

- **Framework**: Next.js 15 (App Router)
- **UI**: React 19, Tailwind CSS v4, Framer Motion 11
- **UI**: React 19, Tailwind CSS v4
- **Design system**: [Livepeer UI](https://livepeer.peaceno.de/design.md) β€” a shadcn component registry (semantic tokens, light + dark)
- **Language**: TypeScript
- **Fonts**: Favorit Pro & Favorit Mono (Dinamo Typefaces)
- **Fonts**: Inter for everything (self-hosted from rsms.me), Geist Mono for code

## Prerequisites

- [Node.js 22.x](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) (includes npm)
- [pnpm v10.x](https://pnpm.io/installation) β€” install with `npm install -g pnpm` or `corepack enable`
- [Node.js 22.x](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
- [pnpm v10.x](https://pnpm.io/installation) β€” install with `corepack enable` (the version is pinned in `package.json`)
- [Docker](https://docs.docker.com/get-docker/) (optional) β€” required for the dev container

> [!TIP]
Expand Down Expand Up @@ -48,12 +49,24 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.

## Scripts

| Command | Description |
| --------------- | ------------------------ |
| `npm run dev` | Start development server |
| `npm run build` | Create production build |
| `npm run start` | Serve production build |
| `npm run lint` | Run ESLint |
| Command | Description |
| ---------------- | ------------------------ |
| `pnpm dev` | Start development server |
| `pnpm build` | Create production build |
| `pnpm start` | Serve production build |
| `pnpm lint` | Run ESLint |
| `pnpm typecheck` | Type-check with `tsc` |

## Design System

UI is built from the [Livepeer UI](https://livepeer.peaceno.de/design.md) shadcn registry. Install the theme and components with the shadcn CLI:

```bash
pnpm dlx shadcn@latest add @livepeer-ui/theme
pnpm dlx shadcn@latest add @livepeer-ui/button
```

Compose with the registry's semantic tokens and roles. See `CLAUDE.md` for the working rules (token discipline, font roles, brand-green constraint, per-page checklist).

## Dev Container (Recommended)

Expand All @@ -66,15 +79,23 @@ Develop inside a pre-configured container β€” consistent tooling, zero local set

```
app/ # Next.js App Router pages
brand/ # Brand guidelines page
primer/ # Livepeer primer page
use-cases/ # Use-case pages
agent/ # Agent product page
ecosystem/ # Ecosystem catalog (+ [slug], submit)
compute/ # Provide GPU compute
token/ # Livepeer Token (LPT)
foundation/ # Foundation
blog/ # Latest / blog (+ [slug])
brand/ # Brand guidelines
primer/ # Livepeer primer (kept, unlinked)
components/
home/ # Homepage sections (Hero, Capabilities, etc.)
layout/ # Header and Footer
ui/ # Shared UI primitives (Button, Card, Container, etc.)
icons/ # Logo components (Symbol, Wordmark, Lockup)
lib/ # Constants, fonts, and custom hooks
ui/ # Shared primitives + registry components
blog/ ecosystem/ # Section-specific components
primer/ icons/ # Primer chapters, logo components
content/
blog/ # Markdown blog posts
ecosystem/ # Markdown ecosystem entries
lib/ # Content loaders, fonts, hooks, subgraph
public/ # Static assets (images, videos, fonts)
```

Expand Down
9 changes: 9 additions & 0 deletions app/agent/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { renderArtCard, ogArt, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";

export const alt = "Livepeer Agent";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;

export default function OpengraphImage() {
return renderArtCard(ogArt.agent);
}
85 changes: 85 additions & 0 deletions app/agent/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Metadata } from "next";

import type { LivepeerOrgPage } from "@/components/livepeer-ui/contracts";
import { LivepeerAgentHero } from "@/components/livepeer-ui/livepeer-agent-hero";
import {
AgentAccessSection,
AgentCapabilitiesSection,
} from "@/components/livepeer-ui/livepeer-agent-sections";
import { agentCapabilities } from "@/lib/agent-capabilities";
import { agentApp } from "@/lib/site";

// Static, in-repo page content matching the registry's content contract
// (see CLAUDE.md β†’ Content). Copy mirrors the public-beta mockup.
//
// Every destination here is on the Agent product app, not this site β€” they all
// resolve through `agentApp` so the host is settled in one place.
type AgentContent = NonNullable<LivepeerOrgPage["agentContent"]>;

const agent: AgentContent = {
hero: {
heading: "Create and edit images and video with your agent.",
// Just what happens. Naming the step that no longer exists would put an
// API key in the reader's head on the way to telling them there isn't one.
description:
"Add this server in your agent's MCP / connector settings. The first connection opens your browser and signs you in.",
serverUrl: agentApp.mcpServerUrl,
signInCta: { label: "Sign in", href: agentApp.signIn },
createAccountCta: { label: "Create account", href: agentApp.createAccount },
},
access: {
heading: "Install Livepeer Agent in your app today",
description:
"Point your product's agent runtime at the same MCP server and Livepeer Agent's image and video workflows are available inside it.",
// No CTA. The band is a statement about what the Agent can do inside
// someone else's product, and the console is one click away in the header.
},
capabilities: {
heading:
"Livepeer Agent brings image, video, audio, 3D, editing, rendering, and production tools across the Livepeer network into one interface.",
cta: { label: "See more", href: agentApp.playbooks },
},
// The mockup has no playbooks section on this page β€” the library lives in the
// Agent app, which the capabilities CTA links to. Kept present because the
// contract requires it, and empty rather than invented.
playbooks: {
heading: "",
description: "",
cta: { label: "", href: agentApp.playbooks },
},
};

const DESCRIPTION =
"Create and edit images and video with your agent. Connect Livepeer Agent over MCP and reach image, video, audio, 3D and production tools across the Livepeer network.";

// openGraph and twitter are declared, not inferred. Next does not fill
// og:title from `title` or og:description from `description`, so a page
// setting only those two inherits the root layout's openGraph object whole β€”
// and served "Livepeer β€” The open inference network" with the home page's
// description to every timeline it was shared into.
export const metadata: Metadata = {
title: "Livepeer Agent",
description: DESCRIPTION,
openGraph: {
title: "Livepeer Agent | Livepeer",
description: DESCRIPTION,
},
twitter: {
card: "summary_large_image",
title: "Livepeer Agent | Livepeer",
description: DESCRIPTION,
},
};

export default function AgentPage() {
return (
<>
<LivepeerAgentHero content={agent.hero} />
<AgentAccessSection content={agent.access} />
<AgentCapabilitiesSection
content={agent.capabilities}
capabilities={agentCapabilities}
/>
</>
);
}
3 changes: 3 additions & 0 deletions app/agent/twitter-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Metadata files cascade, so without this the segment would serve its own
// og:image but the root's twitter:image.
export { default, alt, size, contentType } from "./opengraph-image";
105 changes: 105 additions & 0 deletions app/api/notion-webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import crypto from "node:crypto";

import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";

/**
* Notion tells us the register changed, instead of us waiting to notice.
*
* This is a webhook subscription on the integration itself, which is not the
* same thing as an automation inside the database. Automations exist to
* perform actions and deliberately do not run on API edits, so an agent
* updating a commitment triggers nothing. Webhooks are delivery rather than
* action, and their payload identifies the author as `person`, `bot` or
* `agent` β€” a distinction that would be pointless if bot edits produced no
* events. Worth confirming by test rather than by reading: make an edit
* through the API and see whether one arrives.
*
* Subscribing is an admin job, in the connection's Webhooks tab. See the
* setup notes in content/roadmap/README.md.
*/

/** Only the register reads Notion, so this is the only path worth clearing. */
const PATH = "/roadmap";
Comment on lines +22 to +23

/**
* Notion's handshake: on subscribing, it POSTs a one-off `verification_token`
* rather than an event. That token is then the signing key for every event
* after it, so it has to be captured and configured β€” it is not recoverable
* later. Logged loudly for that reason: the setup step is "read it out of the
* deployment logs and paste it into Vercel", and a quiet log makes that step
* look impossible.
*/
function handleVerification(token: string): NextResponse {
console.log(
`[notion-webhook] verification_token=${token}\n` +
`[notion-webhook] Save this as NOTION_WEBHOOK_SECRET, then paste it ` +
`into Notion's Webhooks tab to confirm the subscription. Events are ` +
`rejected until it is set.`
Comment on lines +34 to +38
);
return NextResponse.json({ received: true });
}

/**
* HMAC-SHA256 of the raw body, keyed by the verification token.
*
* The raw text matters: re-serialising the parsed JSON would reorder or
* reformat it and never match. Compared in constant time out of habit rather
* than need β€” the consequence of forging one of these is a page rebuilt
* early, not a page rewritten.
*/
function signatureMatches(raw: string, header: string, secret: string) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

export async function POST(request: Request): Promise<NextResponse> {
const raw = await request.text();

let body: { verification_token?: string; type?: string };
try {
body = JSON.parse(raw);
} catch {
return NextResponse.json({ error: "Body is not JSON." }, { status: 400 });
}

// The handshake arrives before there is a secret to verify against, so it
// has to be handled before the signature check rather than after it.
if (body.verification_token) {
return handleVerification(body.verification_token);
}

const secret = process.env.NOTION_WEBHOOK_SECRET;
if (!secret) {
console.warn(
"[notion-webhook] Event received but NOTION_WEBHOOK_SECRET is unset, " +
"so it cannot be verified. Re-run the subscription handshake."
);
return NextResponse.json(
{ error: "NOTION_WEBHOOK_SECRET is not configured." },
{ status: 503 }
);
}

const signature = request.headers.get("x-notion-signature") ?? "";
if (!signatureMatches(raw, signature, secret)) {
// Logged rather than silent: a signature scheme that is subtly wrong
// rejects every event and otherwise looks exactly like a webhook nobody
// is sending to.
console.warn(
`[notion-webhook] Rejected ${body.type ?? "an event"}: signature did ` +
`not match. If this is every event, NOTION_WEBHOOK_SECRET is stale β€” ` +
`re-subscribe and capture the new token.`
);
return NextResponse.json({ error: "Bad signature." }, { status: 401 });
}

// Any event the subscription sends is about the register, because the
// integration can only see the register β€” so there is nothing to filter on.
// Revalidating is idempotent and costs a rebuild at worst.
revalidatePath(PATH);
return NextResponse.json({ revalidated: true, path: PATH, type: body.type });
}
Loading
Loading