API — Push and Webhooks

Two outbound notification paths, both driven by background workers. They are configurable per app and can be enabled simultaneously.

FCM push

Fired on every message.new and reaction.new. The push worker:

  1. Loads the app's FCM credentials and push_skip_online flag.
  2. Computes recipients: channel members minus the sender, and minus currently-online users when push_skip_online is enabled (presence from the online:<appId> Redis hash).
  3. Looks up the recipients' registered device tokens (POST /devices).
  4. Sends one FCM HTTP v1 data message per token, authenticated with the app's own service-account JSON (uploaded in the Dashboard — nothing FCM-related lives in server env vars).
  5. Prunes dead tokens (FCM 404 / UNREGISTERED) and increments the push_sent metric.

Delivery is retried up to 3 times with exponential backoff (1 s base).

Payload

The data message is the protocol's PushPayload — every value stringified. message.new:

{
  "type": "message.new",
  "cid": "messaging:pool-abc",
  "channel_id": "pool-abc",
  "channel_type": "messaging",
  "channel_name": "Pool ABC",     // omitted when the channel has no name
  "message_id": "01JF8...",
  "sender_id": "ada",
  "sender_name": "Ada",
  "text_preview": "hello!"        // first 140 characters
}

reaction.new — same shape, plus reaction_type in place of message_id's counterpart and text_preview describing the reacted-to message:

{
  "type": "reaction.new",
  "cid": "messaging:pool-abc",
  "channel_id": "pool-abc",
  "channel_type": "messaging",
  "channel_name": "Pool ABC",
  "message_id": "01JF8...",
  "reaction_type": "thumbs_up",
  "sender_id": "ada",
  "sender_name": "Ada",
  "text_preview": "hello!"        // first 140 chars of the message that was reacted to
}

reaction_type is one of the wire keys in REACTION_TYPES (thumbs_up, thumbs_down, heart, hundred, check, exclamation, laughing); clients map it to an emoji via each SDK's REACTION_EMOJI table (👍 👎 ❤️ 💯 ✅ ❗ 😂 respectively).

Client-side helpers: @loopchat/react-native's registerPushDevice / parsePushPayload, loopchat_flutter's parsePushPayload.

What the client receives

FCM messages are sent data-only — there is no notification block, so nothing appears automatically on Android. What each platform gets:

  • iOS: the worker also attaches an APNs aps.alert alongside the data payload (title = sender name, subtitle = channel name when set, body = the text preview, or "Reacted 👍 to: …" for reactions, with the emoji already rendered — see apnsAlert in server/src/worker/fcm.ts). APNs shows this as a banner with no client code required; thread-id is set to the cid so notifications from the same channel group together.
  • Android: the APNs block is ignored entirely and FCM delivers only the data payload. The app is responsible for showing anything: handle the message in a background handler (e.g. messaging().setBackgroundMessageHandler in RN, or the platform channel equivalent in Flutter), post a local notification through a notification channel you've created, and request the POST_NOTIFICATIONS runtime permission on Android 13+. Tap handling (deep-linking into channel_id/channel_type) is also on the app — the data payload carries what's needed to navigate there.

See JS SDK — React Native and Flutter SDK for the client helpers that parse this payload.

Transports

PUSH_TRANSPORT=fcm (default) sends real pushes. PUSH_TRANSPORT=log records them to a Redis list instead — inspect with LRANGE push:log:<appId> 0 -1 (24 h TTL) — used in dev/test and by the SDK live-e2e suites.

Webhooks

The "handle notifications your own way" path: per-app HTTPS endpoints receiving every event (not just messages), with event-type filtering. Manage webhooks via the Dashboard UI.

For each emitted event, the server matches enabled webhooks whose event_types filter is empty (= all events) or contains the event's type, records a row in webhook_deliveries, and enqueues delivery.

Request

Your endpoint receives a POST with a 10-second timeout:

POST <webhook url>
content-type: application/json
x-loopchat-signature: <hex HMAC-SHA256 of the raw body, keyed by the webhook secret>

Body — the protocol's WebhookEnvelope:

{
  "delivery_id": "01JF8...",
  "app_id": "app_...",
  "event": { "type": "message.new", "cid": "messaging:pool-abc", "message": { ... }, ... },
  "sent_at": "2026-07-08T12:00:00.000Z"
}

delivery_id is unique per delivery — use it for idempotent handling (retries and manual redelivery reuse it).

Verifying the signature

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody: string, header: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  return timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}

The secret (whsec_…) is generated when the webhook is created and visible to project editors/owners in the Dashboard.

Retries and delivery log

Any non-2xx response or network error counts as a failure. Deliveries are attempted up to 5 times with exponential backoff (2 s base); the delivery row tracks status (pending → delivered | failed), attempts, last_error, and delivered_at. The Dashboard exposes the last 100 deliveries per webhook and a redeliver action (3 fresh attempts) for failed ones.

Respond 2xx quickly (within 10 s) and do the real work asynchronously.