# Guide to Oura Webhooks: Features and Best Practices

Oura webhooks notify your application when a user's health data changes: a sleep period is recorded, a daily activity or readiness summary updates, a workout is logged. If you're building on the Oura API, webhooks are how you react to new data without polling each user's records on a timer.

This guide covers how Oura webhooks work, the events you'll handle, the subscription handshake, how to verify the `x-oura-signature`, subscription renewal, and the best practices for production.

## What are Oura webhooks?

Oura webhooks are HTTP POSTs delivered to a `callback_url` you register per subscription, each carrying a thin payload about a data change. Oura signs every delivery with an `x-oura-signature` header, and validates your endpoint at subscription time with a GET challenge handshake.

The payload is thin (it names the change, not the data), so you fetch the full record from the Oura API using the `object_id`.

## Oura webhook features

| Feature | Details |
| --- | --- |
| Configuration | `POST/GET/PUT/DELETE /v2/webhook/subscription` (authed with `x-client-id` + `x-client-secret`) |
| Subscription handshake | GET with `verification_token` + `challenge`; echo `{"challenge": "<value>"}` |
| Signature headers | `x-oura-signature`, `x-oura-timestamp` |
| Signature scheme | HMAC-SHA256 over `timestamp + JSON.stringify(body)`, uppercase hex, keyed with the client secret |
| Payload | Thin: `event_type`, `data_type`, `object_id`, `event_time`, `user_id` |
| Subscriptions | One per `data_type` + `event_type`; expire and must be renewed |
| Acknowledgement | 2xx within 10s; 10 retries on 4xx/5xx/timeout; respond `410` to auto-cancel |
| SDK | None |

## Common events

Oura events combine a `data_type` and an `event_type` (`create`, `update`, `delete`). Common data types:

| Event | Fires when |
| --- | --- |
| `sleep` / `create` | A sleep period is recorded |
| `daily_sleep` / `update` | A daily sleep summary updates |
| `daily_activity` / `update` | A daily activity summary updates |
| `daily_readiness` / `update` | A daily readiness summary updates |
| `workout` / `create` | A workout is logged |

The full `data_type` enum includes `tag`, `workout`, `session`, `sleep`, `daily_sleep`, `daily_readiness`, `daily_activity`, `daily_spo2`, `daily_stress`, `vo2_max`, `meal`, and more (17 in total). You create one subscription per `data_type` + `event_type` combination.

## Setting up Oura webhooks

Create a subscription via `POST /v2/webhook/subscription` (authenticated with `x-client-id` and `x-client-secret` headers), providing `callback_url`, `verification_token`, `event_type`, and `data_type`. Oura immediately validates the callback with the handshake below. Subscriptions have an `expiration_time` and must be renewed via `PUT /v2/webhook/subscription/renew/{id}`.

### The subscription handshake

On create, Oura sends a GET to your `callback_url` with `verification_token` and `challenge` query parameters. Confirm the token matches what you set, then respond 200 with a JSON body echoing the challenge.

```javascript
const VERIFICATION_TOKEN = process.env.OURA_VERIFICATION_TOKEN;

app.get("/webhook", (req, res) => {
  if (req.query.verification_token === VERIFICATION_TOKEN) {
    return res.status(200).json({ challenge: req.query.challenge });
  }
  res.sendStatus(403);
});

```

## Securing Oura webhooks

Each POST carries `x-oura-signature` and `x-oura-timestamp`. To verify, compute an HMAC-SHA256 over `timestamp + JSON.stringify(body)` keyed with your client secret, hex-encode it, uppercase it, and compare against `x-oura-signature`. Note the two specifics: the message includes the timestamp prepended, and the digest is uppercase hex.

```javascript
const crypto = require("crypto");

const CLIENT_SECRET = process.env.OURA_CLIENT_SECRET;

app.post("/webhook", express.json(), (req, res) => {
  const timestamp = req.headers["x-oura-timestamp"];
  const message = timestamp + JSON.stringify(req.body);
  const expected = crypto
    .createHmac("sha256", CLIENT_SECRET)
    .update(message)
    .digest("hex")
    .toUpperCase();

  const a = Buffer.from(expected);
  const b = Buffer.from(req.headers["x-oura-signature"] || "");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  res.sendStatus(200); // acknowledge within 10s
  processQueue.add(req.body); // fetch the full record via object_id, async
});

```

The same check in Python:

```python
import hashlib
import hmac
import json
import os

CLIENT_SECRET = os.environ["OURA_CLIENT_SECRET"].encode()

def verify(body: dict, timestamp: str, signature: str) -> bool:
    message = (timestamp + json.dumps(body, separators=(",", ":"))).encode()
    expected = hmac.new(CLIENT_SECRET, message, hashlib.sha256).hexdigest().upper()
    return hmac.compare_digest(expected, signature or "")

```

Because the message uses `JSON.stringify(body)`, byte-exact reproduction of the serialized body matters. Match your JSON serialization (separators, key order) to what Oura sent, or verify against the raw body string directly.

## Oura webhook limitations and pain points

### The signed message is `timestamp + JSON.stringify(body)`, uppercase

The Problem: Two easy mistakes: signing the body alone (the timestamp is prepended), and lowercase hex (Oura uses uppercase). Either fails verification. And because it's over `JSON.stringify(body)`, a re-serialization that reorders keys or changes whitespace breaks it.

Why It Happens: Oura's scheme binds the timestamp into the message and specifies uppercase hex over the stringified body.

Workarounds:

* Prepend the timestamp, uppercase the hex, and reproduce the exact serialization (or capture the raw body string and sign that).

How Hookdeck Can Help: Hookdeck verifies the `x-oura-signature` at the edge, so your application receives pre-verified events without matching Oura's serialization quirks.

### Thin payloads and subscription renewal

The Problem: Payloads carry only `object_id`, so every event needs an API fetch, and subscriptions expire (`expiration_time`) and must be renewed or you silently stop receiving events.

Why It Happens: Oura keeps payloads minimal and time-boxes subscriptions.

Workarounds:

* Fetch the full record via the API using `object_id`, and renew subscriptions ahead of `expiration_time` via the renew endpoint.

How Hookdeck Can Help: Hookdeck durably queues each event so your worker fetches the record at a controlled rate, and its observability surfaces a drop in volume if a subscription lapses.

### One subscription per data_type + event_type

The Problem: You need a separate subscription for each `data_type` + `event_type` combination, so covering many data types means managing many subscriptions (and their renewals).

Why It Happens: Oura scopes subscriptions narrowly.

Workarounds:

* Script subscription creation and renewal so environments are reproducible.

How Hookdeck Can Help: Hookdeck centralizes the resulting streams into one ingestion point with routing, so many Oura subscriptions feed one clean pipeline.

### Retries and duplicates

The Problem: Oura retries up to 10 times on non-2xx or timeout, so the same event can arrive more than once.

Why It Happens: At-least-once delivery favors eventual delivery.

Workarounds:

* Dedupe on `object_id` + `event_time`, and respond `410` to intentionally auto-cancel a subscription you no longer want.

How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge, so retries don't double-process. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Best practices

### Verify with the timestamp-prefixed, uppercase-hex scheme

Compute HMAC-SHA256 over `timestamp + JSON.stringify(body)`, uppercase the hex, and compare in constant time, reproducing Oura's exact serialization (or signing the raw body string).

### Answer the challenge handshake

Echo `{"challenge": "<value>"}` with a 200 and check `verification_token` matches.

### Acknowledge within 10 seconds, fetch asynchronously

Return 2xx immediately and fetch the full record via `object_id` off a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Renew subscriptions and dedupe

Renew before `expiration_time`, and dedupe on `object_id` + `event_time` so retries don't double-process.

## Conclusion

Oura webhooks deliver thin health-data events verified with an `x-oura-signature` HMAC over `timestamp + JSON.stringify(body)` in uppercase hex, gated by a GET challenge handshake, with subscriptions that expire and need renewal. Get the signing message and encoding right, fetch full records via `object_id`, renew subscriptions, and dedupe.

[Hookdeck](https://hookdeck.com) verifies the signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique health events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Oura webhooks reliably in minutes.