Octov0.11.7
Guides

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:

samples/notion-webhook.yaml (excerpt)
source:
  connector: api
  type: http
  settings:
    path: /notion/events
    headers: [X-Notion-Signature]
    rawBody: true

The 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: notion

The 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:

samples/notion-webhook.yaml (excerpt)
- 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 events

The bootstrap sequence is:

  1. Run the service with NOTION_VERIFICATION_TOKEN unset.
  2. Create the subscription in Notion.
  3. 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.
  4. Export the token as NOTION_VERIFICATION_TOKEN so 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.yaml

The 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:

Live integration: notion-page-created (excerpt)
- 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:

samples/notion-webhook.yaml (excerpt)
- 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:

samples/notion-webhook.yaml (excerpt)
- 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.results

Invoke 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.

Where to go next

On this page