MCP Events is a draft extension to the Model Context Protocol (MCP) that lets an agent subscribe to events an MCP server exposes, such as a new order in a store or a failed build in CI, and react without a user in the loop. In webhook mode, the agent gives your server a callback URL and a signing secret, and your server POSTs each matching event to that URL, signed with Standard Webhooks.

This guide shows you how to add MCP Events webhook delivery to your own MCP server, with Hookdeck Outpost doing the delivery. Your server handles the MCP methods and the parts specific to MCP Events. Outpost signs, filters, delivers, retries and logs.

You'll learn how to map MCP Events subscriptions onto Outpost tenants and destinations, run the verification challenge, handle refresh and expiry, and publish events, then test it all with ChatGPT subscribed to order.created events on your server and receiving each matching order as a signed webhook. The code is TypeScript, adapted from the MCP Events with Hookdeck Outpost demo, and the snippets are fragments rather than complete files, so use the demo for complete, runnable code.

The state of MCP Events

MCP Events is proposed in SEP-3415: Events Extension, a Specification Enhancement Proposal (SEP) from the MCP Triggers and Events Working Group. It grew out of the group's earlier design sketch. The SEP is still a draft and isn't part of the MCP specification, so details may change.

As of September 2026, ChatGPT supports MCP Events in webhook mode only: per OpenAI's MCP Events implementer guide, there's no poll mode, no streaming, and no gap or terminated control envelopes. This guide follows OpenAI's guide where it differs from the sketch, so a server built this way works with ChatGPT. A few details, such as the error codes, come from the sketch or OpenAI's guide and differ in SEP-3415.

Why use Outpost for MCP Events delivery

MCP Events delivery needs what any outbound webhook system needs: signatures with a fresh timestamp on every attempt, dual signatures during secret rotation, bounded retries, per-subscription filtering and a delivery log. Outpost already does all of that, and MCP Events maps onto it without a new destination type:

MCP EventsOutpost
Authenticated principalTenant
Webhook subscriptionWebhook destination, with the subscription ID as the destination ID
delivery.urlconfig.url
delivery.secretcredentials.secret
X-MCP-Subscription-Id headerconfig.customHeaders
Subscription argumentsDestination filter
Event nameTopic
Event occurrenceoutpost.publish(), with the event ID as the Outpost event ID
Secret rotation on refreshcredentials.previousSecret and previousSecretInvalidAt

Two parts of MCP Events have no Outpost equivalent, so you implement them in your MCP server: send the endpoint verification challenge before you create a destination (Step 3), and delete destinations when their subscriptions expire (Step 6).

How it works

sequenceDiagram
    participant A as Agent (subscriber)
    participant S as Your MCP server
    participant O as Hookdeck Outpost
    A->>S: 1. events/subscribe to order.created, total >= 100 USD
    S->>A: 2. Signed verification challenge
    A-->>S: Challenge echoed
    S->>O: 3. Create a destination for the subscription
    Note over S: An order is placed
    S->>O: 4. Publish order.created
    O->>A: 5. Signed webhook (filtered, retried)
  1. The agent calls events/subscribe with the event name, optional arguments, a callback URL, and a whsec_ secret it generated.
  2. Your server POSTs a signed challenge to the callback URL, and the agent echoes it back.
  3. Your server creates an Outpost webhook destination with the callback URL, the secret, and a filter built from the arguments.
  4. When the event happens, your server publishes it to Outpost.
  5. Outpost evaluates the filter, signs the request, delivers it, and retries on failure.

The agent refreshes the subscription before it expires and unsubscribes when it's done. Your server deletes the destination when either happens, or when the subscription lapses.

The examples use one event type, order.created, with two optional arguments: minTotal and currency.

Prerequisites

  • An MCP server in TypeScript using v2 of the official MCP TypeScript SDK (@modelcontextprotocol/server and @modelcontextprotocol/node), which serves the 2026-07-28 protocol revision that ChatGPT requires.
  • A Hookdeck account with a managed Outpost project, and an API key from the project's Settings > Secrets. A self-hosted Outpost works too.
  • Packages: the Outpost TypeScript SDK (the examples use v1.7.0), standardwebhooks and zod. Step 8 also uses express.
  • A persistent store for subscription records, such as a database table. The examples call it store.
  • Authentication. MCP Events webhook subscriptions belong to an authenticated principal, such as the sub of a verified OAuth token, and the SEP says servers without one must not offer webhook mode. For a short-lived dev test, you can map every request to one fixed principal. A production server needs OAuth.
  • To test with ChatGPT: a ChatGPT Plus account or above, and a public HTTPS URL for your server, from a deployment or a tunnel.

Step 1: Configure Outpost

MCP Events deliveries must use the Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature. Outpost's default mode uses different header names, so switch the project to Standard Webhooks mode.

Set three project settings:

  • DESTINATIONS_WEBHOOK_MODE=standard. Outpost then sends the event ID as webhook-id and accepts whsec_ secrets. Leave DESTINATIONS_WEBHOOK_HEADER_PREFIX unset, so it defaults to webhook-.
  • TOPICS. Add one topic per event type, here order.created.
  • RETRY_SCHEDULE. The SEP says servers should cap retries, for example at 3 to 5 attempts over no more than 10 to 15 minutes. Without a schedule, managed Outpost allows up to 10 retries. A schedule of 30,120,600 gives 4 attempts within about 12.5 minutes.

On managed Outpost, set them in the Hookdeck dashboard's Destinations settings and General settings, or with the Config API:

curl -X PATCH "https://api.outpost.hookdeck.com/2025-07-01/config" \
  -H "Authorization: Bearer $OUTPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "DESTINATIONS_WEBHOOK_MODE": "standard",
    "TOPICS": "order.created",
    "RETRY_SCHEDULE": "30,120,600"
  }'

TOPICS replaces the existing list, so include any topics the project already has. These settings apply to the whole project, so use a project that doesn't carry other webhook traffic with different needs. On self-hosted Outpost, set the same names as environment variables.

To check that the settings took effect, fetch the config and look for standard, your topics and the schedule:

curl -s "https://api.outpost.hookdeck.com/2025-07-01/config" \
  -H "Authorization: Bearer $OUTPOST_API_KEY" \
  | jq '{DESTINATIONS_WEBHOOK_MODE, TOPICS, RETRY_SCHEDULE}'

The rest of the guide calls Outpost through the TypeScript SDK. Create a client with your API key. It defaults to managed Outpost; for self-hosted Outpost, pass your instance's API URL as serverURL:

import { Outpost } from "@hookdeck/outpost-sdk";

const outpost = new Outpost({ apiKey: process.env.OUTPOST_API_KEY });

Step 2: Serve the events capability

Advertise MCP Events with an events capability, which OpenAI's guide shows as an empty object in the server/discover response. The sketch shows {"listChanged": true}; use {} for ChatGPT.

Describe each event type for events/list: a name, a description, the delivery modes it supports, an inputSchema for subscription arguments, and a payloadSchema for the event data:

const orderCreated = {
  name: "order.created",
  description:
    "Fires when a new order is placed. Optionally filter by minimum total and currency.",
  delivery: ["webhook"],
  inputSchema: {
    type: "object",
    properties: {
      minTotal: { type: "number", minimum: 0 },
      currency: { type: "string", pattern: "^[A-Z]{3}$" },
    },
    additionalProperties: false,
  },
  payloadSchema: {
    /* JSON Schema for the order: orderId, total, currency, createdAt */
  },
};

The MCP TypeScript SDK doesn't know about MCP Events yet. Cast the capability, and register the three events/* methods as custom request handlers on the underlying low-level Server. With the 2026-07-28 revision, createMcpHandler builds a server per request, so each handler sees that request's principal:

function buildMcpServer(principal: string | undefined) {
  const mcpServer = new McpServer(
    { name: "my-store", version: "1.0.0" },
    { capabilities: { events: {} } as ServerCapabilities },
  );
  // McpServer has no API for custom methods, so register them on the
  // low-level Server it wraps.
  const { server } = mcpServer;
  server.setRequestHandler(
    "events/list",
    { params: anyParams.optional() },
    async () => ({
      events: [orderCreated],
    }),
  );
  server.setRequestHandler(
    "events/subscribe",
    { params: anyParams },
    (params) => subscribe(principal, params),
  );
  server.setRequestHandler(
    "events/unsubscribe",
    { params: anyParams },
    (params) => unsubscribe(principal, params),
  );
  return mcpServer;
}

const mcp = toNodeHandler(
  createMcpHandler((ctx) => buildMcpServer(ctx.authInfo?.clientId)),
);

// In your HTTP server's /mcp route, after authenticating the request:
req.auth = { token, clientId: principal, scopes: [] };
await mcp(req, res);

Authenticate each /mcp request before you hand it to mcp: verify the OAuth access token, use its sub as the principal, and answer 401 without one. For a dev test with ChatGPT's No Authentication option, use one fixed principal such as "dev".

The handlers return MCP Events errors by throwing a ProtocolError from @modelcontextprotocol/server with the code from the sketch, for example throw new ProtocolError(-32602, "Invalid delivery.url"). The steps below name the code for each failure.

Step 3: Implement events/subscribe

A subscribe request looks like this:

{
  "name": "order.created",
  "arguments": { "minTotal": 100, "currency": "USD" },
  "delivery": {
    "mode": "webhook",
    "url": "https://receiver.example.com/mcp-events/abc123",
    "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
  },
  "ttlMs": 3600000
}

The same request also refreshes an existing subscription, so write the handler as a short sequence of steps that covers both, then implement each one in the sections that follow:

async function subscribe(
  principal: string | undefined,
  raw: Record<string, unknown>,
) {
  if (!principal)
    throw new ProtocolError(-32012, "An authenticated principal is required");
  const { name, args, url, secret, ttlMs } = parseSubscribe(raw); // validate the request
  await assertPublicCallback(url); // check the callback URL

  const subscriptionId = deriveSubscriptionId(principal, url.href, name, args);
  const tenantId = tenantIdFor(principal);
  await ensureVerified(principal, url, secret, subscriptionId); // verification challenge

  const expiresAt = new Date(Date.now() + grantTtlMs(ttlMs));
  const existing = store.get(subscriptionId);
  const create = () =>
    createDestination(
      tenantId,
      subscriptionId,
      principal,
      url,
      secret,
      args,
      expiresAt,
    );
  if (!existing) {
    await create();
  } else {
    try {
      // Step 4
      await refreshDestination(
        tenantId,
        subscriptionId,
        secret,
        existing.secretHash,
        expiresAt,
      );
    } catch (error) {
      // Someone deleted the destination in Outpost, for example from the dashboard.
      if (!(error instanceof NotFoundError)) throw error;
      await create();
    }
  }

  store.put({
    id: subscriptionId,
    principal,
    tenantId,
    name,
    arguments: args,
    url: url.href,
    secretHash: sha256(secret),
    expiresAt: expiresAt.toISOString(),
  });
  return {
    id: subscriptionId,
    refreshBefore: expiresAt.toISOString(),
    cursor: null,
    truncated: false,
  };
}

Validate the request

Reject anything malformed with -32602 (InvalidParams), and an unknown event name with -32011 (NotFound). The subscriber chooses the secret, and your server never generates one. MCP Events requires a Standard Webhooks secret: whsec_ followed by base64 of 24 to 64 bytes, so isValidWebhookSecret decodes the base64 and checks the length.

function parseSubscribe(raw: Record<string, unknown>) {
  if (raw.name !== orderCreated.name) {
    throw new ProtocolError(-32011, "Unknown event", { kind: "event" });
  }
  const parsed = subscribeParams.safeParse(raw); // zod schema for arguments, delivery, ttlMs
  if (!parsed.success)
    throw new ProtocolError(-32602, "Invalid subscribe params");
  if (!isValidWebhookSecret(parsed.data.delivery.secret)) {
    throw new ProtocolError(-32602, "delivery.secret must be a whsec_ secret");
  }
  return {
    name: orderCreated.name,
    args: parsed.data.arguments, // defaults to {}
    url: parseCallbackUrl(parsed.data.delivery.url), // new URL(), or -32602
    secret: parsed.data.delivery.secret,
    ttlMs: parsed.data.ttlMs,
  };
}

Parsing the URL with new URL() normalizes it, and arguments defaults to {}. Do the same in events/unsubscribe (Step 5), so both derive the same subscription ID from the same request.

Check the callback URL

The callback URL is supplied by the subscriber, so treat it as untrusted input. The SEP requires servers to validate it. In assertPublicCallback:

  • Require https, and reject URLs with credentials in them.
  • Resolve the host and reject it if any address isn't globally routable, using the IANA IPv4 and IPv6 special-purpose registries (loopback, private ranges, link-local, documentation ranges, and so on).
  • Throw -32602 with data.reason set, such as https_required or callback_address_not_allowed.

The demo's callback.ts implements these rules with net.BlockList.

Derive the subscription ID and tenant

MCP Events identifies a subscription by (principal, delivery.url, name, arguments), and the id your server returns is derived from that key, so it's stable across refreshes and restarts. Hash a canonical serialization. Here canonicalJson is a JSON serializer that sorts object keys, so {"a":1,"b":2} and {"b":2,"a":1} give the same ID:

function deriveSubscriptionId(
  principal: string,
  url: string,
  name: string,
  args: object,
) {
  return `sub_${sha256(canonicalJson({ principal, url, name, arguments: args })).slice(0, 32)}`;
}

function tenantIdFor(principal: string) {
  return /^[A-Za-z0-9_-]{1,48}$/.test(principal)
    ? `mcp_${principal}`
    : `mcp_${sha256(principal).slice(0, 24)}`;
}

Use the subscription ID as the Outpost destination ID. Subscribe then becomes an idempotent upsert: the same request maps to the same destination, and your server never needs a lookup table from subscription to destination.

Give each principal its own Outpost tenant, so one user's subscriptions stay isolated from another's, each user gets their own delivery logs and metrics, and you stay under Outpost's per-tenant destination limit (MAX_DESTINATIONS_PER_TENANT, 20 on a new managed project).

Send the verification challenge

A valid signature stops a third party from forging deliveries, but it doesn't stop someone from subscribing a victim's URL and flooding it. So MCP Events requires your server to prove the endpoint wants the deliveries before it activates the subscription.

In verifyEndpoint, POST a verification control envelope with a single-use challenge, signed with the subscriber's secret. The endpoint must answer with a 2xx and echo the challenge:

const challenge = randomBytes(24).toString("base64url");
const msgId = `msg_verification_${randomBytes(12).toString("hex")}`;
const body = JSON.stringify({ type: "verification", challenge });
const now = new Date();

let result;
try {
  result = await postToCallback(url, body, {
    "webhook-id": msgId,
    "webhook-timestamp": String(Math.floor(now.getTime() / 1000)),
    "webhook-signature": new Webhook(secret).sign(msgId, now, body),
    "X-MCP-Subscription-Id": subscriptionId,
  });
} catch (error) {
  return { ok: false, reason: classifyNetworkError(error) }; // connection_refused, timeout, tls_error
}
if (result.status >= 500) return { ok: false, reason: "http_5xx" };
if (result.status >= 400) return { ok: false, reason: "http_4xx" };
// A 2xx whose JSON body has a `challenge` equal to the one you sent passes.
// Compare with timingSafeEqual. Anything else is "challenge_failed".

Write postToCallback with the SSRF rules from the previous section, because it's the one outbound request your server makes itself. Connect to the address you validated, so a second DNS lookup can't swap in a private address. Never follow redirects, and apply a timeout. fetch can't pin the connected address, so the demo's version uses node:https with a custom lookup. In classifyNetworkError, map network errors to connection_refused, timeout or tls_error.

Call it from ensureVerified, which caches successful checks, and turn a failure into -32015 (CallbackEndpointError):

const result = await verifyEndpoint(url, secret, subscriptionId);
if (!result.ok) {
  throw new ProtocolError(-32015, "CallbackEndpointError", {
    reason: result.reason,
  });
}

The cache is keyed on (principal, url) and lasts a limited time, so refreshes and subscriptions with different arguments don't send more challenges to the same URL. Never return the endpoint's response body or headers in the error. The SEP also says to rate-limit challenges per host.

Create the tenant and destination

Upsert the tenant, then create a webhook destination for the subscription:

await outpost.tenants.upsert(tenantId, {
  metadata: { mcp_principal: principal },
});

try {
  await outpost.destinations.create(tenantId, {
    id: subscriptionId,
    type: "webhook",
    topics: ["order.created"],
    filter: argumentsToFilter(args),
    config: {
      url: url.href,
      customHeaders: JSON.stringify({
        "X-MCP-Subscription-Id": subscriptionId,
      }),
    },
    credentials: { secret },
    metadata: {
      mcp_event: "order.created",
      mcp_expires_at: expiresAt.toISOString(),
    },
  });
} catch (error) {
  if (error instanceof BadRequestError && /already exists/i.test(error.body)) {
    // Your store lost the record, but Outpost still has the destination.
    return refreshDestination(
      tenantId,
      subscriptionId,
      secret,
      undefined,
      expiresAt,
    );
  }
  if (
    error instanceof OutpostError &&
    /maximum number of destinations/i.test(error.body)
  ) {
    throw new ProtocolError(-32013, "Subscription limit reached", {
      limit: "subscriptions",
    });
  }
  throw error;
}
  • id is the subscription ID. If a destination with that ID already exists, Outpost returns 400 with destination already exists, so treat it as a refresh.
  • config.customHeaders is a JSON string. Outpost adds X-MCP-Subscription-Id to every delivery, which is the one MCP-specific header the SEP requires.
  • credentials.secret is the subscriber's whsec_ secret. Only an admin API key can set it.
  • filter decides which events reach this subscription. Outpost evaluates it at publish time, and events that don't match are never sent.
  • The destination limit comes back as 400 with maximum number of destinations per tenant reached. Return -32013 (ResourceExhausted).

MCP Events leaves the meaning of subscription arguments to your server. The SEP says arguments can be filters, which narrow the events a subscription receives, or transforms, which change the payload. Outpost's destination filters handle filter arguments. Outpost doesn't transform payloads per destination, and a publish goes to every matching destination in the tenant (Step 7), so a transform applied before a normal publish would reach every subscription. For a transform argument, apply it in your server and publish the result to that subscription's destination alone, with destinationId set to the subscription ID and an event ID of its own. If you don't need transforms, leave them out of inputSchema.

Map each argument to an Outpost filter operator:

function argumentsToFilter(args: { minTotal?: number; currency?: string }) {
  const order: Record<string, unknown> = {};
  if (args.minTotal !== undefined) order.total = { $gte: args.minTotal };
  if (args.currency !== undefined) order.currency = args.currency;
  return Object.keys(order).length > 0 ? { data: { data: order } } : null;
}

The fields sit under data.data because of how events are published (see Step 7). Outpost evaluates filters against {id, topic, time, metadata, data}, and your server publishes the whole MCP event as data, so the order is at data.data.

Store and return the subscription

In grantTtlMs, grant a finite TTL within your own bounds, using the client's ttlMs as a suggestion: clamp it between your minimum and maximum, and use a default such as one hour when it's missing. ChatGPT doesn't send ttlMs, so your default decides how often it refreshes.

Write the subscription record to your store on every subscribe and refresh, as the handler above does. The sweeper in Step 6 reads expiresAt from it. OpenAI's guide asks servers to keep subscriptions for the lifetime they grant, including across restarts, so persist the store. Each record holds the subscription ID, principal, tenant ID, event name, arguments, callback URL, a hash of the secret, and expiresAt.

The response carries the ID and the granted expiry. cursor: null means the event type has no replay:

{
  "id": "sub_3f1c8e2b0d49f7e6a1b2c3d4e5f60718",
  "refreshBefore": "2026-10-07T15:00:00.000Z",
  "cursor": null,
  "truncated": false
}

To check that it worked, make a subscribe call and open the Outpost section of the Hookdeck dashboard: it shows a tenant named mcp_<principal> with one webhook destination whose ID matches the returned id. To call events/subscribe before you involve ChatGPT, run the demo's test subscriber against your server: clone the demo, start a tunnel for its receiver with npm run tunnel, then run MCP_SERVER_URL=http://localhost:3000/mcp MCP_TOKEN=<token> npm run client -- --min-total 100.

Step 4: Handle refresh and secret rotation

The agent refreshes by calling events/subscribe again with the same key before refreshBefore. It maps to the same subscription ID, so in refreshDestination, update the existing destination instead of creating one:

const patch: DestinationUpdate = {
  type: "webhook",
  // Send the full metadata, as at creation, not only the changed key.
  metadata: {
    mcp_event: "order.created",
    mcp_expires_at: expiresAt.toISOString(),
  },
};

if (sha256(secret) !== knownSecretHash) {
  const current = await outpost.destinations.get(tenantId, subscriptionId);
  const previous =
    current.type === "webhook" ? current.credentials.secret : undefined;
  patch.credentials =
    previous && previous !== secret
      ? {
          secret,
          previousSecret: previous,
          previousSecretInvalidAt: new Date(Date.now() + ROTATION_GRACE_MS),
        }
      : { secret };
}

const destination = await outpost.destinations.update(
  tenantId,
  subscriptionId,
  patch,
);
if (destination.disabledAt)
  await outpost.destinations.enable(tenantId, subscriptionId);

ChatGPT sends the same secret on every refresh, but other clients may send a new one, so support rotation. With previousSecret and previousSecretInvalidAt set, Outpost dual-signs each delivery until the grace period ends: the webhook-signature header carries v1,<current> v1,<previous>. A receiver that still has only one of the two secrets keeps verifying deliveries during the switch.

A successful refresh is the subscriber's liveness signal, so re-enable the destination if Outpost disabled it after repeated failures. If the destination no longer exists, for example because someone deleted it in the Outpost dashboard, destinations.update throws NotFoundError, and the subscribe handler in Step 3 creates it again. The handler then writes the new expiresAt to your store.

To include the optional deliveryStatus in the refresh response, build it from the destination's disabledAt and its latest attempt, from outpost.destinations.listAttempts({ tenantId, destinationId, limit: 1 }). lastError must be one of the fixed categories (connection_refused, timeout, tls_error, http_4xx, http_5xx, challenge_failed), never raw response details.

Step 5: Implement events/unsubscribe

The agent unsubscribes with the same name, arguments and callback URL. Parse them the same way as subscribe, derive the subscription ID, and delete the destination:

// Parse with the same rules as subscribe: new URL() for delivery.url, arguments default to {}.
const id = deriveSubscriptionId(principal, url.href, name, args);

try {
  await outpost.destinations.delete(tenantIdFor(principal), id);
} catch (error) {
  if (!(error instanceof NotFoundError)) throw error; // already gone counts as deleted
}
store.delete(id);
return {};

OpenAI's guide makes unsubscribe idempotent: return {} even if the subscription doesn't exist. The sketch says -32011 (NotFound) instead; follow OpenAI for ChatGPT.

Step 6: Expire subscriptions

MCP Events subscriptions lapse at their TTL unless refreshed. Outpost destinations don't expire, so run a sweeper that deletes destinations whose grant has passed:

async function sweep() {
  for (const record of store.all()) {
    if (Date.parse(record.expiresAt) > Date.now()) continue;
    try {
      await deleteDestination(record.tenantId, record.id); // treats 404 as deleted, as in Step 5
      store.delete(record.id);
    } catch {
      // Outpost unreachable: keep the record and retry on the next sweep.
    }
  }
}

setInterval(() => void sweep(), 30_000).unref();

An expired subscription can still get an event until the next sweep runs, so pick an interval that's short relative to your TTLs. Some clients never unsubscribe, and expiry is then the only cleanup. ChatGPT is one of them when a user uninstalls your plugin (see Step 9).

Step 7: Publish events

Each delivery body must be an MCP event envelope: eventId, name, timestamp, data and cursor. Outpost sends the published data as the HTTP body unchanged, so publish the whole envelope as Outpost's data, and use the event ID as Outpost's event id. tenantsWithLiveSubscriptions returns the distinct tenant IDs of unexpired subscriptions to the event in your store:

for (const tenantId of tenantsWithLiveSubscriptions("order.created")) {
  const eventId = `evt_${order.orderId}_${sha256(tenantId).slice(0, 8)}`;
  const envelope = {
    eventId,
    name: "order.created",
    timestamp: order.createdAt,
    data: order,
    cursor: null,
  };
  const result = await outpost.publish({
    id: eventId,
    tenantId,
    topic: "order.created",
    eligibleForRetry: true,
    data: envelope,
  });
  // result.destinationIds lists the destinations whose filter matched.
}
  • The delivered body is the MCP event. No transformation sits between Outpost and the receiver.
  • webhook-id equals eventId. In Standard Webhooks mode, Outpost sends the event ID as webhook-id, and it stays the same across retries. That's what MCP Events requires for deduplication.
  • Filters address data.data.*. Outpost's data is the envelope, and the order is the envelope's data. That's why the filter in Step 3 is {"data": {"data": {...}}}.
  • One publish per tenant. outpost.publish() takes one tenantId, so publish once for each tenant with a live subscription to the event. Outpost then fans out to every matching destination in that tenant.
  • One event ID per tenant. Outpost uses the event ID as an idempotency key across the whole project. A second publish with the same ID, even to another tenant, returns duplicate: true and isn't delivered. Derive a distinct ID per tenant, as above.

Keep the envelope at or under 256 KiB, the size the SEP recommends.

To check that it worked, publish one order that matches a subscription's filter and one that doesn't. The first lists the destination in destinationIds and shows under Deliveries for the tenant in the Hookdeck dashboard. The second returns an empty destinationIds and is never sent.

Step 8: Verify deliveries on the receiving side

If you're building the receiver, for example in your own agent runtime or to test your server, verify each delivery with a Standard Webhooks library before trusting it. ChatGPT does this for you when it's the subscriber.

Give each subscription its own callback path, and record the secret against that path before you call events/subscribe. The verification challenge arrives before events/subscribe returns the subscription ID, so look secrets up by path, not by X-MCP-Subscription-Id:

app.post(
  "/mcp-events/:path",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body.toString("utf8");
    const secrets = secretsForPath(req.params.path); // current, plus previous during rotation
    const verified = secrets.some((secret) => {
      try {
        new Webhook(secret).verify(raw, {
          "webhook-id": req.header("webhook-id") ?? "",
          "webhook-timestamp": req.header("webhook-timestamp") ?? "",
          "webhook-signature": req.header("webhook-signature") ?? "",
        });
        return true;
      } catch {
        return false;
      }
    });
    if (!verified) return res.status(401).end();

    const body = JSON.parse(raw);
    if (body.type === "verification")
      return res.json({ challenge: body.challenge });
    if (alreadySeen(req.header("webhook-id"))) return res.status(200).end();
    enqueue(body); // persist first, process asynchronously
    res.status(200).end();
  },
);
  • Verify against the raw body bytes, not a re-serialized object.
  • The library rejects timestamps more than 5 minutes old, which the SEP recommends.
  • A body with a top-level type is a control envelope, such as the verification challenge. Anything else is an event.
  • Deduplicate on webhook-id, because retries can deliver the same event more than once.
  • Return 2xx only after you've persisted the event. MCP Events uses 410 for an event the receiver rejects and doesn't want retried, although Outpost currently retries it (see Known gaps).

Step 9: Test with ChatGPT

ChatGPT can subscribe to your server's events and run a task for each delivery. ChatGPT connects to your MCP server from OpenAI's cloud, so the server needs a public HTTPS URL, from a deployment or a tunnel.

  1. Turn on Developer mode, available on ChatGPT Plus or above. OpenAI's docs place the toggle under Settings > Security and login, and it has also appeared at the bottom of Settings > Plugins. If Add custom MCP server appears in the Add menu on the Plugins page, it's already on.
  2. Add your server. Go to Plugins, choose Add > Add custom MCP server, paste your server's /mcp URL, choose the authentication your server supports, and select Create as a plugin. ChatGPT calls server/discover, events/list and tools/list.
  3. Subscribe from a Work chat. MCP Events only run in Work chats, so start one, type @, pick your plugin, and ask it to subscribe, for example: "Subscribe to new orders of 100 USD or more. When one arrives, summarize the order in one sentence." ChatGPT maps the request to events/subscribe arguments, and the subscription becomes a task with the MCP event as its trigger.
  4. Trigger events. Create an order that matches and one that doesn't. The matching one shows in the Outpost dashboard under Deliveries for the principal's tenant. The other is filtered out and never sent.
  5. Check the results. Task runs appear under Scheduled in ChatGPT, not in the chat. The task's panel shows only the latest run; Open chat shows every run.
  6. Unsubscribe. When you delete the task in Scheduled, ChatGPT sends events/unsubscribe, and your server deletes the destination.

OpenAI's guide includes the full testing checklist, and testing the demo in October 2026 showed three more ChatGPT behaviors that aren't in its docs and affect how you build:

  • No ttlMs. ChatGPT doesn't suggest a lifetime, so your default TTL sets how often it refreshes.
  • No unsubscribe on uninstall. Removing the plugin stops the refreshes but sends no events/unsubscribe, so the subscription lives until its TTL and your sweeper deletes the destination.
  • Repeated requests are separate subscriptions. Asking to subscribe again creates a new callback URL, so one event then triggers two deliveries and two runs.

Known gaps

  • Non-retryable status codes. MCP Events and OpenAI say 410 Gone and 413 Payload Too Large must not be retried. Outpost currently retries every failed attempt until the retry schedule runs out. A short RETRY_SCHEDULE limits the extra attempts.
  • Delivery-time SSRF protection. MCP Events wants deliveries to block non-public addresses at connect time. Managed Outpost handles this: it blocks delivery connections to private and link-local addresses at the network level, so redirects and DNS rebinding to internal addresses fail. By default, the open source version of Outpost follows redirects and has no private-address blocklist, so a self-hosted deployment needs an SSRF-filtering proxy: on v1.6.0 or later, set DESTINATIONS_PROXY_URL. Your subscribe-time URL checks still apply either way.
  • Self-hosted versions. Run Outpost v1.6.0 or later, because earlier versions could keep signing with the old secret after a rotation on a busy destination.
  • Poll mode. Outpost stores only events that matched a destination, so an events/poll implementation needs its own event log. ChatGPT doesn't use poll.

Complete example

The MCP Events with Hookdeck Outpost demo is a working MCP server built this way, tested end to end against managed Outpost with ChatGPT as the subscriber. It calls Outpost through the same TypeScript SDK, and includes a test subscriber that answers challenges and verifies deliveries, a mock Outpost for offline tests, and notes on where the sketch and OpenAI's guide differ.

The files that map to the steps above: