A Django app for the Oura API v2, backed by django-healthdatamodel.
Project description
django-oura
A reusable Django app for the Oura API v2. Handles Oura's OAuth 2.0 flow, fetches documents from api.ouraring.com/v2/usercollection, receives webhook notifications, and persists everything through django-healthdatamodel so the same storage and query layer serves Apple Health, Fitbit, Google Health, Garmin, and Oura side-by-side.
The Oura V1 API is sunset, and personal access tokens were retired in December 2025 — this package speaks only V2 with OAuth2. API applications are limited to 10 users before requiring approval from Oura.
Install
pip install django-oura
Add both this app and django-healthdatamodel to INSTALLED_APPS, then run migrations:
INSTALLED_APPS = [
...
"healthdatamodel",
"oura",
]
python manage.py migrate
The model uses settings.AUTH_USER_MODEL so it works with any custom user model.
Configuration
OURA_CLIENT_ID = "..." # from https://cloud.ouraring.com/oauth/applications
OURA_CLIENT_SECRET = "..."
OURA_REDIRECT_URI = "https://your-app.example.com/oura/callback/"
# Optional:
OURA_SCOPES = ["daily", "heartrate", "workout", "spo2"] # the default set
OURA_CONNECT_SUCCESS_URL = "/" # default "/admin/"
OURA_WEBHOOK_VERIFICATION_TOKEN = "..." # only for webhooks
Register the redirect URI on the Oura application — the token exchange fails on any mismatch. Users consent per scope on Oura's authorization page, so the granted set may be narrower than requested; whatever was actually granted is stored on OuraConnection.scopes.
URLs
urlpatterns = [
...
path("oura/", include("oura.urls")),
]
This mounts:
oura/connect/— start the OAuth flow (login required)oura/callback/— the redirect URI targetoura/disconnect/— POST; revokes the token at Oura and marks the connection revokedoura/notifications/— webhook receiver (GET verification challenge + POST events)
Mobile clients that run the OAuth dance themselves can POST the resulting token dict to a project-local endpoint that calls oura.oauth.ingest_tokens.
Getting data
Oura's recommended architecture is one historical pull when a user connects, then webhooks — properly implemented, webhook consumers don't hit the API's 5000-requests-per-5-minutes rate limit.
Pull. oura.ingest.sync_user(connection, start=..., end=...) fetches and ingests the configured data types. Document routes filter by the calendar day the data belongs to (in the user's local timezone), so re-syncing a window picks up revisions to those days.
python manage.py sync_oura # last day, all active connections
python manage.py sync_oura --user alice --days 30
python manage.py sync_oura --data-type daily_activity --data-type sleep
Webhooks. Subscriptions are app-level, one per (event_type, data_type) pair, and Oura verifies your endpoint with a GET challenge at subscription time — oura/notifications/ answers it using OURA_WEBHOOK_VERIFICATION_TOKEN. Create them once the endpoint is live:
python manage.py create_oura_webhook_subscription \
--callback-url https://api.example.com/oura/notifications/ \
--verification-token "$OURA_WEBHOOK_VERIFICATION_TOKEN" \
--data-type daily_activity --data-type sleep --data-type workout
python manage.py list_oura_webhook_subscriptions
python manage.py delete_oura_webhook_subscription <id>
Then connect a signal handler to drive ingest:
from django.dispatch import receiver
from oura.signals import notification_received
from oura.webhooks import process_notification
@receiver(notification_received)
def on_notification(sender, payload, **kwargs):
process_notification(payload) # or hand off to celery / a queue
Notifications carry {event_type, data_type, object_id, user_id}; process_notification routes to a user by Oura's stable user_id, fetches the referenced document with that user's credentials, and ingests it. The receiver view enforces Oura's x-oura-signature HMAC before emitting the signal (set OURA_WEBHOOK_REQUIRE_SIGNATURE = False to accept unsigned POSTs). Oura expects a 2xx within 10 seconds — hand off to a queue if your processing is heavy. Subscriptions expire; renew with oura.webhooks.renew_subscription.
What gets stored
This app does not define Record / Workout tables — those live in django-healthdatamodel. The oura.ingest module maps Oura documents to healthdatamodel inputs and persists them with source="oura":
| Oura data type | healthdatamodel records |
|---|---|
daily_activity |
steps, active calories, basal calories (total − active), equivalent walking distance |
sleep |
one sleep-stage record per sleep_phase_5_min run (deep/light/REM/awake) |
daily_spo2 |
daily SpO2 average |
heartrate |
one heart-rate sample per 5-minute increment |
workout |
one Workout per workout |
Oura-specific scores (readiness, sleep score, resilience, stress) have no HealthKit-schema equivalent and are not ingested yet.
The only model defined here is OuraConnection: per-user OAuth tokens (Oura refresh tokens are single-use — both tokens are rewritten together on every refresh), granted scopes, connection status, and last sync timestamp.
Try it without a ring (sandbox mode)
Oura serves generated fake data at /v2/sandbox/usercollection — same routes
and shapes, no account or OAuth needed. The demo has first-class support:
uv sync
uv run python manage.py migrate
uv run python manage.py createsuperuser
OURA_SANDBOX=1 uv run python manage.py runserver
Sign in, click Sync sandbox data, and browse the results in the admin. A
placeholder OuraConnection is created automatically. Programmatic use:
OuraClient(connection, sandbox=True). Note the sandbox's degenerate corners
(documented in CLAUDE.md) before treating it as calibration ground truth.
Try it on your own data
The repo includes a runnable demo Django project at demo/.
1. Register an Oura API application (one-time)
Create one at cloud.ouraring.com/oauth/applications and register the redirect URI http://localhost:8000/oura/callback/ (exact match, trailing slash included). Unapproved applications are limited to 10 users — fine for the demo.
2. Run the demo
uv sync
uv run python manage.py migrate
uv run python manage.py createsuperuser
export OURA_CLIENT_ID=...
export OURA_CLIENT_SECRET=...
uv run python manage.py runserver
Open http://localhost:8000/, sign in with the superuser you just created, then:
- Click Connect Oura → consent on Oura (pick your scopes) → land back on the homepage with an
OuraConnectionsaved for your user. - Open the Oura app on your phone so your ring syncs (sleep data only reaches Oura's cloud that way), then pick a window and click Sync now.
- Browse the resulting rows at
/admin/healthdatamodel/record/(and.../workout/).
Or drive sync from the terminal:
uv run python manage.py sync_oura --user <your-username> --days 30
Documentation
Summaries of the Oura API docs live under docs/oura/ so they're grep-able offline:
authentication.md— OAuth2 endpoints, scopes, token lifetimes, revocationapi.md— the v2 usercollection routes, params, pagination, payload shapeswebhooks.md— subscription API, verification challenge, signatures, retries
Development
uv sync --group dev
uv run pytest tests/ -v
uv run pre-commit run --all-files
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_oura-0.2.0.tar.gz.
File metadata
- Download URL: django_oura-0.2.0.tar.gz
- Upload date:
- Size: 68.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21fcc40d0b0c9feea520f7ed69065102eac6b2d63666b71b7fa69f661e3e2a7f
|
|
| MD5 |
860d21ce662199738f9b4d3ffe875eed
|
|
| BLAKE2b-256 |
d2556d8b22d92c104614485e730dcff4e831a8f5b3a6bb3686d8997d09900fb5
|
Provenance
The following attestation bundles were made for django_oura-0.2.0.tar.gz:
Publisher:
ci.yml on django-health/django-oura
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_oura-0.2.0.tar.gz -
Subject digest:
21fcc40d0b0c9feea520f7ed69065102eac6b2d63666b71b7fa69f661e3e2a7f - Sigstore transparency entry: 2104666983
- Sigstore integration time:
-
Permalink:
django-health/django-oura@bb0daf94022b7133bc9dc891cb57c95653b436e8 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/django-health
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@bb0daf94022b7133bc9dc891cb57c95653b436e8 -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_oura-0.2.0-py3-none-any.whl.
File metadata
- Download URL: django_oura-0.2.0-py3-none-any.whl
- Upload date:
- Size: 31.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ee4dce707d33ef07bd7d7d27d7412e109c0d37f70eb585117ad06a98ebcdfb2f
|
|
| MD5 |
3d2c123c5729d64a3fe25a715abb3f51
|
|
| BLAKE2b-256 |
46722ffc6075926107a0ef93786fe5bc5132ab75ec153ade2e250ffd9e8d9c2f
|
Provenance
The following attestation bundles were made for django_oura-0.2.0-py3-none-any.whl:
Publisher:
ci.yml on django-health/django-oura
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_oura-0.2.0-py3-none-any.whl -
Subject digest:
ee4dce707d33ef07bd7d7d27d7412e109c0d37f70eb585117ad06a98ebcdfb2f - Sigstore transparency entry: 2104667129
- Sigstore integration time:
-
Permalink:
django-health/django-oura@bb0daf94022b7133bc9dc891cb57c95653b436e8 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/django-health
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@bb0daf94022b7133bc9dc891cb57c95653b436e8 -
Trigger Event:
push
-
Statement type: