Sleep Debt is not available through the Oura API — but you can calculate it yourself from the sleep data that is.
Everything you need is already in
/v2/usercollection/sleep: the nightlytotal_sleep_durationvalues. The debt figure is just a weighted sum over the last 14 of them, against one personal constant you have to calibrate once. This gist gives you the formula, the reference implementation, and — the part that actually matters — how to verify your version is right rather than merely plausible.
Oura's app shows a Sleep Debt figure on its Sleep screen. It appears nowhere in the public API, and no amount of proxying the app will find it: Oura computes it on-device, in a native engine called ecore, and writes it straight to the app's local database. Opening the Sleep Debt screen generates no network traffic at all. So there is nothing to intercept — but there is enough in the API to rebuild the number.
Result: 13 of 15 readings reproduced exactly, with both misses explained by a night missing from the API rather than by the model.
Credit for the decompilation of sleep_debt_calculate goes to Th0rgal/open_oura. Everything below re-derives and validates its parameters against real app readings.
One endpoint. For each day you want to score, the 14 nights before it:
GET /v2/usercollection/sleep?start_date=…&end_date=…
From each session you only need three fields: day, type, and total_sleep_duration. Everything else in the response — the heart rate arrays, the hypnogram — is irrelevant here.
debt = Σ w(i) × (need − slept(day_i)) i = 0 (last night) … 13
w(i) = 1 − 0.75 × i / 13 1.00 → 0.25, linear decay
debt = clamp(debt, 0, 10h)
A weighted sum of how far short of your sleep need you fell each night, over a 14-night window, with recent nights counting more. Details that matter:
- Nights with no data are skipped but still consume their weight slot. They do not shift the window.
- Below 5 valid nights the number is meaningless and the app hides it.
- The app displays debt rounded to the nearest 10 minutes. All 15 of my readings are multiples of 10 minutes — roughly a 1-in-1000 coincidence otherwise. Compare raw values when measuring error, rounded values when asking "did I reproduce it".
- The decompiled default config carries a 45-minute rounding step. That is not what the app uses — 8h 50m and 9h 10m aren't multiples of 45 minutes. Don't be led astray by it.
Oura's docs say debt uses "total sleep each day (including both long and short sleep sessions)". Concretely, summing per day:
Session type |
Counts? |
|---|---|
long_sleep |
yes |
sleep |
yes |
late_nap |
yes |
rest |
no |
rest is Oura detecting stillness, not sleep. There was exactly one rest session in ~600 days of my history (a 35-minute one, efficiency 53) and including it threw that day off by its full 35 minutes. Naps genuinely do count — excluding a 1h 37m late_nap broke a different day by 97 minutes.
need is the one input the binary receives rather than computes, and the API never returns it. Everything hard about this problem lives here.
The central fact: the weights sum to 8.75. So a one-minute error in sleep need becomes an ~8.75-minute error in the debt. Every design decision follows from that amplification.
Oura describes sleep need as personalised from "your typical sleep over the past 90 days". That invites you to model it as a rolling percentile of recent sleep. Don't.
Across two months of readings the implied need never left a one-minute band (7h 10m – 7h 12m). A rolling estimator's own wobble is larger than the drift it would be tracking, so it injects more error than it removes. I swept the obvious families:
| Sleep need model | Debt readings reproduced |
|---|---|
| Constant 7h 11m | 13 / 15 |
| Percentile of trailing window (any window 30–500d, any percentile 40–95, naps in or out) | best 7 / 15 |
mean + k·sd of trailing window |
best 1 / 15 |
The app does display Sleep Need, so it is tempting to just read it off and use it. This is a trap.
The app displays need rounded to the minute. Matching it to ±30s still leaves ~4.4 minutes of debt error. I found a trailing-percentile model that reproduced every displayed need value to the minute — and on debt it scored 5/14, versus 12/14 for a constant fitted through the debt readings, on the set I had at the time.
The debt readings pin need roughly nine times tighter than the need display does, precisely because of the 8.75× amplification. So:
- Debt readings → fit the constant.
- Displayed need → staleness check only, to tell you when to re-fit.
For reference, my displayed need over two and a half months: 7h 12m (Jun 4), 7h 10m (Jul 24), 7h 11m (Aug 14), 7h 11m (Aug 15). About ±1 minute of real drift, which is the model's residual error.
This is the part worth stealing even if you never touch Oura.
Take the difference between two consecutive days. Writing S(x) = need − slept(x):
debt(d+1) − debt(d) = S(d+1) − w(n-1)·S(d+1−n) − δ·Σ S(d−i)
i = 0 … n-2
where δ = 0.75 / (n−1)
Now substitute S(x) = need − slept(x) and collect the need terms. Their coefficient is:
1 − w(n-1) − δ(n−1) = 1 − (1 − δ(n−1)) − δ(n−1) = 0
Sleep need cancels exactly. And it cancels for any linear-decay weighting, not just this one — which is what makes it discriminating. The day-to-day change in debt depends only on the window length, the decay, and your actual sleep. So any two back-to-back readings test the window and decay on their own, with the hardest unknown removed from the problem.
Running it on seven consecutive-day pairs:
| Pair | Observed Δ | Predicted Δ | Error |
|---|---|---|---|
| Jul 13 → 14 | −30m | −28.8m | +1.2m |
| Jul 14 → 15 | +90m | +94.5m | +4.5m |
| Jul 15 → 16 | +30m | +32.8m | +2.8m |
| Jul 16 → 17 | 0m | −0.1m | −0.1m |
| Aug 6 → 7 | +10m | +5.3m | −4.7m |
| Aug 14 → 15 | +80m | +78.9m | −1.1m |
| Aug 15 → 16 | −100m | −95.2m | +4.8m |
Worst error 4.8 minutes, and no other window/decay combination comes close (I swept windows 5–26 and tail weights 0–1.0). That confirms 14 nights and a 0.25 tail weight independently of the decompilation.
This is also how I found the rest bug. Six of the seven pairs matched immediately; the Jul 14 → 15 pair was off by exactly 30 minutes, and Jul 15 was the day with the 35-minute rest session. Because sleep need is out of the equation, a single bad pair points straight at that day's data rather than at your parameters.
Sleep need = 7h 11m, fitted against these same readings.
| Day | Predicted (raw) | Shown | App | Error |
|---|---|---|---|---|
| 2026-07-04 | 6h 06m | 6h 10m | 6h 10m | −3.8m |
| 2026-07-06 | 3h 42m | 3h 40m | 3h 40m | +1.7m |
| 2026-07-09 | 5h 26m | 5h 30m | 5h 30m | −4.2m |
| 2026-07-13 | 6h 05m | 6h 10m | 6h 10m | −4.9m |
| 2026-07-14 | 5h 36m | 5h 40m | 5h 40m | −3.7m |
| 2026-07-15 | 7h 11m | 7h 10m | 7h 10m | +0.8m |
| 2026-07-16 | 7h 44m | 7h 40m | 7h 40m | +3.7m |
| 2026-07-17 | 7h 44m | 7h 40m | 7h 40m | +3.5m |
| 2026-07-23 | 9h 27m | 9h 30m | 9h 30m | −3.0m |
| 2026-07-28 | 8h 55m | 9h 00m | 9h 00m | −4.7m |
| 2026-08-06 | 8h 44m | 8h 40m | 9h 10m | −25.6m |
| 2026-08-07 | 8h 50m | 8h 50m | 9h 20m | −30.3m |
| 2026-08-14 | 7h 30m | 7h 30m | 7h 30m | +0.5m |
| 2026-08-15 | 8h 49m | 8h 50m | 8h 50m | −0.7m |
| 2026-08-16 | 7h 14m | 7h 10m | 7h 10m | +4.1m |
Both misses are windows containing 2026-08-01, a night the API returns nothing for. Solving for what value would fix them gives ~6h 33m, and the two windows imply it independently as 6h 36m and 6h 26m — consistent within rounding noise. So the app has a night the API does not. Rather than fabricate it, the implementation reports missingDays so the caller can flag the value as understated.
end_date is effectively exclusive. /v2/usercollection/sleep?...&end_date=X drops the session belonging to day X. Overshoot by a day. If you don't, your most recent night silently vanishes and debt is understated by a full night's shortfall — it shows up as a suspiciously clean ~2-hour error.
The API can be missing nights the app has. Don't assume absence means the user didn't sleep.
Payloads are heavy. Each session carries heart_rate, movement_30_sec and sleep_phase_5_min arrays — ~800KB for 200 days. You only need 14 nights behind each day you're scoring, so fetch narrowly and cache.
Don't score nap-only days as "today". A day whose only session is an afternoon nap is not a completed night; it reads as a huge shortfall. One of my days would have shown a bogus 10h (the clamp ceiling) that way.
Beware of overfitting. My first fit used three readings, hit sub-minute accuracy on all three, and was completely wrong — it collapsed to ~15-minute errors as soon as more readings arrived. With three readings and three free parameters there are zero degrees of freedom. Treat any fit under ~8 readings as unvalidated, and prefer consecutive-day readings.
Expect to disagree by exactly one rounding step sometimes. With 10-minute display quantisation, any raw value landing near a boundary is a coin flip. One of my days came out at 7h 15.0m against the app's 7h 10m — a raw error of at most a few minutes, but it displayed as 7h 20m. Re-fitting on that single reading moved sleep need by six seconds and corrected it. Judge your model on raw error, not on the displayed value, or you'll chase noise.
Sleep need drifts, so re-fit occasionally. It moved about ±1 minute over two and a half months for me — which is ~9 minutes of debt. If your numbers start drifting by a rounding step, that's the signal, and the Sleep Need the app displays is the cheapest way to spot it.
Watch your own caching. This one isn't Oura's fault, but it cost me a bug report: my dashboard cached the API response for an hour, so a night's data couldn't appear until that expired no matter how often the client polled. If you're caching, make the TTL the ceiling on how stale the number can be, and make sure a client-side poll interval isn't quietly masking it.
Sleep need is personal, so the constant in the implementation is mine, not yours.
- Collect Sleep Debt readings from your app — swipe back through the Sleep screen. Aim for 8+, and include consecutive days, which are worth far more than isolated ones.
- Sweep
needfor the value that reproduces the most readings after 10-minute rounding. It's a single scalar, so a brute-force sweep at 6-second resolution is instant and can't land in a local minimum. - Run the consecutive-pair check. If the pairs agree but your absolute values don't, your need is wrong. If the pairs disagree, something structural is wrong — session filtering, day attribution, or a missing night.
- Re-check occasionally against the Sleep Need the app displays. Beyond ~1.5 minutes of disagreement, re-fit.
sleep-debt.ts is self-contained and dependency-free. Get a personal access token from cloud.ouraring.com, then:
OURA_TOKEN=... node sleep-debt.ts # Node 22+, Deno or BunTo calibrate against your own readings:
import { dailySleepTotals, fetchSessions, calibrate, checkWindow, formatDuration } from "./sleep-debt.ts";
const H = (h: number, m: number) => h * 3600 + m * 60;
// Sleep Debt values read off your app, as [day, seconds].
const readings: [string, number][] = [
["2026-07-13", H(6, 10)], ["2026-07-14", H(5, 40)], ["2026-07-15", H(7, 10)],
["2026-07-16", H(7, 40)], ["2026-07-17", H(7, 40)], ["2026-07-23", H(9, 30)],
["2026-08-14", H(7, 30)], ["2026-08-15", H(8, 50)],
];
const totals = dailySleepTotals(await fetchSessions(process.env.OURA_TOKEN!, "2026-08-15", 60));
// 1. Is the window right? This does not depend on sleep need at all.
const check = checkWindow(totals, readings);
console.log(`${check.pairs.length} pairs, worst ${check.worstMinutes.toFixed(1)}m, ok=${check.looksCorrect}`);
// 2. What sleep need reproduces the most readings?
const fit = calibrate(totals, readings);
console.log(`need ${formatDuration(fit.needSeconds)} -> ${fit.hits}/${fit.total} exact`);Do step 1 first. There is no point tuning sleep need until the window checks out, and a failing window check tells you the problem is in your data rather than your constant.