Skip to main content

Realtime event channels

Recorded events reach live listeners through Supabase Realtime Broadcast. PostgreSQL remains the system of record, and SQS remains the durable job boundary. Broadcast does not persist, retry, acknowledge, or replay a message. A listener that was offline when an event was published does not receive it after reconnecting, and a publish that the provider accepts with no current listeners still succeeds.

The API is the only publisher. It sends HTTP requests with fetch and does not open a Realtime client. The website may subscribe as one signed-in application user. No Telegram bot process subscribes yet, and no feature command publishes an event yet.

Two audiences, one request​

A publish carries exactly one application user id and one versioned envelope. The user id is the address. It is not a field inside the envelope.

The API posts both deliveries together to REALTIME_PUBLISH_URL. It sends the server publish secret (REALTIME_SECRET_KEY) as Authorization: Bearer and as the apikey header. The local API gateway accepts that secret only in apikey.

TopicWho may joinPayload
user:<applicationUserId>The website session for that application userThe event envelope
bot:telegramA future trusted server process using the publish secretThe same envelope

Both messages are private, and the broadcast event name is envelope. The application user id must be a UUID. An invalid id or envelope is rejected before any request is sent.

The envelope itself stays in packages/contracts/src/realtime/. That folder is the shared notification contract, not a feature slice.

FieldConstraint
eventIdNon-empty string
nameLowercase kebab-case string
versionPositive integer
occurredAtParseable date-time string containing T
payloadJSON value owned by the feature that records the event

There is no concrete feature event yet. When a slice records one, it owns the payload contract and publishes only after its save succeeds. A failed save publishes nothing. If the provider then rejects the request, the publish fails and the earlier database write is not rolled back. Callers that retry must keep that write idempotent. The API cannot tell whether the provider delivered one of the two messages before rejecting the request, and it does not replay the missed one.

Logs for a rejected or unreachable publish name the failure and, for an HTTP error, the status code. They omit the publish secret, the URL userinfo, and the event payload.

Who may subscribe​

A migration adds one select policy on realtime.messages for the authenticated role: the topic must equal user: concatenated with auth.uid(). There is no policy that lets that role read bot:telegram or system:health. The migration does not enable the Data API and does not grant access to business tables.

The website credential is how auth.uid() becomes the application user id. The users slice resolves the signed-in Supabase Auth session to an application user, then signs a JWT with the private key in REALTIME_JWT_SECRET:

  • protected header: alg ES256, kid from that key, typ JWT;
  • sub: the application user UUID;
  • role and aud: authenticated;
  • exp: five minutes after issue.

That key must be the private half of the project's current ES256 signing key. Supabase Auth access-token verification still uses the project JWKS and does not use this key. An anonymous caller receives no credential. The response is the token, the topic user:<id>, and the expiry. It does not include the publish secret or the bot topic. See Live user events for what the website does with that credential.

The publish secret bypasses the select policy. It stays on the API. This change does not place it in a bot.

Startup and shutdown​

Before the API accepts HTTP connections, it publishes one discarded probe to the private topic system:health. The probe event name is probe and the payload is a JSON object whose only field, discard, is true. It is not an event envelope and is not replayed. Website tokens cannot join that topic. If the publisher cannot be reached or rejects the secret, the process exits non-zero without listening, and the log does not include the secret or URL userinfo.

Shutdown closes Fastify, the SQS client, the database, analytics, and telemetry. Broadcast publishes are individual requests, so there is no connection to close. Repeated close requests share the same cleanup.

REDIS_URL is not read. The repository Compose file does not run Redis or a realtime server. Local and deployed API processes both use the configured Supabase Realtime endpoint. See Environment variables for the accepted values.