WearLink API documentation
REST and webhook reference for the WearLink wearable data API — authentication, the normalised data model, provider connection flows, nutrition logging and energy balance. Every endpoint returns the same schema regardless of which device the data came from.
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.