mirror of
https://github.com/vercel/chat.git
synced 2026-09-14 18:32:29 +08:00
06b04ac4d9
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>
324 lines
14 KiB
Plaintext
324 lines
14 KiB
Plaintext
---
|
||
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 />
|