getting-startedBuild a partner app

Build a partner app

What this is: the complete path for a third-party developer building an app that integrates with Loop on behalf of users.

Who it’s for: developers at partner companies who got pointed at this site.

Time: 30 minutes to a working hello-world; 1–2 days to a polished integration.

The shape of a partner integration

  1. User clicks “Connect with Loop” in your app.
  2. Loop renders a consent screen listing the scopes you requested.
  3. User approves → your app gets an access token.
  4. Your app calls Loop services with the token.
  5. Loop pushes events to your webhook endpoint.

Step 1: Register your app

Sign in at https://developers.platform.loop.health and create an OAuth client:

FieldValue
Name”Your app name”
TypeWeb / Mobile / SPA
Redirect URIshttps://your-app.example.com/auth/loop/callback
ScopesPick from the scope reference
Webhook URLhttps://your-app.example.com/webhooks/loop (optional)

You get back:

  • client_id — public, embed in your app
  • client_secret — confidential (web apps only)
  • webhook_signing_secret — to verify webhook deliveries

Step 2: Implement the OAuth flow

The authorization_code + PKCE flow. Six steps.

2a. Generate PKCE verifier + challenge (in your backend or session storage)

import crypto from "node:crypto";
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
// Store `verifier` in session keyed by the `state` you'll send.

2b. Redirect user to /authorize

const url = new URL("https://identity.platform.loop.health/v1/oauth/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", process.env.LOOP_CLIENT_ID!);
url.searchParams.set("redirect_uri", "https://your-app.example.com/auth/loop/callback");
url.searchParams.set("scope", "openid profile read:biomarkers");
url.searchParams.set("state", randomState);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
res.redirect(url.toString());

2c. Handle the callback

User returns to your /auth/loop/callback?code=<...>&state=<...>. Verify state matches what you stored; if not, drop (CSRF).

2d. Exchange code for tokens

const res = await fetch("https://identity.platform.loop.health/v1/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: req.query.code,
    redirect_uri: "https://your-app.example.com/auth/loop/callback",
    client_id: process.env.LOOP_CLIENT_ID!,
    client_secret: process.env.LOOP_CLIENT_SECRET!,
    code_verifier: storedVerifier,
  }),
});
const tokens = await res.json(); // { access_token, refresh_token, expires_in, id_token, scope }

2e. Store tokens server-side keyed by your local user.

2f. When access_token expires (after 1 hour), refresh:

const res = await fetch("https://identity.platform.loop.health/v1/oauth/token", {
  method: "POST",
  body: new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: storedRefreshToken,
    client_id: process.env.LOOP_CLIENT_ID!,
    client_secret: process.env.LOOP_CLIENT_SECRET!,
  }),
});
const newTokens = await res.json();
// Save the NEW refresh_token — old one is now dead.

Skip the raw HTTP and use @platform/sdk instead:

import { LoopClient } from "@platform/sdk";
 
const loop = new LoopClient({
  clientId: process.env.LOOP_CLIENT_ID!,
  clientSecret: process.env.LOOP_CLIENT_SECRET!,
  redirectUri: "https://your-app.example.com/auth/loop/callback",
});
 
// In your /callback handler:
const tokens = await loop.exchangeCode({
  code: req.query.code,
  state: req.query.state,
  codeVerifier: storedVerifier,
});
 
// Anywhere afterwards:
import { createClinicalClient } from "@platform/sdk-clinical";
import { platformHost, SERVICE_NAMES } from "@platform/hosts";
 
const clinical = createClinicalClient({
  baseUrl: platformHost({ service: SERVICE_NAMES.CLINICAL }),
  accessToken: tokens.access_token,
});
 
const biomarkers = await clinical.listBiomarkers({ userId: tokens.sub });

SDK handles refresh, error normalization, retries, and idempotency keys.

Step 4: Receive webhooks

If you want push notifications for events (new biomarker, subscription change, etc.):

  1. Subscribe to the events you want in the developer portal.
  2. Implement your webhook endpoint:
import crypto from "node:crypto";
import { WEBHOOK_HEADERS, EVENT_NAMES } from "@platform/contracts";
 
app.post("/webhooks/loop", (req, res) => {
  const sig = req.header(WEBHOOK_HEADERS.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();
  }
 
  const eventId = req.header(WEBHOOK_HEADERS.EVENT_ID);
  if (await alreadyProcessed(eventId)) return res.status(200).end();
 
  const event = JSON.parse(rawBody);
  switch (event.type) {
    case EVENT_NAMES.CLINICAL_BIOMARKER_PARSED_V1:
      await handleBiomarker(event.data);
      break;
    // ... more event types
  }
 
  await markProcessed(eventId);
  res.status(200).end();
});

Full details: Webhooks.

Step 5: Handle errors

Always plan for:

  • invalid_token (401) — refresh and retry once.
  • insufficient_scope (403) — re-authorize asking for the missing scope.
  • rate_limited (429) — honor Retry-After, back off with jitter.
  • not_found (404) — the resource doesn’t exist OR isn’t visible to this user.

Full error reference: Errors.

Step 6: Apply for BAA (if you need PHI scopes)

Scopes like read:biomarkers, read:protocols, read:check_ins return PHI. You can’t complete the OAuth flow for these scopes until you’ve signed a BAA.

Apply at developers.platform.loop.health/baa. Admin review takes 1–3 business days.

Step 7: Go live

Before flipping production traffic:

  • Webhook signature verification implemented + tested
  • Token refresh implemented + tested
  • Error handling covers all platform error codes
  • Rate-limit backoff with jitter implemented
  • Logging excludes tokens and PHI
  • User has a way to disconnect (calls revoke endpoint)
  • BAA signed if requesting PHI scopes
  • Production redirect URI added to your app registration