外部サービスの webhook イベントを受け取り、専用のエージェントインスタンスへ振り分けます。Webhook の送信元(リポジトリ、顧客、デバイス)ごとに、分離された状態、永続ストレージ、リアルタイムのクライアント接続を持つエージェントを置けます。
import { Agent, getAgentByName, routeAgentRequest } from "agents";
export class WebhookAgent extends Agent {
async onRequest(request) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const rawBody = await request.text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(rawBody, signature, this.env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload;
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
await this.processEvent(payload);
return new Response("OK");
}
async processEvent(payload) {
// Store event, update state, trigger actions...
}
}
async function verifyGitHubWebhook(rawBody, signature, secret) {
if (!signature || !/^sha256=[0-9a-f]{64}$/i.test(signature)) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const signatureBytes = Uint8Array.from(
signature.slice("sha256=".length).match(/.{2}/g) ?? [],
(byte) => Number.parseInt(byte, 16),
);
return crypto.subtle.verify(
"HMAC",
key,
signatureBytes,
encoder.encode(rawBody),
);
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/webhooks/github" && request.method === "POST") {
const rawBody = await request.clone().text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(rawBody, signature, env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload;
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
const repository = payload.repository?.full_name;
if (!repository) {
return new Response("Missing repository", { status: 400 });
}
const agentName = repository.toLowerCase().replace(/\//g, "-");
const agent = await getAgentByName(env.WebhookAgent, agentName);
return agent.fetch(request);
}
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
};import { Agent, getAgentByName, routeAgentRequest } from "agents";
export class WebhookAgent extends Agent<Env> {
async onRequest(request: Request): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const rawBody = await request.text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(
rawBody,
signature,
this.env.WEBHOOK_SECRET,
))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload: unknown;
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
await this.processEvent(payload);
return new Response("OK");
}
private async processEvent(payload: unknown) {
// Store event, update state, trigger actions...
}
}
async function verifyGitHubWebhook(
rawBody: string,
signature: string | null,
secret: string,
): Promise<boolean> {
if (!signature || !/^sha256=[0-9a-f]{64}$/i.test(signature)) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const signatureBytes = Uint8Array.from(
signature.slice("sha256=".length).match(/.{2}/g) ?? [],
(byte) => Number.parseInt(byte, 16),
);
return crypto.subtle.verify(
"HMAC",
key,
signatureBytes,
encoder.encode(rawBody),
);
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/webhooks/github" && request.method === "POST") {
const rawBody = await request.clone().text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(
rawBody,
signature,
env.WEBHOOK_SECRET,
))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload: { repository?: { full_name?: string } };
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
const repository = payload.repository?.full_name;
if (!repository) {
return new Response("Missing repository", { status: 400 });
}
const agentName = repository.toLowerCase().replace(/\//g, "-");
const agent = await getAgentByName(env.WebhookAgent, agentName);
return agent.fetch(request);
}
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;Webhook とエージェントを組み合わせると、外部のエンティティごとに、分離された状態付きエージェントインスタンスを割り当てられます。
| ユースケース | 説明 |
|---|---|
| GitHub リポジトリ監視 | リポジトリごとに 1 つのエージェントを置き、コミット、PR、Issue、スターを追跡します |
| CI/CD パイプライン | ビルド / デプロイイベントに反応し、失敗を通知し、デプロイ履歴を追跡します |
| Linear / Jira 追跡 | Issue を自動で振り分け、内容に応じて担当を決め、解決までの時間を追跡します |
| ユースケース | 説明 |
|---|---|
| Stripe 顧客エージェント | 顧客ごとに 1 つのエージェントを置き、支払い、サブスクリプション、異議を追跡します |
| Shopify 注文エージェント | 作成からフルフィルメントまでの注文ライフサイクルと、在庫同期を扱います |
| 支払い照合 | webhook イベントを内部レコードと照合し、不一致にフラグを付けます |
| ユースケース | 説明 |
|---|---|
| Twilio SMS / Voice | 着信メッセージや通話をきっかけに動く会話エージェント |
| Slack Bot | スラッシュコマンド、ボタンクリック、インタラクティブメッセージに応答します |
| メール追跡 | SendGrid / Mailgun の配信イベント、バウンス処理、エンゲージメント分析 |
| ユースケース | 説明 |
|---|---|
| デバイステレメトリ | デバイスごとに 1 つのエージェントを置き、センサーデータストリームを処理します |
| アラート集約 | PagerDuty、Datadog、独自モニタリングからのアラートを集めます |
| ホームオートメーション | 永続状態を保ちながら、IFTTT / Zapier のトリガーに反応します |
| ユースケース | 説明 |
|---|---|
| CRM 同期 | Salesforce / HubSpot の連絡先と案件の更新 |
| カレンダーエージェント | Google Calendar のイベント通知とスケジュール調整 |
| フォーム送信 | Typeform、Tally、独自フォームの webhook と、その後のフォローアップ |
要点は、解析する前に生リクエストを検証し、認証済みペイロードから Agent の識別子を導出することです。ボディの署名は、無関係な URL セグメントや任意のヘッダーを認証しません。
多くの webhook には、ペイロード内に識別子が含まれます。
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/webhooks/github") {
const rawBody = await request.clone().text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(rawBody, signature, env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload;
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
const repository = payload.repository?.full_name;
if (!repository) {
return new Response("Missing repository", { status: 400 });
}
const agentName = repository.toLowerCase().replace(/\//g, "-");
const agent = await getAgentByName(env.RepoAgent, agentName);
return agent.fetch(request);
}
},
};export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/webhooks/github") {
const rawBody = await request.clone().text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(
rawBody,
signature,
env.WEBHOOK_SECRET,
))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload: { repository?: { full_name?: string } };
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
const repository = payload.repository?.full_name;
if (!repository) {
return new Response("Missing repository", { status: 400 });
}
const agentName = repository.toLowerCase().replace(/\//g, "-");
const agent = await getAgentByName(env.RepoAgent, agentName);
return agent.fetch(request);
}
},
} satisfies ExportedHandler<Env>;プロバイダーのボディ署名は、webhook の URL を認証しません。URL にエンティティ ID が含まれる場合は、検証済みプロバイダーペイロードの対応する識別子と比較し、不一致なら getAgentByName() を呼ぶ前に拒否します。
Slack は、認証済みの X-Slack-Team-Id 振り分けヘッダーを送りません。タイムスタンプ付き署名とリプレイウィンドウを生ボディに対して検証し、検証済みのイベントまたはフォームボディから team_id を読み取ります。
ペイロードを信頼したり処理したりする前に、必ず webhook の署名を検証します。
クイックスタートの verifyGitHubWebhook() ヘルパーは、生ボディに対する GitHub の sha256=<hex> 署名を crypto.subtle.verify() で検証します。この形式は GitHub 固有です。他のプロバイダーは、署名のエンコード、署名対象、タイムスタンプチェック、リプレイ対策が異なります。主な webhook プロバイダー にリンクしたドキュメントに従ってください。
| プロバイダー | 署名ヘッダー | アルゴリズム |
|---|---|---|
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 |
| Stripe | Stripe-Signature |
HMAC-SHA256(タイムスタンプ付き) |
| Twilio | X-Twilio-Signature |
HMAC-SHA1 |
| Slack | X-Slack-Signature |
HMAC-SHA256(タイムスタンプ付き) |
| Shopify | X-Shopify-Hmac-Sha256 |
HMAC-SHA256(base64) |
エージェントで受信 webhook を扱うときは onRequest() を使います。Worker 側でまだ検証していない場合は、ボディを解析する前に検証します。次の例は、クイックスタートの verifyGitHubWebhook() ヘルパーを再利用します。
export class WebhookAgent extends Agent {
async onRequest(request) {
// 1. Validate method
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
// 2. Get the GitHub event type
const eventType = request.headers.get("X-GitHub-Event") ?? "unknown";
// 3. Verify the GitHub signature
const signature = request.headers.get("X-Hub-Signature-256");
const body = await request.text();
if (
!(await verifyGitHubWebhook(body, signature, this.env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
// 4. Parse and process
const payload = JSON.parse(body);
await this.handleEvent(eventType, payload);
// 5. Respond quickly
return new Response("OK", { status: 200 });
}
async handleEvent(type, payload) {
// Update state (broadcasts to connected clients)
this.setState({
...this.state,
lastEventType: type,
lastEventTime: new Date().toISOString(),
});
// Store in SQL for history
this
.sql`INSERT INTO events (type, payload, timestamp) VALUES (${type}, ${JSON.stringify(payload)}, ${Date.now()})`;
}
}export class WebhookAgent extends Agent {
async onRequest(request: Request): Promise<Response> {
// 1. Validate method
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
// 2. Get the GitHub event type
const eventType = request.headers.get("X-GitHub-Event") ?? "unknown";
// 3. Verify the GitHub signature
const signature = request.headers.get("X-Hub-Signature-256");
const body = await request.text();
if (
!(await verifyGitHubWebhook(body, signature, this.env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
// 4. Parse and process
const payload = JSON.parse(body);
await this.handleEvent(eventType, payload);
// 5. Respond quickly
return new Response("OK", { status: 200 });
}
private async handleEvent(type: string, payload: unknown) {
// Update state (broadcasts to connected clients)
this.setState({
...this.state,
lastEventType: type,
lastEventTime: new Date().toISOString(),
});
// Store in SQL for history
this
.sql`INSERT INTO events (type, payload, timestamp) VALUES (${type}, ${JSON.stringify(payload)}, ${Date.now()})`;
}
}履歴と再実行のために、SQLite へ webhook イベントを永続化します。
class WebhookAgent extends Agent {
async onStart() {
this.sql`
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
action TEXT,
title TEXT NOT NULL,
description TEXT,
url TEXT,
actor TEXT,
payload TEXT,
timestamp TEXT NOT NULL
)
`;
this.sql`
CREATE INDEX IF NOT EXISTS idx_events_timestamp
ON events(timestamp DESC)
`;
}
}class WebhookAgent extends Agent {
async onStart(): Promise<void> {
this.sql`
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
action TEXT,
title TEXT NOT NULL,
description TEXT,
url TEXT,
actor TEXT,
payload TEXT,
timestamp TEXT NOT NULL
)
`;
this.sql`
CREATE INDEX IF NOT EXISTS idx_events_timestamp
ON events(timestamp DESC)
`;
}
}直近のイベントだけを残し、無制限な増加を防ぎます。
// Keep last 100 events
this.sql`
DELETE FROM events WHERE id NOT IN (
SELECT id FROM events ORDER BY timestamp DESC LIMIT 100
)
`;
// Or delete events older than 30 days
this.sql`
DELETE FROM events
WHERE timestamp < datetime('now', '-30 days')
`;// Keep last 100 events
this.sql`
DELETE FROM events WHERE id NOT IN (
SELECT id FROM events ORDER BY timestamp DESC LIMIT 100
)
`;
// Or delete events older than 30 days
this.sql`
DELETE FROM events
WHERE timestamp < datetime('now', '-30 days')
`;import { Agent, callable } from "agents";
class WebhookAgent extends Agent {
@callable()
getEvents(limit = 20) {
return [
...this.sql`
SELECT * FROM events
ORDER BY timestamp DESC
LIMIT ${limit}
`,
];
}
@callable()
getEventsByType(type, limit = 20) {
return [
...this.sql`
SELECT * FROM events
WHERE type = ${type}
ORDER BY timestamp DESC
LIMIT ${limit}
`,
];
}
}import { Agent, callable } from "agents";
class WebhookAgent extends Agent {
@callable()
getEvents(limit = 20) {
return [
...this.sql`
SELECT * FROM events
ORDER BY timestamp DESC
LIMIT ${limit}
`,
];
}
@callable()
getEventsByType(type: string, limit = 20) {
return [
...this.sql`
SELECT * FROM events
WHERE type = ${type}
ORDER BY timestamp DESC
LIMIT ${limit}
`,
];
}
}webhook が届いたらエージェントの状態を更新します。接続中の WebSocket クライアントへ自動で配信されます。
class WebhookAgent extends Agent {
async processWebhook(eventType, payload) {
// Update state - this automatically broadcasts to all connected clients
this.setState({
...this.state,
stats: payload.stats,
lastEvent: {
type: eventType,
timestamp: new Date().toISOString(),
},
});
}
}class WebhookAgent extends Agent {
private async processWebhook(eventType: string, payload: WebhookPayload) {
// Update state - this automatically broadcasts to all connected clients
this.setState({
...this.state,
stats: payload.stats,
lastEvent: {
type: eventType,
timestamp: new Date().toISOString(),
},
});
}
}クライアント側は次のとおりです。
import { useAgent } from "agents/react";
function Dashboard() {
const [state, setState] = useState(null);
const agent = useAgent({
agent: "webhook-agent",
name: "my-entity-id",
onStateUpdate: (newState) => {
setState(newState); // Automatically updates when webhooks arrive
},
});
return <div>Last event: {state?.lastEvent?.type}</div>;
}イベント ID を使い、同じイベントを二重処理しないようにします。
class WebhookAgent extends Agent {
async handleEvent(eventId, payload) {
// Check if already processed
const existing = [
...this.sql`
SELECT id FROM events WHERE id = ${eventId}
`,
];
if (existing.length > 0) {
console.log(`Event ${eventId} already processed, skipping`);
return;
}
// Process and store
await this.processPayload(payload);
this.sql`INSERT INTO events (id, ...) VALUES (${eventId}, ...)`;
}
}class WebhookAgent extends Agent {
async handleEvent(eventId: string, payload: unknown) {
// Check if already processed
const existing = [
...this.sql`
SELECT id FROM events WHERE id = ${eventId}
`,
];
if (existing.length > 0) {
console.log(`Event ${eventId} already processed, skipping`);
return;
}
// Process and store
await this.processPayload(payload);
this.sql`INSERT INTO events (id, ...) VALUES (${eventId}, ...)`;
}
}Webhook プロバイダーは、速い応答を期待します。重い処理にはキューを使います。
class WebhookAgent extends Agent {
async onRequest(request) {
const payload = await request.json();
// Quick validation
if (!this.isValid(payload)) {
return new Response("Invalid", { status: 400 });
}
// Queue heavy processing
await this.queue("processWebhook", payload);
// Respond immediately
return new Response("Accepted", { status: 202 });
}
async processWebhook(payload) {
// Heavy processing happens here, after response sent
await this.enrichData(payload);
await this.notifyDownstream(payload);
await this.updateAnalytics(payload);
}
}class WebhookAgent extends Agent {
async onRequest(request: Request): Promise<Response> {
const payload = await request.json();
// Quick validation
if (!this.isValid(payload)) {
return new Response("Invalid", { status: 400 });
}
// Queue heavy processing
await this.queue("processWebhook", payload);
// Respond immediately
return new Response("Accepted", { status: 202 });
}
async processWebhook(payload: WebhookPayload) {
// Heavy processing happens here, after response sent
await this.enrichData(payload);
await this.notifyDownstream(payload);
await this.updateAnalytics(payload);
}
}非同期作業が Think のチャットターン 1 回なら、submitMessages() を使います。耐久的な受付 ID をすぐ返し、再試行ではメッセージターンを重複させず、べき等キーを使えます。
const submission = await this.submitMessages(messages, {
idempotencyKey: payload.id,
});
return Response.json(
{ submissionId: submission.submissionId },
{ status: 202 },
);const submission = await this.submitMessages(messages, {
idempotencyKey: payload.id,
});
return Response.json(
{ submissionId: submission.submissionId },
{ status: 202 },
);webhook がターン周辺のアプリケーション副作用(プロバイダースレッドの復元や、ユーザーに見える返信の投稿など)を持つ場合は、そのジョブを startFiber() で囲みます。Managed fiber は状態を保持し、プロバイダーの再試行を重複排除し、onFiberRecovered() または resolveFiber() でアプリ側の復旧結果を記録できます。
プロバイダー固有の検証と解析は、型付きヘルパー 1 つにまとめます。このヘルパーは、主な webhook プロバイダー にリンクしたドキュメントに従い生リクエストを検証し、検証済みボディからのみ agentName を導出する必要があります。
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname.startsWith("/webhooks/")) {
const verified = await verifyAndParseWebhook(request.clone(), env);
if (!verified) {
return new Response("Invalid signature", { status: 401 });
}
switch (verified.provider) {
case "github":
return (
await getAgentByName(env.GitHubAgent, verified.agentName)
).fetch(request);
case "stripe":
return (
await getAgentByName(env.StripeAgent, verified.agentName)
).fetch(request);
case "slack":
return (
await getAgentByName(env.SlackAgent, verified.agentName)
).fetch(request);
}
}
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
};type VerifiedWebhook =
| { provider: "github"; agentName: string }
| { provider: "stripe"; agentName: string }
| { provider: "slack"; agentName: string };
declare function verifyAndParseWebhook(
request: Request,
env: Env,
): Promise<VerifiedWebhook | null>;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname.startsWith("/webhooks/")) {
const verified = await verifyAndParseWebhook(request.clone(), env);
if (!verified) {
return new Response("Invalid signature", { status: 401 });
}
switch (verified.provider) {
case "github":
return (
await getAgentByName(env.GitHubAgent, verified.agentName)
).fetch(request);
case "stripe":
return (
await getAgentByName(env.StripeAgent, verified.agentName)
).fetch(request);
case "slack":
return (
await getAgentByName(env.SlackAgent, verified.agentName)
).fetch(request);
}
}
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;エージェントから、外部サービスへ webhook を送ることもできます。
export class NotificationAgent extends Agent {
async notifySlack(message) {
const response = await fetch(this.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message }),
});
if (!response.ok) {
throw new Error(`Slack notification failed: ${response.status}`);
}
}
async sendSignedWebhook(url, payload) {
const body = JSON.stringify(payload);
const signature = await this.sign(body, this.env.WEBHOOK_SECRET);
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Signature": signature,
},
body,
});
}
}export class NotificationAgent extends Agent {
async notifySlack(message: string) {
const response = await fetch(this.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message }),
});
if (!response.ok) {
throw new Error(`Slack notification failed: ${response.status}`);
}
}
async sendSignedWebhook(url: string, payload: unknown) {
const body = JSON.stringify(payload);
const signature = await this.sign(body, this.env.WEBHOOK_SECRET);
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Signature": signature,
},
body,
});
}
}- 署名は必ず検証します — 未検証の webhook を信頼してはいけません。
- 環境シークレットを使います — シークレットはコードに書かず、
wrangler secret putで保存します。 - 素早く応答します — 再試行を避けるため、数秒以内に 200 / 202 を返します。
- ペイロードを検証します — 処理前に必須フィールドを確認します。
- 拒否を記録します — 無効な署名を追跡し、セキュリティ監視に使います。
- HTTPS を使います — webhook URL には常に TLS を使います。
// Store secrets securely
// wrangler secret put GITHUB_WEBHOOK_SECRET
// Access in agent
const secret = this.env.GITHUB_WEBHOOK_SECRET;// Store secrets securely
// wrangler secret put GITHUB_WEBHOOK_SECRET
// Access in agent
const secret = this.env.GITHUB_WEBHOOK_SECRET;| プロバイダー | ドキュメント |
|---|---|
| GitHub | Webhook events and payloads ↗ |
| Stripe | Webhook signatures ↗ |
| Twilio | Validate webhook requests ↗ |
| Slack | Verifying requests ↗ |
| Shopify | Webhook verification ↗ |
| SendGrid | Event webhook ↗ |
| Linear | Webhooks ↗ |