Skip to content

Mercure active subscriptions

The hub can act as its own publisher: every time a subscription is created or terminated, it publishes an update describing what happened. The hub also exposes a REST API for snapshotting the current set of subscriptions.

Use these events and the API to show who is connected to a document or channel. Cursor positions are application data and require separate updates.

This feature is opt-in. Enable it in your Caddyfile:

mercure {
  subscriptions
  # ...
}

#Subscription events

When the feature is on, the hub publishes a private update each time a subscription opens or closes. The topic follows this pattern:

/.well-known/mercure/subscriptions/{match_type}/{match}/{subscriber}

{match_type}, {match}, and {subscriber} are percent-encoded values. Subscribe to those topics with relative URL Patterns: the spec lets URL Patterns be relative to the hub URL, which is exactly what you want here:

const url = new URL("https://hub.example.com/.well-known/mercure");
url.searchParams.append(
  "match_urlpattern",
  "/.well-known/mercure/subscriptions/:match_type/:match/:subscriber",
);
new EventSource(url, { withCredentials: true });

Each event's data is a JSON document:

{
  "id": "/.well-known/mercure/subscriptions/urlpattern/https%3A%2F%2Fexample.com%2F%3Aselector/urn%3Auuid%3Abb3de268-05b0-4c65-b44e-8f9acefc29d6",
  "type": "subscription",
  "match_type": "urlpattern",
  "match": "https://example.com/:selector",
  "subscriber": "urn:uuid:bb3de268-05b0-4c65-b44e-8f9acefc29d6",
  "active": true,
  "payload": { "username": "alice" }
}

Fields:

  • match, match_type: the matcher the subscriber registered.
  • subscriber: a hub-assigned identifier for the subscriber, shared by every subscription on the same connection.
  • active: true for new subscriptions, false for terminated ones.
  • payload: whatever the subscriber's token carried in the matching subscribe detail's payload (see Authorization).

Subscription events are always private. To receive them, the listening subscriber's token needs a subscribe grant covering the /.well-known/mercure/subscriptions/... topic family.

#Authorization for subscription events

This grant allows presence events for every topic on the hub:

{
  "authorization_details": [
    {
      "type": "https://mercure.rocks/authorization-detail",
      "actions": ["subscribe"],
      "topics": [
        {
          "match": "/.well-known/mercure/subscriptions/:match_type/:match/:subscriber",
          "match_type": "urlpattern"
        }
      ],
      "payload": { "username": "alice" }
    }
  ]
}

Tighten the matcher if a subscriber should only see presence for a specific document's topic:

{
  "match": "/.well-known/mercure/subscriptions/:match_type/https%3A%2F%2Fexample.com%2Fdocs%2F42/:subscriber",
  "match_type": "urlpattern"
}

#Subscription API

Once subscription events are enabled, the hub also exposes a JSON API. Use it to fetch the current set of subscriptions when a client connects, then keep it in sync via subscription events.

URLReturns
GET /.well-known/mercure/subscriptionsAll active subscriptions.
GET /.well-known/mercure/subscriptions/{match_type}/{match}Subscriptions for a specific matcher.
GET /.well-known/mercure/subscriptions/{match_type}/{match}/{subscriber}A single subscription.

The caller needs a subscribe grant covering the API request path. The event grant above does not cover the collection URL; add an exact grant for /.well-known/mercure/subscriptions, or the specific matcher collection you fetch. Grant collection access only to callers allowed to inspect those subscriptions.

Each response carries the reconciliation cursor as the last-event-id attribute of its rel="mercure" Link header, the same mechanism used at discovery time. Pass it to your SSE connection so you don't miss any subscription event between the snapshot and the live stream:

const resp = await fetch(
  "https://hub.example.com/.well-known/mercure/subscriptions",
  { credentials: "include" },
);
const lastEventId = resp.headers
  .get("Link")
  ?.match(/rel="mercure".*?last-event-id="([^"]*)"/)?.[1];
if (!resp.ok) throw new Error(`Subscription API failed: ${resp.status}`);
const snapshot = await resp.json();
renderSnapshot(snapshot.subscriptions);

const url = new URL("https://hub.example.com/.well-known/mercure");
url.searchParams.append(
  "match_urlpattern",
  "/.well-known/mercure/subscriptions/:match_type/:match/:subscriber",
);
if (lastEventId) url.searchParams.append("last_event_id", lastEventId);

const es = new EventSource(url, { withCredentials: true });
// Named subscription events do not trigger onmessage.
es.addEventListener("mercure", (e) => applyDelta(JSON.parse(e.data)));

The hub returns the cursor in the Link header and the subscriptions in the body:

Link: <https://hub.example.com/.well-known/mercure>; rel="mercure"; last-event-id="urn:uuid:5e94c686-2c0b-4f9b-958c-92ccc3bbb4eb"; type="mercure"; content-type="application/json"
{
  "id": "/.well-known/mercure/subscriptions",
  "type": "subscriptions",
  "subscriptions": [
    {
      "id": "/.well-known/mercure/subscriptions/urlpattern/https%3A%2F%2Fexample.com%2F%3Aselector/urn%3Auuid%3Abb3de268",
      "type": "subscription",
      "match": "https://example.com/:selector",
      "match_type": "urlpattern",
      "subscriber": "urn:uuid:bb3de268",
      "active": true,
      "payload": { "username": "alice" }
    }
  ]
}

The data is volatile. Treat it as a cache, validate freshness, and don't rely on collection responses being complete forever. Terminated subscriptions may be omitted or kept with active: false depending on the hub's policy.

#The subscriber identifier

The hub assigns the subscriber identifier (a random urn:uuid:) when a subscription opens; clients cannot choose it. This keeps subscriber identity out of the token's control and avoids leaking a token's sub to other subscribers. Every subscription on the same connection shares the identifier, but a new connection (a reconnect, another tab, another device) gets a new one.

To attach a stable, human-meaningful identity to a subscriber, put it in the subscribe detail's payload (a username, a user URL, an avatar). The payload travels through subscription events, so peers see who is present without an extra round-trip, while the opaque subscriber value stays unguessable.

#Building presence with Mercure subscription events

A minimal presence panel:

  1. On page load, fetch /.well-known/mercure/subscriptions/{match_type}/{matchOfTheDocument} to get who's currently here.
  2. Open an SSE connection to subscription events for that topic.
  3. On active: true, add the subscriber to the panel; on active: false, remove them.

Because the token's subscribe detail payload travels through subscription events, anything you put in there (username, avatar URL, role) is available to peers without an extra round-trip to your origin.

#Mercure subscription events performance

Subscription events are private updates like any other. They go through the hub's normal authorization pipeline. On a multi-thousand-subscriber hub with churn, the rate of subscription events can be significant; make sure the listeners that consume them have matchers narrow enough to receive only what they need.

#Disabling Mercure active subscriptions

Leave subscriptions out of the Caddyfile to disable presence events and the subscription API. Requests to the disabled API return 404.

The subscription API requires a configured subscriber verifier and a token granting access to the requested API path in every protocol mode, including v7 and v8 compatibility. Without a verifier, registry routes are disabled even when anonymous event subscriptions are allowed.