ConnectWebhooks

Webhooks

Webhooks let your app react to events on a user’s Loop account without polling.

Subscribing

In the developer portal, configure:

  • URL — your HTTPS endpoint that accepts POSTs.
  • Event filters — which events you want. You can only subscribe to events backed by a scope the user granted you.
  • Signing secret — auto-generated; used to verify every delivery.

Event format

Every delivery is a POST with a JSON body:

{
  "id": "evt_01HXY...",
  "type": "clinical.biomarker.parsed.v1",
  "user_id": "user_01HXY...",
  "client_id": "client_01HXY...",
  "occurred_at": "2026-05-22T12:34:56Z",
  "data": { ... event-specific payload ... }
}

Headers:

HeaderMeaning
X-Loop-Event-Iddedupe key
X-Loop-Event-Typesame as type in body
X-Loop-SignatureHMAC-SHA256 of body with your signing secret
X-Loop-Delivery-Attempt1-indexed retry count

Signature verification

import crypto from "node:crypto";
 
const sig = req.header("X-Loop-Signature");
const expected = crypto
  .createHmac("sha256", process.env.LOOP_WEBHOOK_SECRET)
  .update(rawBody)
  .digest("hex");
 
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
  return res.status(401).end();
}

Always use timingSafeEqual — string equality leaks timing information.

Using typed constants

Instead of string literals, use the typed constants from @platform/contracts:

import { WEBHOOK_HEADERS, EVENT_NAMES } from "@platform/contracts";
 
// Headers
const sig = req.header(WEBHOOK_HEADERS.SIGNATURE);   // "X-Loop-Signature"
const eventId = req.header(WEBHOOK_HEADERS.EVENT_ID); // "X-Loop-Event-Id"
const type = req.header(WEBHOOK_HEADERS.EVENT_TYPE);  // "X-Loop-Event-Type"
 
// Event names in your handler
if (type === EVENT_NAMES.CLINICAL_BIOMARKER_PARSED_V1) {
  // …
}

The constants are typed — autocomplete helps, and typos are caught at build time.

Delivery semantics

  • At-least-once. You may receive the same event twice; dedupe on id.
  • Ordered per user. Events for one user arrive in the order they happened.
  • Retried with back-off if your endpoint returns non-2xx. Retries: 1m, 5m, 30m, 2h, 12h, then dead-lettered.
  • Dead letter — after the final retry, the event is moved to your DLQ. You can replay from the developer portal.

What you can subscribe to

You can only subscribe to events whose underlying scope is in your active grant set. Examples:

EventScope required
clinical.biomarker.parsed.v1read:biomarkers
clinical.protocol.started.v1read:protocols
clinical.red_flag.detected.v1read:red_flags
payment.charged.v1read:payments
subscription.created.v1read:subscriptions
subscription.cancelled.v1read:subscriptions
community.post.created.v1read:community
follows.created.v1read:follows
membership.tier.changed.v1read:membership
comms.message.sent.v1read:inbox

The full list is in the contracts package. New events show up after the corresponding scope is granted.

Replay

POST /v1/webhooks/<event_id>/replay in the developer portal redelivers a specific event. Useful for backfilling missed deliveries.