How to Connect Third-Party APIs to Notion AI

This blog post was originally written in Chinese and translated into English by AI. The author did not proofread the translation, so there may be errors. If you can read Chinese, you can read the original blog post: https://1q43.blog/?p=12346

I previously wrote about treating the “Hubble Radius” as a private information universe, and connecting it to Notion AI.

But here is the problem: without using Custom Agents, Notion AI does not seem able to connect to third-party APIs. Custom Agents are expensive. So how do we solve this?

Here is a more engineering-oriented practical trick: use a Cloudflare Worker to connect almost any third-party API to Notion AI.

I will use Bocha Search as the example and walk through how it works.

The core idea is very simple:

  • Third-party APIs usually require POST requests, with an API key placed in the request headers.
  • Notion AI currently works better when it reads a GET-accessible webpage through webpage.load.
  • Notion AI’s computer tool can perform hash calculations such as SHA-256.
  • So we need a Cloudflare Worker gateway in the middle: expose a signed GET page to Notion AI, send the real POST API request to the third-party service, and then render the result as HTML.

In other words, this does not make Notion AI call the third-party API directly. It builds Notion AI a “readable bridge.”

If the following feels too cumbersome, you can also simply give this article to your Notion AI and ask it to teach you how to set it up.

1. Why a Worker gateway is needed

Many APIs are called like this:

POST <https://api.example.com/v1/search>
Authorization: Bearer ***
Content-Type: application/json

{
  "query": "search terms",
  "count": 5
}

But what Notion AI handles more easily is an ordinary webpage:


What the Worker does is translate the latter into the former.

Notion AI visits a GET URL. The Worker receives the URL parameters, verifies the signature, assembles those parameters into a POST request, calls the real third-party API, and finally turns the returned result into an HTML page. This way, Notion AI does not need to understand the API’s authentication details, nor does it need to use the POST method to touch the real third-party API.

This makes it possible to connect the default version of Notion AI to third-party APIs without paying extra for Notion’s Custom Agents.

2. The minimum viable architecture

There are three roles in this setup:

  1. Notion AI: understands the user’s intent, generates the query, calculates the dynamic signature, and reads the webpage result.
  2. Cloudflare Worker: handles authentication, parameter conversion, third-party API calls, and HTML rendering.
  3. Third-party API: provides the actual search, database query, model inference, or other capability.

The chain looks like this:

User question
→ Notion AI generates query
→ Calculates ts + sign
→ webpage.load visits the Worker's GET URL
→ Worker verifies the signature
→ Worker sends POST to the third-party API
→ Worker renders JSON as HTML
→ Notion AI reads the page and summarizes it

The most important point here is that the API key should not appear in the prompt, nor should it appear on a Notion page. It should live in the Cloudflare Worker’s environment variables.

3. Using Bocha Search as an example

Bocha’s API is originally a search API. We want Notion AI to be able to use it like this:

Search: winners of the 2024 Nobel Prize in Physics
Return: 5 results
Need summaries: yes

Mapped to the URL exposed by the Worker, it would look roughly like this:


Here:

  • query is the search term and must be URL-encoded.
  • summary controls whether Bocha returns a longer summary.
  • count controls the number of returned results.
  • freshness can restrict the time range, such as one day, one week, one month, or one year.
  • include can restrict search to certain domains.
  • exclude can exclude certain domains.
  • ts is a 10-digit Unix timestamp.
  • sign is the SHA-256 hash of ts + SECRET_KEY.

This signature mechanism is not meant to provide bank-grade security. It is meant to prevent the Worker URL from being casually abused. The timestamp is usually allowed to remain valid only for a few minutes.

4. Example Worker code

First, create a new Worker on Cloudflare Workers, then paste in the following code:

// Helper function: calculate SHA-256 hash
async function generateSHA256(message) {
  const msgUint8 = new TextEncoder().encode(message);
  // Use the stricter object-parameter format to avoid TypeError in some Worker environments
  const hashBuffer = await crypto.subtle.digest({ name: 'SHA-256' }, msgUint8);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

export default {
  async fetch(request, env, ctx) {
    // ==========================================
    // 0. Intercept and allow OPTIONS CORS preflight requests
    // ==========================================
    if (request.method === "OPTIONS") {
      return new Response(null, {
        status: 204,
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "GET, OPTIONS",
          "Access-Control-Allow-Headers": "*",
          "Access-Control-Max-Age": "86400",
        }
      });
    }

    // ==========================================
    // Global error boundary: turn 1101 errors into visible HTML errors
    // ==========================================
    try {
      const url = new URL(request.url);
      
      // ==========================================
      // 1. Configuration items (replace with your real values)
      // ==========================================
      const BOCHA_API_KEY = 'Bocha API Key'; 
      const SECRET_KEY = 'a 32-character random key you generated'; 
      const THRESHOLD_SECONDS = 300; 

      // ==========================================
      // 2. Dynamic token verification logic
      // ==========================================
      const tsParam = url.searchParams.get('ts');
      const signParam = url.searchParams.get('sign');
      let isAuthenticated = false;

      if (tsParam && signParam) {
        const requestTimestamp = parseInt(tsParam, 10);
        if (!isNaN(requestTimestamp)) {
          const currentTimestamp = Math.floor(Date.now() / 1000);
          if (Math.abs(currentTimestamp - requestTimestamp) <= THRESHOLD_SECONDS) {
            const messageToHash = tsParam + SECRET_KEY;
            const expectedSign = await generateSHA256(messageToHash);
            if (signParam.toLowerCase() === expectedSign.toLowerCase()) {
              isAuthenticated = true;
            }
          }
        }
      }

      if (!isAuthenticated) {
        return new Response('<h1>403 Forbidden</h1><p>Invalid Signature or Expired Timestamp</p>', { 
          status: 403, 
          headers: { 'Content-Type': 'text/html;charset=UTF-8' }
        });
      }

      // ==========================================
      // 3. Extract parameters and build payload
      // ==========================================
      const query = url.searchParams.get('query');
      if (!query) {
        return new Response('<h1>400 Bad Request</h1><p>Missing parameter: query</p>', { 
          status: 400, 
          headers: { 'Content-Type': 'text/html;charset=UTF-8' }
        });
      }

      const payload = { query: query };
      if (url.searchParams.has('freshness')) payload.freshness = url.searchParams.get('freshness');
      if (url.searchParams.has('summary')) payload.summary = url.searchParams.get('summary') === 'true';
      if (url.searchParams.has('include')) payload.include = url.searchParams.get('include');
      if (url.searchParams.has('exclude')) payload.exclude = url.searchParams.get('exclude');
      if (url.searchParams.has('count')) {
        const countVal = parseInt(url.searchParams.get('count'), 10);
        if (!isNaN(countVal)) payload.count = countVal;
      }

      // ==========================================
      // 4. Send the request and render HTML
      // ==========================================
      const bochaResponse = await fetch("https://api.bocha.cn/v1/web-search", {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${BOCHA_API_KEY}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify(payload)
      });

      const bochaJson = await bochaResponse.json();

      let htmlContent = `<!DOCTYPE html>
      <html>
      <head><meta charset="utf-8"><title>Search Results: ${query}</title></head>
      <body>
        <h1>Search results: ${query}</h1>
        <hr>
      `;

      if (bochaJson.data && bochaJson.data.webPages && Array.isArray(bochaJson.data.webPages.value)) {
        const results = bochaJson.data.webPages.value;
        if (results.length === 0) {
          htmlContent += `<p>No relevant web results were found.</p>`;
        } else {
          results.forEach((item, index) => {
            htmlContent += `
              <article style="margin-bottom: 24px;">
                <h2><a href="${item.url || ''}">${index + 1}. ${item.name || 'Untitled'}</a></h2>
                <p><strong>Source:</strong> ${item.siteName || 'Unknown'} | <strong>Time:</strong> ${item.datePublished || item.dateLastCrawled || 'Unknown'}</p>
                <p><strong>Snippet:</strong> ${item.snippet || ''}</p>
            `;
            if (item.summary) {
              htmlContent += `<p><strong>Summary:</strong> ${item.summary}</p>`;
            }
            htmlContent += `</article><hr>`;
          });
        }
      } else {
        htmlContent += `<h2>API request failed or returned an unexpected structure</h2><pre>${JSON.stringify(bochaJson, null, 2)}</pre>`;
      }

      htmlContent += `</body></html>`;

      return new Response(htmlContent, {
        status: 200,
        headers: {
          "Content-Type": "text/html;charset=UTF-8",
          "Access-Control-Allow-Origin": "*"
        }
      });
      
    } catch (error) {
      // If another fatal error occurs, it will be intercepted here and printed directly on the webpage
      return new Response(`
        <h1>Worker Error (1101 Prevented)</h1>
        <p>An uncaught internal error occurred:</p>
        <pre style="background:#f4f4f4; padding:15px;">${error.stack || error.message || error}</pre>
      `, { 
        status: 500,
        headers: { 'Content-Type': 'text/html;charset=UTF-8' }
      });
    }
  },
};

5. The calling prompt for Notion AI

The Worker is only the bridge. What actually turns it into a Notion AI skill is a clear set of calling instructions.

You can write it like this:

API endpoint: `https://your-bocha-worker.example.workers.dev`

[URL parameter instructions (must be URL-encoded)]

- `query` (required): your search term.
- `summary` (optional): whether to return a long summary. Pass "true" or "false". Recommended as "true" when researching complex materials.
- `count` (optional): number of results to return, 1–50. Default is 10.
- `freshness` (optional): time-range limit. Possible values: "noLimit" (default), "oneDay", "oneWeek", "oneMonth", "oneYear", or a date range such as "2025-01-01..2025-04-06".
- `include` (optional): restrict search to specified domains, separated by `|` for multiple domains, e.g. [qq.com|m.163.com](http://qq.com%7Cm.163.com).
- `exclude` (optional): exclude specified domains, same format as above.

[Mandatory authentication steps]
Before each request, you must generate dynamic authentication parameters as follows:

1. Get the current accurate 10-digit Unix timestamp, in seconds.
2. Concatenate the timestamp with the string "the 32-character random key you designed" in the format: timestamp + secret.
3. Use the calculation tool to compute the SHA-256 hash of the concatenated string, output in lowercase.
4. Put the timestamp into the `ts` parameter and the hash into the `sign` parameter.
5. After assembling the authenticated URL, stop using the computer tool and instead use the webpage browsing tool (web.loadPage) to perform the search.

[Call format example]
If you want to search for "winners of the 2024 Nobel Prize in Physics", need summaries, and want 5 results, the final request URL should look like this:
`https://bocha-notionai.xiaoyao-f87.workers.dev/?query=winners%20of%20the%202024%20Nobel%20Prize%20in%20Physics&summary=true&count=5&ts=1715000000&sign=calculated_hash_value`

After receiving the JSON response, parse the `data.webPages.value` array, extract the `name`, `url`, `snippet`, and `summary` fields, then organize and summarize them when answering the user’s question.

In this prompt, Notion AI only needs to know “how to generate the signature” and “how to assemble the URL.” The real API key stays inside the Worker.

If you are worried about putting the SECRET_KEY into the prompt as well, you can add a more conservative layer: let the Worker accept a fixed internal token, or use Cloudflare Access, IP restrictions, one-time short links, and so on. But for personal use, timestamp + hash signature is already lightweight enough.

6. What this method can connect to

Bocha is only one example. As long as a third-party service can be called by a Worker, it can be connected to Notion AI in a similar way:

  • Search APIs: Chinese-language search, vertical-site search, private search engines.
  • Data APIs: self-hosted databases, spreadsheets outside Notion, CRM systems, logging systems.
  • Model APIs: transcription, summarization, classification, vector retrieval, image understanding.
  • Automation APIs: webhooks, internal tools, scripts on personal servers.

All of them can be wrapped into a webpage that Notion AI can understand.

The significance of this is not merely “giving Notion AI one more tool.” More precisely, it lets an individual turn their external systems into Notion AI’s observable boundary.

Once these APIs are connected, Notion AI is no longer limited to Notion pages and public webpages. It can access your subscription feeds, your private search, your automation pipelines, and your local knowledge systems.

评论尸 Avatar

如果你觉得本文有信息增量,请:

喜欢作者

 

精选评论