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. 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:

{
  "type": "message.new",
  "cid": "messaging:pool-abc",
  "channel_id": "pool-abc",
  "channel_type": "messaging",
  "message_id": "01JF8...",
  "sender_id": "ada",
  "sender_name": "Ada",
  "text_preview": "hello!"        // first 140 characters
}

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

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 (pendingdelivered | 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.