Work with Notion
Handle Notion webhooks, query data sources, and render pages as Markdown.
This guide connects a flow to a Notion workspace: it receives 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 on the author's platform.
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. verificationToken can wait — the
handshake below produces it — 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; the empty default is
what lets the first run start with no token.)
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 has to 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 directly, 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. That request is the only way to learn the token — so it
is also the only one whose signature cannot be checked, because the token it
would be checked against is what the request carries.
notion-verify-request resolves that chicken-and-egg: while no token is known,
it 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 eventsSo the 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 — you do not have to restart to start processing them.
- 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 — so nobody can push a new token into a running service. To rotate a
subscription, unset verificationToken and restart to reopen the window.
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 flow — a page-title logger running in production —
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'The title extraction deserves a closer look, because it trips everyone up once:
a Notion page's properties is a map keyed by the property's display name
("Name", "Task", whatever the database calls it), so you can't hard-code the
key. The trick is to filter over the map's keys for the property whose type is
"title" — CEL's filter on a map iterates keys — then index back in with the
key you found. The size(...) > 0 check guards against genuinely untitled
pages, whose title array is empty.
The webhook payload carries the page's ID as body.entityId — events are
thin, so 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. A Notion database holds its data sources, so pass the
database ID and let the block derive the data source — or pass dataSource
directly if you have that ID:
- 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"'Note the quoting: 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, which is exactly 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>"}'This pairs naturally with AI flows: Markdown is the format LLMs read best, so
notion-page-to-markdown is the bridge between a Notion knowledge base and an
ai-agent prompt.