Work with Notion
Handle Notion webhooks, query data sources, and render pages as Markdown.
This guide receives Notion webhooks, verifies their signatures, reacts to page
events, queries a data source, and renders page content as Markdown. It follows
samples/notion-webhook.yaml
plus a page-title logger running in production.
Set up the integration
Create a Notion integration at
notion.com/my-integrations and copy its
token (ntn_...). Share the pages and data sources you want to reach with the
integration; it only sees what you share.
Declare the connector with the token. The handshake below produces
verificationToken, so declare its env var not required, with an empty
default, and start the service with it unset. A variable a setting references
must be set or defaulted, or config load fails.
env:
- name: NOTION_TOKEN
required: true
# Not required, and defaulted to empty: it does not exist until the handshake
# delivers it.
- name: NOTION_VERIFICATION_TOKEN
default: ""
connectors:
- name: notion
type: notion
settings:
token: ${NOTION_TOKEN}
verificationToken: ${NOTION_VERIFICATION_TOKEN}Start the service, then create a webhook subscription in Notion pointing at its
/notion/events route (use a tunnel such as ngrok while developing). The
service must be running to receive the handshake.
The webhook endpoint
Notion signs each webhook over the exact request bytes, so the source delivers the raw body and captures the signature header:
source:
connector: api
type: http
settings:
path: /notion/events
headers: [X-Notion-Signature]
rawBody: trueThe first block authenticates every request. notion-verify-request reads the
raw body, checks the signature against the connector's verificationToken,
aborts on a mismatch, and re-parses the verified JSON into body for the rest
of the flow:
process:
- type: notion-verify-request
name: verify
settings:
connector: notionThe verification handshake
Notion's first request to a new subscription is a one-time handshake carrying
a verification_token, and its signature cannot be checked against a token it
carries itself. While no token is known, notion-verify-request accepts the
handshake, captures the token for the running process, logs it, and sets
vars.notionVerification so your flow can branch on it:
- type: if
name: handshake-or-event
condition: has(vars.notionVerification)
then:
process:
- type: log
name: log-token
settings:
logger: out
message: '"notion verification token: " + vars.notionVerification'
else:
process:
# ... handle real eventsThe bootstrap sequence is:
- Run the service with
NOTION_VERIFICATION_TOKENunset. - Create the subscription in Notion.
- Copy the token from your logs into Notion's UI to confirm the subscription. Real events verify against the captured token right away, with no restart.
- Export the token as
NOTION_VERIFICATION_TOKENso it survives a restart: the captured one lives in memory, for the life of the process.
# First run: no verification token exists yet.
export NOTION_TOKEN=ntn_...
export NOTION_DATABASE_ID=...
octo run --config samples/notion-webhook.yaml
# Later runs: persist the token the handshake produced.
export NOTION_VERIFICATION_TOKEN=secret_...
octo run --config samples/notion-webhook.yamlThe handshake is accepted unsigned only while no token is known. Once one is
configured or captured, an unsigned handshake is rejected like any other unsigned
request. To rotate a subscription, unset verificationToken and restart.
React to page events: a title logger
The else branch handles real events. notion-event filters the stream by
type, and notion-retrieve-page fetches the affected page's properties into
vars.notionPage. This production flow announces every page created in the
workspace:
- type: notion-event
name: page-events
settings:
eventTypes: [page.created]
- type: notion-retrieve-page
name: fetch-page
settings:
connector: notion
page: body.entityId
- type: multi-transform
name: extract-title
settings:
transforms:
- setVar: titleKey
value: >
vars.notionPage.properties.filter(k,
vars.notionPage.properties[k].type == "title")[0]
- setVar: pageTitle
value: >
(size(vars.notionPage.properties[vars.titleKey].title) > 0) ?
vars.notionPage.properties[vars.titleKey].title[0].plain_text :
"(untitled)"
- type: log
name: announce
settings:
logger: out
message: '"page created: " + vars.pageTitle'A Notion page's properties is a map keyed by the property's display name
("Name", "Task", whatever the database calls it), so filter the map's keys for
the property whose type is "title" (CEL's filter on a map iterates keys),
then index back in with that key. The size(...) > 0 check guards against
untitled pages, whose title array is empty.
The webhook payload carries the page's ID as body.entityId; fetch the page
when you need its contents. The sample subscribes to page.content_updated
instead of page.created; eventTypes takes any list of Notion event types,
and a CEL filter setting narrows further.
Query a data source
notion-query-datasource runs a query and stores the response in
vars.notionResults. Pass the database ID and let the block derive the data
source, or pass dataSource directly:
- name: query-recent
source:
connector: ticker
type: cron
settings:
schedule: "@every 5m"
process:
- type: notion-query-datasource
name: query
settings:
connector: notion
database: '"${NOTION_DATABASE_ID}"'
pageSize: 10
- type: log
name: log-count
settings:
logger: out
message: '"data source returned " + string(size(vars.notionResults.results)) + " rows"'database is a CEL expression, so the env var is interpolated inside CEL string
quotes ('"${NOTION_DATABASE_ID}"'). The block also accepts filter and sorts
expressions that pass through to Notion's query API.
Render a page as Markdown
notion-retrieve-blocks fetches a page's child blocks, and
notion-page-to-markdown converts them. Its source defaults to
body.results, where the previous block leaves them:
- name: page-to-markdown
process:
- type: notion-retrieve-blocks
name: fetch-content
settings:
connector: notion
block: body.pageId
- type: notion-page-to-markdown
name: render
settings:
source: body.resultsInvoke it with a page ID and get the rendered Markdown back:
octo invoke --config samples/notion-webhook.yaml \
--flow page-to-markdown --data '{"pageId": "<page-id>"}'notion-page-to-markdown is the bridge between a Notion knowledge base and an
ai-agent prompt.