DEV Community

Cover image for MCP Events explained: build and test a webhook-emitting MCP server ChatGPT can subscribe to
Hassann
Hassann

Posted on Originally published at apidog.com

MCP Events explained: build and test a webhook-emitting MCP server ChatGPT can subscribe to

MCP Events let an MCP server push updates to ChatGPT as soon as they occur, eliminating agent-side polling. Since OpenAI DevDay on September 29, 2026, ChatGPT supports the proposed MCP Events specification at protocol version 2026-07-28 (MCP 2.0) on all plans. To support it, implement events/list, events/subscribe, and events/unsubscribe; advertise events in server/discover; verify callback endpoints; and sign every webhook delivery with Standard Webhooks HMAC. ChatGPT accepts webhook delivery only.

Try Apidog today

This guide shows the request shapes, server-side validation rules, and an end-to-end test workflow. If you need MCP fundamentals first, read what MCP is. You can send JSON-RPC requests and mock callback endpoints in Apidog.

MCP Events at a glance

Item What ChatGPT expects
Protocol MCP 2.0, version 2026-07-28
Capability "events": {} in server/discover capabilities
Methods events/list, events/subscribe, events/unsubscribe on the same authenticated endpoint as tools
Delivery Webhook only: no polling, streaming, gap, or terminated notifications
Signing Standard Webhooks HMAC-SHA256
Headers webhook-id, webhook-timestamp, webhook-signature, X-MCP-Subscription-Id
Secret whsec_ plus base64 that decodes to 24–64 bytes, supplied by ChatGPT
Payload limit 256 KiB (262,144 bytes), one event per request
Subscription ID Deterministic from principal, callback URL, event name, and arguments
Callbacks HTTPS, challenge-verified, no private addresses, no redirects

Sources: OpenAI’s MCP Events guide and the draft MCP Events design sketch.

Why use events instead of polling?

Without events, an agent repeatedly calls a tool and diffs results. That wastes requests when nothing changed and introduces delay when something does change.

With MCP Events, your server sends a notification when it detects a matching change. The implementation trade-offs are the same as traditional webhooks vs. polling, but applied to MCP.

Typical use cases include:

  • Watch a project board for new tasks, then have ChatGPT read linked documents and draft a plan.
  • Turn channel bug reports into draft pull requests using message.created filtered by channel_id.
  • Apply document review comments using comment.created filtered by document_id.

The extension comes from MCP’s Triggers and Events Working Group (repository) and remains experimental. Pin your implementation to 2026-07-28. For the broader launch, see the DevDay 2026 hub.

1. Advertise and define events

First, add events to your server/discover response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2026-07-28"],
    "capabilities": {
      "tools": {},
      "events": {}
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, implement events/list. Each event definition should include:

  • name
  • description
  • delivery: ["webhook"]
  • inputSchema for subscription arguments, such as document_id
  • payloadSchema for the data field sent in each delivery

Implementation rules:

  1. Use stable, descriptive event names.
  2. Validate filters against inputSchema.
  3. Apply filters on the server, not in the client.
  4. Return only events the authenticated account is authorized to access.

2. Implement events/subscribe

When a user asks ChatGPT to monitor something, ChatGPT calls events/subscribe:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "events/subscribe",
  "params": {
    "name": "comment.created",
    "arguments": {
      "document_id": "doc_123"
    },
    "delivery": {
      "mode": "webhook",
      "url": "https://receiver.example.com/mcp-events/callback_123",
      "secret": "whsec_<base64-encoded-signing-key>"
    },
    "cursor": null
  }
}
Enter fullscreen mode Exit fullscreen mode

Before creating or refreshing a subscription:

  1. Authorize the user for the event and requested arguments.
  2. Validate the event name and arguments against the event definition.
  3. Require a whsec_ secret whose decoded value is 24–64 bytes.
  4. Require an HTTPS callback URL.
  5. Reject private, local, and non-public callback addresses.
  6. Verify the callback endpoint before sending application data.
  7. Store the subscription owner, filters, URL, secret, and expiration.

Return a subscription record:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "id": "sub_123",
    "refreshBefore": "2026-10-02T12:00:00Z",
    "cursor": null,
    "truncated": false
  }
}
Enter fullscreen mode Exit fullscreen mode

Make subscriptions deterministic and idempotent

Use these rules to avoid duplicate subscriptions:

  • Deterministic IDs: Derive id from the authenticated principal, callback URL, event name, and canonicalized arguments. The design sketch suggests a truncated SHA-256 hash.
  • Idempotent upserts: A repeated subscribe request with the same identity should update the existing record. Canonicalize JSON arguments before comparing them so key ordering does not create duplicates.
  • Refresh support: ChatGPT calls events/subscribe again before refreshBefore, using the same identity and the last saved cursor. Return a new expiry time.
  • Secret rotation: If a refresh includes a new secret, replace the stored secret and sign with both keys during a short transition period.
  • Replay support: Return cursor: null for event types your server cannot replay.

3. Verify the callback before delivering data

Before sending application events, POST a signed verification request with a fresh, single-use, short-lived challenge:

{
  "type": "verification",
  "challenge": "a-single-use-random-value"
}
Enter fullscreen mode Exit fullscreen mode

Use a unique webhook-id, such as msg_verification_123, and include the same signing headers used for event delivery.

ChatGPT replies with:

{
  "challenge": "a-single-use-random-value"
}
Enter fullscreen mode Exit fullscreen mode

Only activate the subscription after a successful 2xx response and a constant-time challenge comparison.

If verification fails, return JSON-RPC error -32015 (CallbackEndpointError) with a reason such as:

{
  "reason": "challenge_failed"
}
Enter fullscreen mode Exit fullscreen mode

or:

{
  "reason": "timeout"
}
Enter fullscreen mode Exit fullscreen mode

Cache successful verification per principal and callback URL for a bounded period so normal subscription refreshes do not trigger unnecessary challenges.

Harden outbound webhook requests

The callback verification exists because the subscriber provides the signing secret. Without verification, an attacker could point your server at a victim URL and cause unwanted traffic.

Apply these controls to verification and normal deliveries:

  • Require HTTPS.
  • Resolve and validate the target at connection time.
  • Block loopback, private, local, and other non-public IP ranges.
  • Do not follow redirects.
  • Use explicit development-only allowlists for local testing.

4. Deliver and sign events

When a matching change occurs, POST one event object to the callback URL:

{
  "eventId": "evt_456",
  "name": "comment.created",
  "timestamp": "2026-10-01T12:05:00Z",
  "data": {
    "document_id": "doc_123",
    "comment_id": "comment_456",
    "text": "Can we add the rollout dates to this section?",
    "url": "https://docs.example.com/doc_123#comment_456"
  },
  "cursor": null
}
Enter fullscreen mode Exit fullscreen mode

Send these headers:

Content-Type: application/json
webhook-id: evt_456
webhook-timestamp: <unix-seconds>
webhook-signature: v1,<base64-signature>
X-MCP-Subscription-Id: sub_123
Enter fullscreen mode Exit fullscreen mode

Serialize the JSON body once and sign those exact bytes. Do not parse, mutate, and reserialize the payload between signing and sending.

Delivery rules:

  • Send one event per request.
  • Keep each payload below 256 KiB.
  • For large records, send a summary and expose a read tool for full content.
  • Treat user-authored text as data, never as model instructions.
  • Retry transient failures with capped exponential backoff.
  • Keep the same event ID across retries, but generate fresh signing headers for each attempt.
  • Do not retry 410 Gone or 413 Payload Too Large.
  • Design write tools to be idempotent because events can arrive out of order.

For retry design guidance, see reliable webhook design.

5. Verify Standard Webhooks signatures

Per the Standard Webhooks specification, sign this exact content:

${webhook-id}.${webhook-timestamp}.${body}
Enter fullscreen mode Exit fullscreen mode

Use these rules:

  • Strip whsec_ from the secret.
  • Base64-decode the remaining value to get the HMAC key.
  • Sign with HMAC-SHA256.
  • Send one or more space-separated signatures in webhook-signature.
  • Each signature uses the format v1,<base64>.

The MCP draft requires receivers to:

  • Reject timestamps older than five minutes.
  • Deduplicate deliveries by webhook-id.

ChatGPT verifies deliveries on its side, but running a strict local receiver is useful when testing your sender.

// receiver.mjs: strict Standard Webhooks receiver for local tests (Node 18+)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.WEBHOOK_SECRET; // whsec_...
const MAX_BYTES = 256 * 1024;
const TOLERANCE_S = 5 * 60;
const seen = new Set();

export function verify(raw, h, secret, now = Math.floor(Date.now() / 1000)) {
  const id = h["webhook-id"];
  const ts = h["webhook-timestamp"];
  const sigs = h["webhook-signature"];

  if (!id || !ts || !sigs) return false;

  const t = Number(ts);
  if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_S) {
    return false;
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${ts}.`)
    .update(raw)
    .digest();

  return sigs.split(" ").some((signature) => {
    const [version, b64] = signature.split(",");
    const received = Buffer.from(b64 ?? "", "base64");

    return (
      version === "v1" &&
      received.length === expected.length &&
      timingSafeEqual(received, expected)
    );
  });
}

if (SECRET) {
  createServer((req, res) => {
    const chunks = [];
    let size = 0;

    req.on("data", (chunk) => {
      size += chunk.length;
      if (size <= MAX_BYTES) chunks.push(chunk);
    });

    req.on("end", () => {
      if (size > MAX_BYTES) {
        return res.writeHead(413).end();
      }

      const raw = Buffer.concat(chunks);

      if (!verify(raw, req.headers, SECRET)) {
        return res.writeHead(401).end();
      }

      let body;
      try {
        body = JSON.parse(raw);
      } catch {
        return res.writeHead(400).end();
      }

      if (body.type === "verification") {
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ challenge: body.challenge }));
      }

      const id = req.headers["webhook-id"];

      if (!seen.has(id)) {
        seen.add(id);
        console.log(
          req.headers["x-mcp-subscription-id"],
          body.name,
          id
        );
      }

      res.writeHead(200).end();
    });
  }).listen(8787);
}
Enter fullscreen mode Exit fullscreen mode

Run the receiver:

WEBHOOK_SECRET=whsec_... node receiver.mjs
Enter fullscreen mode Exit fullscreen mode

This receiver:

  • Returns 413 for payloads larger than 256 KiB.
  • Returns 401 for invalid or stale signatures.
  • Echoes verification challenges.
  • Logs each webhook-id once.

The verify() implementation was checked against the Standard Webhooks JavaScript library signing test vector. For more background, see webhook signature verification.

Your server should reject localhost by default because it is a private target. For development, either configure an explicit allowlist or expose the receiver through a tunnel.

6. Test the full subscription lifecycle

Use Apidog to test the failure modes documented by OpenAI.

Create an environment with:

MCP_URL
MCP_TOKEN
CALLBACK_URL
WEBHOOK_SECRET
Enter fullscreen mode Exit fullscreen mode

Send each JSON-RPC request as a POST to {{MCP_URL}} with:

Authorization: Bearer {{MCP_TOKEN}}
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: <method-name>
Enter fullscreen mode Exit fullscreen mode

These headers follow the Streamable HTTP binding.

Every request body also needs params._meta containing:

  • io.modelcontextprotocol/protocolVersion
  • io.modelcontextprotocol/clientCapabilities

OpenAI’s examples omit _meta, but missing metadata also returns -32602. Include it so validation tests fail for the intended reason.

Test checklist

  1. Discovery

    • Call server/discover.
    • Assert $.result.capabilities.events exists.
    • Assert $.result.supportedVersions contains 2026-07-28.
    • Call events/list.
    • Assert every event includes webhook in delivery.
  2. Idempotent subscription

    • Subscribe using {{CALLBACK_URL}} and {{WEBHOOK_SECRET}}.
    • Extract $.result.id into SUB_ID.
    • Send the identical request again.
    • Send it again with reordered arguments keys.
    • Assert the returned ID equals {{SUB_ID}} every time.
  3. Validation

    • Send a secret that decodes to fewer than 24 bytes.
    • Send an http:// callback URL.
    • Send a private-IP callback URL.
    • Assert each invalid request returns -32602 (InvalidParams).
  4. Verification challenge

    • Create an Apidog mock endpoint that responds with:
     { "challenge": "wrong" }
    
  • Subscribe using the cloud mock URL.
  • Assert error code -32015.
  • Assert $.error.data.reason is challenge_failed.
  • Point CALLBACK_URL to the local receiver and confirm the subscription succeeds.
  1. Oversize payload

    • Trigger an event larger than 262,144 bytes.
    • Your sender should reject it before delivery.
    • If it reaches the receiver, expect 413.
    • Confirm the sender logs only one attempt and does not retry.
  2. Replay and tampering

    • Copy a signed outbound delivery into a new Apidog request.
    • Resend it immediately: expect 200 and no duplicate receiver log entry.
    • Change one body byte: expect 401.
    • Resend the original after five minutes: expect 401.

Save these requests as a scenario and run them in CI with the Apidog CLI. For additional coverage, see the MCP server testing playbook and how to test webhooks.

FAQ

What are MCP Events?

An experimental MCP extension that lets a server push event notifications to a client instead of requiring polling. ChatGPT supports webhook mode at 2026-07-28.

Does ChatGPT support polling or streaming for MCP Events?

No. ChatGPT supports webhook delivery and callback verification only. Polling, streaming, and gap and terminated notifications are not supported.

Which ChatGPT plans get MCP Events?

OpenAI’s DevDay recap says the feature is available on all plans.

Who creates the signing secret?

The subscriber. ChatGPT sends a whsec_ secret in delivery.secret; your server validates, stores, and signs with that secret. Your server does not generate its own replacement secret.

How is this different from the Agents API?

MCP Events push data from your server into ChatGPT. The OpenAI Agents API runs agents you build and reports their progress through streaming or webhooks.

Next step

Start with one server, one event, and one filter:

  1. Add "events": {} to server/discover.
  2. Implement one events/list definition.
  3. Add events/subscribe with validation and callback verification.
  4. Sign a single event delivery.
  5. Run the six tests against a local receiver before connecting ChatGPT.

Download Apidog to save the checks as a scenario and run them on every commit.

Top comments (0)