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:
- Loads the app's FCM credentials and
push_skip_onlineflag. - Computes recipients: channel members minus the sender, and minus currently-online users when
push_skip_onlineis enabled (presence from theonline:<appId>Redis hash). - Looks up the recipients' registered device tokens (
POST /devices). - 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).
- Prunes dead tokens (FCM 404 /
UNREGISTERED) and increments thepush_sentmetric.
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 (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.