Webhooks

Last updated Aug 17, 2026 Receive webhook events from external services and route them to dedicated agent instances. Each webhook source (repository, customer, device) can have its own agent with isolated state, persistent storage, and real-time client connections.

Quick start

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>;

Use cases

Webhooks combined with agents enable patterns where each external entity gets its own isolated, stateful agent instance.

Developer tools

Use case Description
GitHub Repo Monitor One agent per repository tracking commits, PRs, issues, and stars
CI/CD Pipeline Agent React to build/deploy events, notify on failures, track deployment history
Linear/Jira Tracker Auto-triage issues, assign based on content, track resolution times

E-commerce and payments

Use case Description
Stripe Customer Agent One agent per customer tracking payments, subscriptions, and disputes
Shopify Order Agent Order lifecycle from creation to fulfillment with inventory sync
Payment Reconciliation Match webhook events to internal records, flag discrepancies

Communication and notifications

Use case Description
Twilio SMS/Voice Conversational agents triggered by inbound messages or calls
Slack Bot Respond to slash commands, button clicks, and interactive messages
Email Tracking SendGrid/Mailgun delivery events, bounce handling, engagement analytics

IoT and infrastructure

Use case Description
Device Telemetry One agent per device processing sensor data streams
Alert Aggregation Collect alerts from PagerDuty, Datadog, or custom monitoring
Home Automation React to IFTTT/Zapier triggers with persistent state

SaaS integrations

Use case Description
CRM Sync Salesforce/HubSpot contact and deal updates
Calendar Agent Google Calendar event notifications and scheduling
Form Submissions Typeform, Tally, or custom form webhooks with follow-up actions

Routing webhooks to agents

The key pattern is verifying the raw request before parsing it, then deriving the Agent identity from authenticated payload data. A body signature does not authenticate an unrelated URL segment or arbitrary header.

Extract entity from payload

Most webhooks include an identifier in the payload:

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>;

Validate entity IDs in URLs

A provider's body signature does not authenticate the webhook URL. If the URL includes an entity ID, compare it with the corresponding identity from the verified provider payload and reject a mismatch before calling getAgentByName().

Derive Slack identity from the verified body

Slack does not send an authenticated X-Slack-Team-Id routing header. Validate Slack's timestamped signature and replay window against the raw body, then read team_id from the verified event or form body.

Signature verification

Always verify webhook signatures before trusting or processing the payload.

GitHub HMAC-SHA256 pattern

The quick start's verifyGitHubWebhook() helper verifies GitHub's sha256=<hex> signature over the raw body with crypto.subtle.verify(). This format is GitHub-specific. Other providers use different signature encodings, signed inputs, timestamp checks, and replay protections. Follow the provider documentation linked under Common webhook providers.

Provider-specific headers

Provider Signature Header Algorithm
GitHub X-Hub-Signature-256 HMAC-SHA256
Stripe Stripe-Signature HMAC-SHA256 (with timestamp)
Twilio X-Twilio-Signature HMAC-SHA1
Slack X-Slack-Signature HMAC-SHA256 (with timestamp)
Shopify X-Shopify-Hmac-Sha256 HMAC-SHA256 (base64)

Processing webhooks

The onRequest handler

Use onRequest() to handle incoming webhooks in your agent. If the Worker has not already verified the request, verify it before parsing the body. This example reuses the quick start's verifyGitHubWebhook() helper:

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()})`;
    }
}

Storing webhook events

Use SQLite to persist webhook events for history and replay.

Event table schema

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)
    `;
    }
}

``ts class WebhookAgent extends Agent { async onStart(): Promise&lt;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 ) `;