How a voice agent writes to Notion — system design & call flow

System design

A phone call turns into a database row with no human in the middle. This is the full architecture: the live Vapi call path through a custom tool and a PHP bridge, the parallel Hermes + Notion MCP agent path, and the API rules that decide whether an implementation works or silently fails.

What you’ll learn
Contents
  1. The one architectural fact
  2. Lane A — the live call path
  3. The tool-call protocol
  4. Lane B — the agent and build path
  5. The full system diagram
  6. Notion API rules that break implementations
  7. Failure modes and what they actually mean
  8. The 20-second intro

The one architectural fact

A Vapi assistant cannot call the Notion API itself. It has no Notion credentials, no Notion SDK, and no general-purpose HTTP tool you should hand a workspace token to. What it has is a custom function tool whose server.url points at an endpoint you control.

So the assistant makes a phone-call-shaped request: it POSTs a tool-calls webhook to your URL, your endpoint does the privileged write, and your endpoint hands back a result the assistant reads aloud. Everything else in this design follows from that single constraint — the endpoint is the trust boundary, and it is the only component holding the Notion token.

Two paths, one destination. The live call path is event-driven and writes a single row per call. The agent path exists so the same workspace can be built, seeded and verified without a phone involved. They share the integration token and the databases, and nothing else — no bridge, no webhook, no assistant.

Lane A — the live call path

Caller → Vapi → tool → bridge → Notion

  1. Caller dials the number attached to a Vapi phone-number resource. No app, no login, no menu tree.
  2. The assistant answers. Speech-to-text feeds the model, which decides what to ask and when it has enough to book. Voice output is generated per turn.
  3. The assistant calls the tool. Once it has the details, it emits a function tool call — name bookAppointment, arguments carrying date, time, name, phone, job description.
  4. Vapi POSTs the webhook to the tool's server.url with an auth header. That URL is your endpoint, running as a small PHP script behind nginx → Apache + php-fpm.
  5. The bridge validates and writes. It checks the header token, normalises the arguments (which may arrive as an object or a JSON string), then creates the Notion page with POST /v1/pages, parented to the bookings database.
  6. The bridge answers, the assistant speaks. The response carries one result per tool call, keyed by toolCallId; the model turns it into "you're booked for Tuesday at eleven".

Two deployment details matter more than they look. First, the token and webhook secret live in a directory outside the document root, readable only by the site user — never in the script itself. Second, the endpoint must be idempotent enough for a retry: a duplicate webhook should not create a second booking for the same caller and slot.

The tool-call protocol

What Vapi sends to server.url:

{
  "message": {
    "type": "tool-calls",
    "toolCallList": [
      { "id": "call_1", "type": "function",
        "function": { "name": "bookAppointment",
                      "arguments": { "date": "2026-11-03", "time": "11:00" } } }
    ]
  }
}

arguments may arrive as an object or as a JSON-encoded string — handle both. Older payloads may use toolWithToolCallList, where each item wraps {function, toolCall}; treat it as an alias, not a separate code path.

What your endpoint must return:

{
  "results": [
    { "name": "bookAppointment",
      "toolCallId": "call_1",
      "result": "{\"ok\":true,\"booking\":\"2026-11-03 11:00\"}" }
  ]
}

The result is a JSON-encoded string, not an object. Return it that way and the assistant can read it; return a raw object and the tool call is reported as failed even though the write succeeded.

Do not duplicate the endpoint. If the tool carries server.url and the assistant carries a top-level server.url pointing at the same place, Vapi delivers the tool call twice and you book twice. Set it only on the tool. Likewise, tools belong in model.tools — an assistant.tools block is not read.

Lane B — the agent and build path

Hermes Agent → Notion MCP server → same API

  1. Hermes runs a local MCP server as a child process over stdio: @notionhq/notion-mcp-server, launched by the agent runtime.
  2. The token is mapped at boot — the secret store's NOTION_API_KEY is handed to the server as NOTION_TOKEN. No token in the config file, no token in chat.
  3. Typed tools replace hand-rolled HTTP: search, retrieve page markdown, create page, patch page, query data source, append blocks — roughly two dozen calls.
  4. The same databases are reachable because the integration is shared with them, so schema work, seeding and verification happen without touching the phone flow.

Keeping both paths in the design is not redundancy — it is what makes the live path debuggable. When a booking fails at 2 a.m., the agent can query the same database and prove whether the row exists, was skipped, or was written with the wrong property shape.

The full system diagram

Both lanes on one canvas: voice platform in amber, backend in green, Notion in violet, secrets in rose. Lane A is the top half; lane B is the bottom half; the Notion API gateway in the middle is shared by both.

System design: caller to Vapi assistant to custom tool to PHP bridge to the Notion API, alongside a Hermes Agent path through the Notion MCP server into the same Notion databases.

On a phone the diagram scrolls sideways, or open it full size.

Notion API rules that break implementations

RuleWhy it bites
Notion-Version: 2025-09-03 on every raw requestOmitting it returns version-mismatch errors, or older behaviour on legacy integrations. The MCP server and CLI set it for you; hand-written curl does not.
Write with database_id, query with data_source_idDatabases now expose data sources. POST /v1/pages wants the database id; POST /v1/data_sources/{id}/query wants the data-source id. They are different UUIDs; the create response echoes the one you need.
Property schema goes in initial_data_source.propertiesA top-level properties key on database create is rejected outright.
Database properties are create-time onlyYou cannot add a property by PATCHing a database. Adding a column means a new database and a migration.
Append blocks is PATCH /v1/blocks/{id}/childrenPOST returns a method error. Calendar view date_property_id additionally wants the url-encoded property id, not the name.
~3 requests/second per integrationShared by the bridge, the MCP server and any CLI work. Bursts 429; batch reads and back off.

Failure modes and what they actually mean

SymptomReal cause
Every call returns 404 object_not_foundThe page or database was never shared with the integration. A 404 on Notion almost always means "not shared", not "missing".
Reads work, writes return 403The integration lacks Insert or Update content capability.
Booking saved but the caller heard a failureresult was returned as an object instead of a JSON-encoded string, or the header token check rejected the retry.
Two identical bookingsTool-level and assistant-level server.url both set, or no idempotency key on the webhook.
Tool call times out but the row existsThe endpoint did the write and then stalled past the tool's timeoutSeconds. Keep the write path fast; move slow work after the response.
Nothing in Notion, no error anywhereThe assistant never fired the tool — usually a description that does not tell it when to call, or required parameters it cannot collect.

The 20-second intro

A short, deliberately silent intro built from this same design — no voice-over and no music, so narration sits on top of it. Hook, the wire, the diagram assembling itself, outro.

1920×1080 · 30 fps · 20 s · silent · 2.3 MB

Published by diyyourdata AI Automation · Diy Your Data · Melbourne
Canonical: this page

Bottom line
the assistant never touches Notion: it calls one tool, your bridge holds the token and writes the row, and a parallel MCP path lets the same agent build and verify that workspace without a phone call.
📍 Location Tracking
We use your IP address (country/city only) for analytics. No personal data collected.