Skip to main content

How to build a webhook MCP server

A webhook MCP server gives an AI agent access to webhook events as tools: Claude, Cursor, or any MCP client can ask "what just happened?" and get back verified events from GitHub, Stripe, or any other provider. This guide builds one in a single file of Node.js, with no tunnel and no public URL on your machine.

Building webhooks?
Svix is the enterprise ready webhook receiver. With Svix, you can have a secure, reliable, and scalable webhook receiver in minutes. Give it a try!

Sending is easy, receiving is the real problem

Webhook MCP servers come in two directions. The sending direction is trivial: expose a tool that POSTs a payload to a URL, and the agent can notify Slack or trigger a downstream service. Most of the small webhook MCP servers on GitHub and the MCP directories do only this, and we'll add it at the end in a few lines.

The direction people actually get stuck on is receiving. Providers deliver webhooks to a public HTTPS URL, but your MCP server runs locally as a subprocess of the MCP client, with no inbound HTTP at all. Tunneling a port out with ngrok sort of works, but then you're verifying webhook signatures yourself, reconfiguring the provider every time the tunnel URL changes, and dropping events whenever the tunnel is down.

The cleaner architecture is pull instead of push. Point the provider at a hosted webhook gateway, let it verify and store every event, and have the MCP server fetch new events over outbound HTTPS when the agent asks. We'll use Svix Ingest as the gateway because its polling endpoints are built for exactly this, and it verifies signatures for every major provider out of the box.

What you'll need

  • Node.js 18 or later, for built-in fetch and ES modules.
  • A free Svix Ingest account, no credit card required.
  • An MCP client: Claude Code, Claude Desktop, and Cursor all work.
  • A provider to receive from. We use GitHub here; any provider works and the steps don't change.

Step 1: Set up the gateway

In the Svix Ingest dashboard, create a Source and pick your provider type (GitHub for this walkthrough). Svix hands you a public ingest URL; paste it into your GitHub repo under Settings → Webhooks → Add webhook, set a signing secret on both sides, and pick the events you care about. Picking the provider type is what gets you signature verification with no verification code on your side.

Then add a Polling Endpoint to the Source. Svix gives you a poller URL and a sk_poll_* API key. Those two values are all the MCP server needs. The Claude Code Channels tutorial walks through this same dashboard setup with screenshots if you want more detail.

Step 2: Write the server

Create the project:

mkdir webhook-mcp && cd webhook-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk zod

Then create server.js:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const POLLER_URL = process.env.SVIX_POLLER_URL;
const POLLER_KEY = process.env.SVIX_POLLER_KEY;

if (!POLLER_URL || !POLLER_KEY) {
console.error("Set SVIX_POLLER_URL and SVIX_POLLER_KEY");
process.exit(1);
}

const mcp = new McpServer({ name: "webhooks", version: "1.0.0" });

// The iterator marks where the last poll left off, so each call
// only returns events the agent hasn't seen yet.
let iterator = null;

mcp.registerTool(
"poll_webhook_events",
{
title: "Poll webhook events",
description:
"Fetch verified webhook events that arrived since the last poll. " +
"Returns an empty list if nothing new has happened.",
inputSchema: { limit: z.number().int().min(1).max(100).optional() },
},
async ({ limit }) => {
const url = new URL(POLLER_URL);
if (iterator) url.searchParams.set("iterator", iterator);
if (limit) url.searchParams.set("limit", String(limit));

const res = await fetch(url, {
headers: { Authorization: `Bearer ${POLLER_KEY}` },
});
if (!res.ok) {
return {
content: [{ type: "text", text: `Poll failed: HTTP ${res.status}` }],
isError: true,
};
}

const { data, iterator: next } = await res.json();
iterator = next;

return {
content: [
{
type: "text",
text: data.length
? JSON.stringify(data, null, 2)
: "No new webhook events.",
},
],
};
}
);

const transport = new StdioServerTransport();
await mcp.connect(transport);

That's the whole server. Every event it returns has already had its signature verified by the gateway, and because Svix stores events durably, nothing is lost while the server isn't running: the next poll picks up from the iterator and catches up.

Step 3: Register it with your MCP client

For Claude Code, add it to .mcp.json in your project (or ~/.mcp.json to make it global):

{
"mcpServers": {
"webhooks": {
"command": "node",
"args": ["/absolute/path/to/webhook-mcp/server.js"],
"env": {
"SVIX_POLLER_URL": "https://api.us.svix.com/api/v1/app/app_.../poller/poll_...",
"SVIX_POLLER_KEY": "sk_poll_..."
}
}
}
}

Claude Desktop uses the same mcpServers block in claude_desktop_config.json (Settings → Developer → Edit Config), and Cursor uses it in .cursor/mcp.json. Restart the client and the poll_webhook_events tool shows up.

Step 4: Trigger an event and ask the agent

Push a commit to the repo you wired up, then ask your agent something like "check for new webhook events and summarize them." It calls the tool, gets the GitHub push payload back, and tells you who pushed what.

From there you can give it standing instructions in CLAUDE.md or your client's rules files: run the tests when a push touches test files, draft a reply when a support event arrives, and so on.

Adding the sending direction

If you also want the agent to fire webhooks outward, register a second tool:

mcp.registerTool(
"send_webhook",
{
title: "Send a webhook",
description: "POST a JSON payload to the configured outbound webhook URL.",
inputSchema: { payload: z.record(z.string(), z.any()) },
},
async ({ payload }) => {
const res = await fetch(process.env.OUTBOUND_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return {
content: [{ type: "text", text: `Delivered: HTTP ${res.status}` }],
};
}
);

Point OUTBOUND_WEBHOOK_URL at a Slack incoming webhook, a Zapier catch hook, or your own endpoint. If you're sending webhooks to your own customers rather than to one fixed URL, that's a different problem, and the one Svix itself solves.

Pull tools, push channels, and what's coming in the spec

This server is pull-based: events sit in the gateway until the agent asks. That fits the tool model of every MCP client today, but it means the agent reacts when prompted, not the moment an event lands. If you want events pushed into a running session, Claude Code Channels do exactly that; see receiving webhooks in Claude Code Channels for the push-based version of this setup.

Longer term, push is coming to the protocol itself: MCP went stateless in the 2026-07-28 release and webhook delivery for asynchronous task results is planned next. What is an MCP webhook? covers the distinction, and MCP vs webhooks covers how the protocol and the pattern fit together.