Gareth Wilson Gareth Wilson

Guide to Oura Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPOST/GET/PUT/DELETE /v2/webhook/subscription (authed with x-client-id + x-client-secret)
Subscription handshakeGET with verification_token + challenge; echo {"challenge": "<value>"}
Signature headersx-oura-signature, x-oura-timestamp
Signature schemeHMAC-SHA256 over timestamp + JSON.stringify(body), uppercase hex, keyed with the client secret
PayloadThin: event_type, data_type, object_id, event_time, user_id
SubscriptionsOne per data_type + event_type; expire and must be renewed
Acknowledgement2xx within 10s; 10 retries on 4xx/5xx/timeout; respond 410 to auto-cancel
SDKNone

Common events

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

EventFires when
sleep / createA sleep period is recorded
daily_sleep / updateA daily sleep summary updates
daily_activity / updateA daily activity summary updates
daily_readiness / updateA daily readiness summary updates
workout / createA 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.

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.

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:

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.

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.

Renew subscriptions and dedupe

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

Make Oura webhooks production-ready

Hookdeck verifies x-oura-signature, deduplicates, and durably queues every health event

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 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 for free and handle Oura webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.