Introduction

The WearLink API is a REST API for collecting, normalising, and querying wearable health data across multiple providers — Garmin, Oura, Fitbit, WHOOP, Polar, Strava, plus Apple Health, Google Fit, and Samsung Health via SDK — through a single consistent interface.

Base URL

https://wearlink.io/api/v1

Every endpoint below is rooted here. Responses are JSON; all timestamps are RFC 3339 UTC. Auth is either a server-side API key in the X-WearLink-API-Key header, or a dashboard JWT from POST /auth/login. Rate-limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) ride on every response.

Authentication

Two mechanisms are supported. Server-to-server integrations use an API key. Developer dashboard operations (managing webhooks, settings, team) use a short-lived JWT.

API key — server-side calls

Pass your key in the X-WearLink-API-Key header. Keys are managed in Settings → Credentials.

bash
curl https://wearlink.io/api/v1/users \
  -H "X-WearLink-API-Key: sk-your-api-key-here"
python
import httpx

client = httpx.Client(
    base_url="https://wearlink.io/api/v1",
    headers={"X-WearLink-API-Key": "sk-your-api-key-here"},
)
users = client.get("/users").json()
javascript
const BASE = "https://wearlink.io/api/v1";
const HEADERS = { "X-WearLink-API-Key": "sk-your-api-key-here" };

const users = await fetch(`${BASE}/users`, { headers: HEADERS })
  .then(r => r.json());

JWT — developer session

POST credentials to /auth/login with multipart/form-data. The token expires in 60 minutes by default.

bash
# Login — returns { "access_token": "eyJ...", "expires_in": 3600 }
curl -X POST https://wearlink.io/api/v1/auth/login \
  -F "username=you@example.com" \
  -F "password=your-password"

# Use the token
curl https://wearlink.io/api/v1/api/v1/auth/me \
  -H "Authorization: Bearer eyJ..."

Managing API keys

GET
/developer/api-keys

List all API keys

JWT
POST
/developer/api-keys

Create a new key — returns full key once

JWT
POST
/developer/api-keys/{key_id}/rotate

Rotate a key (delete + create)

JWT
DELETE
/developer/api-keys/{key_id}

Permanently delete a key

JWT
bash
# Create a named API key
curl -X POST https://wearlink.io/api/v1/developer/api-keys \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{"name": "Production server"}'

# Response
{
  "id": "sk-a1b2c3d4e5f6...",
  "name": "Production server",
  "created_at": "2026-04-23T10:00:00Z"
}

Quick Start

From zero to real health data in four steps.

1

Create your account

Sign up — a default API key is created automatically. Copy it from Settings → Credentials.

2

Create an end-user

bash
curl -X POST https://wearlink.io/api/v1/users \
  -H "X-WearLink-API-Key: sk-your-key" \
  -H "Content-Type: application/json" \
  -d '{"first_name": "Jane", "last_name": "Doe", "email": "jane@example.com"}'

# { "id": "550e8400-...", "first_name": "Jane", "email": "jane@example.com", ... }
3

Get an SDK token and connect a provider

Generate a short-lived token for the user, then pass it to your mobile SDK or the hosted connect widget.

bash
curl -X POST https://wearlink.io/api/v1/users/USER_ID/token \
  -H "X-WearLink-API-Key: sk-your-key"

# { "access_token": "eyJ...", "token_type": "bearer", "expires_in": 3600 }
4

Fetch normalised health data

bash
# Workout sessions
curl "https://wearlink.io/api/v1/users/USER_ID/events/workouts?start_date=2026-04-01&end_date=2026-04-23" \
  -H "X-WearLink-API-Key: sk-your-key"

# Sleep sessions
curl "https://wearlink.io/api/v1/users/USER_ID/events/sleep?start_date=2026-04-01&end_date=2026-04-23" \
  -H "X-WearLink-API-Key: sk-your-key"

Provider Setup

How provider credentials work on WearLink today: each provider (Garmin, Oura, Fitbit, WHOOP, Polar, Strava) requires an OAuth app registered with the vendor. On paid plans we provision those credentials for your workspace — you've registered and we'll wire them in within one business day. A self-service credential manager is on the roadmap. Apple Health, Google Fit, and Samsung Health use a mobile SDK model and do not require OAuth setup.

The guides below walk through registering an app with each vendor so you can obtain a Client ID and Client Secret. Your callback URL in every provider's dashboard is {YOUR_BACKEND_URL}/v1/providers/{provider}/callback — substitute {provider} with the slug listed for each vendor.

Garmin (slug: garmin)

  1. Go to developerportal.garmin.com and apply for Health API access.
  2. Once approved, create a new app in the Connect Developer Program.
  3. Set the OAuth callback to {YOUR_BACKEND_URL}/v1/providers/garmin/callback.
  4. Copy the Consumer Key and Consumer Secret into WearLink's Settings → Providers → Garmin.
  5. Configure the webhook ping URL to {YOUR_BACKEND_URL}/v1/webhooks/garmin.

Oura (slug: oura)

  1. Sign in at cloud.ouraring.com/oauth/applications and create a new application.
  2. Set the redirect URI to {YOUR_BACKEND_URL}/v1/providers/oura/callback.
  3. Request scopes: email personal daily heartrate workout session spo2Daily tag.
  4. Copy the Client ID and Client Secret into Settings → Providers → Oura.

Fitbit (slug: fitbit)

  1. Register an app at dev.fitbit.com/apps/new.
  2. Choose OAuth 2.0 Application Type: Server.
  3. Set the callback URL to {YOUR_BACKEND_URL}/v1/providers/fitbit/callback.
  4. Select scopes: activity heartrate location nutrition profile settings sleep weight.
  5. Copy the OAuth 2.0 Client ID and Client Secret into Settings → Providers → Fitbit.

WHOOP (slug: whoop)

  1. Apply for developer access at developer.whoop.com.
  2. Create a new app once approved.
  3. Set the redirect URI to {YOUR_BACKEND_URL}/v1/providers/whoop/callback.
  4. Request scopes: read:recovery read:cycles read:sleep read:workout read:profile read:body_measurement.
  5. Copy the Client ID and Client Secret into Settings → Providers → WHOOP.

Polar (slug: polar)

  1. Sign in at admin.polaraccesslink.com and register a new application.
  2. Choose AccessLink (not Training Center).
  3. Set the callback URL to {YOUR_BACKEND_URL}/v1/providers/polar/callback.
  4. Copy the Client ID and Client Secret into Settings → Providers → Polar.

Strava (slug: strava)

  1. Go to strava.com/settings/api and create an API application.
  2. Set the Authorization Callback Domain to your backend's host (e.g. api.yourdomain.com).
  3. The full callback URL will be {YOUR_BACKEND_URL}/v1/providers/strava/callback.
  4. Copy the Client ID and Client Secret into Settings → Providers → Strava.
  5. Configure the push-subscription endpoint at {YOUR_BACKEND_URL}/v1/webhooks/strava.

Apple Health / Samsung Health / Google Fit (SDK)

These providers do not use OAuth redirects. Instead, integrate the WearLink mobile SDK into your iOS or Android app — the SDK reads data from HealthKit, Samsung Health, or Google Fit locally and pushes it to POST /sdk/users/{user_id}/sync. See the Apple Health SDK section below for the full integration flow.

Users

A user represents one of your end-users. Each user can connect multiple providers and accumulates health data that you can query via the data endpoints below.

GET
/users

List users — supports ?page, ?limit, ?search, ?sort_by

API Key
POST
/users

Create a user

API Key
GET
/users/{user_id}

Get a single user by UUID

API Key
PATCH
/users/{user_id}

Update name or email

JWT
DELETE
/users/{user_id}

Delete user and all their data

JWT
GET
/users/{user_id}/connections

List active provider connections

API Key
DELETE
/users/{user_id}/connections/{provider}

Disconnect a provider

API Key

Create a user

python
import httpx

client = httpx.Client(
    base_url="https://wearlink.io/api/v1",
    headers={"X-WearLink-API-Key": "sk-your-key"},
)

user = client.post("/users", json={
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane@example.com",
}).json()
# { "id": "550e8400-e29b-41d4-a716-...", "first_name": "Jane", ... }

connections = client.get(f"/users/{user['id']}/connections").json()
# [{ "provider": "garmin", "status": "connected", "connected_at": "..." }, ...]

User data summary

Get a count breakdown of all data stored for a user, grouped by series type and provider.

bash
curl https://wearlink.io/api/v1/users/USER_ID/summaries/data \
  -H "X-WearLink-API-Key: sk-your-key"

Health Data

Health data comes in two flavours: events (discrete sessions like workouts and sleep) and summaries (daily aggregates of activity, sleep stages, and body metrics). All endpoints require a date range.

Workout events

GET
/users/{user_id}/events/workouts

Discrete workout sessions — running, cycling, yoga, etc.

API Key
GET
/users/{user_id}/events/sleep

Discrete sleep sessions including naps

API Key

Required: start_date, end_date. Optional: cursor, limit (1–100, default 50).

bash
curl "https://wearlink.io/api/v1/users/USER_ID/events/workouts?start_date=2026-04-01&end_date=2026-04-23&limit=20" \
  -H "X-WearLink-API-Key: sk-your-key"

# Response (truncated)
{
  "data": [{
    "id": "uuid",
    "type": "running",
    "start_time": "2026-04-15T06:00:00Z",
    "end_time": "2026-04-15T06:35:00Z",
    "duration_seconds": 2100,
    "distance_meters": 5200,
    "calories_kcal": 342,
    "avg_heart_rate_bpm": 145,
    "max_heart_rate_bpm": 165,
    "avg_pace_sec_per_km": 404,
    "elevation_gain_meters": 85,
    "source": { "provider": "garmin", "device": "Fenix 7" }
  }],
  "pagination": { "next_cursor": "eyJ...", "has_more": true, "total_count": 312 }
}

Sleep events

bash
curl "https://wearlink.io/api/v1/users/USER_ID/events/sleep?start_date=2026-04-01&end_date=2026-04-23" \
  -H "X-WearLink-API-Key: sk-your-key"

# Response
{
  "data": [{
    "id": "uuid",
    "start_time": "2026-04-14T22:30:00Z",
    "end_time": "2026-04-15T06:45:00Z",
    "duration_seconds": 29700,
    "sleep_duration_seconds": 27900,
    "efficiency_percent": 93.9,
    "is_nap": false,
    "stages": {
      "awake_minutes": 30,
      "light_minutes": 108,
      "deep_minutes": 180,
      "rem_minutes": 147
    },
    "avg_heart_rate_bpm": 56,
    "avg_hrv_sdnn_ms": 48.5,
    "avg_respiratory_rate": 14.1,
    "avg_spo2_percent": 97.2,
    "source": { "provider": "oura", "device": "Oura Ring Gen3" }
  }],
  "pagination": { "has_more": false, "total_count": 22 }
}

Daily summaries

GET
/users/{user_id}/summaries/activity

Daily activity totals — steps, calories, distance, heart rate

API Key
GET
/users/{user_id}/summaries/sleep

Daily sleep metrics — stages, efficiency, interruptions

API Key
GET
/users/{user_id}/summaries/body

Body metrics — weight, BMI, resting HR, HRV, temperature, blood pressure

API Key
python
# Activity summary
r = client.get("/users/USER_ID/summaries/activity", params={
    "start_date": "2026-04-01",
    "end_date": "2026-04-23",
})
for day in r.json()["data"]:
    print(day["date"], day["steps"], day["active_calories_kcal"])

# Body summary (latest + 7-day averages)
body = client.get("/users/USER_ID/summaries/body").json()
print(body["averaged"]["resting_heart_rate_bpm"])
print(body["latest"]["weight_kg"])

Timeseries

The timeseries endpoint returns granular biometric samples — raw data points as recorded by the device. Use this when you need heart rate data every few seconds, minute-by-minute SpO₂, or full HRV traces.

GET
/users/{user_id}/timeseries

Granular time-stamped biometric samples

API Key

Query parameters

start_timerequiredISO 8601 datetime (e.g. 2026-04-15T00:00:00Z)
end_timerequiredISO 8601 datetime
typesoptionalComma-separated series types — see list below
resolutionoptionalraw | 1min | 5min | 15min | 1hour (default raw)
cursoroptionalPagination cursor from previous response
limitoptional1–100, default 50

Available series types

heart_rateheart_rate_variabilityresting_heart_ratespo2respiratory_ratebody_temperaturestepscaloriesdistanceblood_glucoseblood_pressurevo2_maxrunning_powercycling_powerstressbody_composition+ 80 more
bash
curl "https://wearlink.io/api/v1/users/USER_ID/timeseries?start_time=2026-04-15T00:00:00Z&end_time=2026-04-15T23:59:59Z&types=heart_rate,spo2&resolution=5min" \
  -H "X-WearLink-API-Key: sk-your-key"

# Response
{
  "data": [
    {
      "id": "uuid",
      "timestamp": "2026-04-15T06:05:00Z",
      "series_type": "heart_rate",
      "value": 72.5,
      "unit": "bpm",
      "source": { "provider": "apple", "device": "Apple Watch Series 9" }
    }
  ],
  "pagination": { "next_cursor": "eyJ...", "has_more": true, "total_count": 1440 }
}

Health Scores

Pre-computed wellness scores derived from multiple data sources. Includes sleep quality, recovery, readiness, stress, and VO₂ max estimates.

GET
/users/{user_id}/health-scores

List health scores with optional date range and category filter

API Key

Query parameters

start_dateoptionalFilter from date (YYYY-MM-DD)
end_dateoptionalFilter to date
categoryoptionalsleep | recovery | readiness | stress | vo2max | fitness_age
provideroptionalFilter by source provider
limitoptional1–1000, default 50
offsetoptionalOffset-based pagination, default 0
bash
curl "https://wearlink.io/api/v1/users/USER_ID/health-scores?category=sleep&start_date=2026-04-01&end_date=2026-04-23" \
  -H "X-WearLink-API-Key: sk-your-key"

# Response
{
  "data": [{
    "id": "uuid",
    "date": "2026-04-15",
    "category": "sleep",
    "score": 83,
    "range_min": 0,
    "range_max": 100,
    "provider": "oura"
  }],
  "pagination": { "total_count": 22, "has_more": false }
}

Apple Health & SDK Integration

Apple Health uses a push-based SDK model — your iOS app reads HealthKit data and sends it to WearLink. There is no OAuth flow. This section covers the complete integration.

To receive Apple Watch and iPhone health data, integrate the WearLink iOS SDK into your app. The SDK reads HealthKit data locally and syncs it to POST /sdk/users/{user_id}/sync.

Step 1 — Generate an SDK token for the user

From your backend, request a short-lived SDK token for the user. Pass this token to your iOS app.

bash
curl -X POST https://wearlink.io/api/v1/users/USER_ID/token \
  -H "X-WearLink-API-Key: sk-your-key"

# { "access_token": "eyJ...", "token_type": "bearer", "expires_in": 3600 }
python
# In your backend — generate token per-user on demand
token = httpx.post(
    f"https://wearlink.io/api/v1/users/{user_id}/token",
    headers={"X-WearLink-API-Key": "sk-your-key"},
).json()["access_token"]
# Send this token to your iOS app securely

Step 2 — Request HealthKit permissions (iOS)

swift
import HealthKit

let healthStore = HKHealthStore()

// Request permissions for the data types you want to sync
let readTypes: Set<HKObjectType> = [
    HKQuantityType.quantityType(forIdentifier: .heartRate)!,
    HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!,
    HKQuantityType.quantityType(forIdentifier: .stepCount)!,
    HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
    HKQuantityType.quantityType(forIdentifier: .oxygenSaturation)!,
    HKObjectType.workoutType(),
    HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!,
]

healthStore.requestAuthorization(toShare: [], read: readTypes) { success, error in
    if success { startSync() }
}

Step 3 — Sync HealthKit data to the API

Use the SDK token to authenticate the sync call. The endpoint accepts batches of records, sleep stages, and workouts in a single request.

bash
curl -X POST https://wearlink.io/api/v1/sdk/users/USER_ID/sync \
  -H "Authorization: Bearer SDK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "apple",
    "sdkVersion": "1.0.0",
    "syncTimestamp": "2026-04-23T08:00:00Z",
    "data": {
      "records": [
        {
          "id": "hk-record-uuid",
          "type": "HKQuantityTypeIdentifierHeartRate",
          "startDate": "2026-04-23T06:30:00Z",
          "endDate":   "2026-04-23T06:30:10Z",
          "value": 72,
          "unit": "count/min",
          "source": {
            "deviceName": "Apple Watch",
            "deviceModel": "Watch6,3",
            "deviceType": "watch",
            "operatingSystemVersion": { "majorVersion": 10, "minorVersion": 3, "patchVersion": 1 }
          }
        }
      ],
      "workouts": [
        {
          "id": "hk-workout-uuid",
          "type": "running",
          "startDate": "2026-04-23T06:00:00Z",
          "endDate":   "2026-04-23T06:35:00Z",
          "values": [
            { "type": "distance", "value": 5200, "unit": "m" },
            { "type": "activeEnergyBurned", "value": 342, "unit": "kcal" },
            { "type": "averageHeartRate", "value": 145, "unit": "count/min" }
          ],
          "source": { "deviceName": "Apple Watch", "deviceModel": "Watch6,3", "deviceType": "watch" }
        }
      ],
      "sleep": [
        {
          "id": "hk-sleep-uuid",
          "stage": "asleep_deep",
          "startDate": "2026-04-22T23:15:00Z",
          "endDate":   "2026-04-23T01:00:00Z",
          "source": { "deviceName": "Apple Watch", "deviceModel": "Watch6,3", "deviceType": "watch" }
        }
      ]
    }
  }'

# Returns HTTP 202 — processing is asynchronous
# { "status_code": 202, "response": "Import task queued successfully", "user_id": "..." }

Supported Apple Watch data types

Heart & Cardiovascular

HKQuantityTypeIdentifierHeartRate
HKQuantityTypeIdentifierRestingHeartRate
HKQuantityTypeIdentifierHeartRateVariabilitySDNN
HKQuantityTypeIdentifierOxygenSaturation
HKQuantityTypeIdentifierHeartRateRecoveryOneMinute

+ more

Activity & Fitness

HKQuantityTypeIdentifierStepCount
HKQuantityTypeIdentifierActiveEnergyBurned
HKQuantityTypeIdentifierAppleExerciseTime
HKQuantityTypeIdentifierVO2Max
HKQuantityTypeIdentifierFlightsClimbed

+ more

Body Composition

HKQuantityTypeIdentifierBodyMass
HKQuantityTypeIdentifierBodyMassIndex
HKQuantityTypeIdentifierBodyFatPercentage
HKQuantityTypeIdentifierLeanBodyMass
HKQuantityTypeIdentifierBodyTemperature

+ more

Running / Cycling

HKQuantityTypeIdentifierRunningPower
HKQuantityTypeIdentifierRunningSpeed
HKQuantityTypeIdentifierRunningStrideLength
HKQuantityTypeIdentifierCyclingPower
HKQuantityTypeIdentifierCyclingCadence

+ more

Sleep stages

in_bedsleepingawakeasleep_lightasleep_deepasleep_remunknown

Workout types (150+)

All major HKWorkoutActivityType values are supported:

runningcyclingswimmingyogahikingbasketballsoccertennisrowingskiingstrength_traininghiitpilatesboxinggolfdancecrossfittriathlonwalking+ 130 more

Apple Health XML export

Users can also export all historical Apple Health data as an XML archive (Health app → profile → Export All Health Data) and upload it directly.

POST
/users/{user_id}/import/apple/xml/direct

Upload XML file directly (multipart/form-data)

API Key
POST
/users/{user_id}/import/apple/xml/s3

Get presigned S3 URL for large XML uploads

API Key
bash
# Direct upload (up to 50 MB)
curl -X POST https://wearlink.io/api/v1/users/USER_ID/import/apple/xml/direct \
  -H "X-WearLink-API-Key: sk-your-key" \
  -F "file=@export.xml"

# For large files: get a presigned S3 URL first
curl -X POST https://wearlink.io/api/v1/users/USER_ID/import/apple/xml/s3 \
  -H "X-WearLink-API-Key: sk-your-key" \
  -H "Content-Type: application/json" \
  -d '{"filename": "export.xml", "expiration_seconds": 300}'

Webhooks

Subscribe to events and WearLink will POST a signed JSON payload to your endpoint whenever new data is ingested. Backed by Svix — deliveries are retried automatically, and you get per-endpoint delivery logs in the dashboard.

Managing endpoints

GET
/webhooks/endpoints

List your webhook endpoints

JWT
POST
/webhooks/endpoints

Create a new endpoint

JWT
GET
/webhooks/endpoints/{id}

Get endpoint details

JWT
PATCH
/webhooks/endpoints/{id}

Update URL or filters

JWT
DELETE
/webhooks/endpoints/{id}

Delete an endpoint

JWT
GET
/webhooks/endpoints/{id}/secret

Get signing secret for verification

JWT
POST
/webhooks/endpoints/{id}/test

Send a test event

JWT
GET
/webhooks/endpoints/{id}/attempts

List delivery attempts

JWT
GET
/webhooks/event-types

List all available event types

Public
bash
# Create an endpoint filtering to workout and sleep events only
curl -X POST https://wearlink.io/api/v1/webhooks/endpoints \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/hooks/wearlink",
    "description": "Production webhook",
    "filter_types": ["workout.created", "sleep.created", "connection.created"]
  }'

# { "id": "ep_01abc...", "url": "https://yourapp.com/hooks/...", ... }

Event types

Events are organised in two levels. Subscribe to a session event or group event to receive all related data, or subscribe to individual granular series events for specific metrics.

Loading event summary…
Granular events (e.g. series.heart_rate.created, series.vo2_max.created) are also available for every individual metric type. Call GET /webhooks/event-types to get the full list of 80+ granular events.

Payload verification

Every delivery is signed with your endpoint secret. Verify using the Svix library or manually.

python
from svix.webhooks import Webhook
from fastapi import Request, HTTPException

async def handle_webhook(request: Request):
    secret = "YOUR_ENDPOINT_SECRET"  # from GET /webhooks/endpoints/{id}/secret
    wh = Webhook(secret)

    try:
        payload = wh.verify(await request.body(), dict(request.headers))
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid signature")

    event_type = payload["type"]
    data = payload["data"]

    if event_type == "workout.created":
        print(f"New workout: {data['type']} for user {data['user_id']}")
    elif event_type == "sleep.created":
        print(f"Sleep score: {data.get('efficiency_percent')}%")
javascript
import { Webhook } from "svix";

app.post("/webhooks/wearlink", express.raw({ type: "application/json" }), (req, res) => {
  const wh = new Webhook(process.env.WEBHOOK_SECRET);

  let payload;
  try {
    payload = wh.verify(req.body, req.headers);
  } catch (err) {
    return res.status(400).json({ error: "Invalid signature" });
  }

  const { type, data } = payload;
  console.log("Event:", type, "User:", data.user_id);
  res.json({ ok: true });
});

Event Catalog

Every webhook event you can subscribe to, pulled live from the running API so this page never drifts from the server's reality. Names are stable — subscribing to a group event like workout.* delivers every child event below it.

Loading event catalog…

Apple Health XML Import

iOS users can export their full Apple Health archive as a ZIP from the Health app and upload it to backfill years of historical data without going through HealthKit's permission dance again. Two ingestion paths are supported.

Direct upload (small archives, ≤50 MB)

POST /api/v1/users/{user_id}/import/apple/xml/direct
Content-Type: multipart/form-data; boundary=...

(file=export.zip)
# 202 Accepted
# {"job_id":"...","status":"queued"}

S3-presigned upload (larger archives)

POST /api/v1/users/{user_id}/import/apple/xml/s3
{"file_size_bytes": 220000000}
# returns {presigned_put_url, key}; PUT the file directly to S3,
# then SNS notifies the platform to start processing.

Both paths emit normalised webhook events as records land — workouts, sleep sessions, body metrics. SNS notifications (POST /api/v1/sns/notification) trigger the S3 path's processing kickoff once the upload finishes.

Data Sync & Backfill

Most provider data lands automatically via webhooks. For the cases that don't (forced re-sync after a permission change, first-time historical pull, missed-webhook recovery), use the explicit sync endpoints.

Provider sync (today)

POST /api/v1/providers/{provider}/users/{user_id}/sync
# Pulls the latest available data window for that provider.

POST /api/v1/providers/{provider}/users/{user_id}/sync/historical
# Kicks off a full historical pull (provider-dependent: Garmin 30d,
# Strava all activities, Fitbit 90d, etc.)

Garmin 30-day backfill control

GET    /api/v1/providers/garmin/users/{user_id}/backfill/status
POST   /api/v1/providers/garmin/users/{user_id}/backfill/cancel
POST   /api/v1/providers/garmin/users/{user_id}/backfill/{type_name}/retry

Garmin's webhook-driven backfill is split per data type (workouts, sleep, body, daily). Retry one type without re-running the whole job; cancel mid-flight if the user disconnected.

Data Export (GDPR / user requests)

Generate a complete, downloadable archive of an end-user's normalised data — workouts, sleep, body metrics, timeseries — to satisfy GDPR/DPDP "data portability" requests or your own user-export feature. Async with a token-gated download URL.

POST /api/v1/users/{user_id}/export/async
# 202 Accepted, returns {export_id, status:"queued"}

# Once the worker finishes, the user (or your API) hits:
GET /api/v1/data-exports/{token}/download
# Streams a single ZIP — NDJSON files per data type inside.

The token URL is single-use, time-bound, and signed; safe to hand directly to the end user without exposing internal IDs. A synchronous POST /users/{user_id}/export endpoint exists for small accounts where waiting in-band is acceptable.

Connections

List or revoke a user's provider connections. Useful for building an account-settings screen ("disconnect Garmin"), troubleshooting permission issues, or proactively revoking access when a user offboards.

GET /api/v1/users/{user_id}/connections
# returns [{provider:"strava", connected_at:"...", status:"active"}, ...]

DELETE /api/v1/users/{user_id}/connections/{provider}
# Revokes the OAuth token, marks the connection inactive,
# stops further webhook ingestion for this provider+user.

Disconnecting does not delete already-ingested data. Use the data-export flow above to give the user a copy first if you want to keep them in good standing.

Nutrition

Photo-first food logging powered by AI vision. Users upload a meal photo, type a manual entry, or both — and the platform returns recognised dishes, portion estimates, and macros (calories, protein, carbs, fat, fibre, sugar, sodium) under the same per-tenant access model as the rest of the API. Designed to recognise Indian thalis, biryanis, and South-Asian regional dishes alongside Western plates.

How recognition works

  1. Image lands at POST /nutrition/log/image; the route returns 202 Accepted within ~50 ms with a log_id and status: "pending".
  2. EXIF GPS coordinates are stripped before the image is persisted (privacy-critical — see below). Image is stored in object storage; only the pointer + redacted EXIF dict lives in Postgres.
  3. Background recognize_meal Celery task pulls the bytes, sends to the vision model, and parses the strict-JSON dish list.
  4. Each dish is fuzzy-matched against the food database (USDA + curated regional entries; pg_trgm GIN index for sub-millisecond lookup) to populate macros from the recognised gram weight.
  5. If primary confidence is < 0.80, an optional second-opinion verifier is consulted; agreement → accept, disagreement → status stays pending and a human-review queue picks it up. Without a verifier, the accept threshold tightens to 0.85.
  6. On accept, status flips to recognized and a nutrition.log.recognized webhook fires with the items + total kcal.

User-facing endpoints (7)

  • POST /api/v1/nutrition/log/image — multipart photo upload (jpeg/png/heic, ≤5 MB). Returns {job_id, log_id, status:"pending"}.
  • POST /api/v1/nutrition/log/manual — JSON entry, no image. Status is manual on insert; macros come from the body, not the vision pipeline.
  • GET /api/v1/nutrition/log — paginated list, filter by date_from, date_to, meal_type; default 50 items, max 200.
  • GET /api/v1/nutrition/log/{log_id} — single log + items.
  • PATCH /api/v1/nutrition/log/{log_id}/items/{item_id} — user correction (rename dish, fix grams, override macros); sets is_corrected=true on the item and fires nutrition.log.corrected per changed field.
  • DELETE /api/v1/nutrition/log/{log_id} — soft-delete (status flips to discarded; row stays for audit/analytics, hidden from list).
  • GET /api/v1/nutrition/summary?date_from=&date_to= — daily macro aggregate. Range cap: 92 days.

Per-tier daily image quota

Image uploads are billed against your plan's daily quota. The quota counter is per-developer (the billing tenant), counts every image-backed log including soft-deleted rows, and resets at 00:00 UTC. Manual logs (POST /log/manual) are not affected — log them as much as you want.

PlanPhotos / dayManual logs
Hobby (free)20Unlimited
Developer500Unlimited
Scale5,000Unlimited
EnterpriseUnlimitedUnlimited

Quota-exceeded response

When today's image count is at or above the cap, POST /nutrition/log/image returns 429 Too Many Requests with a machine-readable detail body. Surface this to your users instead of retrying:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{
  "detail": {
    "code": "quota_exceeded",
    "message": "Daily nutrition image quota reached (20 for the hobby plan). Resets at 00:00 UTC. Manual logs are not affected.",
    "limit": 20,
    "used": 20,
    "plan": "hobby",
    "resource": "nutrition_image"
  }
}

Image upload — example

curl -H "Authorization: Bearer $TOKEN" \
  -F "file=@/path/to/meal.jpg" \
  -F "meal_type=lunch" \
  "https://wearlink.io/api/v1/nutrition/log/image?user_id=$USER_ID"
# 202 Accepted
# {"job_id":"...","log_id":"...","status":"pending"}

# Poll the same log until recognized:
curl -H "Authorization: Bearer $TOKEN" \
  "https://wearlink.io/api/v1/nutrition/log/$LOG_ID?user_id=$USER_ID"

Recognized response shape

{
  "id": "5b1e...",
  "user_id": "f0d3...",
  "detected_at": "2026-04-26T13:52:11Z",
  "meal_type": "lunch",
  "status": "recognized",
  "confidence_score": 0.87,
  "vision_model": "claude-opus-4-7",
  "human_reviewed": false,
  "items": [
    {
      "id": "...", "dish_name": "biryani",
      "quantity_grams": 280, "quantity_uncertainty": 0.20,
      "calories_kcal": 560, "protein_g": 17, "carbs_g": 84,
      "fat_g": 19.6, "fiber_g": 2.8, "sugar_g": 5.6,
      "food_db_source": "in_curated", "food_db_id": "in_curated:biryani",
      "is_corrected": false
    }
  ]
}

Privacy & data handling

  • EXIF GPS block is stripped before persistence — coordinates never reach storage.
  • Images are stored in object storage with retention; cleartext EXIF (camera, ISO, timestamp, etc.) is kept as a redacted JSONB dict on the row for debugging.
  • Image-only status: even if recognition fails, the photo is retained so a human reviewer or the user themselves can correct the entry later.
  • Soft-delete preserves the row; use the data-export flow to give the user a true wipe if requested.

Macro database

Recognised dishes are macro-enriched from a fused database that prefers locally-curated regional entries (Indian dishes with recipe-aware portion macros) and falls back to USDA FoodData Central for Western foods. Lookups use a trigram similarity index, so spelling variations ( "biriyani" "biryani", "tomatoes" "tomato") resolve cleanly. Each item carries food_db_source and food_db_id so customers can audit the macro source per dish.

Human review (low-confidence safety net)

Logs with confidence_score < 0.60 (or where the verifier disagreed) land in an admin review queue. Reviewers can approve, reject, or edit any item; every action is audit-logged. The end user keeps seeing status: "pending" until the queue resolves, then nutrition.log.recognized fires as if recognition had happened normally.

Webhooks

Three event types fire over Svix — nutrition.log.created (photo accepted, awaiting recognition), nutrition.log.recognized (vision finished, items + macros ready), nutrition.log.corrected (user PATCHed an item). Subscribe to only the ones your product needs — see the Event Catalog above for full payload schemas.

Pair this with the Energy Balance endpoint to expose calories-in vs calories-out per day in your app.

Energy Balance

Daily calories-in (from nutrition logs) versus calories-out (active + basal from wearable summaries). Designed to power weight-management coaching, calorie-deficit dashboards, and "did I eat enough today?" notifications without your team wiring the join yourself.

Endpoint

GET /api/v1/users/{user_id}/energy_balance
   ?date_from=2026-04-20&date_to=2026-04-26

Response (per day)

{
  "user_id": "...",
  "days": [
    {
      "date": "2026-04-25",
      "calories_consumed_kcal": 2150.0,
      "calories_burned_kcal":   2380.5,
      "active_calories_kcal":    480.5,
      "basal_calories_kcal":    1900.0,
      "net_energy_balance_kcal": -230.5,
      "data_completeness": "wearable_and_nutrition"
    }
  ]
}

data_completeness is one of none, nutrition_only, wearable_only, or wearable_and_nutrition. Either side is allowed to be missing — the endpoint degrades gracefully when only one signal is present, returning net_energy_balance_kcal: null rather than failing.

Range cap: 92 days. Use multiple calls for longer windows.

Audit Log Export

For SOC 2 and customer security questionnaires, every privileged action your developer account takes is recorded as an append-only audit-log entry. Stream the full history as newline-delimited JSON via GET /api/v1/audit-logs/export.

  • Response media type: application/x-ndjson
  • One JSON object per line; download header attached so browsers save to disk
  • Optional filters: since=, until=, action=
  • Scoped automatically to the calling developer; you only see your own entries
  • Streaming with bounded memory — large exports do not load fully into RAM

Example

curl -H "Authorization: Bearer $TOKEN" \
  "https://wearlink.io/api/v1/audit-logs/export?since=2026-01-01" \
  -o audit-logs.jsonl

Kotlin SDK (preview)

The Kotlin/JVM SDK is published as source under sdks/kotlin/ in the platform repository. The v0.1 surface is intentionally narrow — four methods covering provider listing, user connections, activity summaries, and meal-photo upload — designed to unblock native Android integrations while we expand coverage.

Methods

  • listProviders() — returns the provider catalogue with the is_configured flag
  • connectionsFor(userId) — provider connections for an end user
  • summariesFor(userId, dateFrom, dateTo) — daily activity summaries
  • uploadMealImage(userId, file, mealType) — submit a photo to the nutrition pipeline

Maven Central publishing is staged — until the artifact is released, consume the package by depending on the source module directly. A standalone Swift-CI workflow ships alongside, building the existing iOS Swift SDK on every PR.

Platform Admin (SaaS)

Internal-only. Every endpoint in this section requires the caller's developer record to have is_superuser=true. Non-admin callers receive a generic 404 Not Found so the routes aren't discoverable from the API surface. The platform enforces a single root admin (tusharagrawal0104@gmail.com); the superuser-toggle endpoint blocks any other caller from minting more admins.

The admin portal at /admin renders eleven sections backed by the endpoints below. Every page is read-mostly; the handful of mutations (lock / unlock developer, mark invoice refunded, run-now a beat task, publish announcement, resolve support ticket) writes a row to audit_log attributed to the admin.

Overview & growth

Tenant counts, MRR / ARR, plan distribution, 14-day signup growth, plus a "Things to do" strip surfacing the count of pending nutrition reviews, open support tickets, locked developers, failed-login bursts in the last hour, and unverified accounts.

GET
/admin/overview

All cards on the SaaS overview page

JWT

Developers & end-users

Cross-tenant search and detail. Every list endpoint accepts q (ILIKE on email/name), limit, offset. Detail returns the developer's organization, subscription, API-key + end-user counts, and the last 20 audit rows for that actor.

GET
/admin/developers

Paginated list with plan / locked / superuser filters

JWT
GET
/admin/developers/{id}

Developer detail with audit history

JWT
POST
/admin/developers/{id}/superuser

Toggle is_superuser (sole-root only)

JWT
POST
/admin/developers/{id}/lock

Force 7-day lockout

JWT
POST
/admin/developers/{id}/unlock

Clear lockout + reset failed-login counter

JWT
GET
/admin/end-users

Cross-tenant end-user search

JWT
GET
/admin/end-users/{id}

End-user detail (with developer email)

JWT

Finance (revenue, subscriptions, invoices)

All amounts returned in both INR (_inr) and USD (_usd) — Razorpay collects in INR but the platform bills customers in USD. The usd_inr_rate field on every response says which rate was used (env var USD_INR_RATE, default 84.0).

GET
/admin/finance/summary

MRR, ARR, MoM change, refunds

JWT
GET
/admin/finance/subscriptions

All Razorpay subscription rows

JWT
GET
/admin/finance/invoices

All invoices with filters (status, plan, date)

JWT
POST
/admin/finance/invoices/{id}/mark-refunded

Bookkeeping flip — issue refund in Razorpay first

JWT

COGS — cost of goods sold

Computes approximate variable costs from observable platform data. Every unit rate is environment-overridable so each deployment can plug in its contracted rate. Defaults are publicly listed prices as of 2026-04; override per-environment via the env vars below.

GET
/admin/cogs/summary

Per-line costs, totals, gross margin %, plan-vs-cost unit economics

JWT

Cost lines

AI vision recognitions

Anthropic Claude vision blended rate. Counted as nutrition_logs with vision_model NOT NULL in the period.

Rate

$0.014 / image (₹1.20)

Env override

COST_PER_VISION_RECOGNITION_INR

Object storage (nutrition images)

S3-equivalent rate. Computed from sum(size_bytes) of nutrition_images with s3_key still set.

Rate

$0.022 / GB-month (₹1.85)

Env override

COST_PER_GB_STORAGE_INR_PER_MONTH

Webhook deliveries

Svix blended estimate. Counted from audit_log rows with action LIKE 'webhook%' until the Svix usage API is wired.

Rate

$0.0005 / delivery (₹0.04)

Env override

COST_PER_WEBHOOK_DELIVERY_INR

Razorpay processing fees

Domestic INR transactions. International USD transactions carry a higher fee — set env var per Razorpay quote.

Rate

2.36% of paid INR invoiced

Env override

RAZORPAY_FEE_PCT

Fixed infra (Postgres + workers + Redis)

Flat monthly. Bump as you scale instances or move providers.

Rate

$143 / month (₹12,000)

Env override

FIXED_INFRA_INR_PER_MONTH

The endpoint also returns per-plan unit economics at 100% utilization — what the AI cost would be if a paying tenant fully consumed their daily image cap every day. Negative margins indicate a structurally underpriced plan; the admin UI flags those rows in red so the price book gets reviewed before a power user pushes the platform underwater.

Insights — SaaS metrics

ARPU, LTV, churn rate, free→paid conversion, plan mix with MRR contribution, activation funnel, daily active developers, weekly cohort retention, provider popularity, and top tenants by activity.

GET
/admin/insights

Single payload with every insight matrix

JWT
json
{
  "financials": {
    "arpu_usd": 99.0,
    "ltv_usd": 1188.0,            // ARPU × estimated_lifespan_months
    "estimated_lifespan_months": 12.0,  // 100 / churn_rate; defaults 12 when no history
    "churn_rate_pct": 0.0,        // lifetime cancellation rate
    "free_to_paid_conversion_pct": 0.0
  },
  "plan_mix": [
    { "plan": "developer", "active": 0, "monthly_usd": 99,
      "contributing_mrr_usd": 0, "pct_of_mrr": 0.0 }
  ],
  "funnel": [...],
  "dau": [...],
  "weekly_cohorts": [...],
  "providers": [...],
  "top_developers": [...]
}

Audit, system, jobs

Read-only timeline for every privileged action, plus operational dashboards for Postgres / Redis / Celery / MinIO. Every beat task in the schedule has a one-click run now trigger with the audit-row to prove who hit it.

GET
/admin/audit

Cross-tenant audit timeline with filters (action, prefix, since, q)

JWT
GET
/admin/system/health

DB sizes, Redis stats, Celery queue depth, MinIO reachability

JWT
GET
/admin/jobs

Beat-schedule registry

JWT
POST
/admin/jobs/{name}/run

Trigger a beat task immediately

JWT

Support, announcements

Inbound contact-form submissions land here (one row per submit, written to audit_log with action contact.submit). Announcements are platform-wide banners — exactly one is "active" at a time; publishing a new one auto-deactivates the previous. The active read endpoint is unauthenticated so the dashboard can show the banner before the user logs in.

GET
/admin/support

Open + resolved support tickets with filters

JWT
POST
/admin/support/{id}/resolve

Mark resolved (or reopen)

JWT
GET
/admin/announcements/active

Public read — current platform banner or null

Public
GET
/admin/announcements/history

Past banners (admin)

JWT
POST
/admin/announcements/publish

Publish a new banner (severity, title, body, optional CTA)

JWT
POST
/admin/announcements/{id}/dismiss

Deactivate a specific banner

JWT

Nutrition human-review

Pending logs and low-confidence (<0.6) recognitions land in this queue. Approve to fire nutrition.log.recognized; reject to soft-delete. Per-item edits override macros and are audit-logged. Bulk approve/reject is capped at 200 logs per call.

GET
/admin/nutrition/review/queue

List pending + low-confidence logs

JWT
GET
/admin/nutrition/review/{id}

Full log detail with presigned image URL

JWT
POST
/admin/nutrition/review/{id}/approve

Flip status to recognized + fire webhook

JWT
POST
/admin/nutrition/review/{id}/reject

Soft-delete (status → discarded)

JWT
PATCH
/admin/nutrition/review/{id}/items/{itemId}

Edit a single item's macros

JWT
POST
/admin/nutrition/review/bulk

Bulk approve / reject (≤200 logs)

JWT

Full API Reference

The hand-written sections above cover the high-touch flows. Below is the complete catalog of public endpoints, generated from the live OpenAPI schema. When the backend ships a new endpoint, it appears here automatically — no docs PR required. Click a tag to expand its endpoints.

Fetching live API schema…

Errors & Pagination

Error responses

All errors follow the same shape: an HTTP status code and a detail field.

200OK — success
201Created — resource created
202Accepted — async task queued (SDK sync)
400Bad Request — validation error or business logic failure
401Unauthorized — missing or invalid API key / JWT
404Not Found — resource does not exist
409Conflict — duplicate resource (e.g. email already registered)
429Too Many Requests — rate limit hit, retry after 60 s
500Server Error — contact support if persistent
501Not Implemented — endpoint is planned but not yet available
json
// Standard error
{ "detail": "An account with this email already exists" }

// Rate limit
{
  "detail": "Too many requests. Please wait 60 seconds and try again.",
  "headers": { "Retry-After": "60" }
}

Cursor-based pagination

Most list endpoints use cursor-based pagination. Pass the next_cursor from the response as the cursor query parameter in your next request.

python
def fetch_all_workouts(user_id: str, start: str, end: str):
    cursor = None
    workouts = []

    while True:
        params = {"start_date": start, "end_date": end, "limit": 50}
        if cursor:
            params["cursor"] = cursor

        page = client.get(f"/users/{user_id}/events/workouts", params=params).json()
        workouts.extend(page["data"])

        if not page["pagination"]["has_more"]:
            break
        cursor = page["pagination"]["next_cursor"]

    return workouts

Rate limits

Default limits (per API key, per IP):

  • Registration: 5 requests / 60 seconds
  • Login: 10 requests / 60 seconds
  • General API: no hard limit (fair-use basis, contact us for higher quotas)

When rate-limited, you receive a 429 with a Retry-After header.

Ready to start building?

Create a free account, get your API key instantly, and explore the full reference in Swagger UI.