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/v1Every 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.
curl https://wearlink.io/api/v1/users \
-H "X-WearLink-API-Key: sk-your-api-key-here"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()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.
# 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
/developer/api-keysList all API keys
/developer/api-keysCreate a new key — returns full key once
/developer/api-keys/{key_id}/rotateRotate a key (delete + create)
/developer/api-keys/{key_id}Permanently delete a key
# 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.
Create your account
Sign up — a default API key is created automatically. Copy it from Settings → Credentials.
Create an end-user
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", ... }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.
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 }Fetch normalised health data
# 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
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)
- Go to developerportal.garmin.com and apply for Health API access.
- Once approved, create a new app in the Connect Developer Program.
- Set the OAuth callback to
{YOUR_BACKEND_URL}/v1/providers/garmin/callback. - Copy the Consumer Key and Consumer Secret into WearLink's Settings → Providers → Garmin.
- Configure the webhook ping URL to
{YOUR_BACKEND_URL}/v1/webhooks/garmin.
Oura (slug: oura)
- Sign in at cloud.ouraring.com/oauth/applications and create a new application.
- Set the redirect URI to
{YOUR_BACKEND_URL}/v1/providers/oura/callback. - Request scopes:
email personal daily heartrate workout session spo2Daily tag. - Copy the Client ID and Client Secret into Settings → Providers → Oura.
Fitbit (slug: fitbit)
- Register an app at dev.fitbit.com/apps/new.
- Choose OAuth 2.0 Application Type: Server.
- Set the callback URL to
{YOUR_BACKEND_URL}/v1/providers/fitbit/callback. - Select scopes:
activity heartrate location nutrition profile settings sleep weight. - Copy the OAuth 2.0 Client ID and Client Secret into Settings → Providers → Fitbit.
WHOOP (slug: whoop)
- Apply for developer access at developer.whoop.com.
- Create a new app once approved.
- Set the redirect URI to
{YOUR_BACKEND_URL}/v1/providers/whoop/callback. - Request scopes:
read:recovery read:cycles read:sleep read:workout read:profile read:body_measurement. - Copy the Client ID and Client Secret into Settings → Providers → WHOOP.
Polar (slug: polar)
- Sign in at admin.polaraccesslink.com and register a new application.
- Choose AccessLink (not Training Center).
- Set the callback URL to
{YOUR_BACKEND_URL}/v1/providers/polar/callback. - Copy the Client ID and Client Secret into Settings → Providers → Polar.
Strava (slug: strava)
- Go to strava.com/settings/api and create an API application.
- Set the Authorization Callback Domain to your backend's host (e.g.
api.yourdomain.com). - The full callback URL will be
{YOUR_BACKEND_URL}/v1/providers/strava/callback. - Copy the Client ID and Client Secret into Settings → Providers → Strava.
- 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.
/usersList users — supports ?page, ?limit, ?search, ?sort_by
/usersCreate a user
/users/{user_id}Get a single user by UUID
/users/{user_id}Update name or email
/users/{user_id}Delete user and all their data
/users/{user_id}/connectionsList active provider connections
/users/{user_id}/connections/{provider}Disconnect a provider
Create a user
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.
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
/users/{user_id}/events/workoutsDiscrete workout sessions — running, cycling, yoga, etc.
/users/{user_id}/events/sleepDiscrete sleep sessions including naps
Required: start_date, end_date. Optional: cursor, limit (1–100, default 50).
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
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
/users/{user_id}/summaries/activityDaily activity totals — steps, calories, distance, heart rate
/users/{user_id}/summaries/sleepDaily sleep metrics — stages, efficiency, interruptions
/users/{user_id}/summaries/bodyBody metrics — weight, BMI, resting HR, HRV, temperature, blood pressure
# 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.
/users/{user_id}/timeseriesGranular time-stamped biometric samples
Query parameters
start_timerequiredISO 8601 datetime (e.g. 2026-04-15T00:00:00Z)end_timerequiredISO 8601 datetimetypesoptionalComma-separated series types — see list belowresolutionoptionalraw | 1min | 5min | 15min | 1hour (default raw)cursoroptionalPagination cursor from previous responselimitoptional1–100, default 50Available series types
heart_rateheart_rate_variabilityresting_heart_ratespo2respiratory_ratebody_temperaturestepscaloriesdistanceblood_glucoseblood_pressurevo2_maxrunning_powercycling_powerstressbody_composition+ 80 morecurl "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.
/users/{user_id}/health-scoresList health scores with optional date range and category filter
Query parameters
start_dateoptionalFilter from date (YYYY-MM-DD)end_dateoptionalFilter to datecategoryoptionalsleep | recovery | readiness | stress | vo2max | fitness_ageprovideroptionalFilter by source providerlimitoptional1–1000, default 50offsetoptionalOffset-based pagination, default 0curl "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
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.
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 }# 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 securelyStep 2 — Request HealthKit permissions (iOS)
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.
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
HKQuantityTypeIdentifierHeartRateHKQuantityTypeIdentifierRestingHeartRateHKQuantityTypeIdentifierHeartRateVariabilitySDNNHKQuantityTypeIdentifierOxygenSaturationHKQuantityTypeIdentifierHeartRateRecoveryOneMinute+ more
Activity & Fitness
HKQuantityTypeIdentifierStepCountHKQuantityTypeIdentifierActiveEnergyBurnedHKQuantityTypeIdentifierAppleExerciseTimeHKQuantityTypeIdentifierVO2MaxHKQuantityTypeIdentifierFlightsClimbed+ more
Body Composition
HKQuantityTypeIdentifierBodyMassHKQuantityTypeIdentifierBodyMassIndexHKQuantityTypeIdentifierBodyFatPercentageHKQuantityTypeIdentifierLeanBodyMassHKQuantityTypeIdentifierBodyTemperature+ more
Running / Cycling
HKQuantityTypeIdentifierRunningPowerHKQuantityTypeIdentifierRunningSpeedHKQuantityTypeIdentifierRunningStrideLengthHKQuantityTypeIdentifierCyclingPowerHKQuantityTypeIdentifierCyclingCadence+ more
Sleep stages
in_bedsleepingawakeasleep_lightasleep_deepasleep_remunknownWorkout types (150+)
All major HKWorkoutActivityType values are supported:
runningcyclingswimmingyogahikingbasketballsoccertennisrowingskiingstrength_traininghiitpilatesboxinggolfdancecrossfittriathlonwalking+ 130 moreApple 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.
/users/{user_id}/import/apple/xml/directUpload XML file directly (multipart/form-data)
/users/{user_id}/import/apple/xml/s3Get presigned S3 URL for large XML uploads
# 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
/webhooks/endpointsList your webhook endpoints
/webhooks/endpointsCreate a new endpoint
/webhooks/endpoints/{id}Get endpoint details
/webhooks/endpoints/{id}Update URL or filters
/webhooks/endpoints/{id}Delete an endpoint
/webhooks/endpoints/{id}/secretGet signing secret for verification
/webhooks/endpoints/{id}/testSend a test event
/webhooks/endpoints/{id}/attemptsList delivery attempts
/webhooks/event-typesList all available event types
# 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.
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.
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')}%")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.
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}/retryGarmin'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
- Image lands at
POST /nutrition/log/image; the route returns202 Acceptedwithin ~50 ms with alog_idandstatus: "pending". - 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.
- Background
recognize_mealCelery task pulls the bytes, sends to the vision model, and parses the strict-JSON dish list. - 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.
- If primary confidence is < 0.80, an optional second-opinion verifier is consulted; agreement → accept, disagreement → status stays
pendingand a human-review queue picks it up. Without a verifier, the accept threshold tightens to 0.85. - On accept, status flips to
recognizedand anutrition.log.recognizedwebhook 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 ismanualon insert; macros come from the body, not the vision pipeline.GET /api/v1/nutrition/log— paginated list, filter bydate_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); setsis_corrected=trueon the item and firesnutrition.log.correctedper changed field.DELETE /api/v1/nutrition/log/{log_id}— soft-delete (status flips todiscarded; 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.
| Plan | Photos / day | Manual logs |
|---|---|---|
| Hobby (free) | 20 | Unlimited |
| Developer | 500 | Unlimited |
| Scale | 5,000 | Unlimited |
| Enterprise | Unlimited | Unlimited |
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-26Response (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 theis_configuredflagconnectionsFor(userId)— provider connections for an end usersummariesFor(userId, dateFrom, dateTo)— daily activity summariesuploadMealImage(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)
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.
/admin/overviewAll cards on the SaaS overview page
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.
/admin/developersPaginated list with plan / locked / superuser filters
/admin/developers/{id}Developer detail with audit history
/admin/developers/{id}/superuserToggle is_superuser (sole-root only)
/admin/developers/{id}/lockForce 7-day lockout
/admin/developers/{id}/unlockClear lockout + reset failed-login counter
/admin/end-usersCross-tenant end-user search
/admin/end-users/{id}End-user detail (with developer email)
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).
/admin/finance/summaryMRR, ARR, MoM change, refunds
/admin/finance/subscriptionsAll Razorpay subscription rows
/admin/finance/invoicesAll invoices with filters (status, plan, date)
/admin/finance/invoices/{id}/mark-refundedBookkeeping flip — issue refund in Razorpay first
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.
/admin/cogs/summaryPer-line costs, totals, gross margin %, plan-vs-cost unit economics
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.
/admin/insightsSingle payload with every insight matrix
{
"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.
/admin/auditCross-tenant audit timeline with filters (action, prefix, since, q)
/admin/system/healthDB sizes, Redis stats, Celery queue depth, MinIO reachability
/admin/jobsBeat-schedule registry
/admin/jobs/{name}/runTrigger a beat task immediately
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.
/admin/supportOpen + resolved support tickets with filters
/admin/support/{id}/resolveMark resolved (or reopen)
/admin/announcements/activePublic read — current platform banner or null
/admin/announcements/historyPast banners (admin)
/admin/announcements/publishPublish a new banner (severity, title, body, optional CTA)
/admin/announcements/{id}/dismissDeactivate a specific banner
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.
/admin/nutrition/review/queueList pending + low-confidence logs
/admin/nutrition/review/{id}Full log detail with presigned image URL
/admin/nutrition/review/{id}/approveFlip status to recognized + fire webhook
/admin/nutrition/review/{id}/rejectSoft-delete (status → discarded)
/admin/nutrition/review/{id}/items/{itemId}Edit a single item's macros
/admin/nutrition/review/bulkBulk approve / reject (≤200 logs)
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 — success201Created — resource created202Accepted — async task queued (SDK sync)400Bad Request — validation error or business logic failure401Unauthorized — missing or invalid API key / JWT404Not Found — resource does not exist409Conflict — duplicate resource (e.g. email already registered)429Too Many Requests — rate limit hit, retry after 60 s500Server Error — contact support if persistent501Not Implemented — endpoint is planned but not yet available// 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.
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 workoutsRate 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.