Files
Ben Sabic 06b04ac4d9 Add Vercel Connect support to Notion (#812)
Adds function-backed Notion access-token resolution so the adapter can
use short-lived Vercel Connect credentials for every API request, retry,
and multipart upload. Direct Notion webhooks continue to use
`NOTION_VERIFICATION_TOKEN` and native HMAC verification because Connect
does not forward Notion triggers.

```ts
import { createNotionAdapter } from "@chat-adapter/notion";
import { connectNotionAdapter } from "@vercel/connect/chat";

createNotionAdapter({
  ...connectNotionAdapter("notion/acme-notion"),
  verificationToken: process.env.NOTION_VERIFICATION_TOKEN,
});
```

`create-chat-sdk` now recognizes Notion as Connect-capable, preserves
the native webhook verification token, and emits direct-webhook
guidance:

```bash
npm create chat-sdk@latest -- my-bot --adapter notion memory --connect -y
```

Validated with the Notion adapter suite (71 tests), create-chat-sdk
suite (209 tests), package type checks/builds, and repository
lint/format checks.

Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
2026-08-12 11:00:21 +10:00

324 lines
14 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Notion
description: Respond to comments on Notion pages and discussion threads.
packageName: "@chat-adapter/notion"
slug: notion
type: platform
logo: notion
tagline: Participate in Notion page and block comment discussions via webhooks and the Comments API. Supports Post+Edit streaming.
beta: true
features:
postMessage: yes
editMessage: yes
deleteMessage: yes
fileUploads:
status: partial
label: Up to 3 native attachments
streaming:
status: partial
label: Post+Edit
scheduledMessages: no
cardFormat:
status: yes
label: Markdown fallback
buttons: no
linkButtons: no
selectMenus: no
tables:
status: partial
label: Flattened markdown
fields: yes
imagesInCards: no
modals: no
slashCommands: no
mentions: yes
addReactions: no
removeReactions: no
typingIndicator: no
messageUpdatedEvents: no
messageDeletedEvents: no
directMessages: no
ephemeralMessages: no
userLookup: no
parentSubject: yes
nativeClient: no
customApiEndpoint: yes
fetchMessages: yes
fetchSingleMessage: no
fetchThreadInfo: yes
fetchChannelMessages: no
listThreads: no
fetchChannelInfo: no
postChannelMessage: no
---
## Install
<PackageInstall package="@chat-adapter/notion" />
## Quick start
<Callout type="info">
The adapter auto-detects credentials from `NOTION_TOKEN`, `NOTION_VERIFICATION_TOKEN`, and optional `NOTION_BOT_USERNAME` / `NOTION_VERSION` / `NOTION_MENTION_MODE` / `NOTION_KEYWORDS`.
</Callout>
```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
import { createNotionAdapter } from "@chat-adapter/notion";
import { createRedisState } from "@chat-adapter/state-redis";
const bot = new Chat({
userName: "my-bot",
adapters: {
notion: createNotionAdapter(),
},
state: createRedisState(),
});
bot.onNewMention(async (thread, message) => {
await thread.post("Hello from Notion!");
});
```
```typescript title="app/api/webhooks/notion/route.ts" lineNumbers
import { bot } from "@/lib/bot";
export async function POST(request: Request): Promise<Response> {
return bot.webhooks.notion(request);
}
```
## Vercel Connect
Use Vercel Connect for the outbound Notion access token:
```typescript title="lib/bot.ts" lineNumbers
import { createNotionAdapter } from "@chat-adapter/notion";
import { connectNotionAdapter } from "@vercel/connect/chat";
const notion = createNotionAdapter({
...connectNotionAdapter("notion/acme-notion"),
verificationToken: process.env.NOTION_VERIFICATION_TOKEN,
});
```
<Callout type="info">
Connect does not forward Notion webhooks. Configure the subscription directly
in Notion and retain `NOTION_VERIFICATION_TOKEN` for native HMAC verification.
`NOTION_TOKEN` is not needed when using `connectNotionAdapter`.
</Callout>
## Connection setup
The adapter targets a **single workspace** via an [access-token connection](https://developers.notion.com/docs/getting-started).
1. Open the [Developer Portal](https://app.notion.com/developers/connections) and click **New connection**.
2. Enter a connection name, choose **Access token** as the authentication method, and select the workspace the connection is installable in. Only one workspace is supported; the connection is installed automatically. Click **Create connection**.
3. On the connection page, copy the **Access token** → set as `NOTION_TOKEN`.
4. Under **Capabilities → Comment capabilities**, enable **Read comments** and **Insert comments**.
5. Under **Capabilities → Content capabilities**, leave **Read content**, **Update content**, and **Insert content** as-is (**Read content** is required for [`message.subject`](/docs/subject) page metadata).
6. Under **Capabilities → User capabilities**, leave **Read user information including email addresses** as-is (used for author names and mention resolution).
7. On the **Content access** tab, choose which pages and databases the connection can access. The connection is only available and webhooks only fire for those pages and databases.
Capability or access errors (typically HTTP 403) usually mean a missing comment capability or that the page/database was not enabled under **Content access**.
## Webhook subscription
Subscriptions are created on the connection's **Webhooks** tab (not via the API).
1. Deploy your app so `https://your-domain.com/api/webhooks/notion` is publicly reachable.
2. On the connection page, open the **Webhooks** tab and click **Create a subscription**.
3. Set the **Webhook URL**, leave the default **API version** as-is, and under **Events** deselect every category except **Comment** (this selects **Comment created**, **Comment deleted**, and **Comment updated**). The adapter requires **Comment created**; deleted/updated events are acknowledged and ignored by the base adapter.
4. Notion sends a one-time POST containing `verification_token`. The adapter logs it at **warn** and returns `200`.
5. Paste that token into Notion's UI and click **Verify**.
6. Set the same value as `NOTION_VERIFICATION_TOKEN` (or pass `verificationToken` in config), then restart so signed deliveries can be verified.
After verification, every delivery is checked with HMAC-SHA256 over the **raw body** against `X-Notion-Signature` (`sha256=<hex>`).
Event-id dedupe is **best-effort**: in-memory plus state-backed when a Chat state adapter is configured. It is not a substitute for Notion's delivery semantics across cold starts without durable state.
<Callout type="warn">
**Webhook URL is locked after verification.** Notion does not let you change the subscription URL in place. To point at a new endpoint you must **delete the subscription and create a new one**, then repeat the verification-token paste flow and update `NOTION_VERIFICATION_TOKEN`. Choose a stable production URL before verifying.
</Callout>
## Environment variables
| Variable | Required | Description |
|----------|----------|-------------|
| `NOTION_TOKEN` | Yes | Connection access token (Bearer token). |
| `NOTION_VERIFICATION_TOKEN` | Yes | HMAC key from the webhook verification handshake. |
| `NOTION_BOT_USERNAME` | No | Bot display name override (default `notion-bot`). |
| `NOTION_MENTION_MODE` | No | `mention` \| `all-comments` \| `keyword` (default `mention`). |
| `NOTION_KEYWORDS` | No | Comma-separated keywords when `NOTION_MENTION_MODE=keyword`. |
| `NOTION_VERSION` | No | Override `Notion-Version` header (default pinned below). |
## Configuration
<TypeTable
type={{
token: {
type: "string | (() => string | Promise<string>)",
description:
"Connection access token or resolver invoked per API request. Auto-detected from `NOTION_TOKEN`.",
},
verificationToken: {
type: "string",
description:
"Webhook HMAC key from subscription verification. Auto-detected from `NOTION_VERIFICATION_TOKEN`.",
},
notionVersion: {
type: "string",
default: '"2026-03-11"',
description:
"Value for the `Notion-Version` header. Auto-detected from `NOTION_VERSION`. Changing this is at your own risk — the adapter is tested against the pin below.",
},
userName: {
type: "string",
default: '"notion-bot"',
description:
"Bot display name. Auto-detected from `NOTION_BOT_USERNAME`.",
},
mentionMode: {
type: '"mention" | "all-comments" | "keyword"',
default: '"mention"',
description:
'How inbound comments become Chat SDK mentions. Default `"mention"` matches plain-text `@userName` / `@botUserId`. Auto-detected from `NOTION_MENTION_MODE`. See Mention detection.',
},
keywords: {
type: "string[]",
description:
'Case-insensitive word-boundary keywords when `mentionMode` is `"keyword"`. Auto-detected from `NOTION_KEYWORDS` (comma-separated).',
},
streamingEditIntervalMs: {
type: "number",
default: "1500",
description:
"Minimum interval between Post+Edit streaming edits (ms). Tuned for Notion's ~3 req/s average connection rate limit.",
},
externalUrlPollDelaysMs: {
type: "number[]",
default: "[0, 5000, 10000]",
description:
"Delays (ms) between Retrieve File Upload polls after an `external_url` create. Default is an immediate recheck, then 5s / 10s (~15s max) so serverless webhook handlers stay within typical timeouts. Use Notion's longer `[5000, 15000, 30000, 45000]` when imports are slow and the caller can wait; `[]` skips waiting and links unless create already returned `uploaded`.",
},
apiBaseUrl: {
type: "string",
default: '"https://api.notion.com/v1"',
description:
"Override the Notion API base URL (constructor-only; not from env). Useful for tests and proxies. Backs `customApiEndpoint`.",
},
}}
/>
`token` and `verificationToken` are required at runtime (via env or config).
### Mention detection
| Mode | Behavior |
|------|----------|
| `"mention"` (default) | `isMention` when the comment plain text contains `@userName` or `@botUserId`. Notion connection bots are not @-mentionable in the composer, so users type the `@` token manually. |
| `"all-comments"` | Every non-bot comment on connected pages is treated as a mention (useful for dedicated Q&A pages). |
| `"keyword"` | `isMention` when the comment matches any configured `keywords` (case-insensitive, word-boundary). |
```typescript
createNotionAdapter({
userName: "docs-bot",
// default mentionMode: "mention" → triggers on "@docs-bot" or "@<bot-user-id>"
});
```
```typescript
createNotionAdapter({
mentionMode: "keyword",
keywords: ["@docs-bot", "hey bot"],
});
```
## Thread model
One Notion **page** is one Chat SDK **channel**. Each **discussion** is a **thread**.
| Surface | Thread ID | Outbound behavior |
|---------|-----------|-------------------|
| Page comment surface (channel root) | `notion:{pageId}` | Creates a **page-level** comment (starts a new page-level discussion). |
| Existing discussion | `notion:{pageId}:{discussionId}` | Replies in that discussion. |
| Whole-block discussion (outbound) | `encodeThreadId({ pageId, blockId })` → `notion:{pageId}:block:{blockId}` (or the adapter helper) | Starts a discussion on an entire block via `parent.block_id`. |
Inbound `comment.created` events always include a `discussion_id`. The adapter resolves the containing page (including block-parent comments) and dispatches on `notion:{pageId}:{discussionId}` so replies stay threaded.
<Callout type="warn">
You can start **page-level** and **whole-block** discussions via the API. You **cannot** start **selected-text-range** (inline highlight) discussions through the public Comments API — those must already exist in Notion.
</Callout>
Helper for deep links:
```typescript
const notion = bot.getAdapter("notion");
const url = notion.getPageUrl(thread.id); // includes ?d={discussionId} when present
```
## Message subject
`await message.subject` resolves the parent Notion **page** (`GET /v1/pages/{pageId}`) on first access and caches the result.
<Callout type="info">
Requires **Capabilities → Content capabilities → Read content**. Without it, `message.subject` returns `null` (typically after a 403 from the Pages API). Comment posting still works with only the comment capabilities.
</Callout>
| Field | Value |
|-------|-------|
| `type` | `"page"` |
| `id` | Page UUID |
| `title` | Page title property (plain text) |
| `url` | Notion page URL |
| `status` | `"archived"` when the page is archived or in trash; otherwise omitted |
| `author` | Page `created_by` when present |
| `raw` | Full page API response |
Returns the Notion **page object** (title, properties, URL, and the full payload on `raw`) from the Pages API — not the page’s **block children** / body content (`GET /v1/blocks/.../children`). See [Message Subject](/docs/subject).
## Streaming and rate limits
Notion allows updating a connection's own comments, so the adapter uses **Post+Edit** streaming: post the first chunk, then `PATCH` the same comment as tokens arrive.
Notion's average rate limit is about **~3 requests/second** per connection (plus shared workspace limits). The adapter:
- Throttles streaming edits with a default minimum interval of **≥ 1500ms** (`streamingEditIntervalMs`)
- Always flushes a final edit
- Applies a shared limiter to all API calls and honors `Retry-After` on HTTP 429
Override per stream with `updateIntervalMs` on the stream options if needed.
<Callout type="info">
Notion rich-text spans are limited to about **~2000 characters per span**. Long posts are automatically split into sequential comments to stay under the limit. Streaming edits a single comment in place, so an unusually long streamed response can still approach this ceiling.
</Callout>
## Conversation history
`fetchMessages` uses Notion's list-comments API (`GET /v1/comments?block_id={pageId}`), paginated ascending, then filters client-side to the requested `discussion_id`. List-comments ordering is **assumed oldest-first** (Notion does not document order). With the SDK default `direction: "backward"`, you get the newest page of matching discussion comments from that assumed order.
<Callout type="warn">
**Open comments only.** List-comments returns unresolved discussions. Once a discussion is resolved in Notion, those comments disappear from history fetches.
</Callout>
## API version
Every request sends:
```http
Notion-Version: 2026-03-11
```
That pin is exported as `DEFAULT_NOTION_VERSION` from `@chat-adapter/notion`. Override with `notionVersion` / `NOTION_VERSION` only if you accept breakage risk when Notion's versioned API diverges.
## Cards and files
- **Cards** — no native Notion card UI; JSX cards render as markdown via `fallbackText` / flattened content (same pattern as Linear/GitHub). Interactive buttons / `callbackUrl` are unsupported.
- **Files** — outbound files use Notion’s **File Uploads API** and attach up to **3** as native comment attachments.
- Binary uploads use `single_part` (up to Notion’s ~20 MiB single-part limit); public URLs use `external_url`, polled until `uploaded` before attach (default: immediate recheck, then 5s / 10s — configurable via `externalUrlPollDelaysMs`).
- Overflow beyond 3, uploads still pending after the poll window, and other upload failures fall back to **markdown links** in the comment body. Edits link files in markdown only.
## Feature support
<FeatureSupport />