Teams Notion Webhook の実装(失敗談) : hkob の雑記録 (517)

はじめに

hkob の雑記録の第517回目(連続90日目)は、Teams から Notion の日付なしタスクを生成する Webhook の実装を進めていきましたが、最後にライセンスの壁にぶち当たってしまった件を解説します。

TL;DR

先に書いておきますが、今回の実装は失敗に終わりました。ワークフローの設定まで終わり、保存した段階で Power Automate Premium ライセンスが必要と出て来ました。そういえば数年前もこれでハマったことを今頃思い出しました。

Power Automate Premium ライセンスが必要

途中までの作業記録

なんだかせっかくここまで実装してきたので、もったいないのでそのまま貼っておきます。使わないものにコメントをつけるのも面倒なので、ログそのままです。

作業手順書(Teams → Notion Tasks 手動取り込み)

目的

Microsoft Teams の特定メッセージを、手動フロー(Power Automate)実行により Notion の Tasks(Tasks)へタスク登録する。見落とし防止・GTD の Inbox 化。

完成形(期待する動作)

  • Teams 上で「タスク化したいメッセージ」を選ぶ
  • Power Automate の手動フローを実行する
  • Worker(Webhook)が冪等性を保証しつつ Notion Tasks に 1 件のタスクを作成する
  • 同一メッセージを再実行しても Tasks は重複しない(External ID により防止)

0. 前提チェック

  • Tasks データソースに External ID(冪等性キー)プロパティが存在すること
  • Worker(Notion Workers)が外部から到達可能な Webhook エンドポイントを持つこと
  • Power Automate で HTTP(POST)アクションが利用できること

1. Worker(Webhook)を先に実装(エンドポイント確定)

Notion Workers では Webhook 作成時にエンドポイント(URL)が自動発行される。したがって、Power Automate 側を作る前に Worker を deploy して Webhook URL を確定する(moodle webhook と同じ順序)。

1.1 ローカル作業ディレクトリを作成

  1. mkdir -p ~/Dropbox/workers/teams-webhook
  2. cd ~/Dropbox/workers/teams-webhook

実行結果

hkob@hM5Air ~> mkdir -p ~/Dropbox/workers/teams-webhook
hkob@hM5Air ~> cd ~/Dropbox/workers/teams-webhook
hkob@hM5Air ~/D/w/teams-webhook>

1.2 Worker 雛形を作成

  1. ntn workers new .
    • git 初期化 / 依存関係インストールは moodle と同様
  2. (必要なら)ntn update

実行結果

ntn workers new .
✔ Template downloaded
✔ Initialize a git repository? · yes
✔ Install dependencies? · yes
✔ Template files extracted
✔ Git repository initialized
✔ Dependencies installed with npm
✔ Worker project created

Next steps:

  1. ntn workers deploy

1.3 src/index.ts を実装(受信 → 認証 → 冪等性 → Tasks 作成)

src/index.ts(ひな形)

以下をそのまま src/index.ts に貼り付けてから、必要に応じて調整する(Tasks のプロパティ名はこのワークスペースの Tasks スキーマに合わせてある)。

補足: VSCode で crypto が見つからない場合(Node 型定義)

名前 'crypto' が見つかりません が出る場合は、Node.js の型定義を追加する。

  1. npm i -D @types/node
  2. (必要なら)tsconfig.jsoncompilerOptions.types"node" を追加

実行結果

npm i -D @types/node

added 1 package, and audited 16 packages in 755ms

2 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

実行結果(tsconfig.json 変更)

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "nodenext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleResolution": "nodenext",
    "types": ["node"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
import crypto from "crypto";
import { Worker, WebhookVerificationError } from "@notionhq/workers";

const worker = new Worker();
export default worker;

/**
 * Power Automate から送られてくる Teams メッセージの正規化ペイロード(想定)
 */
type TeamsWebhookPayload = {
    event?: string;
    occurredAt?: string;
    tenantId?: string;
    conversation?: {
        type?: "channel" | "chat" | string;
        teamId?: string;
        teamName?: string;
        channelId?: string;
        channelName?: string;
        chatId?: string;
    };
    message?: {
        id?: string;
        webUrl?: string;
        subject?: string;
        text?: string;
        from?: {
            id?: string;
            displayName?: string;
        };
    };
};

function requireEnv(name: string): string {
    const v = process.env[name];
    if (!v) throw new Error(`${name} not configured`);
    return v;
}

function verifySharedSecret(headers: Record<string, string>): void {
    const secret = requireEnv("TEAMS_WEBHOOK_SECRET");

    // Workers の headers は lowercase 化されている前提で扱う
    const got = headers["x-webhook-secret"] ?? "";
    if (!got) throw new WebhookVerificationError("Missing X-Webhook-Secret");

    if (got.length !== secret.length) throw new WebhookVerificationError("Invalid X-Webhook-Secret");
    if (!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(secret))) {
        throw new WebhookVerificationError("Invalid X-Webhook-Secret");
    }
}

function buildExternalId(body: TeamsWebhookPayload): string {
    const c = body.conversation ?? {};
    const m = body.message ?? {};
    const convType = (c.type ?? "unknown") as string;
    const messageId = (m.id ?? "").trim();
    if (!messageId) throw new Error("message.id is required");

    if (convType === "channel") {
        const teamId = (c.teamId ?? "").trim();
        const channelId = (c.channelId ?? "").trim();
        if (!teamId || !channelId) throw new Error("conversation.teamId/channelId is required for channel");
        return `teams:channel:${teamId}:${channelId}:message:${messageId}`;
    }

    if (convType === "chat") {
        const chatId = (c.chatId ?? "").trim();
        if (!chatId) throw new Error("conversation.chatId is required for chat");
        return `teams:chat:${chatId}:message:${messageId}`;
    }

    // どちらでもない場合は最低限 messageId で収束させる(後で厳格化してもよい)
    return `teams:${convType}:message:${messageId}`;
}

function truncate(s: string, n: number): string {
    return s.length > n ? s.slice(0, n - 1) + "…" : s;
}

worker.webhook("onTeamsMessageToNotionTask", {
    title: "Teams message → create Notion task",
    description: "Receives a Teams message payload (via Power Automate) and creates a row in Tasks DB with idempotency.",
    execute: async (events: any[], { notion }: any) => {
        const tasksDataSourceId = requireEnv("TASKS_DATA_SOURCE_ID");
        const assigneeUserId = process.env.ASSIGNEE_USER_ID ?? ""; // 任意(固定アサインする場合のみ)

        for (const event of events) {
            verifySharedSecret(event.headers as Record<string, string>);

            const body = event.body as TeamsWebhookPayload;

            // 最低限のバリデーション
            if (!body?.conversation?.type) throw new Error("conversation.type is required");
            if (!body?.message?.id) throw new Error("message.id is required");
            if (!body?.message?.webUrl) throw new Error("message.webUrl is required");
            if (!body?.message?.from?.displayName) throw new Error("message.from.displayName is required");
            if (!body?.occurredAt) throw new Error("occurredAt is required");

            const externalId = buildExternalId(body);

            // Task title
            const fromName = (body.message?.from?.displayName ?? "").trim();
            const subject = (body.message?.subject ?? "").trim();
            const text = (body.message?.text ?? "").trim();
            const head = subject || truncate(text.replace(/\s+/g, " "), 60);
            const taskName = `【Teams】${fromName}: ${head || externalId}`;

            const link = (body.message?.webUrl ?? "").trim();
            const summary = truncate(text, 2000);

            // 冪等性チェック(External ID)
            const existing = await notion.dataSources.query({
                data_source_id: tasksDataSourceId,
                filter: { property: "External ID", rich_text: { equals: externalId } },
                page_size: 1,
            });
            if (existing.results.length > 0) continue;

            // Tasks 作成(最小プロパティ)
            await notion.pages.create({
                parent: { data_source_id: tasksDataSourceId },
                properties: {
                    "Task name": { title: [{ text: { content: taskName } }] },
                    ...(summary ? { Summary: { rich_text: [{ text: { content: summary } }] } } : {}),
                    ...(link ? { Link: { url: link } } : {}),
                    "External ID": { rich_text: [{ text: { content: externalId } }] },
                    ...(assigneeUserId ? { Assignee: { people: [{ id: assigneeUserId }] } } : {}),
                },
            });
        }
    },
});

認証(最小構成: 共有シークレット)

  • Header X-Webhook-Secret を検証し、不一致なら 401 Unauthorized

入力バリデーション(最低限)

  • conversation.type
  • message.id
  • message.webUrl
  • message.from.displayName
  • occurredAt
  • message.text(保存方針により省略可)

冪等性(External ID)

  • teams:channel:<teamId>:<channelId>:message:<messageId>
  • teams:chat:<chatId>:message:<messageId>

Tasks への作成(最小プロパティ)

  • Task name / Summary / Link / External ID(必要なら Date)

1.4 deploy(Webhook URL を発行させる)

  1. ntn workers deploy
    • 目的: Worker を作成/更新し、workers.json を生成し、Webhook URL を確定させる
  2. ntn workers webhooks list
    • 発行された Webhook URL(https://...)を控える(Power Automate の送信先に使う)

実行結果

ntn workers deploy
Deploying to workspace: hkob's ambassador workspace
✔ Worker name · teams-webhook
[build:out] Running npm install...
[build:out] added 14 packages, and audited 15 packages in 1s
[build:out] 2 packages are looking for funding
[build:out] run `npm fund` for details
[build:out] found 0 vulnerabilities
[build:err] npm notice
[build:err] npm notice New major version of npm available! 10.9.7 -> 11.16.0
[build:err] npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.16.0
[build:err] npm notice To update run: npm install -g npm@11.16.0
[build:err] npm notice
[build:out] Running npm run build...
[build:out] > @notionhq/workers-template@0.0.0 build
[build:out] > tsc
[build:out] Running npm install --omit=dev...
[build:out] up to date, audited 9 packages in 358ms
[build:out] 2 packages are looking for funding
[build:out] run `npm fund` for details
[build:out] found 0 vulnerabilities
[build:out] Moving package.json...
[build:out] Moving package-lock.json...
[build:out] Moving node_modules...
[build:out] Creating bundle from dist...
[build:out] Uploading bundle...
[build:out] Bundle upload complete.
[build:out] Preparing runtime filesystem for snapshot...
✔ Worker created

Webhooks:
  + onTeamsMessageToNotionTask

Webhook URLs:
  → https://www.notion.so/webhooks/worker/d08cad25-f90c-4f24-832c-38049a6b0b3a/019eac17-67e5-7867-83fe-bd77124d0d00/VJii1qhslrMjAtK0/onTeamsMessageToNotionTask  (onTeamsMessageToNotionTask)

To list webhook URLs later, run: ntn workers webhooks list

実行結果(webhooks list)

ntn workers webhooks list
Webhook URLs for teams-webhook (019eac17-67e5-7867-83fe-bd77124d0d00):
onTeamsMessageToNotionTask → https://www.notion.so/webhooks/worker/d08cad25-f90c-4f24-832c-38049a6b0b3a/019eac17-67e5-7867-83fe-bd77124d0d00/VJii1qhslrMjAtK0/onTeamsMessageToNotionTask

1.5 環境変数(secrets)を設定

  1. ntn workers env set TASKS_DATA_SOURCE_ID=...
  2. ntn workers env set ASSIGNEE_USER_ID=...(Assignee を固定で入れる場合)
  3. ntn workers env set TEAMS_WEBHOOK_SECRET=...X-Webhook-Secret の照合に使う)
  4. context.notion を使うための API トークン注入(moodle と同様の手順・制約に従う)

実行結果(TASKS_DATA_SOURCE_ID)

ntn workers env set TASKS_DATA_SOURCE_ID=1f70ce2a-6520-80ce-bc36-000b08bc3b11
✔ Set environment variable 'TASKS_DATA_SOURCE_ID'

実行結果(ASSIGNEE_USER_ID)

ntn workers env set ASSIGNEE_USER_ID=fffd872b594c811b82180002b0392cd8
✔ Set environment variable 'ASSIGNEE_USER_ID'

実行結果(TEAMS_WEBHOOK_SECRET)

ntn workers env set TEAMS_WEBHOOK_SECRET=(secret)
✔ Set environment variable 'TEAMS_WEBHOOK_SECRET'

実行結果(NOTION_API_TOKEN: env push)

cp ../moodle-webhook/.env .
ntn workers env push
The following changes will be pushed to the remote environment:
  + NOTION_API_TOKEN
✔ Push changes? · yes
✔ Environment variables pushed successfully

1.6 curl で疎通確認 → runs logs で確認

  1. curl -i -X POST <WEBHOOK_URL> -H 'Content-Type: application/json' -H 'X-Webhook-Secret: ...' --data-raw '<json>'
  2. ntn workers runs list
  3. ntn workers runs logs <run-id>

実行結果(curl)

HTTP/2 202
date: Tue, 09 Jun 2026 11:27:49 GMT
content-type: application/json; charset=utf-8
content-length: 50
cf-ray: a08fca0e79737897-NRT
cf-cache-status: DYNAMIC
etag: W/"32-p6+EaX7E5WNiL4DZzDx3VOZOpcU"
server: cloudflare
...

実行結果(runs list)

ntn workers runs list
RUN ID                                NAME                                EXIT CODE  STARTED AT                ENDED AT
019eac24-1431-75ed-9ce4-309795ca1b66  webhook:onTeamsMessageToNotionTask  1          2026-06-09T11:28:26.161Z  2026-06-09T11:28:30.581Z
019eac23-8909-7e3a-ab9b-b21199022a93  webhook:onTeamsMessageToNotionTask  1          2026-06-09T11:27:50.537Z  2026-06-09T11:27:53.960Z
019eac21-ff3d-7ad2-bd3d-d8bcdac44b4d  fetchAndSaveCapabilities            0          2026-06-09T11:26:09.725Z  2026-06-09T11:26:12.795Z
019eac20-9e5c-7df5-9699-695cff4dcaee  fetchAndSaveCapabilities            0          2026-06-09T11:24:39.389Z  2026-06-09T11:24:40.847Z
019eac20-2e8e-746c-9351-66f053439fbf  fetchAndSaveCapabilities            0          2026-06-09T11:24:10.766Z  2026-06-09T11:24:14.124Z
019eac1c-dd8f-790c-b36c-b9f1e5783eff  fetchAndSaveCapabilities            0          2026-06-09T11:20:33.423Z  2026-06-09T11:20:34.918Z
019eac1a-dc29-7e38-875b-87d559236167  fetchAndSaveCapabilities            0          2026-06-09T11:18:21.993Z  2026-06-09T11:18:25.301Z
019eac17-b59e-7c2c-a68c-19e5e8f979de  fetchAndSaveCapabilities            0          2026-06-09T11:14:55.518Z  2026-06-09T11:14:58.835Z
019eac17-70c4-7b68-8e61-3dd406dd4a63  deploy                              0          2026-06-09T11:14:37.899Z  2026-06-09T11:14:55.233Z

※ webhook:onTeamsMessageToNotionTask が exit code=1 のため、次に runs logs を確認する。

実行結果(runs logs: 019eac24-1431-75ed-9ce4-309795ca1b66)

ntn workers runs logs 019eac24-1431-75ed-9ce4-309795ca1b66

{"_tag":"error","error":{"name":"ExecutionError","message":"Error during worker execution: Error: conversation.type is required","trace":"ExecutionError: Error during worker execution: Error: conversation.type is required\n    at Object.handler (file:///vercel/sandbox/node_modules/@notionhq/workers/dist/capabilities/webhook.js:28:61)"}}
node:internal/process/promises:394
triggerUncaughtException(err, true /* fromPromise */);

対応(原因と次アクション)

  • 原因: 送信した JSON に conversation.type が入っていない(または入れ子が想定と異なる)ため、Worker がバリデーションで停止。
  • 次: curl--data-raw を、下の「最小 JSON 例」に差し替えて再実行し、再度 runs list / runs logs を確認する。

最小 JSON 例(channel の場合)

{
  "event": "teams.message.selected",
  "occurredAt": "2026-06-09T11:27:49Z",
  "conversation": {
    "type": "channel",
    "teamId": "TEAM_ID",
    "channelId": "CHANNEL_ID"
  },
  "message": {
    "id": "MESSAGE_ID",
    "webUrl": "https://teams.microsoft.com/l/message/...",
    "text": "test",
    "from": { "displayName": "hkob" }
  }
}

最小 JSON 例(chat の場合)

{
  "event": "teams.message.selected",
  "occurredAt": "2026-06-09T11:27:49Z",
  "conversation": {
    "type": "chat",
    "chatId": "CHAT_ID"
  },
  "message": {
    "id": "MESSAGE_ID",
    "webUrl": "https://teams.microsoft.com/l/message/...",
    "text": "test",
    "from": { "displayName": "hkob" }
  }
}

実行結果(runs list: 成功)

ntn workers runs list
RUN ID                                NAME                                EXIT CODE  STARTED AT                ENDED AT
019eac29-bf71-7c50-a553-689a381fa76f  webhook:onTeamsMessageToNotionTask  0          2026-06-09T11:34:37.681Z  2026-06-09T11:34:40.381Z

実行結果(runs logs: 019eac29-bf71-7c50-a553-689a381fa76f)

ntn workers runs logs 019eac29-bf71-7c50-a553-689a381fa76f

{"_tag":"success","value":{"status":"success"}}

2. Power Automate(手動フロー)を作成

  1. Power Automate で新規フローを作成(インスタント / 手動起動)。
  2. トリガに Teams の 「選択されたメッセージに対して(V2)」 を設定。
  3. 次の情報を取得できるように、後続ステップで Teams メッセージの出力を参照できる状態にする。
    • team/channel(または chat)情報
    • messageId
    • webUrl
    • 本文(text)
    • 送信者(from.displayName / from.id)
    • 日時(occurredAt 相当)

3. Webhook 送信(HTTP POST)を追加

  1. アクションに HTTP を追加。
  2. Method: POST
  3. URI: Workers で自動発行された Webhook URL(Step 1 で控えた URL)
  4. Headers に共有シークレットを設定(推奨)
    • 例: X-Webhook-Secret: <secret>
  5. Body に JSON を設定(Worker が期待する構造に合わせる)。
    • ポイント: conversation.type / message.id / message.webUrl / message.from.displayName / occurredAt が必須。
    • 例(Power Automate の動的コンテンツを埋め込む想定。まずはこの形で通す):
{
  "event": "teams.message.selected",
  "occurredAt": "@{utcNow()}",
  "tenantId": "@{triggerOutputs()?['body/teamsFlowRunContext/ChannelData/Tenant/Id']}",
  "conversation": {
    "type": "channel",
    "teamId": "@{triggerOutputs()?['body/teamsFlowRunContext/ChannelData/Team/Id']}",
    "channelId": "@{triggerOutputs()?['body/teamsFlowRunContext/ChannelData/Channel/Id']}"
  },
  "message": {
    "id": "@{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/Id']}",
    "webUrl": "@{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/LinkToMessage']}",
    "subject": "@{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/Subject']}",
    "text": "@{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/Body/PlainText']}",
    "from": {
      "id": "@{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/From/User/Id']}",
      "displayName": "@{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/From/User/DisplayName']}"
    }
  },
  "mentions": []
}

Team.Name / Channel.Name は現状 null で来ているため、Body には含めない(必要なら別途 Graph API 等で解決)。

  • 上の triggerBody()?['...'] のキー名は、実際のトリガ出力に合わせて読み替える(不明な場合は、フローのテスト実行で出力 JSON を確認してキーを合わせる)。

HTTP Webhook の設定

ここで保存を押したときに一番上のライセンスが必要というメッセージが出てしまい、プロジェクト終了となりました。

おわりに

ここまで worker を作ったので、webhook 直接呼び出しなしで回避できる手段が取れればいいなと思っています。

https://hkob.notion.site/hkob-16dd8e4e98ab807cbe3cf3cc94cdfe0f?pvs=4