# Wearables API reference

Canonical page: https://docs.ovok.com/wearables/api-reference

Every cloud wearables route, in full. Examples use the sandbox host, `https://api.sandbox.ovok.com`.

## Routes [#routes]

| Method | Path | Caller | What it does |
| --- | --- | --- | --- |
| `GET` | [`/v1/wearables/providers`](#list-providers) | Project admin | Lists every provider and the project's setup of it. |
| `GET` | [`/v1/wearables/providers/:provider`](#get-a-provider) | Project admin | Reads one provider's setup. |
| `POST` | [`/v1/wearables/providers`](#set-up-a-provider) | Project admin | Saves the project's OAuth app at a provider. |
| `PATCH` | [`/v1/wearables/providers/:provider`](#change-a-provider) | Project admin | Changes a saved app, or switches it on or off. |
| `DELETE` | [`/v1/wearables/providers/:provider`](#remove-a-provider) | Project admin | Removes a saved app. |
| `GET` | [`/v1/wearables/connections`](#list-connections) | Patient | Lists the patient's connections. |
| `POST` | [`/v1/wearables/connections/:provider/authorize`](#start-a-connection) | Patient | Returns the provider's consent page. |
| `POST` | [`/v1/wearables/connections/:provider/sync`](#sync-a-connection) | Patient | Pulls the patient's latest data now. |
| `DELETE` | [`/v1/wearables/connections/:provider`](#disconnect) | Patient | Disconnects the patient. |
| `GET` | [`/v1/wearables/oauth/:provider/callback`](#oauth-callback) | The provider, through the patient's browser | Finishes a connection and returns to the patient app. |
| `POST` | [`/v1/wearables/webhooks/:projectId/:provider[/:token]`](#receive-a-notification) | The provider | Takes a new-data notification and queues a sync. |
| `HEAD` | [`/v1/wearables/webhooks/:projectId/:provider[/:token]`](#check-a-webhook-url) | The provider | Answers whether the webhook URL is live. |

## Conventions [#conventions]

**Authentication.** Admin and patient routes take a bearer token: `Authorization: Bearer <access token>`. The project, and for patient routes the Patient, come from the token; no route takes a project or patient parameter. The OAuth callback and the webhooks take no token.

```bash
export OVOK_TOKEN='<access token>'
```

**`:provider`** is one of `oura`, `whoop`, `strava`, `withings`, `polar`, `suunto`, `garmin`, `ultrahuman`. Any other value answers [`422 VALIDATION_FAILED`](/errors/validation-failed).

**Errors** are [Problem Details](/errors) (`application/problem+json`). Branch on `type` and `status`, never on `detail`, and keep `traceId` when you report a problem:

```json
{
  "type": "https://docs.ovok.com/errors/provider-not-available",
  "status": 404,
  "detail": "oura is not set up or not enabled for this project.",
  "instance": "/v1/wearables/connections/oura/authorize",
  "traceId": "877c7c38-e426-4c3d-bac0-0c4725a9df73",
  "errors": []
}
```

**Secrets** (`secret`, `subscriptionKey`, `webhookSecret`, provider tokens) are encrypted before they are stored and never returned by any route. No wearables route sends email.

## Provider setup [#provider-setup]

These routes manage the project's OAuth app at each provider. The caller needs a **project admin** token, and must also be a Practitioner, an admin, or hold the System Owner access policy. Reading is admin-only too, because the answer names the project's client IDs. Every write is logged with the acting user, the project and the provider.

### The provider object [#the-provider-object]

`GET`, `POST` and `PATCH` answer with this object; `GET /v1/wearables/providers` with a list of them.

| Field | Type | Meaning |
| --- | --- | --- |
| `provider` | string | The provider. |
| `configured` | boolean | The project holds credentials for it that this server can read. Credentials copied from another project read as not configured. |
| `enabled` | boolean | Configured and switched on. Only then can patients connect it. |
| `clientId` | string or `null` | The OAuth client ID, or `null` when not configured. |
| `redirectUri` | string or `null` | The callback URL to register at the provider. The same for every project on the server; `null` when the server has no public base URL. |
| `webhookUrl` | string or `null` | Where the provider sends notifications of new data, unique to the project. Paste it in Suunto's portal; Ovok registers Withings' and Polar's itself. Withings' ends in a secret token: keep it private. `null` when the provider sends no notifications (Oura, WHOOP, Strava, Garmin, Ultrahuman), is not set up, or the server has no public base URL. |
| `webhookReady` | boolean or `null` | `true` when Ovok holds the secret that checks the provider's notifications: Polar's signing key from its webhook setup, the secret Ovok makes for Withings, or Suunto's `webhookSecret`. `false` until then. `null` when the provider sends no notifications. |

### List providers [#list-providers]

`GET /v1/wearables/providers`

Returns every supported provider, set up or not, always in this order: `oura`, `whoop`, `strava`, `withings`, `polar`, `suunto`, `garmin`, `ultrahuman`. Use it to find the callback URL to register and to check `webhookReady`.

**Auth:** project admin. **Parameters:** none.

```bash
curl --request GET \
  --url 'https://api.sandbox.ovok.com/v1/wearables/providers' \
  --header "Authorization: Bearer ${OVOK_TOKEN}"
```

`200`, shortened to three of the eight entries:

```json
{
  "providers": [
    {
      "provider": "oura",
      "configured": true,
      "enabled": true,
      "clientId": "<client id>",
      "redirectUri": "https://api.sandbox.ovok.com/v1/wearables/oauth/oura/callback",
      "webhookUrl": null,
      "webhookReady": null
    },
    {
      "provider": "polar",
      "configured": true,
      "enabled": true,
      "clientId": "<client id>",
      "redirectUri": "https://api.sandbox.ovok.com/v1/wearables/oauth/polar/callback",
      "webhookUrl": "https://api.sandbox.ovok.com/v1/wearables/webhooks/<project id>/polar",
      "webhookReady": true
    },
    {
      "provider": "suunto",
      "configured": false,
      "enabled": false,
      "clientId": null,
      "redirectUri": "https://api.sandbox.ovok.com/v1/wearables/oauth/suunto/callback",
      "webhookUrl": null,
      "webhookReady": false
    }
  ]
}
```

#### Status codes [#status-codes]

| Status | Code | What to do |
| --- | --- | --- |
| `200` | | The list. |
| [`401`](/errors/unauthenticated) | | The token is missing or invalid. Sign in again. |
| [`403`](/errors/forbidden) | [`PROJECT_CONTEXT_REQUIRED`](/errors/project-context-required) | The session has no project. Sign in to the project. |
| [`403`](/errors/forbidden) | | The caller is not a project admin. |

**Side effects:** none.

### Get a provider [#get-a-provider]

`GET /v1/wearables/providers/:provider`

Returns one [provider object](#the-provider-object), set up or not.

**Auth:** project admin.

| Path parameter | Type | Required | Meaning |
| --- | --- | --- | --- |
| `provider` | string | Yes | The provider. |

```bash
curl --request GET \
  --url 'https://api.sandbox.ovok.com/v1/wearables/providers/withings' \
  --header "Authorization: Bearer ${OVOK_TOKEN}"
```

`200`:

```json
{
  "provider": "withings",
  "configured": true,
  "enabled": true,
  "clientId": "<client id>",
  "redirectUri": "https://api.sandbox.ovok.com/v1/wearables/oauth/withings/callback",
  "webhookUrl": "https://api.sandbox.ovok.com/v1/wearables/webhooks/<project id>/withings/<token>",
  "webhookReady": true
}
```

#### Status codes [#status-codes-1]

| Status | Code | What to do |
| --- | --- | --- |
| `200` | | The provider. A provider not set up answers `200` with `configured: false`. |
| [`401`](/errors/unauthenticated) | | The token is missing or invalid. Sign in again. |
| [`403`](/errors/forbidden) | [`PROJECT_CONTEXT_REQUIRED`](/errors/project-context-required) | The session has no project. |
| [`403`](/errors/forbidden) | | The caller is not a project admin. |
| [`422`](/errors/validation-failed) | | `provider` is not supported. |

**Side effects:** none.

### Set up a provider [#set-up-a-provider]

`POST /v1/wearables/providers`

Saves the project's OAuth app at a provider, so the project's patients can connect it. Register the app at the provider first, with the provider's `redirectUri` from [List providers](#list-providers). [Set up providers](/wearables/set-up-providers) has a quickstart per provider.

**Auth:** project admin.

| Body field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `provider` | string | Yes | The provider. |
| `clientId` | string, 1–500 characters | Yes | The app's OAuth client ID. |
| `secret` | string, 1–2000 characters | Yes | The app's OAuth client secret. Never returned. |
| `subscriptionKey` | string or `null`, 1–2000 characters | Suunto only | Suunto's API-management subscription key. Required for Suunto, refused for every other provider. Default `null`. Never returned. |
| `webhookSecret` | string or `null`, 1–2000 characters | No; Suunto only | The notification secret set beside the notification URL in Suunto's portal. Refused for every other provider. Default `null`. Never returned. |
| `enabled` | boolean | No | Whether patients may connect it. Default `true`. |

Unknown fields are refused.

```bash
curl --request POST \
  --url 'https://api.sandbox.ovok.com/v1/wearables/providers' \
  --header "Authorization: Bearer ${OVOK_TOKEN}" \
  --header 'Content-Type: application/json' \
  --data '{"provider":"oura","clientId":"<client id>","secret":"<client secret>"}'
```

Suunto, with its subscription key and notification secret:

```bash
curl --request POST \
  --url 'https://api.sandbox.ovok.com/v1/wearables/providers' \
  --header "Authorization: Bearer ${OVOK_TOKEN}" \
  --header 'Content-Type: application/json' \
  --data '{"provider":"suunto","clientId":"<client id>","secret":"<client secret>","subscriptionKey":"<subscription key>","webhookSecret":"<notification secret>"}'
```

`201`, with `Location: /v1/wearables/providers/oura`:

```json
{
  "provider": "oura",
  "configured": true,
  "enabled": true,
  "clientId": "<client id>",
  "redirectUri": "https://api.sandbox.ovok.com/v1/wearables/oauth/oura/callback",
  "webhookUrl": null,
  "webhookReady": null
}
```

#### Per provider [#per-provider]

| Provider | What differs |
| --- | --- |
| Suunto | `subscriptionKey` is required. `webhookSecret` is optional: without it, `webhookReady` is `false`, Suunto's notifications answer `401`, and Suunto syncs daily and on demand only. |
| Withings | Ovok makes a random secret for the project's webhook URL token, stored encrypted with the credentials. The client ID of Ovok's legacy Withings device integration is refused. |
| Polar | With `enabled: true`, Ovok then creates the client's webhook at Polar for `SLEEP` and `ACTIVITY_SUMMARY` events, pointing at `webhookUrl`, and stores its signing key. Polar first sends the URL a `PING`, which Ovok answers. If the client already has a webhook for this project, Ovok points it at `webhookUrl` and switches it on, replacing it when Ovok lacks its signing key. A webhook pointing at another project, or anywhere else, is left alone. |
| Others | No extra fields or provider calls. |

#### Status codes [#status-codes-2]

| Status | Code | What to do |
| --- | --- | --- |
| `201` | | Stored. `Location` names the provider. For Polar, check `webhookReady`: `false` means the webhook setup failed or belongs to another project; saving again retries. |
| [`401`](/errors/unauthenticated) | | The token is missing or invalid. Sign in again. |
| [`403`](/errors/forbidden) | [`PROJECT_CONTEXT_REQUIRED`](/errors/project-context-required) | The session has no project. |
| [`403`](/errors/forbidden) | | The caller is not a project admin. |
| [`409`](/errors/conflict) | [`PROVIDER_CONFIGURED`](/errors/provider-configured) | The provider is already set up. Change it with [`PATCH`](#change-a-provider). |
| [`409`](/errors/conflict) | | Someone changed the project while this request wrote; nothing was stored. Read the provider again and retry. |
| [`422`](/errors/validation-failed) | [`SUBSCRIPTION_KEY_REQUIRED`](/errors/subscription-key-required) | Suunto needs `subscriptionKey`. |
| [`422`](/errors/validation-failed) | [`SUBSCRIPTION_KEY_NOT_USED`](/errors/subscription-key-not-used) | Only Suunto takes `subscriptionKey`. Remove it. |
| [`422`](/errors/validation-failed) | [`WEBHOOK_SECRET_NOT_USED`](/errors/webhook-secret-not-used) | Only Suunto takes `webhookSecret`. Remove it. |
| [`422`](/errors/validation-failed) | [`CLIENT_ID_RESERVED`](/errors/client-id-reserved) | The Withings client ID belongs to Ovok's legacy device integration. Register an app of your own. |
| [`422`](/errors/validation-failed) | | A field is missing, empty, too long or unknown. `errors` names it. |
| [`500`](/errors/internal-error) | | The credentials could not be confirmed as stored. Read the provider, then retry. |
| [`501`](/errors/not-implemented) | [`FEATURE_NOT_CONFIGURED`](/errors/feature-not-configured) | This server cannot encrypt secrets, so nothing was stored. Contact Ovok support. |

**Side effects:** stores the encrypted credentials on the project and logs the change; for Withings, makes the webhook secret; for Polar, creates or updates the webhook at Polar. A failed Polar webhook setup never undoes the stored credentials.

### Change a provider [#change-a-provider]

`PATCH /v1/wearables/providers/:provider`

Changes a provider already set up. Fields you leave out keep their value. Send at least one.

**Auth:** project admin.

| Path parameter | Type | Required | Meaning |
| --- | --- | --- | --- |
| `provider` | string | Yes | The provider. |

| Body field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `clientId` | string, 1–500 characters | No | A new OAuth client ID. |
| `secret` | string, 1–2000 characters | No | A new client secret. |
| `subscriptionKey` | string, 1–2000 characters | No; Suunto only | A new subscription key. |
| `webhookSecret` | string, 1–2000 characters | No; Suunto only | A new notification secret. Change it here when you change it in Suunto's portal. |
| `enabled` | boolean | No | `false` stops new connections and the sync of existing ones; connected patients stay connected and can disconnect. `true` switches it back on. |

```bash
curl --request PATCH \
  --url 'https://api.sandbox.ovok.com/v1/wearables/providers/oura' \
  --header "Authorization: Bearer ${OVOK_TOKEN}" \
  --header 'Content-Type: application/json' \
  --data '{"secret":"<new client secret>"}'
```

`200` with the [provider object](#the-provider-object) after the change.

#### Per provider [#per-provider-1]

| Provider | What differs |
| --- | --- |
| Withings | Changing `secret` keeps `webhookUrl`. Changing `clientId` makes a new webhook secret, so a new `webhookUrl`; patients connect again to the new app anyway. The legacy device integration's client ID is refused. |
| Suunto | Changing `clientId` keeps the stored `webhookSecret`. Send a new one in the same request if the new app signs with another secret. |
| Polar | Changing `clientId` drops the webhook's signing key, which belonged to the old client; it gets a new one when the webhook is set up again. Every change with `enabled: true`, this one included, sets the webhook up again, as [`POST`](#set-up-a-provider) does. `enabled: false` leaves the webhook at Polar; its notifications answer `404` until Polar is switched back on. |

#### Status codes [#status-codes-3]

| Status | Code | What to do |
| --- | --- | --- |
| `200` | | Changed. |
| [`401`](/errors/unauthenticated) | | The token is missing or invalid. Sign in again. |
| [`403`](/errors/forbidden) | [`PROJECT_CONTEXT_REQUIRED`](/errors/project-context-required) | The session has no project. |
| [`403`](/errors/forbidden) | | The caller is not a project admin. |
| [`404`](/errors/not-found) | [`PROVIDER_NOT_CONFIGURED`](/errors/provider-not-configured) | The provider is not set up. Set it up with [`POST`](#set-up-a-provider). |
| [`409`](/errors/conflict) | | Someone changed the project while this request wrote; nothing was stored. Read the provider again and retry. |
| [`422`](/errors/validation-failed) | [`SUBSCRIPTION_KEY_NOT_USED`](/errors/subscription-key-not-used) | Only Suunto takes `subscriptionKey`. |
| [`422`](/errors/validation-failed) | [`WEBHOOK_SECRET_NOT_USED`](/errors/webhook-secret-not-used) | Only Suunto takes `webhookSecret`. |
| [`422`](/errors/validation-failed) | [`CLIENT_ID_RESERVED`](/errors/client-id-reserved) | The Withings client ID belongs to Ovok's legacy device integration. |
| [`422`](/errors/validation-failed) | | The body is empty, a field is invalid, or a field is unknown. |
| [`500`](/errors/internal-error) | | The change could not be confirmed as stored. Read the provider, then retry. |
| [`501`](/errors/not-implemented) | [`FEATURE_NOT_CONFIGURED`](/errors/feature-not-configured) | This server cannot encrypt secrets, so nothing was stored. |

**Side effects:** stores the encrypted credentials and logs the change; for Polar with `enabled: true`, updates the webhook at Polar.

### Remove a provider [#remove-a-provider]

`DELETE /v1/wearables/providers/:provider`

Removes the project's credentials. Patients can no longer connect the provider, and existing connections stop syncing; they stay listed until the patient disconnects.

**Auth:** project admin.

| Path parameter | Type | Required | Meaning |
| --- | --- | --- | --- |
| `provider` | string | Yes | The provider. |

```bash
curl --request DELETE \
  --url 'https://api.sandbox.ovok.com/v1/wearables/providers/oura' \
  --header "Authorization: Bearer ${OVOK_TOKEN}"
```

`204` with no body.

#### Status codes [#status-codes-4]

| Status | Code | What to do |
| --- | --- | --- |
| `204` | | Removed. |
| [`401`](/errors/unauthenticated) | | The token is missing or invalid. Sign in again. |
| [`403`](/errors/forbidden) | [`PROJECT_CONTEXT_REQUIRED`](/errors/project-context-required) | The session has no project. |
| [`403`](/errors/forbidden) | | The caller is not a project admin. |
| [`404`](/errors/not-found) | [`PROVIDER_NOT_CONFIGURED`](/errors/provider-not-configured) | The provider is not set up; nothing to remove. |
| [`409`](/errors/conflict) | | Someone changed the project while this request wrote. Read it again and retry. |
| [`422`](/errors/validation-failed) | | `provider` is not supported. |

**Side effects:** deletes the stored credentials and logs the change; for Polar, deletes the client's webhook at Polar when it points at this project's `webhookUrl`. A failure at Polar never stops the removal. Patients' grants at the provider are not revoked.

## Patient connections [#patient-connections]

These routes act on the signed-in patient's own connections. Any session that is not a patient's answers [`403 PATIENT_PROFILE_REQUIRED`](/errors/patient-profile-required): an admin cannot connect, sync or disconnect on a patient's behalf.

### List connections [#list-connections]

`GET /v1/wearables/connections`

Returns one entry per provider the project has enabled, plus any provider the patient is still connected to after the project disabled or removed it. Render your connect screen from it.

**Auth:** patient. **Parameters:** none.

```bash
curl --request GET \
  --url 'https://api.sandbox.ovok.com/v1/wearables/connections' \
  --header "Authorization: Bearer ${OVOK_TOKEN}"
```

`200`:

```json
{
  "connections": [
    {
      "provider": "oura",
      "connection": {
        "connectedAt": "2026-10-21T09:00:00.000Z",
        "syncedThrough": "2026-10-22T08:00:00.000Z",
        "reconnectRequired": false
      }
    },
    { "provider": "withings", "connection": null }
  ]
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `provider` | string | The provider. |
| `connection` | object or `null` | `null` when the patient has not connected it. |
| `connection.connectedAt` | date-time | When the patient last connected it. |
| `connection.syncedThrough` | date-time or `null` | Up to when its data is synced; `null` before the first sync. |
| `connection.reconnectRequired` | boolean | `true` when the provider no longer accepts the connection. Connecting again clears it. |

#### Status codes [#status-codes-5]

| Status | Code | What to do |
| --- | --- | --- |
| `200` | | The connections. |
| [`401`](/errors/unauthenticated) | | The token is missing or invalid. Sign in again. |
| [`403`](/errors/forbidden) | [`PATIENT_PROFILE_REQUIRED`](/errors/patient-profile-required) | Not a patient session. |
| [`403`](/errors/forbidden) | [`PROJECT_CONTEXT_REQUIRED`](/errors/project-context-required) | The session has no project. |

**Side effects:** none.

### Start a connection [#start-a-connection]

`POST /v1/wearables/connections/:provider/authorize`

Returns the provider's consent page for the signed-in patient. Open it in the system browser or an auth session, never an embedded WebView. After consent the provider sends the browser to the [OAuth callback](#oauth-callback), which ends at your patient app. [Connect a patient](/wearables/connect-a-patient) walks through the flow.

**Auth:** patient. **Body:** none.

| Path parameter | Type | Required | Meaning |
| --- | --- | --- | --- |
| `provider` | string | Yes | The provider to connect. |

```bash
curl --request POST \
  --url 'https://api.sandbox.ovok.com/v1/wearables/connections/oura/authorize' \
  --header "Authorization: Bearer ${OVOK_TOKEN}"
```

`200`:

```json
{
  "provider": "oura",
  "authorizationUrl": "https://cloud.ouraring.com/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&state=…&scope=…"
}
```

`authorizationUrl` is valid for 10 minutes and one use; ask for a new one each time. The return address, from the project's [`PATIENT_APP_URL`](/settings-and-features/settings/patient-app-url), is fixed when you call the route.

#### Per provider [#per-provider-2]

| Provider | What differs |
| --- | --- |
| Strava | The URL forces Strava's consent screen, so a forwarded link cannot connect silently. |
| Garmin | The URL carries a PKCE challenge. |
| Others | The provider's own scopes, listed in [Set up providers](/wearables/set-up-providers). |

#### Status codes [#status-codes-6]

| Status | Code | What to do |
| --- | --- | --- |
| `200` | | Open `authorizationUrl`. |
| [`401`](/errors/unauthenticated) | | The token is missing or invalid. Sign in again. |
| [`403`](/errors/forbidden) | [`PATIENT_PROFILE_REQUIRED`](/errors/patient-profile-required) | Not a patient session. |
| [`403`](/errors/forbidden) | [`PROJECT_CONTEXT_REQUIRED`](/errors/project-context-required) | The session has no project. |
| [`404`](/errors/not-found) | [`PROVIDER_NOT_AVAILABLE`](/errors/provider-not-available) | The project has not set up the provider, or disabled it. Hide it and re-read the list. |
| [`409`](/errors/conflict) | [`APP_URL_NOT_CONFIGURED`](/errors/app-url-not-configured) | The project has no usable `PATIENT_APP_URL`. A project admin sets it. |
| [`422`](/errors/validation-failed) | | `provider` is not supported. |
| [`501`](/errors/not-implemented) | [`FEATURE_NOT_CONFIGURED`](/errors/feature-not-configured) | This server has no public base URL for the callback. Contact Ovok support. |
| [`503`](/errors/service-unavailable) | [`STATE_STORE_UNAVAILABLE`](/errors/state-store-unavailable) | Ovok could not store the pending authorization. Retry after `Retry-After`. |

**Side effects:** stores a one-time authorization for 10 minutes. Nothing is sent to the provider yet.

### Sync a connection [#sync-a-connection]

`POST /v1/wearables/connections/:provider/sync`

Pulls the patient's latest data from the provider now and writes it as Observations, instead of waiting for the daily sync at 08:00 UTC. [Sync and read the data](/wearables/sync-and-read-data) describes the readings.

**Auth:** patient. **Body:** none.

| Path parameter | Type | Required | Meaning |
| --- | --- | --- | --- |
| `provider` | string | Yes | The connected provider. |

```bash
curl --request POST \
  --url 'https://api.sandbox.ovok.com/v1/wearables/connections/oura/sync' \
  --header "Authorization: Bearer ${OVOK_TOKEN}"
```

`200`:

```json
{ "provider": "oura", "created": 42, "syncedThrough": "2026-10-22T08:00:00.000Z" }
```

| Field | Type | Meaning |
| --- | --- | --- |
| `provider` | string | The provider. |
| `created` | integer | Observations this sync created. Readings already held are updated in place and not counted. |
| `syncedThrough` | date-time | Up to when the data is now synced. |

What is read: the last 30 days on the first sync, then from 2 days before `syncedThrough`, never further back than 30 days. At most one on-demand sync per connection every 15 minutes, counted from `syncedThrough`, so also just after the daily sync.

#### Per provider [#per-provider-3]

| Provider | What differs |
| --- | --- |
| Oura, WHOOP, Strava, Withings | As above. |
| Polar | Sleep and nightly recharge go back 28 days only. Polar's token cannot be refreshed: once Polar refuses it, the answer is `409 RECONNECT_REQUIRED`. |
| Ultrahuman | Asked one day at a time, so a first sync makes 31 requests. |
| Suunto | Daily steps only. |
| Garmin | Not synced yet: `501 SYNC_NOT_SUPPORTED`. |

#### Status codes [#status-codes-7]

| Status | Code | What to do |
| --- | --- | --- |
| `200` | | Synced. |
| [`401`](/errors/unauthenticated) | | The token is missing or invalid. Sign in again. |
| [`403`](/errors/forbidden) | [`PATIENT_PROFILE_REQUIRED`](/errors/patient-profile-required) | Not a patient session. |
| [`403`](/errors/forbidden) | [`PROJECT_CONTEXT_REQUIRED`](/errors/project-context-required) | The session has no project. |
| [`404`](/errors/not-found) | [`CONNECTION_NOT_FOUND`](/errors/connection-not-found) | The patient is not connected to this provider, or disconnected during the sync. Re-read the list. |
| [`404`](/errors/not-found) | [`PROVIDER_NOT_AVAILABLE`](/errors/provider-not-available) | The project disabled or removed the provider. |
| [`409`](/errors/conflict) | [`SYNC_IN_PROGRESS`](/errors/sync-in-progress) | A sync of this connection is running, possibly the daily one. Re-read the list in a minute. |
| [`409`](/errors/conflict) | [`RECONNECT_REQUIRED`](/errors/reconnect-required) | The provider no longer accepts the connection. Ask the patient to connect again. |
| [`409`](/errors/conflict) | [`CONNECTION_CHANGED`](/errors/connection-changed) | The patient reconnected during the sync; nothing was written over the new connection. Sync again. |
| [`422`](/errors/validation-failed) | | `provider` is not supported. |
| [`429`](/errors/too-many-requests) | [`SYNC_TOO_SOON`](/errors/sync-too-soon) | The connection synced under 15 minutes ago. Retry after `Retry-After` seconds; the provider was not asked. |
| [`500`](/errors/internal-error) | [`SYNC_FAILED`](/errors/sync-failed) | The sync failed on Ovok's side. Nothing moved; try again later. |
| [`501`](/errors/not-implemented) | [`SYNC_NOT_SUPPORTED`](/errors/sync-not-supported) | Ovok does not sync this provider yet. The connection is kept. |
| [`502`](/errors/upstream-error) | [`PROVIDER_UNAVAILABLE`](/errors/provider-unavailable) | The provider failed, could not be reached, or answered something unreadable. Nothing moved; try again later. |
| [`503`](/errors/service-unavailable) | [`PROVIDER_RATE_LIMITED`](/errors/provider-rate-limited) | The provider is limiting requests. Retry after `Retry-After` seconds. |

**Side effects:** calls the provider's data API; refreshes and re-stores the access token when it expires within 5 minutes or the provider refuses it; creates or updates Observations; moves `syncedThrough` only after the writes. When the provider refuses the grant, marks the connection `reconnectRequired`.

### Disconnect [#disconnect]

`DELETE /v1/wearables/connections/:provider`

Deletes the patient's connection. Its tokens are no longer readable through the API. Observations already written stay.

**Auth:** patient.

| Path parameter | Type | Required | Meaning |
| --- | --- | --- | --- |
| `provider` | string | Yes | The provider to disconnect. |

```bash
curl --request DELETE \
  --url 'https://api.sandbox.ovok.com/v1/wearables/connections/oura' \
  --header "Authorization: Bearer ${OVOK_TOKEN}"
```

`204` with no body.

#### Per provider [#per-provider-4]

| Provider | What differs |
| --- | --- |
| Withings | Ovok first revokes its notification subscriptions for the patient (`appli` 4, 16 and 44, on its own callback URL only). |
| Polar | Ovok first deregisters the patient at Polar, which also revokes your app's access. |
| Others | Nothing is revoked at the provider. The patient removes your app's access in their provider account. |

A failure at the provider never stops the disconnect.

#### Status codes [#status-codes-8]

| Status | Code | What to do |
| --- | --- | --- |
| `204` | | Disconnected. |
| [`401`](/errors/unauthenticated) | | The token is missing or invalid. Sign in again. |
| [`403`](/errors/forbidden) | [`PATIENT_PROFILE_REQUIRED`](/errors/patient-profile-required) | Not a patient session. |
| [`403`](/errors/forbidden) | [`PROJECT_CONTEXT_REQUIRED`](/errors/project-context-required) | The session has no project. |
| [`404`](/errors/not-found) | [`CONNECTION_NOT_FOUND`](/errors/connection-not-found) | The patient is not connected to this provider. Re-read the list. |
| [`422`](/errors/validation-failed) | | `provider` is not supported. |

**Side effects:** calls Withings or Polar as above; deletes the connection. Its resource history keeps earlier versions, with the tokens still encrypted.

## OAuth callback [#oauth-callback]

`GET /v1/wearables/oauth/:provider/callback`

Where the provider sends the patient's browser after consent. Register it as the app's redirect URL at the provider; never call it yourself.

**Auth:** none. The one-time `state` from [Start a connection](#start-a-connection) identifies the patient and project.

| Parameter | In | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| `provider` | path | string | Yes | The provider. |
| `state` | query | string, 1–200 characters | Yes | The one-time state from the authorization URL. |
| `code` | query | string, up to 4000 characters | On consent | The provider's authorization code. |
| `error` | query | string, up to 200 characters | On refusal | The provider's error, such as `access_denied`. |

With a valid `state`, the answer is always `302` to `<PATIENT_APP_URL>/wearables/<provider>?result=<result>`:

```http
HTTP/1.1 302 Found
Location: https://app.example.com/wearables/oura?result=connected
```

| `result` | Meaning |
| --- | --- |
| `connected` | The tokens are stored. |
| `denied` | The patient refused, or the provider sent an `error`. |
| `failed` | The code exchange or the save failed, the project disabled the provider meanwhile, or Polar refused to register the patient. Nothing stays connected. |

`result` is for display only; read [List connections](#list-connections) for the state of record.

#### Per provider [#per-provider-5]

| Provider | What differs |
| --- | --- |
| Withings | Its token answer is unwrapped from Withings' `{ status, body }` envelope. After connecting, Ovok subscribes the patient to Withings' notifications. A failed subscription is logged and never fails the connect; the daily sync is the backstop. |
| Polar | After the code exchange, Ovok registers the patient with Polar. A patient who has not accepted Polar's mandatory consents is refused: `result=failed`, nothing connected. |
| Garmin | The code exchange sends the PKCE verifier. |

#### Status codes [#status-codes-9]

| Status | Code | What to do |
| --- | --- | --- |
| `302` | | To the patient app with `result`. |
| [`422`](/errors/validation-failed) | [`INVALID_STATE`](/errors/invalid-state) | The `state` expired, was already used, is unknown, or was started for another provider. The patient starts again from your app. |
| [`422`](/errors/validation-failed) | | `state` is missing, a parameter is too long, or `provider` is not supported. |
| [`503`](/errors/service-unavailable) | [`STATE_STORE_UNAVAILABLE`](/errors/state-store-unavailable) | Ovok could not read the pending authorization. The `state` is kept, so the same callback can be retried after `Retry-After`. |

**Side effects:** spends the `state`; exchanges the code with the provider; stores the encrypted tokens and the provider's account id on the connection, replacing an earlier connection to the same provider; calls Withings or Polar as above.

## Webhooks [#webhooks]

Withings, Polar and Suunto tell Ovok when a patient has new data. Use the provider's `webhookUrl` from [List providers](#list-providers); never call these routes yourself.

| Provider | URL | Proof | Registered by |
| --- | --- | --- | --- |
| Withings | `…/webhooks/<project id>/withings/<token>` | The secret token at the end of the URL | Ovok, per patient at connect time |
| Polar | `…/webhooks/<project id>/polar` | `Polar-Webhook-Signature`: HMAC-SHA256 of the body under the webhook's signing key | Ovok, when the project saves Polar |
| Suunto | `…/webhooks/<project id>/suunto` | `X-HMAC-SHA256-Signature`: HMAC-SHA256 of the body under `webhookSecret` | You, in Suunto's portal |

Oura, WHOOP, Strava, Ultrahuman and Garmin send no notifications to Ovok.

### Receive a notification [#receive-a-notification]

`POST /v1/wearables/webhooks/:projectId/:provider`, or `POST /v1/wearables/webhooks/:projectId/:provider/:token` for Withings

Takes a provider's new-data notification and queues a sync of each connection it names. It never writes data itself.

**Auth:** none. The provider's signature, or the URL token for Withings, is the proof. A call without a signature header, or without a token of the right shape, is refused before anything is looked up.

| Parameter | In | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| `projectId` | path | UUID | Yes | The project whose app sends the notification. |
| `provider` | path | string | Yes | The provider. |
| `token` | path | string, up to 128 characters | Withings only | The secret token from Withings' `webhookUrl`. |
| body | body | JSON object or form fields, at most 64 KB | Yes | The provider's own notification. Ovok reads only the provider's user ids from it. |

What Withings sends, for illustration only:

```bash
curl --request POST \
  --url 'https://api.sandbox.ovok.com/v1/wearables/webhooks/<project id>/withings/<token>' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'userid=12345678&appli=44&startdate=1729551600&enddate=1729580400'
```

`202`:

```json
{ "outcome": "queued" }
```

| `outcome` | Status | Meaning |
| --- | --- | --- |
| `queued` | `202` | A sync of each matching connection is queued, about 30 seconds later. Notifications about the same connection within one 30-second window share one sync. |
| `no_connection` | `202` | No patient in the project is connected to the named account. Nothing is queued. Accepted so the provider keeps the URL. |
| `handshake` | `200` | A setup call, such as Polar's `PING` when the webhook is created. Nothing is queued. |

#### Per provider [#per-provider-6]

| Provider | What differs |
| --- | --- |
| Withings | Unsigned, so the URL ends with a token: base64url of an HMAC-SHA256 under the project's Withings webhook secret. The body is form fields `userid`, `appli`, `startdate` and `enddate`; Ovok reads `userid`. Changing the client secret keeps the URL; changing the client ID makes a new one. |
| Polar | Signed with `Polar-Webhook-Signature` under the signing key Ovok stored at webhook setup. `SLEEP` and `ACTIVITY_SUMMARY` notifications queue a sync. `PING` answers `200` `handshake` without a signature check. |
| Suunto | Signed with `X-HMAC-SHA256-Signature` under the `webhookSecret` you saved. The account is `username` in the body. Until the secret is stored, every call answers `401`. |

#### Status codes [#status-codes-10]

| Status | Code | What it means |
| --- | --- | --- |
| `202` | | Accepted: `queued` or `no_connection`. |
| `200` | | A handshake. |
| [`400`](/errors/malformed-request) | [`MALFORMED_NOTIFICATION`](/errors/malformed-notification) | The body is neither a JSON object nor form fields, or does not parse. |
| [`401`](/errors/unauthenticated) | [`INVALID_SIGNATURE`](/errors/invalid-signature) | The signature or URL token is missing or wrong. `WWW-Authenticate` is sent. For Suunto, check `webhookSecret`. |
| [`404`](/errors/not-found) | [`PROJECT_NOT_FOUND`](/errors/project-not-found) | There is no such project. |
| [`404`](/errors/not-found) | [`PROVIDER_NOT_AVAILABLE`](/errors/provider-not-available) | The project has not set up the provider, or disabled it. |
| [`404`](/errors/not-found) | [`WEBHOOK_NOT_SUPPORTED`](/errors/webhook-not-supported) | Ovok takes no notifications from this provider. |
| [`413`](/errors/content-too-large) | | The body is larger than 64 KB. |
| [`422`](/errors/validation-failed) | | `projectId` is not a UUID, or `provider` is not supported. |
| [`429`](/errors/too-many-requests) | | More than 300 calls a minute from one address. `Retry-After` is sent. |
| [`503`](/errors/service-unavailable) | [`SYNC_QUEUE_UNAVAILABLE`](/errors/sync-queue-unavailable) | The sync could not be queued. `Retry-After: 30`; the provider retries, and the daily sync is the backstop. |

**Side effects:** queues one delayed sync per matching connection. A queued sync that finds the connection already syncing is queued once more, 30 seconds later. The notification itself writes no data.

### Check a webhook URL [#check-a-webhook-url]

`HEAD /v1/wearables/webhooks/:projectId/:provider`, or with `/:token`

Answers whether the project takes notifications from the provider, for providers that check a URL before using it (Withings does).

**Auth:** none. The token, when present, is not checked here.

| Path parameter | Type | Required | Meaning |
| --- | --- | --- | --- |
| `projectId` | UUID | Yes | The project. |
| `provider` | string | Yes | The provider. |
| `token` | string, up to 128 characters | No | Ignored. |

```bash
curl --head 'https://api.sandbox.ovok.com/v1/wearables/webhooks/<project id>/suunto'
```

#### Status codes [#status-codes-11]

| Status | Code | What it means |
| --- | --- | --- |
| `200` | | The project has set up and enabled the provider, and Ovok takes notifications from it. No body. |
| [`404`](/errors/not-found) | [`PROJECT_NOT_FOUND`](/errors/project-not-found) | There is no such project. |
| [`404`](/errors/not-found) | [`PROVIDER_NOT_AVAILABLE`](/errors/provider-not-available) | The project has not set up the provider, or disabled it. |
| [`404`](/errors/not-found) | [`WEBHOOK_NOT_SUPPORTED`](/errors/webhook-not-supported) | Ovok takes no notifications from this provider. |
| [`422`](/errors/validation-failed) | | `projectId` is not a UUID, or `provider` is not supported. |
| [`429`](/errors/too-many-requests) | | More than 300 calls a minute from one address. |

**Side effects:** none.
