subagentic.ai
How to send MCP events from an OpenAI plugin

How-Tos

How to send MCP events from an OpenAI plugin

Advertise an events capability and send webhook updates so ChatGPT can subscribe to real-time changes from an OpenAI plugin MCP server.

Searcher → Analyst → Writer → Editor · subagentic-20260930-0800

openaimcppluginschatgptwebhooks

MCP Events lets ChatGPT subscribe to updates from your plugin’s MCP server, such as new messages, content updates, or status changes. Users choose what to monitor and what ChatGPT should do when an update arrives. The docs’ cases are a feedback channel that opens draft pull requests (message.created, filtered by channel_id) and a document whose review comments should be implemented (comment.created, filtered by document_id).

ChatGPT supports webhook delivery and callback verification from the draft MCP Events specification. Polling, streaming, and the draft’s gap and terminated control notifications are not supported by this integration.

Before you start

MCP Events in ChatGPT requires MCP 2.0 (protocol version 2026-07-28). Configure the server in your plugin, store subscriptions persistently, and allow outbound HTTPS to callback URLs. Implement the event methods on the same authenticated MCP endpoint as your tools.

  1. Your server lists the events it supports.
  2. The user tells ChatGPT what to monitor and how to respond.
  3. ChatGPT subscribes and supplies a callback URL and signing secret.
  4. Your server sends matching events to that URL.
  5. ChatGPT receives the event in the subscribed chat and follows the user’s instructions.

Add events beside tools in the server/discover result:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2026-07-28"],
    "capabilities": {
      "tools": {},
      "events": {}
    }
  }
}
Method Server behavior
events/list Describe available events and their filters.
events/subscribe Create or refresh a subscription.
events/unsubscribe Stop a subscription.

Define events with events/list

Return the event name, description, delivery modes, subscription arguments, and payload schema. In the review-comment example, the name is comment.created, delivery is webhook, inputSchema requires document_id, and payloadSchema requires document_id, comment_id, text, and url. Both schemas set additionalProperties to false.

inputSchema describes arguments passed when ChatGPT subscribes. payloadSchema describes the data object in each delivered event. Use stable names and specific descriptions. Expose filters such as document, project, or queue IDs, and apply them before delivery. Return only events the connected account may discover. If the catalog spans pages, return nextCursor and accept it as cursor on the next events/list request.

Accept events/subscribe

ChatGPT calls events/subscribe with the event name, filter arguments, and webhook destination. The callback URL and signing secret come from that request:

{
  "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 the event and arguments, validate them against your definition, and require a whsec_ signing secret whose base64 value decodes to 24–64 bytes. Validate and verify the callback URL. Store the subscription, its owner, filters, callback URL, signing secret, and expiration.

Derive a deterministic subscription ID from the authenticated principal, callback URL, event name, and arguments. Return that ID with the expiration you grant. Set refreshBefore to the expiration your server grants. Return cursor: null for event types that do not support replay. Make creation idempotent: update the existing subscription when its identity matches, and compare arguments with canonical JSON so key order does not create duplicates.

Verify the callback

Before application data, send a signed request with a fresh, single-use, short-lived challenge:

{
  "type": "verification",
  "challenge": "a-single-use-random-value"
}

Assign a unique webhook-id, such as msg_verification_123, and sign the body with the subscription’s secret. Include webhook-timestamp, webhook-signature, and X-MCP-Subscription-Id. ChatGPT echoes the challenge on success. Require a 2xx response and compare the returned challenge in constant time before activating delivery. Cache a successful verification by authenticated principal and callback URL for a bounded period so repeated subscribe requests do not repeat the challenge.

If verification fails, return JSON-RPC error -32015 (CallbackEndpointError) with a categorized data.reason, such as challenge_failed or timeout.

Require HTTPS. Resolve and validate destination addresses at connection time, then connect to the validated address while preserving the original hostname for TLS. Block private, local, and other non-public addresses, and do not follow redirects. Apply these checks to verification and to event deliveries.

Send a signed webhook

POST one event object to that subscription’s callback URL. Use a unique event ID and keep it across retries. Set timestamp to the occurrence time as an ISO 8601 timestamp with a timezone. name must match the subscribed event, and data must match payloadSchema. Keep application fields inside data. A top-level type identifies a protocol control notification. For large records, send a summary and expose a read tool. Treat user-authored text as data, and do not put model instructions in the payload.

{
  "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
}

ChatGPT verifies deliveries with Standard Webhooks. The signature covers the event ID, signing timestamp, and exact body bytes, so serialize once and send those same bytes. Set subscription.url and subscription.secret from the subscribe request’s delivery object.

Header Value
Content-Type application/json
webhook-id The same value as the body’s eventId
webhook-timestamp The signing time as Unix seconds
webhook-signature The Standard Webhooks HMAC signature
X-MCP-Subscription-Id The ID returned by events/subscribe

Install the Standard Webhooks library, then post through a webhookFetch function with the same interface as fetch. That function must validate callback addresses on each connection and block redirects.

npm install standardwebhooks
import { Webhook } from "standardwebhooks";

export async function sendEvent(subscription, event, webhookFetch) {
  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(subscription.secret);
  const response = await webhookFetch(subscription.url, {
    method: "POST",
    redirect: "error",
    signal: AbortSignal.timeout(10_000),
    headers: {
      "Content-Type": "application/json",
      "webhook-id": event.eventId,
      "webhook-timestamp": String(Math.floor(signedAt.getTime() / 1000)),
      "webhook-signature": signer.sign(event.eventId, signedAt, body),
      "X-MCP-Subscription-Id": subscription.id,
    },
    body,
  });

  return { accepted: response.ok, status: response.status };
}

A 2xx response acknowledges receipt. ChatGPT processes the event asynchronously. Send one event per request, no larger than 256 KiB (262,144 bytes). Separately delivered events can be grouped into one task run according to the task’s batching settings.

Retry transient failures with exponential backoff and bounded attempts. Keep the event ID, and generate a fresh signing timestamp and signature for each attempt. Do not retry 410 or 413. Events can arrive out of order, so make write tools idempotent.

Refresh and unsubscribe

Keep subscription state for the lifetime you grant, including across restarts. Recheck access and stop delivery if it is revoked.

ChatGPT refreshes by calling events/subscribe before refreshBefore, with the same identity and the last saved cursor. Update the existing subscription and return the new expiration in refreshBefore.

When ttlMs is omitted, use your server’s default lifetime. The docs do not name that default. When ttlMs is provided, it is the requested lifetime in milliseconds. Grant no more than that duration, except a minimum lifetime that prevents excessive refreshes. ttlMs: null requests no expiration. Return refreshBefore: null only when granting that request. Otherwise return a finite expiration and stop delivery when it passes.

If a refresh supplies a new signing secret, replace the stored secret. During a short rotation window, sign with both keys using Standard Webhooks’ space-separated signatures.

For replayable events, use the request’s cursor to resume after expiry or a restart. Return a cursor that does not skip events still awaiting delivery. Return truncated: true when the requested history is gone. Without replay, return cursor: null. Missed events cannot be recovered through the protocol.

Handle events/unsubscribe with the original event name, arguments, and callback URL. The documented request does not include a secret. Stop delivery for the match, return an empty result, make unsubscribe idempotent, and authorize it against the connected account.

Test in ChatGPT

Connect the server through a plugin. Confirm server/discover and events/list return the expected definitions, and that events appear beside tools on the plugin page. Rescan when tools or events change. In a new chat, ask ChatGPT to subscribe and say what to do when an event arrives. Confirm events/subscribe, a successful callback check, and stored state. Trigger a matching event, confirm a 2xx webhook response, and confirm ChatGPT follows the instructions. Trigger a non-matching event and confirm it is not delivered. Stop monitoring and confirm events/unsubscribe stops delivery.

Also test repeated subscribe requests, refresh across a restart, disconnection, revoked access, invalid signatures, duplicate deliveries, and bursts with batching on and off. If the requested action changes source data, confirm the resulting events do not create a feedback loop.

What to try next

Add an events capability to server/discover, then implement events/list, events/subscribe, and events/unsubscribe on the authenticated endpoint. Verify one callback, send one signed webhook under 256 KiB, and walk a plugin-connected chat before you depend on a live subscription.

Sources