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.
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.createdfiltered bychannel_id. - Apply document review comments using
comment.createdfiltered bydocument_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": {}
}
}
}
Next, implement events/list. Each event definition should include:
namedescriptiondelivery: ["webhook"]-
inputSchemafor subscription arguments, such asdocument_id -
payloadSchemafor thedatafield sent in each delivery
Implementation rules:
- Use stable, descriptive event names.
- Validate filters against
inputSchema. - Apply filters on the server, not in the client.
- 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
}
}
Before creating or refreshing a subscription:
- Authorize the user for the event and requested arguments.
- Validate the event name and arguments against the event definition.
- Require a
whsec_secret whose decoded value is 24–64 bytes. - Require an HTTPS callback URL.
- Reject private, local, and non-public callback addresses.
- Verify the callback endpoint before sending application data.
- 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
}
}
Make subscriptions deterministic and idempotent
Use these rules to avoid duplicate subscriptions:
-
Deterministic IDs: Derive
idfrom 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/subscribeagain beforerefreshBefore, 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: nullfor 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"
}
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"
}
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"
}
or:
{
"reason": "timeout"
}
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
}
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
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 Goneor413 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}
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);
}
Run the receiver:
WEBHOOK_SECRET=whsec_... node receiver.mjs
This receiver:
- Returns
413for payloads larger than 256 KiB. - Returns
401for invalid or stale signatures. - Echoes verification challenges.
- Logs each
webhook-idonce.
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
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>
These headers follow the Streamable HTTP binding.
Every request body also needs params._meta containing:
io.modelcontextprotocol/protocolVersionio.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
-
Discovery
- Call
server/discover. - Assert
$.result.capabilities.eventsexists. - Assert
$.result.supportedVersionscontains2026-07-28. - Call
events/list. - Assert every event includes
webhookindelivery.
- Call
-
Idempotent subscription
- Subscribe using
{{CALLBACK_URL}}and{{WEBHOOK_SECRET}}. - Extract
$.result.idintoSUB_ID. - Send the identical request again.
- Send it again with reordered
argumentskeys. - Assert the returned ID equals
{{SUB_ID}}every time.
- Subscribe using
-
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).
-
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.reasonischallenge_failed. - Point
CALLBACK_URLto the local receiver and confirm the subscription succeeds.
-
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.
-
Replay and tampering
- Copy a signed outbound delivery into a new Apidog request.
- Resend it immediately: expect
200and 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:
- Add
"events": {}toserver/discover. - Implement one
events/listdefinition. - Add
events/subscribewith validation and callback verification. - Sign a single event delivery.
- 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)