MCP Events let your MCP server push updates to ChatGPT the moment they happen, so no agent has to poll for them. Since OpenAI DevDay on September 29, 2026, ChatGPT supports the proposed MCP Events spec at protocol version 2026-07-28 (MCP 2.0), on all plans. Your server adds three methods (events/list, events/subscribe, events/unsubscribe), advertises events in server/discover, passes a callback verification challenge, and signs every delivery with Standard Webhooks HMAC. ChatGPT accepts webhook delivery only.
This guide covers each message shape OpenAI documents, the security rules your server enforces, and a test pass for the whole loop. New to the protocol? Start with what MCP is. You’ll send the JSON-RPC calls and mock the callback in Apidog.
MCP Events at a glance
| Item | What ChatGPT expects |
|---|---|
| Protocol | MCP 2.0, version 2026-07-28 |
| Capability | "events": {} in the server/discover capabilities |
| Methods | events/list, events/subscribe, events/unsubscribe, on the same authenticated endpoint as your 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: principal, callback URL, event name, arguments |
| Callbacks | HTTPS, challenge-verified, no private addresses, no redirects |
Sources: OpenAI’s MCP Events guide and the draft MCP Events design sketch.
Why events instead of polling
Without events, an agent watching for review comments calls a tool on a timer and diffs the results, burning requests when nothing changed and lagging when something did. MCP Events flip the direction: your server knows when a record changes, so it sends the update. The trade-offs are the classic webhooks vs polling ones, now inside MCP.
OpenAI’s DevDay recap uses a project board: the user asks ChatGPT to watch for new tasks, and when one lands, ChatGPT reads the linked documents and drafts a plan. The docs add two more cases: turn bug reports in a channel into draft pull requests (message.created, filtered by channel_id), and apply review comments to a document (comment.created, filtered by document_id).
The spec comes from MCP’s Triggers and Events Working Group (repo) and is labelled experimental, so pin your code to 2026-07-28. For the rest of the launch, see the DevDay 2026 hub.
Advertise events and define them
Add events to the capabilities in your server/discover response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": {}, "events": {} }
}
}
Then return definitions from events/list. Each carries a name, a description, delivery: ["webhook"], an inputSchema for subscription arguments (your filters, such as document_id), and a payloadSchema for the data object in each delivery. Use stable names and specific descriptions, apply filters on your server, and list only events the connected account may see.
events/subscribe: request and response
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
}
}
Before accepting, authorize the user for that event and those arguments, validate both against your definition, require a whsec_ secret whose base64 decodes to 24-64 bytes, verify the callback (next section), and store the subscription with its owner, filters, URL, secret and expiration. Then reply:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"id": "sub_123",
"refreshBefore": "2026-10-02T12:00:00Z",
"cursor": null,
"truncated": false
}
}
Three rules keep subscriptions correct:
- Deterministic IDs. Derive
idfrom the authenticated principal, callback URL, event name and arguments. The design sketch suggests a truncated SHA-256 of that key. - Idempotent upserts. A repeat subscribe with the same identity updates the existing record. Compare arguments as canonical JSON so key order can’t create duplicates.
- Refresh before
refreshBefore. ChatGPT re-callsevents/subscribebefore that time with the same identity and its last saved cursor; you return a new expiration. If a refresh carries a new secret, replace it and sign with both keys for a short window. Returncursor: nullfor events you can’t replay.
Verify the callback before any data
Before sending application data, POST a signed verification body with a fresh, single-use, short-lived challenge:
{ "type": "verification", "challenge": "a-single-use-random-value" }
Give it a unique webhook-id (for example msg_verification_123) and the same signing headers as a delivery. ChatGPT echoes {"challenge": "a-single-use-random-value"} with a 2xx. Compare in constant time, then activate delivery. On failure, return JSON-RPC error -32015 (CallbackEndpointError) with a data.reason such as challenge_failed or timeout. Cache a pass per principal and URL for a bounded period so refreshes don’t re-challenge.
The challenge exists because the subscriber supplies the secret: without it, anyone could aim your server at a victim’s URL and flood it. Apply the same hardening to every outbound request: HTTPS only, resolve and validate the address at connection time, block private, local and other non-public addresses, and never follow redirects.
Deliver and sign events
When a matching event 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
}
Send Content-Type: application/json, webhook-id (equal to eventId), webhook-timestamp (Unix seconds at signing), webhook-signature, and X-MCP-Subscription-Id. Serialize the body once and send those exact bytes, since the signature covers them.
Stay under 256 KiB; for large records, send a summary and expose a read tool. Treat user-authored text as data and never put model instructions in a payload. Retry transient failures with capped exponential backoff, keeping the event ID and signing each attempt fresh. Don’t retry 410 or 413. Events can arrive out of order, so make write tools idempotent. The reliable webhook design guide covers retry schedules.
Verify a Standard Webhooks signature
Per the Standard Webhooks spec, the signed content is ${webhook-id}.${webhook-timestamp}.${body}, the HMAC-SHA256 key is the base64-decoded secret after stripping whsec_, and the header holds one or more space-separated v1,<base64> signatures. The MCP draft tells receivers to reject timestamps older than 5 minutes and deduplicate on webhook-id.
ChatGPT verifies on its side, but a strict receiver of your own makes a good local test oracle. This one uses Node built-ins only:
// 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"], ts = h["webhook-timestamp"], 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((s) => {
const [version, b64] = s.split(",");
const got = Buffer.from(b64 ?? "", "base64");
return version === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
});
}
if (SECRET) createServer((req, res) => {
const chunks = [];
let size = 0;
req.on("data", (c) => { size += c.length; if (size <= MAX_BYTES) chunks.push(c); });
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);
Run it with WEBHOOK_SECRET=whsec_... node receiver.mjs. It returns 413 above 256 KiB and 401 for a bad or stale signature, echoes challenges, and logs each webhook-id once. We checked verify() against the signing test vector in the Standard Webhooks JavaScript library. For the theory, read webhook signature verification.
Your server blocks private addresses, so it won’t deliver to localhost by default. The draft allows non-public targets only when explicitly configured: add a dev-only allowlist entry, or expose the receiver through a tunnel.
Test it before ChatGPT does
Here’s an Apidog test pass for the failure modes OpenAI lists. Create an environment with MCP_URL, MCP_TOKEN, CALLBACK_URL and WEBHOOK_SECRET, and send each JSON-RPC call as a POST to {{MCP_URL}} with Authorization: Bearer {{MCP_TOKEN}} plus the headers the Streamable HTTP binding requires: Accept: application/json, text/event-stream, MCP-Protocol-Version: 2026-07-28, and Mcp-Method set to the method. Each body also needs params._meta with io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities. OpenAI’s samples omit it, and a missing _meta also returns -32602, so step 3 would pass for the wrong reason.
- Discovery. Send
server/discoverand assert$.result.capabilities.eventsexists and$.result.supportedVersionscontains2026-07-28. Sendevents/listand assert each event’sdeliveryincludeswebhook. - Idempotent subscribe. Send the subscribe body above using
{{CALLBACK_URL}}and{{WEBHOOK_SECRET}}, with an Extract Variable post-processor that saves$.result.idasSUB_ID. Resend it unchanged, then with theargumentskeys reordered, and assert$.result.idequals{{SUB_ID}}both times. - Validation. Send a secret that decodes to under 24 bytes, then an
http://callback. The draft maps both to-32602(InvalidParams); assert on$.error.code. A private-IP callback must fail too. - Verification challenge. Create an Apidog mock endpoint that answers
{"challenge": "wrong"}and subscribe with its cloud mock URL as the callback. Assert$.error.codeis-32015and$.error.data.reasonischallenge_failed. Then pointCALLBACK_URLat your receiver and confirm the subscribe succeeds. - Oversize payload. Trigger an event whose body tops 262,144 bytes. Your sender should refuse it; if one slips through, the receiver answers
413, and your server logs should show one attempt, not a retry. - Replay and tampering. Copy a signed delivery’s headers and body from your server’s outbound log into a new Apidog request. Resend it at once:
200, and no second log line. Change one body byte:401. Resend after 5 minutes:401.
Save the steps as a scenario and run it in CI with the Apidog CLI. The MCP server testing playbook covers tool calls, and how to test webhooks goes deeper on receivers. Then connect the server to ChatGPT through a plugin and run OpenAI’s lifecycle checklist.
FAQ
What are MCP Events? An experimental MCP extension that lets a server push event notifications to a client instead of the client polling. ChatGPT supports its 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 the gap and terminated notifications aren’t supported.
Which ChatGPT plans get MCP Events? OpenAI’s DevDay recap says the feature is available to all plans.
Who creates the signing secret? The subscriber. ChatGPT sends a whsec_ secret in delivery.secret; your server validates, stores and signs with it, and never generates its own.
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 by streaming or webhooks.
Next step
Add "events": {} to one server, expose one event with one filter, and run the six checks against a local receiver before connecting ChatGPT. Download Apidog to keep those checks as a scenario that runs on every commit.



