Skip to content

Guide · 11 min read

Webhooks vs polling for wearable data: rate limits, revisions and delivery

Why polling wearable APIs fails past a few hundred users, how each vendor implements webhooks differently, and how to design an idempotent consumer.

Published · By the WearLink engineering team

Diagram of wearable data flowing from vendor APIs through webhooks into an application

Polling a wearable API is the natural first move. You have an access token, the vendor has a summary endpoint, and a cron job every fifteen minutes gets the demo working by lunchtime. The same cron job is usually the first thing to fall over once the product has a few hundred connected users. This guide explains why, how each vendor actually signals change, why the first delivery of a record is not the last, and how to build a consumer that survives all of it.

Why polling stops scaling

Four mechanics combine against you, and they are all structural rather than tuning problems.

  • Per-application quotas. Most vendors enforce a budget for your whole application on top of any per-user limit. Strava is the clearest case, where limits apply per application rather than per user, so a large user base hits the ceiling together. Every new user you onboard spends the same shared budget.
  • Per-user quotas. Where a per-user limit exists, it caps how often you can look at any single account. Fifteen-minute polling across several data types per user can exhaust that on its own, before the application-level budget comes into it.
  • Most polls find nothing. A ring or a watch syncs a handful of times a day, typically when the phone app is opened. Between syncs the API returns exactly what it returned last time. The large majority of your requests, and therefore your quota, is spent confirming that nothing changed.
  • Sync timing is bursty. Sleep data lands when people wake up and check their phone. In a single timezone that is a narrow morning window. A polling schedule that is spread evenly across the day is idle for most of it and behind during the one hour it matters, which is also the hour your users open your app expecting last night's sleep.

You can slow the schedule to stay under quota, but then freshness suffers in the window that matters. You can poll faster for active users, but the definition of active needs a signal you do not have. The signal exists on the vendor side; you need them to send it.

How each vendor delivers change

Every major provider has some form of change notification, but the shapes differ enough that a consumer written for one does not transfer to the next. The important distinction is between a thin notification, which tells you something changed and expects you to fetch it, and a data push, which contains the record. The table describes the mechanism in general terms; check the vendor's current documentation for exact endpoint paths and limits before building.

ProviderChange notificationWhat the notification containsNotes
OuraWebhook subscription per data type and event typeAn event type and a user id. No document body.You fetch the record from the REST API. Sleep and readiness documents are revised as the ring syncs more data.
WHOOPWebhook per app, signedUser id, object type and object id. Thin.Fetch the cycle, sleep, recovery or workout by id. Rate limits punish fan-out backfill; pace historical pulls per user.
FitbitSubscription per user per collection (activities, sleep, body and so on)Which collection changed, for which user, for which date. Thin.Budget for the second round trip. Per-application and per-user quotas apply to the fetch. Intraday data needs separate approval.
StravaOne push subscription per application, created after a validation handshakeObject type, object id, owner id and the aspect (create, update, delete), with the changed fields on updates.Activities are editable and deletable, so update and delete events are not optional. Application-wide rate limits apply to every fetch.
WithingsNotification subscription per user per categoryUser id, category and a date range. Thin.Fetch measures for the range. Subscriptions are per user and need to be created when the user connects.
GarminPush, configured per applicationThe data itself, batched by data type.Backfill is also delivered through the same push channel rather than by polling. In provider onboarding at WearLink.
PolarWebhook per applicationEvent type, user id and a reference to fetch. Thin.Data is exposed as transactions that are fetched and committed. In provider onboarding at WearLink.
DexcomPolling of the user's data rangeNot applicableGlucose readings arrive with a delay from the sensor, so a reconciliation sweep over the recent window is the practical model. In provider onboarding at WearLink.

The pattern is clear enough: with the exception of Garmin, the notification is a doorbell, not a parcel. Your consumer still needs a well-behaved fetch path, still consumes quota on that fetch, and still has to cope with the fetched record differing from what it fetched last time. Which brings us to the part most teams discover in production.

Revisions: the first delivery is not the final value

Wearable data is restated. This is not a bug in any one vendor; it is a property of the domain.

  • Sleep is recalculated. Oura recomputes sleep and readiness as the ring syncs more of the night. The document you fetch at 7am and the document the user sees in the Oura app at 9am can differ.
  • Workouts are edited. A Strava athlete changes the activity type, the title, the privacy setting, or deletes the activity entirely. Each is a separate event, and a copy that ignores updates and deletes drifts away from what the athlete sees.
  • Days are backfilled. A watch that was out of range for a week syncs all of it in one go. The notification for last Tuesday arrives today, after you have already closed last Tuesday in your reporting.
  • Timezones move. Fitbit reports in the user's device timezone, which changes when they travel. A day boundary can shift under a record you already stored.

A consumer that treats the first delivery as final, inserts a row, and moves on will be wrong in a way that nobody notices for weeks. The correct design has three parts.

  1. Store the source revision. Whatever the provider gives you as an updated timestamp, version or revision marker, keep it on the row. If the provider offers nothing, use the time you fetched it and accept that ordering is weaker.
  2. Upsert on the provider's record id. Never on your own auto-increment id and never on (user, date) alone, because two records can legitimately share a date and a revised record must land on the same row it replaces.
  3. Emit your own change events downstream. Once the row is written, your analytics, notifications and caches need to know that a value they already consumed has changed. Do not let them assume that a record they saw once will never move.
If your product shows a sleep score to the user, and the vendor's own app shows a different number for the same night, the user assumes you are broken. A revision-aware consumer is the difference between matching the vendor and explaining the discrepancy in support tickets.

Designing an idempotent consumer

Idempotent means that receiving an event once, twice, or ten times, in any order, leaves the same state behind. Every sender you will ever integrate with retries on failure, and several will also send genuine duplicates. Build for it from the start; retrofitting idempotency onto an insert-based consumer means a data migration.

Verify the signature first

Check the signature before parsing the body, before logging it, before anything. A failed verification is a hard 400 that should not be retried, and it is the only defence you have against someone posting fabricated health data into your pipeline. Use the vendor's library where one exists; hand-rolled HMAC comparisons are a common source of timing-attack and encoding bugs.

Return 2xx fast, process asynchronously

The request handler should do the minimum: verify, record the event id, enqueue, acknowledge. Fetching from a vendor API inside the handler ties your acknowledgement to the vendor's latency, and a slow vendor turns into timeouts on your endpoint, which turns into retries, which turns into duplicate fetches against a quota you are trying to protect.

Idempotency key is the event id

Record the sender's event id in a table with a unique constraint before doing any work. A conflict on insert means you have seen it; acknowledge and stop. This is cheap, it is the first line of duplicate defence, and it also gives you an audit trail of what arrived when.

handler.ts (illustrative)
// Illustrative. Verify, record, enqueue, acknowledge. Nothing else in the request path.
import { Webhook } from "svix";

app.post("/hooks/wearables", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    // 1. Verify before touching the body. A bad signature is a 400, not a retry.
    event = new Webhook(process.env.WEBHOOK_SECRET).verify(req.body, req.headers);
  } catch {
    return res.status(400).send("bad signature");
  }

  // 2. Idempotency key = the sender's event id. First writer wins; duplicates are no-ops.
  const fresh = await db.query(
    "INSERT INTO webhook_events (event_id, type, received_at) VALUES ($1, $2, now()) ON CONFLICT DO NOTHING",
    [event.id, event.type]
  );
  if (fresh.rowCount === 0) return res.status(200).send("duplicate");

  // 3. Hand off. The worker does the upsert; this handler never waits on it.
  await queue.enqueue("wearable-event", { eventId: event.id, type: event.type, data: event.data });

  // 4. Acknowledge fast so the sender stops retrying.
  return res.status(200).send("queued");
});

Upsert keyed on (provider, record id)

The worker that drains the queue writes with an upsert, not an insert. The conflict target is the provider plus the provider's own id for the record. This is the second line of duplicate defence, and unlike the event id table it also handles the case where two different events describe the same record, which is exactly what a revision is.

Handle out-of-order delivery with an updated_at comparison

Retries and parallel workers mean a revision can arrive before the original it revises. The fix is one clause: only apply the update when the incoming revision is newer than the stored one. An older delivery then becomes a no-op, and a zero-row result is the correct outcome rather than an error.

upsert.sql (illustrative)
-- Illustrative. One row per provider record; a newer revision replaces, an older one is ignored.
INSERT INTO sleep_sessions (provider, provider_record_id, user_id, start_at, end_at,
                            efficiency_percent, source_updated_at, payload)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (provider, provider_record_id) DO UPDATE
SET user_id            = excluded.user_id,
    start_at           = excluded.start_at,
    end_at             = excluded.end_at,
    efficiency_percent = excluded.efficiency_percent,
    source_updated_at  = excluded.source_updated_at,
    payload            = excluded.payload
WHERE excluded.source_updated_at > sleep_sessions.source_updated_at;
-- Zero rows affected on a stale or duplicate delivery. That is the correct outcome, not an error.

Dead-letter after N attempts

Some events will fail repeatedly, because the vendor fetch keeps timing out, because the user has revoked access, or because the payload hits a bug in your parser. Bound the retries. After a fixed number of attempts, move the event to a dead-letter store with the error, alert on the queue depth, and keep the main queue moving. An unbounded retry loop on one poisoned event can starve every healthy user behind it.

Keep a replay path from the API

Webhooks are a fast path, not the only path. When a dead-lettered event is fixed, or when you discover a bug that mis-stored a week of data, you need a way to say "re-fetch this window for this user" and have it flow through the same upsert. Because the upsert is idempotent, replay is safe to run as often as you like. Build this before you need it.

When polling is still right

None of the above means polling is wrong. It means unbounded, evenly spaced polling as the primary sync mechanism is wrong. There are four places where a deliberate fetch is the correct tool.

  • Initial backfill. When a user connects, there is history to pull and no notification will ever cover it. Do this once, paced per user rather than fanned out across every new signup at once, because WHOOP and Strava in particular will throttle a burst.
  • Reconciliation sweeps. A daily, low-rate pass over the recent window for each active user catches anything a dropped notification missed. It is cheap because it is slow and bounded, and it runs through the same idempotent upsert, so a sweep that finds nothing new writes nothing.
  • Providers without notifications. Where a vendor offers no change signal, or where the data arrives at the vendor with a delay anyway, a scheduled fetch of the recent range is simply how it works.
  • Fallback when a subscription lapses. Subscriptions expire, get dropped when a user re-authorises, and occasionally vanish silently. Re-register on a schedule, track the last delivery time per user, and alert on silence for any user who was recently active. A user whose ring is syncing daily but who has produced no notification for three days almost certainly has a lapsed subscription, not a dormant ring.
The healthy shape is: webhooks carry the load, a slow sweep guarantees completeness, and both write through one idempotent path. If you find yourself tuning the sweep faster to compensate for missing notifications, fix the subscriptions instead.

What WearLink does

WearLink exists because every team building on wearable data ends up writing the same consumer, once per vendor, and getting the revision handling wrong at least once. So we run that layer and expose a single shape on the other side.

  • We hold the vendor subscriptions. For Oura, WHOOP, Fitbit, Strava and Withings, WearLink registers the per-user or per-application subscription when the user connects, re-registers it on a schedule, and handles the validation handshakes and per-collection quirks described above.
  • We refetch on notification. When a vendor sends a thin notification, WearLink fetches the changed window against the vendor's quota, not yours, with backfill queued and paced per connection.
  • We normalise and deliver the full record. Each record becomes one signed event with the complete payload, so there is no follow-up fetch. A sleep.created event carries the session; a workout.created event carries the workout. Deliveries are made through Svix, signed with your endpoint secret, retried automatically, and logged per endpoint so you can inspect every attempt. Verify with the Svix library as shown in the API docs.
  • We re-deliver on revision. When a provider restates a record, WearLink fetches the new version and delivers it again with the updated payload. Your consumer applies the same upsert rule from this guide, and the row converges on what the vendor shows.

There are 109 event types, split into 22 summary objects such as sleep.created and 87 granular series streams such as series.heart_rate.created. Subscribe to a group like workout.* to receive every child event, or filter an endpoint down to the handful you need. The full list is in the event catalogue, generated from the running API so it does not drift.

The consumer you write against WearLink is the one described in this guide: verify, record the event id, enqueue, upsert on the record id, apply only newer revisions. The difference is that you write it once, and the vendor-specific subscription management, refetching and pacing is not your on-call problem. If you want to see the shape of the data first, the sleep and workouts pages show the normalised records field by field.

FAQ

Frequently asked

Why does polling a wearable API stop working as users grow?
Because the quota is shared. Most vendors enforce per-application limits alongside per-user limits, so every extra user consumes the same application budget. At the same time, the majority of polls return nothing new, because a wearable only syncs a few times a day. You spend your quota confirming that nothing changed, and the polls that would find something are all bunched into the same morning window.
Do wearable webhooks contain the actual health data?
Usually not. Oura, Fitbit and WHOOP send a thin notification identifying the user, the data type and sometimes a date range, and expect you to fetch the record from the REST API. Garmin is the main exception and pushes the data itself. Strava sends a small event describing which activity changed and how. Design the consumer to fetch on notification rather than to parse the notification body as data.
What is an idempotent webhook consumer?
One that produces the same stored state whether a given event arrives once, twice or ten times, in any order. In practice that means verifying the signature, recording the event id before doing any work, upserting on the provider record id rather than inserting, and only overwriting when the incoming revision is newer than the one already stored.
Do providers change data after they have sent it?
Yes, routinely. Sleep and readiness are recalculated as more data syncs from the ring or watch, athletes edit and delete activities, and backfilled days arrive after the fact. The first delivery of a record is a draft, not a final value. Store the provider revision or updated timestamp and be prepared to replace the row.
Does WearLink require me to fetch after a webhook?
No. WearLink holds the vendor subscriptions, refetches the changed window when a provider notifies it, normalises the result and delivers one signed event per record with the full payload included. If the provider later revises the record, WearLink re-delivers it. Your consumer needs the same idempotent upsert either way, but it does not need a second round trip.

Stop polling. Subscribe once.

WearLink holds the vendor subscriptions, refetches on notification and delivers one signed event per record with the full payload. Connect Oura, WHOOP, Fitbit, Strava and Withings through one endpoint.