> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/cloudflare/agents/llms.txt
> Use this file to discover all available pages before exploring further.

# GitHub Webhook Example

> Real-time GitHub repository activity monitor with webhook handling, signature verification, and SQLite event storage

A real-time GitHub repository activity monitor built with Cloudflare Agents. Demonstrates how to handle webhooks with Agents, verify signatures, store events in SQLite, and stream updates to connected clients.

## What it demonstrates

* **Webhook Handling** - Receive and process GitHub webhooks
* **Signature Verification** - HMAC-SHA256 verification of webhook payloads
* **Agent-per-Repository** - Each repo gets its own isolated agent instance
* **Real-time Updates** - WebSocket connection streams events as they arrive
* **Event History** - Events stored in SQLite for persistence
* **Beautiful Dashboard** - Dark-themed UI with live event feed

## Architecture

```
GitHub → POST /webhooks/github/owner/repo → Worker → RepoAgent (Durable Object)
                                                            ↓
Browser ← WebSocket ← Agent broadcasts state updates ←─────┘
```

## Server Implementation

```typescript src/server.ts theme={null}
import { Agent, callable, getAgentByName, routeAgentRequest } from "agents";
import type { GitHubWebhookPayload, GitHubEventType, StoredEvent } from "./github-types";

export type RepoState = {
  repoFullName: string;
  stats: {
    stars: number;
    forks: number;
    openIssues: number;
  };
  lastUpdated: string | null;
  webhookConfigured: boolean;
};

export class RepoAgent extends Agent<Env, RepoState> {
  initialState: RepoState = {
    repoFullName: "",
    stats: { stars: 0, forks: 0, openIssues: 0 },
    lastUpdated: null,
    webhookConfigured: false
  };

  async onStart(): Promise<void> {
    // Initialize the events table
    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_login TEXT,
        actor_avatar TEXT,
        timestamp TEXT NOT NULL
      )
    `;

    this.sql`
      CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp DESC)
    `;
  }

  async onRequest(request: Request): Promise<Response> {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const eventType = request.headers.get("X-GitHub-Event") as GitHubEventType;
    if (!eventType) {
      return new Response("Missing X-GitHub-Event header", { status: 400 });
    }

    // Verify the signature
    const signature = request.headers.get("X-Hub-Signature-256");
    const body = await request.text();

    if (this.env.GITHUB_WEBHOOK_SECRET) {
      const isValid = await this.verifySignature(
        body,
        signature,
        this.env.GITHUB_WEBHOOK_SECRET
      );
      if (!isValid) {
        return new Response("Invalid signature", { status: 401 });
      }
    }

    // Parse and process the payload
    const payload = JSON.parse(body) as GitHubWebhookPayload;
    await this.processWebhook(eventType, payload);

    return new Response("OK", { status: 200 });
  }

  private async verifySignature(
    payload: string,
    signature: string | null,
    secret: string
  ): Promise<boolean> {
    if (!signature) return false;

    const encoder = new TextEncoder();
    const key = await crypto.subtle.importKey(
      "raw",
      encoder.encode(secret),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["sign"]
    );

    const signatureBytes = await crypto.subtle.sign(
      "HMAC",
      key,
      encoder.encode(payload)
    );

    const expectedSignature = `sha256=${Array.from(
      new Uint8Array(signatureBytes)
    )
      .map((b) => b.toString(16).padStart(2, "0"))
      .join("")}`;

    return signature === expectedSignature;
  }

  private async processWebhook(
    eventType: GitHubEventType,
    payload: GitHubWebhookPayload
  ): Promise<void> {
    const repo = payload.repository;
    if (!repo) return;

    // Update stats from repository data
    this.setState({
      ...this.state,
      repoFullName: repo.full_name,
      stats: {
        stars: repo.stargazers_count,
        forks: repo.forks_count,
        openIssues: repo.open_issues_count
      },
      lastUpdated: new Date().toISOString(),
      webhookConfigured: true
    });

    // Create and store the event
    const event = this.createEvent(eventType, payload);
    if (event) {
      this.sql`
        INSERT OR REPLACE INTO events 
        (id, type, action, title, description, url, actor_login, actor_avatar, timestamp)
        VALUES (${event.id}, ${event.type}, ${event.action || null}, 
                ${event.title}, ${event.description}, ${event.url}, 
                ${event.actor.login}, ${event.actor.avatar_url}, ${event.timestamp})
      `;

      // Cleanup old events (keep last 100)
      this.sql`
        DELETE FROM events WHERE id NOT IN (
          SELECT id FROM events ORDER BY timestamp DESC LIMIT 100
        )
      `;
    }
  }

  @callable()
  getEvents(limit = 20): StoredEvent[] {
    const rows = [
      ...this.sql<{
        id: string;
        type: string;
        action: string | null;
        title: string;
        description: string;
        url: string;
        actor_login: string;
        actor_avatar: string;
        timestamp: string;
      }>`SELECT * FROM events ORDER BY timestamp DESC LIMIT ${limit}`
    ];

    return rows.map((row) => ({
      id: row.id,
      type: row.type as GitHubEventType,
      action: row.action || undefined,
      title: row.title,
      description: row.description,
      url: row.url,
      actor: {
        login: row.actor_login,
        avatar_url: row.actor_avatar
      },
      timestamp: row.timestamp
    }));
  }
}

// Worker entry point
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Webhook endpoint: POST /webhooks/github/:owner/:repo
    if (
      url.pathname.startsWith("/webhooks/github/") &&
      request.method === "POST"
    ) {
      const clonedRequest = request.clone();
      const payload = (await clonedRequest.json()) as {
        repository?: { full_name?: string };
      };

      const repoFullName = payload.repository?.full_name;
      if (!repoFullName) {
        return new Response("Missing repository in payload", { status: 400 });
      }

      // Get the agent for this specific repository
      const agentName = sanitizeRepoName(repoFullName);
      const agent = await getAgentByName(env.RepoAgent, agentName);

      return agent.fetch(request);
    }

    // Default agent routing for WebSocket connections
    return (
      (await routeAgentRequest(request, env)) ||
      new Response("Not found", { status: 404 })
    );
  }
} satisfies ExportedHandler<Env>;

function sanitizeRepoName(fullName: string): string {
  return fullName
    .toLowerCase()
    .replace(/\//g, "-")
    .replace(/[^a-z0-9-]/g, "");
}
```

## How It Works

<Steps>
  <Step title="GitHub sends webhook">
    When an event occurs (push, PR, issue, etc.), GitHub POSTs to `/webhooks/github/owner/repo` with a signed payload.
  </Step>

  <Step title="Worker routes to agent">
    The Worker extracts the repository name and routes to the appropriate `RepoAgent` Durable Object (one per repo).
  </Step>

  <Step title="Agent verifies signature">
    The agent verifies the HMAC-SHA256 signature using the webhook secret to prevent spoofing.
  </Step>

  <Step title="Event stored in SQLite">
    The agent parses the event, updates repository stats, and stores the event in SQLite for persistence.
  </Step>

  <Step title="State broadcast to clients">
    The agent's state is automatically broadcast to all connected WebSocket clients, updating the UI in real-time.
  </Step>
</Steps>

## Supported Events

| Event Type      | Description                         |
| --------------- | ----------------------------------- |
| `push`          | Commits pushed to a branch          |
| `pull_request`  | PR opened, closed, merged, etc.     |
| `issues`        | Issue opened, closed, labeled, etc. |
| `issue_comment` | Comment on an issue or PR           |
| `star`          | Repository starred/unstarred        |
| `fork`          | Repository forked                   |
| `release`       | Release published                   |
| `ping`          | Webhook configured                  |

## Setup Instructions

<Steps>
  <Step title="Install dependencies">
    ```bash theme={null}
    npm install
    ```
  </Step>

  <Step title="Configure webhook secret">
    Copy `.dev.vars.example` to `.dev.vars`:

    ```bash theme={null}
    cp .dev.vars.example .dev.vars
    ```

    Edit `.dev.vars`:

    ```
    GITHUB_WEBHOOK_SECRET=your-secret-here
    ```
  </Step>

  <Step title="Start development server">
    ```bash theme={null}
    npm start
    ```
  </Step>

  <Step title="Expose local server">
    Since GitHub needs to reach your webhook endpoint, use ngrok:

    ```bash theme={null}
    ngrok http 5173
    ```

    Copy the ngrok URL (e.g., `https://abc123.ngrok.io`).
  </Step>

  <Step title="Configure GitHub webhook">
    1. Go to your GitHub repository → **Settings** → **Webhooks**
    2. Click **Add webhook**
    3. Configure:
       * **Payload URL**: `https://your-ngrok-url.ngrok.io/webhooks/github/owner/repo`
       * **Content type**: `application/json`
       * **Secret**: Same value as `GITHUB_WEBHOOK_SECRET`
       * **Events**: Select which events to receive
    4. Click **Add webhook**
  </Step>

  <Step title="Connect to your repo">
    Open `http://localhost:5173`, enter your repository name (e.g., `cloudflare/agents`), and click Connect.
  </Step>
</Steps>

## Key Patterns

### Webhook Routing

```typescript theme={null}
const agentName = sanitizeRepoName(payload.repository.full_name);
const agent = await getAgentByName(env.RepoAgent, agentName);
return agent.fetch(request);
```

Each repository gets its own agent instance, identified by the sanitized repo name.

### Signature Verification

```typescript theme={null}
const key = await crypto.subtle.importKey(
  "raw",
  secret,
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);
const signature = await crypto.subtle.sign("HMAC", key, payload);
```

GitHub signs every webhook with HMAC-SHA256. Always verify signatures to prevent spoofing.

### Event Storage in SQLite

```typescript theme={null}
this.sql`INSERT INTO events (id, type, title, ...) VALUES (...)`;
```

Events are stored in SQLite and automatically persist across hibernation.

### Real-time State Broadcasting

When `setState()` is called, the new state is automatically broadcast to all connected clients via WebSocket.

## Deployment

```bash theme={null}
npm run deploy
```

After deploying:

1. Set the webhook secret in Cloudflare:
   ```bash theme={null}
   wrangler secret put GITHUB_WEBHOOK_SECRET
   ```

2. Update your GitHub webhook URL to your deployed worker URL:
   ```
   https://your-worker.workers.dev/webhooks/github/owner/repo
   ```

## Extending This Example

Ideas for enhancements:

* **AI PR Summaries** - Use OpenAI to summarize PR diffs
* **Slack Notifications** - Forward important events to Slack
* **Multi-Repo Dashboard** - Monitor all your repos in one view
* **Custom Alerts** - Schedule reminders for stale PRs
* **Webhook Replay** - Re-send events for testing

## Related Examples

<CardGroup cols={2}>
  <Card title="Email Agent" icon="envelope" href="/examples/email-agent">
    Process emails with secure routing
  </Card>

  <Card title="x402 Payments" icon="dollar-sign" href="/examples/x402-payments">
    HTTP payment gating with verification
  </Card>

  <Card title="Workflows" icon="diagram-project" href="/examples/workflows">
    Multi-step workflows with approval gates
  </Card>

  <Card title="Webhooks Guide" icon="book" href="/guides/webhooks">
    In-depth guide to webhook handling
  </Card>
</CardGroup>
