Notion
Join Notion page and block comment discussions through webhooks and the Comments API, with Post+Edit streaming.
Create a new bot
Scaffold a Next.js bot with the Notion adapter using create-chat-sdk:
pnpm create chat-sdk@latest my-bot --adapter notion memory -yThe generated app uses the Memory state adapter for local development. To add the adapter to an existing app, start at Install.
Install
pnpm add @chat-adapter/notionQuick start
The adapter auto-detects credentials from NOTION_TOKEN, NOTION_VERIFICATION_TOKEN, and optional NOTION_BOT_USERNAME / NOTION_VERSION / NOTION_MENTION_MODE / NOTION_KEYWORDS.
For managed credentials, see Vercel Connect.
Platform setup
The adapter targets a single workspace through an access-token connection.
Create the connection
- Open the Developer Portal and click New connection.
- 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, and the connection is installed automatically. Click Create connection.
- On the connection page, copy the Access token and set it as
NOTION_TOKEN.
Set capabilities
- Under Capabilities then Comment capabilities, enable Read comments and Insert comments.
- Under Capabilities then Content capabilities, leave Read content, Update content, and Insert content as they are. Read content is required for
message.subjectpage metadata and for verifying the containing page before editing or deleting block-parent comments. - Under Capabilities then User capabilities, leave Read user information including email addresses as it is. The adapter uses it for author names and mention resolution.
Choose content access
On the Content access tab, choose which pages and databases the connection can access. The connection is available, and webhooks fire, only for those pages and databases.
Capability or access errors, typically HTTP 403, usually mean a comment capability is missing or the page or database was not enabled under Content access.
Authentication
Vercel Connect
Use Vercel Connect for the outbound Notion access token:
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.
Create the connector and attach it to your project with the Vercel CLI. Notion webhooks go directly to your app, so omit --triggers. vercel env pull downloads a development OIDC token for local runs:
Access token
Set NOTION_TOKEN to the access token you copied in Platform setup, or pass it as token.
Configuration
| Prop | Type | Default | Description |
|---|---|---|---|
token? | string | (() => string | Promise<string>) | Connection access token or resolver invoked per API request. Auto-detected from `NOTION_TOKEN`. | |
verificationToken? | string | Webhook HMAC key from subscription verification. Auto-detected from `NOTION_VERIFICATION_TOKEN`. | |
notionVersion? | string | "2026-03-11" | Value for the `Notion-Version` header. Auto-detected from `NOTION_VERSION`. Change it at your own risk: the adapter is tested against the pinned version (see API version). |
userName? | string | "notion-bot" | Bot display name. Auto-detected from `NOTION_BOT_USERNAME`. |
mentionMode? | "mention" | "all-comments" | "keyword" | "mention" | How inbound comments become Chat SDK mentions. Default `"mention"` matches plain-text `@userName` / `@botUserId`. Auto-detected from `NOTION_MENTION_MODE`. See Mention detection. |
keywords? | string[] | Case-insensitive word-boundary keywords when `mentionMode` is `"keyword"`. Auto-detected from `NOTION_KEYWORDS` (comma-separated). | |
streamingEditIntervalMs? | number | 1500 | Minimum interval between Post+Edit streaming edits (ms). Tuned for Notion's ~3 req/s average connection rate limit. |
externalUrlPollDelaysMs? | number[] | [0, 5000, 10000] | 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? | string | "https://api.notion.com/v1" | 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, from the environment or config.
Environment variables
| Variable | Required | Description |
|---|---|---|
NOTION_TOKEN | Yes, unless using Vercel Connect | 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 the Notion-Version header (default pinned in API version). |
Webhooks
You create subscriptions on the connection's Webhooks tab, not through the API.
- Deploy your app so
https://your-domain.com/api/webhooks/notionis publicly reachable. - On the connection page, open the Webhooks tab and click Create a subscription.
- Set the Webhook URL, leave the default API version as it is, and under Events deselect every category except Comment. This selects Comment created, Comment deleted, and Comment updated. The adapter requires Comment created; the base adapter acknowledges and ignores deleted and updated events.
- Notion sends a one-time POST containing
verification_token. The adapter logs it at warn level and returns200. - Paste that token into Notion's UI and click Verify.
- Set the same value as
NOTION_VERIFICATION_TOKEN(or passverificationTokenin config), then restart so signed deliveries can be verified.
After verification, the adapter checks every delivery with HMAC-SHA256 over the raw body against X-Notion-Signature (sha256=<hex>).
Event-ID deduplication is best-effort: in memory, plus state-backed when a Chat state adapter is configured. It does not substitute for Notion's delivery semantics across cold starts unless the state adapter is durable.
Notion locks the webhook URL after verification and does not let you change it in place. To point at a new endpoint, delete the subscription and create a new one, then repeat the verification-token flow and update NOTION_VERIFICATION_TOKEN. Choose a stable production URL before verifying.
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, which suits dedicated Q&A pages. |
"keyword" | isMention when the comment matches any configured keywords (case-insensitive, word-boundary). Comments without a keyword are left undetermined, so Chat SDK still matches @userName in the text. |
Streaming and rate limits
Notion allows a connection to update its own comments, so the adapter uses Post+Edit streaming: it posts the first chunk, then PATCHes the same comment as tokens arrive.
Notion's average rate limit is about 3 requests per 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-Afteron HTTP 429
Override the interval per stream with updateIntervalMs on the stream options.
Notion limits rich-text spans to about 2000 characters each. The adapter splits long posts 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.
Message 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. The adapter assumes list-comments returns oldest first, because Notion does not document the order. With the SDK default direction: "backward", you get the newest page of matching discussion comments from that assumed order.
List-comments returns only unresolved discussions. Once a discussion is resolved in Notion, its comments disappear from history fetches.
Message subject
await message.subject resolves the parent Notion page (GET /v1/pages/{pageId}) on first access and caches the result.
Requires Capabilities then Content capabilities then 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.
| 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 |
The subject is the Notion page object from the Pages API (title, properties, URL, and the full payload on raw), not the page's block children or body content (GET /v1/blocks/.../children). See Message Subject.
Cards and files
Notion has no native card UI. JSX cards render as markdown through fallbackText or flattened content, the same pattern as Linear and GitHub. Interactive buttons and callbackUrl are unsupported.
Outbound files use Notion's File Uploads API and attach up to 3 files as native comment attachments:
- Binary uploads use
single_part, up to Notion's single-part limit of about 20 MiB. - Public URLs use
external_urland are polled untiluploadedbefore attaching. By default the adapter rechecks immediately, then after 5s and 10s; configure this withexternalUrlPollDelaysMs. - Files beyond the first 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.
API version
Every request sends:
That pin is exported as DEFAULT_NOTION_VERSION from @chat-adapter/notion. Override it with notionVersion or NOTION_VERSION only if you accept the risk of breakage when Notion's versioned API diverges.
Thread model
One Notion page is one Chat SDK channel, and each discussion is a thread.
| Surface | Thread ID | Outbound behavior |
|---|---|---|
| Page comment surface (channel root) | notion:{pageId} | Creates a page-level comment, starting a new page-level discussion. |
| Existing discussion | notion:{pageId}:{discussionId} | Replies in that discussion. |
| Whole-block discussion (outbound) | notion:{pageId}:block:{blockId}, from encodeThreadId({ pageId, 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 for block-parent comments, and dispatches on notion:{pageId}:{discussionId} so replies stay threaded.
Comment edits and deletions require Read comments to retrieve the target before writing. The adapter throws a ValidationError if the comment is missing or belongs to another page, discussion, or block than the supplied thread ID. For block-parent comments, it retrieves the current block ancestry rather than relying on cached page ownership.
Posts also throw a ValidationError when the thread ID points at another page, and the check runs before any file is uploaded or comment is sent. A whole-block thread ID must name a block on its page, which the adapter confirms from the current block ancestry. Child page and child database blocks are rejected. A discussion thread ID must name a discussion the adapter has recorded for that page. It records a discussion when a comment.created webhook arrives for it, when the bot starts it, or when it finds the discussion in the page's comment list, and keeps the record in the state adapter so every instance shares it. The comment list only covers unresolved, page-level discussions. A post to any other discussion with no record, such as an inline or block discussion whose thread ID was saved before this check existed, throws until a new comment in that discussion arrives through the webhook.
You can start page-level and whole-block discussions through the API. You cannot start selected-text-range (inline highlight) discussions through the public Comments API; those must already exist in Notion.
Use getPageUrl to build deep links:
Feature support
Messaging
| Feature | Supported |
|---|---|
| Post message | |
| Message replies | |
| Edit message | |
| Delete message | |
| File uploads | Up to 3 native attachments |
| Streaming | Post+Edit |
| Scheduled messages |
Rich content
| Feature | Supported |
|---|---|
| Card format | Markdown fallback |
| Buttons | |
| Link buttons | |
| Select menus | |
| Tables | Flattened markdown |
| Charts | |
| Fields | |
| Images in cards | |
| Modals |
Conversations
| Feature | Supported |
|---|---|
| Slash commands | |
| Mentions | |
| Add reactions | |
| Remove reactions | |
| Typing indicator | |
| Mark as read | |
| Message edit events | |
| Message delete events | |
| DMs | |
| Ephemeral messages | |
| User lookup | |
| Parent subject | |
| Native client | |
| Custom API endpoint |
Message history
| Feature | Supported |
|---|---|
| Fetch messages | |
| Fetch single message | |
| Fetch thread info | |
| Fetch channel messages | |
| List threads | |
| Fetch channel info | |
| Post channel message |