What It Takes to Add MCP Events to Your MCP Server

Adding MCP Events to an MCP server looks small. The draft specification adds three JSON-RPC methods, and ChatGPT's integration only uses webhook delivery. You can have events/list returning event definitions in an afternoon.

The methods aren't where the work is. Once a client subscribes, your MCP server becomes a webhook producer. It has to store subscriptions durably, turn upstream activity into signed HTTP requests, deliver them to URLs it doesn't control, retry the failures and stop when access is revoked. And it has to keep doing that when one busy channel has 1,500 subscribers.

This guide is for backend developers who own an MCP server and plan to build the event pipeline themselves: their own ingest queue, delivery queue, workers, retry scheduler and monitoring. It walks through every component that build needs to send MCP Events reliably at scale, with the numbers and failure modes that matter. If you're new to MCP Events, start with What are MCP Events? first.

The details below follow the draft MCP Events design, now proposed as SEP-3415, and OpenAI's implementation guide as of October 2026. The specification isn't final, so field names and error codes may change, but the infrastructure work applies whatever they end up being called.

Rather not run your own delivery pipeline?

Hookdeck Outpost handles signed delivery, retries and delivery logs for you.

The architecture at a glance

MCP Events sender architecture: an MCP client subscribes through your MCP endpoint into a subscription store, upstream webhooks flow through ingest, match and fan-out into a delivery queue, and delivery workers send signed POSTs to the subscriber's callback URL, with observability and background jobs alongside

The top row is the MCP-specific control plane. The highlighted boxes are the event infrastructure you build and operate yourself: the ingest and delivery queues, the workers around them and the monitoring that tells you when they fall behind.

The protocol surface

Start with the part everyone expects. Your server advertises an events capability and implements three methods on the same authenticated endpoint as its tools. ChatGPT requires MCP 2.0 (protocol version 2026-07-28).

MethodWhat your server must do
events/listReturn each event type's name, description, delivery modes, an inputSchema for subscription arguments and a payloadSchema for event data. Only list events the caller is allowed to see. Paginate with nextCursor.
events/subscribeAuthorize the caller, validate the arguments and the whsec_ secret, verify the callback URL, store the subscription and return its id, refreshBefore and cursor. Must be idempotent.
events/unsubscribeFind the subscription by name, arguments and callback URL, stop delivery and return {}. Also idempotent.

Two design rules from the draft save pain later:

  • Treat event names as a public API. A subscription can outlive the events/list response it came from. Add optional fields freely, but never remove, rename or retype fields in a published payloadSchema. A breaking change gets a new event name.
  • Keep payloads small. Send enough to triage the event (IDs, a title, a URL) and expose a tool that fetches the full record. Small payloads keep you under ChatGPT's 256 KiB limit, cut the prompt-injection surface and keep sensitive data out of logs and queues.

Everything else in this guide sits behind events/subscribe.

Subscription storage

A webhook subscription is durable state that names a user, a public URL and a signing secret. Where you keep it decides how your server behaves on restart, on refresh and when a user leaves.

Identity

The draft keys a subscription on four values: (principal, delivery.url, name, arguments). The principal is your identifier for the authenticated user, such as the OAuth sub. All four are immutable. A subscribe call that changes any of them creates a different subscription.

Derive the subscription id deterministically from that key, for example a truncated SHA-256 of a canonical serialization. Compare arguments as canonical JSON (sorted keys, no insignificant whitespace), or {"a":1,"b":2} and {"b":2,"a":1} become two subscriptions and the agent gets every event twice.

import { createHash } from "node:crypto";
import canonicalize from "canonicalize"; // RFC 8785 JSON Canonicalization Scheme

export function subscriptionId(principal: string, url: string, name: string, args: unknown) {
  const key = canonicalize({ principal, url, name, args })!;
  return "sub_" + createHash("sha256").update(key).digest("hex").slice(0, 24);
}

The id is a routing handle, not a credential. It appears in the X-MCP-Subscription-Id header on every delivery and is never accepted as input to unsubscribe.

Lifetime

Every subscription has a TTL. The client suggests one with ttlMs, you grant one with refreshBefore, and the client re-calls events/subscribe before it expires. The draft recommends grants from a few minutes up to about a day. Refresh traffic grows as TTLs shrink, but storage and the time an abandoned subscription keeps delivering grow as they lengthen.

Your TTL choice also decides how durable your storage needs to be:

GrantStorage you needWhat happens on restart
Minutes to an hourIn-memory or a cache is acceptableClients re-subscribe on their next refresh. Events during the gap are recovered only if the event type supports cursor replay.
Up to a dayA databaseSubscriptions survive; delivery resumes.
No expiry (ttlMs: null granted as refreshBefore: null)A database, plus your own cleanup for dead endpointsYou must persist them. A client that never refreshes will never notice a dropped subscription.

Refuse no-expiry requests unless you have a clear reason. A finite TTL is also your revocation backstop: a subscription whose owner lost access dies at its next refresh at the latest.

A minimal schema

create table event_subscriptions (
  id              text primary key,          -- derived from the key above
  principal       text not null,
  event_name      text not null,
  arguments       jsonb not null,            -- canonical form
  callback_url    text not null,
  secret_ref      text not null,             -- pointer into a secret store or KMS-encrypted value
  previous_secret_ref text,                  -- set during rotation
  refresh_before  timestamptz,               -- null only for granted no-expiry
  verified_at     timestamptz,
  reverified_at   timestamptz not null,      -- last access re-check
  active          boolean not null default true,
  failed_since    timestamptz
);
create index on event_subscriptions (event_name);
create index on event_subscriptions (principal);
create index on event_subscriptions (refresh_before);

Store secrets encrypted or in a secret manager, never in plain text next to the URL they sign for. A leaked row would let anyone forge events to that endpoint.

Getting events from upstream

Your MCP server rarely creates events itself. It wraps a product, and the events come from that product. The draft describes two ways to get them:

  • Emit: the upstream pushes to you, usually as its own webhooks, a WebSocket or a message bus. Your server calls emit() for each one. This is the right model for sources like PagerDuty or Slack's Events API.
  • Check: your server asks the upstream for changes since a cursor, such as Gmail's historyId, a Kubernetes resourceVersion or a Stripe /v1/events page. This is the right model when the upstream has an addressable change feed.

The choice decides what you can promise. With a check function backed by a durable upstream cursor, a client can resubscribe after downtime with its last cursor and get the events it missed. With emit-only sources, events that arrive while your server or a subscription is down are gone, and you should return cursor: null so clients know not to expect replay.

Ingesting upstream webhooks

If the upstream sends you webhooks, your MCP server is now a webhook consumer as well as a producer. The usual consumer rules apply before anything reaches your event pipeline:

  • Verify the upstream's signature on the raw body.
  • Persist the event and return 2xx quickly. Do the matching and fan-out asynchronously.
  • Deduplicate on the upstream's event ID. Reuse that ID as your MCP eventId, so an event that arrives by webhook and again through a backfill carries the same ID downstream.

Provisioning upstream listeners

Some events need setup per subscription, like joining a Slack channel or registering a GitHub repository webhook. Run that setup in a subscribe hook and tear it down on unsubscribe or expiry. Reference-count the upstream resource: if 400 subscriptions watch the same channel, you want one upstream listener, not 400, and you must not delete it when the first subscription expires.

Make the hooks idempotent. After a restart, short-TTL subscriptions come back through refresh calls, and each one will run the hook again.

Matching and fan-out

One upstream event can match many subscriptions. A message in a shared channel matches every subscription filtered on that channel. Each match becomes its own delivery, with its own URL, secret, payload shape and retry state.

Do this in three steps, off the request path:

  1. Match. Find the active, unexpired subscriptions for the event name, then apply each one's arguments. Arguments are yours to define (filters, globs, flags like redact_pii), so matching is your code. Index for the common filter: a lookup on (event_name, channel_id) beats scanning every subscription for message.created.
  2. Transform. Shape the payload per subscription. Apply redaction flags, drop fields the subscriber shouldn't see and check the result against the event's payloadSchema and the 256 KiB limit.
  3. Enqueue. Write one delivery job per match to a durable queue, then return. The job holds the subscription ID, the event ID and the serialized body.

Authorization belongs in the match step too. A user who subscribed to a channel may have lost access to it since. The draft only says servers should re-verify "periodically", so pick a number. Either re-check access at match time, or run a sweeper that re-checks every subscription on a fixed interval and deletes the ones that fail (see section 10).

Never deliver inline from the code that received the upstream event. With 500 matches and a 10-second delivery timeout, one slow endpoint holds the request open long enough for the upstream to time out and retry, and you fan out the same event again.

Running your own queues

If you build this yourself, you'll run at least two queues, and they fail in different ways:

QueueHoldsWhat goes wrong
Ingest queueVerified, deduplicated upstream events, waiting for fan-outUpstream bursts outpace fan-out. Events are lost if you acknowledge the upstream before persisting.
Delivery queueOne job per event per matching subscription, waiting for a workerFan-out multiplies volume. Slow endpoints and retries build a backlog that delays everyone.

The ingest queue protects you from the upstream. Providers don't wait: GitHub, for example, expects a 2xx within 10 seconds. Your ingest endpoint should verify, persist and return, whatever state your delivery side is in. The delivery queue protects healthy subscribers from unhealthy ones, which takes more work (see section 9).

What your queue has to support

  • Durability. Jobs survive a broker restart and a worker crash. Workers acknowledge a job only after the delivery attempt is recorded.
  • Visibility timeouts. A job a worker has claimed must reappear if that worker dies. Set the timeout well above your delivery timeout, for example 60 seconds for a 10-second request. A job that reappears produces a duplicate delivery, which is why receivers deduplicate on webhook-id.
  • Delayed jobs. Backoff means scheduling an attempt minutes ahead. Although note that some brokers cap this.
  • A dead-letter queue. Jobs that exhaust their retries go somewhere you can inspect and replay, not into a log line.
  • Retention longer than your worst outage. Alert on the age of the oldest job, not just queue depth.
  • Idempotent enqueueing. If fan-out crashes halfway through 1,500 matches and runs again, it must not create 1,500 more jobs. A unique key on (subscription_id, event_id) handles it.

No broker knows which jobs belong to which destination. The per-host concurrency caps and fair scheduling from section 9 are always your code.

A Postgres delivery queue

At moderate volume, a job table next to your subscription store is the simplest thing that works:

create table delivery_jobs (
  id               bigserial primary key,
  subscription_id  text not null references event_subscriptions (id) on delete cascade,
  event_id         text not null,
  host             text not null,          -- for per-host concurrency caps
  body             bytea not null,         -- serialized once, signed per attempt
  attempts         int not null default 0,
  run_at           timestamptz not null default now(),
  status           text not null default 'pending',  -- pending | in_flight | done | dead
  locked_until     timestamptz,
  last_error       text,
  unique (subscription_id, event_id)       -- fan-out can safely re-run
);
create index on delivery_jobs (run_at) where status = 'pending';

-- Claim due jobs without blocking other workers
with next as (
  select id from delivery_jobs
  where status = 'pending' and run_at <= now()
  order by run_at
  limit 50
  for update skip locked
)
update delivery_jobs j
set status = 'in_flight', locked_until = now() + interval '60 seconds', attempts = j.attempts + 1
from next where j.id = next.id
returning j.*;

-- Visibility timeout: return jobs from crashed workers
update delivery_jobs set status = 'pending'
where status = 'in_flight' and locked_until < now();

After each attempt, a worker marks the job done, schedules the next attempt by setting run_at and status = 'pending', or marks it dead when retries run out. Delete or archive finished rows on a schedule, or the table grows without limit.

Operating it

The queue is now production infrastructure, and so are the jobs around it. Plan for:

  • Scaling workers on the age of the oldest due job, not on CPU. Delivery workers spend most of their time waiting on the network.
  • Alerts on oldest-job age, dead-letter growth and the ratio of retries to first attempts.
  • A runbook to pause delivery for one subscription or host, drain a backlog, and replay dead-lettered jobs after a fix.
  • Broker capacity planning, upgrades and failover if you don't use a managed queue.

Callback verification

The client supplies the callback URL and the signing secret, so HMAC alone can't stop abuse. Anyone could subscribe a victim's URL with a secret of their choosing and use your server to flood it. The draft therefore requires you to confirm the endpoint wants deliveries before the first one.

The handshake ChatGPT expects:

  1. POST a signed control message to the callback URL: {"type": "verification", "challenge": "<random nonce>"}. Give it a unique webhook-id such as msg_verification_<random> and the usual signature headers.
  2. Require a 2xx response whose body is {"challenge": "<the same nonce>"}.
  3. Compare the nonce in constant time. Make it single-use and expire it within seconds.
  4. On failure, reject the subscribe call with a CallbackEndpointError and a category in data.reason: challenge_failed, timeout, connection_refused, tls_error, http_4xx or http_5xx. OpenAI's guide uses code -32015; SEP-3415 renumbers it to -32027.

Return only the category. Never echo the endpoint's response body, headers or status line back to the caller, or your subscribe method becomes a probe for internal services.

Cache successful verification per (principal, url) for a bounded period. One user subscribing the same URL to 50 event types should trigger one challenge, not 50. Rate-limit verification requests per destination host as well, so nobody can use events/subscribe to aim challenge traffic at a third party.

The draft also allows an allowlist, out-of-band verification, or a /.well-known/mcp-webhook-receiver.json file published by the receiver's origin. ChatGPT uses the challenge handshake, so implement that first.

Delivery

A delivery worker takes a job off the queue, signs the body and POSTs it to the subscriber. Three things have to be right in that small step.

Sign with Standard Webhooks

MCP Events uses the Standard Webhooks scheme: HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body), base64-encoded with a v1, prefix. Every delivery carries these headers:

HeaderValue
Content-Typeapplication/json
webhook-idThe event's eventId (control messages use msg_<type>_<random>)
webhook-timestampSigning time in Unix seconds, regenerated on every attempt
webhook-signaturev1,<signature>; two space-separated values during secret rotation
X-MCP-Subscription-IdThe subscription id, so the receiver can pick the right secret

The signature covers the exact body bytes. Serialize the body once, sign those bytes and send the same bytes. Re-serializing between signing and sending is the most common cause of signatures that fail for no visible reason.

Make outbound HTTP safe

Every callback URL is attacker-controlled input, and your delivery workers will request it from inside your network. Without care, events/subscribe becomes a server-side request forgery (SSRF) tool that reaches your cloud metadata endpoint or internal admin services. The draft's rules:

  • Require https:// and reject anything else at subscribe time with -32602.
  • Resolve the hostname when you connect, not only at subscribe time, and reject private, loopback, link-local and other non-public addresses. Checking only at subscribe time loses to DNS rebinding, where the name resolves to a public IP first and 169.254.169.254 later.
  • Connect to the IP you validated while keeping the original hostname for TLS SNI and the Host header.
  • Never follow redirects. A 302 to an internal address bypasses every check above.
  • Apply all of this to verification requests too.

In Node.js, overriding the DNS lookup on an undici Agent puts the check at connection time:

import { Agent, request } from "undici";
import { lookup as dnsLookup } from "node:dns/promises";
import ipaddr from "ipaddr.js";
import { Webhook } from "standardwebhooks";

const isPublic = (ip: string) => ipaddr.process(ip).range() === "unicast";

// Validates every address at connect time; hostname is kept for SNI and Host.
export const callbackAgent = new Agent({
  connect: {
    lookup(hostname, options, cb) {
      dnsLookup(hostname, { all: true }).then((addrs) => {
        if (addrs.length === 0 || !addrs.every((a) => isPublic(a.address))) {
          return cb(new Error("blocked_destination"), "", 0);
        }
        if (options.all) return cb(null, addrs as any);
        cb(null, addrs[0].address, addrs[0].family);
      }, (err) => cb(err, "", 0));
    },
  },
});

export async function deliver(sub: { id: string; url: string; secret: string }, event: { eventId: string }) {
  const body = JSON.stringify(event); // serialize once
  if (Buffer.byteLength(body) > 256 * 1024) throw new Error("payload_too_large");

  const signedAt = new Date();
  const res = await request(sub.url, {
    method: "POST",
    dispatcher: callbackAgent, // undici does not follow redirects unless you add an interceptor
    headersTimeout: 10_000,
    bodyTimeout: 10_000,
    headers: {
      "content-type": "application/json",
      "webhook-id": event.eventId,
      "webhook-timestamp": String(Math.floor(signedAt.getTime() / 1000)),
      "webhook-signature": new Webhook(sub.secret).sign(event.eventId, signedAt, body),
      "x-mcp-subscription-id": sub.id,
    },
    body,
  });
  await res.body.dump(); // never read or store the endpoint's response body
  return res.statusCode;
}

If your workers run in a VPC, add a second layer: send callback traffic through an egress proxy or subnet that has no route to internal services. Application checks can have bugs; a network that can't reach the metadata endpoint can't be talked into it.

Timeouts and payload size

The draft notes that a timeout of about 5 seconds is common, and OpenAI's example uses 10. Pick one in that range and treat a timeout as a failed attempt. Receivers are expected to acknowledge fast and process later. Cap request bodies at 256 KiB, and send one event per request.

Retries, suspension and dead endpoints

Some deliveries will fail. Receivers deploy, time out, run out of capacity or disappear. Your retry policy decides whether those failures cost a few seconds of delay or a lost event, and whether one broken endpoint drags down everyone else.

What to retry

ResponseWhat to do
2xxDone. The receiver has the event.
410 GoneStop retrying this event. OpenAI's guide lists 410 as non-retryable.
413 Payload Too LargeStop. Retrying the same bytes will fail the same way. Log it, because it means your payload is too big.
425 Too Early, 503Retry. The receiver may not have finished setting up the subscription yet.
429Retry, and honor Retry-After if present. (The draft doesn't specify this; it's standard practice.)
Any other status, timeout or connection errorRetry with backoff.

How to retry

The draft recommends bounding retries to 3 to 5 attempts within no more than 10 to 15 minutes. Here's a schedule that fits, with 20% random jitter on each delay so a recovering endpoint isn't hit by every retry at once:

AttemptDelay before itTime since first attempt
1none0
230 seconds30 seconds
390 seconds2 minutes
44 minutes6 minutes
58 minutes14 minutes

On every attempt, keep the same webhook-id (it's the receiver's deduplication key) and generate a fresh webhook-timestamp and signature. Receivers reject timestamps older than five minutes, so replaying the original signature fails from attempt 4 onward.

Retry each event independently. That means events can arrive out of order, which the protocol allows: receivers order by timestamp when they need to. Strict per-subscription ordering would let one failing event block every event behind it.

Keep exhausted events in a dead-letter store with the subscription ID, the last error category and the payload. You'll want them when a customer asks why their agent missed something, and for replaying after you fix a bug.

Suspending failing subscriptions

An endpoint that has been down for hours shouldn't get five attempts for every new event. The draft suggests suspending delivery on a sustained failure rate rather than a fixed count, for example above 95% failures over a rolling 60-minute window with at least 100 attempts. That threshold ignores a brief outage on a healthy endpoint and doesn't suspend a quiet subscription over a handful of failures.

While a subscription is suspended:

  • Stop attempting deliveries, and pause the retry window for pending events instead of letting it run out.
  • Report it on the next events/subscribe refresh in an optional deliveryStatus object: active: false, lastDeliveryAt, failedSince and a lastError category.
  • Treat a successful refresh as the client saying it's back. Set active: true and resume pending retries.

If the client never refreshes, the subscription expires at its TTL and you delete it. That's the cleanup finite TTLs give you for free.

What changes at scale

Everything above works with a single worker and a few hundred subscriptions. Problems start when fan-out multiplies upstream traffic and a few bad endpoints take up most of your delivery capacity.

A worked example

Take a chat MCP server with one popular channel. 1,500 subscriptions filter on that channel, and during an incident it gets 200 messages in 10 minutes. Healthy receivers answer in 200 ms. 5% of subscribed endpoints are down and time out at 10 seconds. These are illustrative numbers, but none are extreme:

Value
Deliveries created1,500 × 200 = 300,000 in 10 minutes
Delivery rate500 per second
Concurrent requests to keep up with healthy endpoints475/s × 0.2 s ≈ 95
Concurrent requests stuck on dead endpoints (first attempts only)25/s × 10 s = 250
Extra attempts from retries (4 per failed delivery)15,000 × 4 = 60,000
Share of total worker time spent on the 5% of dead endpoints750,000 s of 807,000 s ≈ 93%

The last row is the important one. With a shared pool and a FIFO queue, 75 broken endpoints get 93% of your delivery time and 1,425 healthy ones wait behind them. Agents that should react in seconds react in minutes. The upstream burst also arrives exactly when you're least able to absorb it.

What fixes it

  • Isolate destinations. Cap in-flight requests per destination host (for example 5 to 10), so one host can't take over the pool. Give each subscription, or each host, its own logical queue and schedule between them fairly.
  • Split first attempts from retries. Send retries to a separate queue with its own worker budget. Fresh events to healthy endpoints should never wait behind a retry for a dead one.
  • Trip a breaker early. Long before the 60-minute suspension threshold, a per-host circuit breaker can stop sending to a host that has failed its last 20 attempts and probe it occasionally instead.
  • Be fair across tenants. Schedule by principal or workspace as well as by host, so one customer with 10,000 subscriptions can't starve everyone else.
  • Reuse connections. Keep a pool of keep-alive connections per host. A TLS handshake on every delivery costs more than the request it carries.
  • Shed load honestly. If you must skip events for a subscription, say so: set truncated: true on the next refresh. The draft also defines a gap control message, but ChatGPT doesn't support it yet. Optional throttled and retryAfterMs fields in deliveryStatus tell the client its deliveries are delayed, not failing.

Keeping subscriptions honest

A subscription is created under one user's access token, then outlives that token by hours or days. Several background jobs keep the stored set matching reality.

Refresh

A refresh is an events/subscribe call with the same key. Treat it as an upsert:

  • Re-grant the TTL and return a new refreshBefore.
  • Replace the secret if the client sent a new one (see rotation below).
  • Set active: true and resume any suspended delivery.
  • Return the latest safe cursor, so the client's position advances even when no events arrived.
  • Re-run the authorization check. A refresh is a free chance to catch revoked access.

Expiry

Run a sweeper that deletes subscriptions past refresh_before, cancels their pending deliveries and releases upstream listeners through the reference counts from section 3. Without it, abandoned subscriptions keep consuming fan-out and delivery capacity long after the client stopped caring.

Access re-checks

The draft says servers should re-verify permissions "periodically" but sets no interval. Choose one you can state, such as every 15 minutes, and run the same authorization check you ran at subscribe time. Delete subscriptions that fail. Also hook your user deprovisioning path (SCIM, account deletion, workspace removal) into a delete-by-principal query.

This matters more than usual because ChatGPT doesn't support the draft's terminated control message. When you end a subscription, ChatGPT finds out only when its next refresh fails with a Forbidden error (-32024 in SEP-3415). Your TTL is therefore the longest a user can think a dead automation is still running, and your re-check interval is the longest a revoked user can keep receiving data.

Secret rotation

The client rotates a secret by sending a new delivery.secret on refresh. Store it, keep the old one for a short grace window, and sign with both during that window as space-separated v1, signatures. Receivers accept a delivery if either signature verifies, so in-flight retries don't fail mid-rotation.

Removing or changing event types

If you remove an event type or change its schema incompatibly, existing subscriptions lose their contract. The next refresh should fail with a NotFound error (data.kind: "event", -32023 in SEP-3415) for a removed type, or an Unsupported error (-32026) for a changed schema. The client then re-reads events/list. Purely additive schema changes must not end subscriptions.

Feedback loops

If an agent reacts to comment.created by posting a comment, and that comment fires comment.created again, you've built a loop that runs until something rate-limits it. OpenAI's guide calls this out explicitly. Tag actions taken through your tools so your matcher can skip events your own tools caused, or exclude them with a subscription argument.

Observability

When an agent doesn't react, the user's first question is whether the event was sent. You need to answer that per event, per subscription, in minutes.

Log every attempt. For each one, record the event ID, subscription ID, principal, attempt number, timestamp, response status or error category, and latency. Don't store response bodies: they're untrusted, can be large, and can leak data from whatever sits at the URL. Keep payloads only as long as your retry and replay window needs them, and treat them as user data.

Track these metrics:

MetricWhy it matters
Time from upstream event to first delivery attempt (p50, p99)The latency agents feel. Rising p99 usually means queue backlog.
Delivery success rate, overall and per hostSeparates a bad endpoint from a bad deploy of yours.
Queue depth and age of oldest job, first-attempt and retry queuesYour early warning before latency rises.
Fan-out per upstream event (p99)Shows which channels or resources need dedicated capacity.
Active, suspended and expiring subscriptionsShows churn and slow leaks.
Verification failures by categoryDistinguishes misconfigured receivers from abuse.
Dead-lettered eventsEvery one is an event an agent never saw.

Surface status to clients. The deliveryStatus object on refresh responses is the only channel the protocol gives you to tell a client its deliveries are failing. Fill it in. lastError must be one of the fixed categories (connection_refused, timeout, tls_error, http_4xx, http_5xx, challenge_failed), never raw response details.

Give support a lookup. Build an internal view where someone can paste a subscription ID or event ID and see every attempt. The first few customer tickets will pay for it.

Testing checklist

OpenAI's guide lists the scenarios to test before you ship. Here they are with the failure modes from this guide added:

  • [ ] events/list returns only events the connected account may see, and they appear on your plugin page after a rescan
  • [ ] Subscribing twice with the same arguments in a different key order returns the same subscription ID
  • [ ] Callback verification succeeds against a real receiver and fails cleanly with CallbackEndpointError against one that doesn't echo
  • [ ] Callback URLs that are http://, private, loopback, link-local or redirecting are rejected, including hostnames that change resolution after subscribe
  • [ ] Every delivery verifies with an off-the-shelf Standard Webhooks library
  • [ ] Retries keep webhook-id and get fresh timestamps and signatures; 410 and 413 are not retried
  • [ ] Events that don't match a subscription's filters are not delivered
  • [ ] Subscriptions survive a server restart for as long as you promised, and refresh correctly afterwards
  • [ ] Killing a worker mid-delivery causes at most a duplicate delivery, never a lost event
  • [ ] Re-running fan-out for the same event creates no extra delivery jobs
  • [ ] A delivery outage triggers an alert long before your queue's retention period runs out
  • [ ] Revoking a user's access to a resource stops delivery within your stated re-check interval
  • [ ] Disconnecting the account removes all of that user's subscriptions
  • [ ] Secret rotation produces two signatures during the grace window
  • [ ] A burst of 10x normal traffic to one resource doesn't delay deliveries to unrelated subscriptions
  • [ ] One endpoint timing out on every request doesn't slow delivery to healthy ones
  • [ ] Duplicate deliveries are harmless, and an agent's own action doesn't trigger an endless loop
  • [ ] events/unsubscribe stops delivery straight away, and calling it twice is harmless

Build it yourself or use managed infrastructure

Only a small part of this guide is specific to MCP: the three methods, the subscription key and TTL, callback verification and authorization. The rest is event infrastructure. Here's everything you'll run in production if you build it all yourself:

ComponentWhat it involves
Ingest endpoint and ingest queueSignature verification per upstream, deduplication, persisting before acknowledging within each provider's deadline
Fan-out workersSubscription matching, authorization, payload shaping, idempotent enqueueing
Delivery queue and dead-letter queueDurability, visibility timeouts, delayed retries, retention, inspection and replay
Delivery workersSigning, SSRF-safe egress, timeouts, per-host concurrency caps, fair scheduling
Retry scheduling and suspensionBackoff with jitter, non-retryable statuses, failure-rate tracking per subscription
Background jobsExpiry, access re-checks, secret rotation, upstream listener reference counts
ObservabilityAttempt logs, latency and backlog metrics, alerts, a support lookup
OperationsCapacity planning, scaling on queue age, runbooks, on-call

None of these is exotic. Together they form a distributed system you'll operate for as long as you offer events, and most of its incidents will start on endpoints you don't control.

Hookdeck covers the two legs of that pipeline that aren't MCP-specific:

  • The delivery leg. Hookdeck Outpost is outbound webhook infrastructure, and MCP Events maps onto it: one tenant per principal, one webhook destination per subscription, with the callback URL, the client's whsec_ secret and a filter built from the subscription's arguments. Outpost signs each attempt in Standard Webhooks format with a fresh timestamp, signs with both secrets during rotation, retries on a schedule you set and logs every attempt. Outpost is open source (Apache 2.0), with a managed version that also blocks deliveries to private addresses. The MCP Events with Outpost guide walks through the whole server, including its known gaps: Outpost currently retries 410 and 413 responses, and the verification challenge and subscription expiry stay in your code.
  • The ingest leg. Hookdeck Event Gateway receives the upstream webhooks your server turns into MCP Events. It verifies their signatures, deduplicates, holds them in a durable queue, delivers them to your fan-out workers at a rate they can absorb with retries, and gives you logs, metrics and replay.

The MCP layer stays yours either way: the event methods, the subscription lifecycle, callback verification and authorization. What moves off your plate is most of sections 5, 7, 8 and 11 for delivery, and the ingest half of sections 3 and 5. Try it free.

Continue learning

Sources

FAQs

How do I add events to an MCP server?

Advertise an events capability and implement events/list, events/subscribe and events/unsubscribe on your authenticated MCP endpoint. For webhook delivery, which ChatGPT requires, you also need durable subscription storage, callback verification, Standard Webhooks signing, retries and a background job that expires subscriptions and re-checks access.

Which delivery mode should I implement first?

Webhook. It's the only mode ChatGPT supports, and it suits remote servers that can't hold connections open. The draft's push (events/stream) and poll (events/poll) modes are useful for local servers and simple clients, and you can add them later.

Who generates the signing secret for MCP Events?

The client. It sends a whsec_ secret in events/subscribe, and your server must reject values that don't decode to 24 to 64 bytes. Your server never generates the secret, but it must store it securely and sign every delivery with it.

How many times should an MCP server retry a failed event?

The draft suggests 3 to 5 attempts within 10 to 15 minutes, with exponential backoff. Keep the same webhook-id on every attempt, re-sign each one with a fresh timestamp, and don't retry 410 or 413 responses.

Do I need to store MCP Event subscriptions in a database?

If you grant TTLs longer than about an hour, yes. With short TTLs you can keep subscriptions in memory and rely on clients to re-subscribe after a restart. If you grant subscriptions with no expiry, they persist until the client unsubscribes or you end them, so they need durable storage.

How do I stop sending events when a user loses access?

Re-check access on a fixed schedule and at every refresh, and delete subscriptions that fail. ChatGPT doesn't support the draft's terminated message, so it learns a subscription has ended only when its next refresh fails. Short TTLs keep that window small.

What's the maximum MCP Event payload size?

ChatGPT accepts one event per request, with a body of at most 256 KiB (262,144 bytes). The draft recommends the same limit. Send a summary and let the agent fetch the full record through a tool.