SolidHooksSolidHooks
Get Started

ChatGPT MCP Events explained: turn inbound webhooks into agent events

Spec status checked: 26 October 2026. MCP Events is a draft extension to the Model Context Protocol, so details may change.

MCP Events lets ChatGPT subscribe to events from your MCP server, such as a new comment or a status change. Delivery is a Standard Webhooks POST to a callback URL that ChatGPT provides, signed with a secret that ChatGPT also provides. So in practice, ChatGPT MCP Events is webhooks. The hard part usually isn't the last hop to ChatGPT. It's the inbound side: most of the services an agent should react to, like Stripe, GitHub or your inbound email, don't speak MCP Events at all.

How ChatGPT MCP Events work

OpenAI's MCP Events docs describe the flow in five steps. Your server lists the events it supports, the user tells ChatGPT what to monitor and how to respond, ChatGPT subscribes through your MCP server and supplies a callback URL and signing secret, your server sends matching events to that URL, and ChatGPT follows the user's instructions in the subscribed chat.

The three methods

A server advertises support by adding events to the capabilities in its server/discover response, then implements three methods on the same authenticated MCP endpoint as its tools:

  • events/list describes the available events. Each definition has a name, a description, the delivery modes it supports, an inputSchema for the filters a subscriber can pass (a document ID, a channel) and a payloadSchema for the data each event carries.
  • events/subscribe creates or refreshes a subscription.
  • events/unsubscribe stops one. It should be idempotent.

What ChatGPT supports today

According to OpenAI, MCP Events is available in Work chats on ChatGPT web, in Work chats in the desktop app with Cloud selected, and with dots. Workspace controls for plugins and event-triggered tasks apply. It requires protocol version 2026-07-28, and your server needs persistent subscription storage and outbound HTTPS access to callback URLs.

The draft spec defines more than ChatGPT uses. Svix's explainer describes poll, push and webhook modes. ChatGPT supports webhook delivery and callback verification only. Polling, streaming and the draft's gap and terminated control notifications aren't supported by this integration.

Subscriptions expire

A webhook endpoint normally lives until someone deletes it. An MCP Events subscription has a lifetime:

  • The client can request one with ttlMs. Your server grants no more than that, except to enforce a minimum, and returns the expiry as refreshBefore.
  • ChatGPT calls events/subscribe again before refreshBefore, with the same identity and its last saved cursor.
  • ttlMs: null asks for no expiry. Return refreshBefore: null only if you grant it.

Subscription creation must be idempotent. OpenAI says to derive a deterministic subscription ID from the authenticated principal, the callback URL, the event name and the arguments, and to compare arguments as canonical JSON so that key order doesn't create duplicates. A refresh can also carry a replacement secret, which we'll come back to under rotation.

The verification challenge

Before you send any real data, your server has to prove the callback URL wants it. You POST a signed request whose body has type: "verification" and a fresh, single-use challenge value, with its own unique webhook-id. ChatGPT echoes the challenge back. You require a 2xx, compare the returned challenge in constant time, and only then activate delivery. If it fails, events/subscribe returns JSON-RPC error -32015 (CallbackEndpointError) with a reason such as challenge_failed or timeout.

OpenAI also asks you to treat callback URLs as untrusted: require HTTPS, resolve and validate the address at connection time, block private and local addresses, and don't follow redirects, for verification requests as well as deliveries.

The delivery format

Each event is one HTTPS POST to the subscription's callback URL. The body is a JSON envelope with five fields: eventId, name, timestamp (ISO 8601 with a timezone), data (matching the event's payloadSchema) and cursor. Here's an illustrative request for a payment event (our own example, not taken from any vendor's docs):

http
POST /callbacks/abc HTTP/1.1Content-Type: application/jsonwebhook-id: evt_01J9PAY7              ← same value as eventId, stable across retrieswebhook-timestamp: 1793011500         ← Unix seconds, fresh on every attemptwebhook-signature: v1,K3x...=         ← Standard Webhooks HMAC-SHA256, base64X-MCP-Subscription-Id: sub_7f2c...    ← the ID your server returned from events/subscribe {"eventId":"evt_01J9PAY7","name":"payment.succeeded","timestamp":"2026-10-26T09:05:00Z","data":{"payment_id":"pi_123","amount":4200,"currency":"gbp"},"cursor":null}

The rules, per OpenAI's docs:

  • One event per request, with a complete body no larger than 256 KiB (262,144 bytes). For large records, send a summary and expose a read tool.
  • Stable IDs. Use a unique event ID and preserve it across retries. Generate a fresh timestamp and signature for each attempt.
  • Retries. Retry transient failures with exponential backoff and bounded attempts. Don't retry a 410 or 413.
  • Ordering. Events can arrive out of order, so make write tools idempotent and dedupe on eventId.
  • A 2xx means receipt. ChatGPT processes the event asynchronously and may batch several events into one task run.
  • Payloads are data. Treat comments and other user-written text as data, and don't put instructions to the model inside the payload. Svix's post makes the same point about prompt injection: keep payloads light.

Every header except X-MCP-Subscription-Id is plain Standard Webhooks, so existing verification libraries just work.

Verifying Standard Webhooks signatures

The method, in six steps:

  1. 1.Strip the whsec_ prefix and base64-decode the rest to get the key bytes. (OpenAI requires ChatGPT's secrets to decode to 24–64 bytes.)
  2. 2.Build the signed content: {webhook-id}.{webhook-timestamp}.{raw body}.
  3. 3.HMAC-SHA256 it with the key and base64 the result.
  4. 4.webhook-signature is a space-delimited list of v1,<sig> entries. Accept if any entry matches, compared in constant time. During a secret rotation you'll see two.
  5. 5.Reject timestamps outside a tolerance window (five minutes is typical) to stop replays.
  6. 6.Dedupe on webhook-id, return 2xx quickly and do the work asynchronously.

The one-liner (Node.js)

typescript
// needs engineer testimport { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.DESTINATION_SECRET!); // whsec_...// rawBody must be the exact string received, before any JSON parsingconst payload = wh.verify(rawBody, {  "webhook-id": req.headers["webhook-id"] as string,  "webhook-timestamp": req.headers["webhook-timestamp"] as string,  "webhook-signature": req.headers["webhook-signature"] as string,}); // throws if the signature or timestamp is invalid

Two hops, one method

In the architecture below, your MCP server uses this code twice:

  • (a) Verifying the inbound hop. When SolidHooks delivers a provider event to your MCP server, it signs with Standard Webhooks by default, using the destination's secret, which you read from GET /v1/destinations/{id}/signing-secret (SolidHooks docs). Call wh.verify(rawBody, headers).
  • (b) Signing the outbound hop. When your server sends an MCP Event to ChatGPT, serialise the envelope once, sign those exact bytes with that subscription's whsec_ secret, and add X-MCP-Subscription-Id:
typescript
// needs engineer testimport { Webhook } from "standardwebhooks"; const body = JSON.stringify(envelope); // serialise once, send these bytesconst timestamp = new Date(); // fresh per attemptconst wh = new Webhook(subscription.secret); // whsec_...const headers = {  "Content-Type": "application/json",  "webhook-id": envelope.eventId,  "webhook-timestamp": String(Math.floor(timestamp.getTime() / 1000)),  "webhook-signature": wh.sign(envelope.eventId, timestamp, body),  "X-MCP-Subscription-Id": subscription.id,};

During a rotation window, sign with both the old and new secrets and join the two v1, entries with a space.

Don't parse JSON before verifying, compare signatures with ==, trust only the first signature entry, or log secrets.

One caveat from SolidHooks' docs: the reference JavaScript standardwebhooks library converts a Buffer to a string before hashing, so it rejects bodies that aren't valid UTF-8. JSON events are fine.

Where do the events come from? Turning provider webhooks into MCP Events

OpenAI's docs cover the server side of the protocol. They don't say where your events come from. For most agents, the answer is someone else's webhooks.

The architecture

text
 Stripe / GitHub / Resend / inbound email                │  provider webhook (provider's own signature)                ▼ ┌──────────────────────────────────────────┐ │ Inbound gateway                           │ │ verify provider signature · store event   │ │ dedupe · retry delivery · replay          │ └──────────────────────────────────────────┘                │  Standard Webhooks (gateway's secret)                ▼ ┌──────────────────────────────────────────┐ │ Your MCP server                           │ │ events/list · events/subscribe · verify   │ │ challenge · map to envelope · sign per    │ │ subscription (whsec_) · retry to ChatGPT  │ └──────────────────────────────────────────┘                │  MCP Event (subscriber's secret + X-MCP-Subscription-Id)                ▼              ChatGPT  →  task runs in the subscribed chat

The gateway deals with providers: different signature schemes, bursts, outages on your side. The MCP server owns the protocol: subscriptions, expiry, the challenge, the envelope and per-subscriber signing. The subscriber (ChatGPT, or a platform acting on its behalf) owns the callback URL and the secret.

Example: Stripe payment_intent.succeeded → payment.succeeded

  1. 1.Stripe sends payment_intent.succeeded to the gateway, which verifies Stripe's signature and stores the event.
  2. 2.The gateway delivers it to your MCP server's ingest route, signed with Standard Webhooks.
  3. 3.Your server verifies that hop, then finds active subscriptions for payment.succeeded whose filters match (for example a customer ID).
  4. 4.For each one, it builds a small envelope, with IDs, amount, currency and a link rather than the full Stripe object, signs it with that subscription's secret and POSTs it.

Use a deterministic eventId derived from the upstream event ID and the subscription, so a retry or replay from the gateway doesn't create a second event.

Example: GitHub issues or Resend inbound email

The same pattern works for a GitHub issues webhook mapped to issue.created (filtered by repository), or an inbound email mapped to email.received (filtered by sender or address). The mapping code is the only part that changes. Issue bodies and email text are user-written content, so send a summary and let the agent fetch the full record with a read tool.

Backfilling after a lapsed subscription

If a subscription lapses or your server restarts, events that happened in between are gone unless you have a durable record. OpenAI's docs say that for replayable event types you should use the request's cursor to resume, and return truncated: true when history is no longer available. For event types without replay, you return cursor: null, and missed events can't be recovered through the protocol. Svix's post makes the same point: MCP Events can only replay what your server still has. A gateway that stores every inbound event gives you that log. You can replay from it into your MCP server and redeliver to the refreshed subscription.

Doing this with SolidHooks

SolidHooks doesn't emit MCP Events itself, and it doesn't handle events/subscribe, the verification challenge or subscription expiry. Your MCP server does that part. SolidHooks is the inbound layer in the diagram above. Here's the setup, using features from the SolidHooks docs:

  1. 1.Create a source per provider. POST /v1/sources with a provider preset returns an ingestUrl (https://sdhk.events/<maskId>) to paste into the provider's webhook settings. Presets include stripe; HMAC presets for github, linear, shopify, nuvion and paystack; Standard Webhooks presets for openai, anthropic and resend; generic; and email for inbound email.
  2. 2.Create a destination pointing at your MCP server's ingest route. New destinations sign with Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature), so the same verification code covers both hops. Rotation can run with an overlap of 1, 7 or 30 days (secret rotation).
  3. 3.Connect them, with filters on event type if you only need some events.
  4. 4.In your MCP server, verify the SolidHooks delivery, map it to an MCP Event envelope and sign it with each matching ChatGPT subscription's whsec_ secret.

What you get on the inbound side:

  • Retries on an exponential schedule when your MCP server is down, with idempotency keys to drop duplicate provider sends (retries).
  • A stored, queryable event history, including filtered and unmatched events, kept for your plan's retention period, plus replay to re-run an event through its connections when you need to backfill.
  • Private delivery through Connect, so your MCP server's ingest route can sit inside a private network.
  • Agent-ready setup: a REST API, llms.txt, Agent Auth and a hosted MCP server at https://api.solidhooks.com/mcp. That MCP server is for managing SolidHooks from Claude Code, Cursor or VS Code, so an agent can build the pipeline for you. It doesn't emit events.

FAQ

Receive the webhooks your agent should act on

Receive and verify the webhooks your agent should act on: start free with 10,000 deliveries a month. Or let your agent set up the pipeline through Agent Auth or the hosted MCP server.