Skip to content

Instantly share code, notes, and snippets.

@pugson
Created August 16, 2026 12:29
Show Gist options
  • Select an option

  • Save pugson/4c2107eb5a4ef7cc52e6a107c6a8b0fe to your computer and use it in GitHub Desktop.

Select an option

Save pugson/4c2107eb5a4ef7cc52e6a107c6a8b0fe to your computer and use it in GitHub Desktop.
Oura Sleep Debt isn't in the API — but you can calculate it from sleep data. Formula, reference implementation, and how to verify it against your own app readings.

Reconstructing Oura's Sleep Debt from the public API

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 nightly total_sleep_duration values. 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.

What you need from the API

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.


The formula

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.

What counts as sleep

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.

Sleep need — the hard part

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.

Use a constant, not a rolling estimate

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

Fit against debt readings, not against displayed sleep need

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.


The useful trick: validating the window without knowing sleep need

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.


Validation

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.


Gotchas

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.


Calibrating it for yourself

Sleep need is personal, so the constant in the implementation is mine, not yours.

  1. 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.
  2. Sweep need for 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.
  3. 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.
  4. 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 Bun

To 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.

/**
* Oura Sleep Debt, reconstructed from the public API.
*
* Sleep Debt is NOT exposed by the Oura API — the app computes it on-device and
* keeps it in local storage. But it can be calculated from data the API does
* return: the nightly `total_sleep_duration` values from
* `/v2/usercollection/sleep`.
*
* 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)
*
* Formula recovered from `sleep_debt_calculate` by github.com/Th0rgal/open_oura,
* with every parameter re-verified here against readings from the app.
*
* The one value you must calibrate is `needSeconds` — see calibrate() below and
* the accompanying writeup. It is personal; the default is the author's.
*
* No dependencies. Runs on Node 22+ (`node sleep-debt.ts`) or Deno/Bun.
*/
export interface SleepDebtConfig {
/** Nights in the weighted sum. */
windowDays: number;
/** Weight of the oldest night; the newest is always 1.0. */
tailWeight: number;
/** The app never shows more than this. */
maxDebtSeconds: number;
/** Below this many nights of data the number is meaningless. */
minValidDays: number;
/**
* Personal sleep need. The one input Oura neither publishes nor computes.
* CALIBRATE THIS — see calibrate(). A one-minute error here becomes an
* ~8.75-minute error in the debt, because the weights sum to 8.75.
*/
needSeconds: number;
/** The app rounds the displayed value to 10 minutes. 0 disables rounding. */
displayRoundingSeconds: number;
}
export const DEFAULT_CONFIG: SleepDebtConfig = {
windowDays: 14,
tailWeight: 0.25,
maxDebtSeconds: 10 * 3600,
minValidDays: 5,
needSeconds: 431.1 * 60, // 7h 11m — the author's; yours will differ
displayRoundingSeconds: 600,
};
/**
* Session types that count toward a day's total sleep. Naps count — Oura uses
* "both long and short sleep sessions". `rest` does not: it is Oura detecting
* stillness rather than sleep, and counting it corrupts that day outright.
*/
export const SLEEP_SESSION_TYPES = new Set(["long_sleep", "sleep", "late_nap"]);
/** Oura's own bands, from its Sleep Debt support article. */
export type SleepDebtLevel = "none" | "low" | "moderate" | "high";
export interface SleepDebtResult {
/** Clamped and rounded the way the app displays it. */
seconds: number;
/** Before display rounding — use this when measuring error. */
rawSeconds: number;
needSeconds: number;
validDays: number;
/** False when there isn't enough data for the number to mean anything. */
isValid: boolean;
/** Nights in the window with no data. The API can omit nights the app has. */
missingDays: number;
level: SleepDebtLevel;
}
/** A session as returned by `/v2/usercollection/sleep`. */
export interface OuraSleepSession {
day: string;
type?: string;
total_sleep_duration: number | null;
}
export type DailySleepTotals = Map<string, number>;
const shiftDay = (day: string, delta: number) => {
const date = new Date(`${day}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + delta);
return date.toISOString().slice(0, 10);
};
/** Collapse sessions into one total per day, counting sleep but not rest. */
export function dailySleepTotals(sessions: OuraSleepSession[]): DailySleepTotals {
const totals: DailySleepTotals = new Map();
for (const session of sessions) {
const duration = session.total_sleep_duration;
if (!duration || !session.day) continue;
if (session.type && !SLEEP_SESSION_TYPES.has(session.type)) continue;
totals.set(session.day, (totals.get(session.day) ?? 0) + duration);
}
return totals;
}
export function sleepDebtLevel(seconds: number): SleepDebtLevel {
if (seconds <= 0) return "none";
if (seconds < 2 * 3600) return "low";
if (seconds <= 5 * 3600) return "moderate";
return "high";
}
/** Sleep debt as of the end of `day` — what the app shows on that day's screen. */
export function sleepDebtForDay(
totals: DailySleepTotals,
day: string,
config: SleepDebtConfig = DEFAULT_CONFIG
): SleepDebtResult {
const { windowDays, tailWeight, needSeconds } = config;
const decay = windowDays === 1 ? 0 : (1 - tailWeight) / (windowDays - 1);
let debt = 0;
let validDays = 0;
for (let i = 0; i < windowDays; i++) {
const slept = totals.get(shiftDay(day, -i));
// Missing nights are skipped but still consume their weight slot.
if (!slept) continue;
validDays++;
debt += (1 - decay * i) * (needSeconds - slept);
}
const rawSeconds = Math.max(0, Math.min(config.maxDebtSeconds, debt));
const step = config.displayRoundingSeconds;
return {
seconds: step > 0 ? Math.round(rawSeconds / step) * step : rawSeconds,
rawSeconds,
needSeconds,
validDays,
isValid: validDays >= config.minValidDays,
missingDays: windowDays - validDays,
level: sleepDebtLevel(rawSeconds),
};
}
export const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.round((seconds % 3600) / 60);
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
};
/**
* Fetch enough history to score `day`.
*
* Note the end_date overshoot: Oura's filter EXCLUDES the session belonging to
* end_date. Without the +1 your most recent night silently disappears and the
* debt comes out a whole night's shortfall too low.
*/
export async function fetchSessions(
token: string,
day: string,
windowDays = DEFAULT_CONFIG.windowDays
): Promise<OuraSleepSession[]> {
const start = shiftDay(day, -(windowDays + 1));
const end = shiftDay(day, 1);
const response = await fetch(
`https://api.ouraring.com/v2/usercollection/sleep?start_date=${start}&end_date=${end}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!response.ok) throw new Error(`Oura API ${response.status}: ${await response.text()}`);
return (await response.json()).data;
}
/**
* Find the sleep need that best reproduces your own readings.
*
* Collect Sleep Debt values from the app (8+, including consecutive days) and
* pass them as [day, seconds] pairs. Sleep need is a single scalar, so a
* brute-force sweep is instant and can't get stuck in a local minimum.
*
* Fit against DEBT readings, not against the Sleep Need the app displays: the
* app rounds need to the minute, and ±30s there is still ±4.4 minutes of debt.
*/
export function calibrate(
totals: DailySleepTotals,
readings: [day: string, debtSeconds: number][],
config: SleepDebtConfig = DEFAULT_CONFIG
) {
let best = { needSeconds: config.needSeconds, hits: -1, rmse: Infinity };
for (let needSeconds = 5 * 3600; needSeconds <= 10 * 3600; needSeconds += 6) {
const candidate = { ...config, needSeconds };
let hits = 0;
let sumSquares = 0;
for (const [day, actual] of readings) {
const result = sleepDebtForDay(totals, day, candidate);
if (result.seconds === actual) hits++;
sumSquares += (result.rawSeconds - actual) ** 2;
}
const rmse = Math.sqrt(sumSquares / readings.length);
if (hits > best.hits || (hits === best.hits && rmse < best.rmse)) {
best = { needSeconds, hits, rmse };
}
}
return { ...best, total: readings.length };
}
/**
* Validate the window and decay WITHOUT knowing sleep need.
*
* In debt(d+1) − debt(d) the sleep-need terms cancel exactly — and they cancel
* for any linear-decay weighting, which is what makes this discriminating. So
* consecutive-day readings test windowDays and tailWeight on their own.
*
* If these pairs agree but your absolute values don't, your sleep need is off.
* If the pairs disagree, something structural is wrong: session filtering, day
* attribution, or a night missing from the data.
*/
export function checkWindow(
totals: DailySleepTotals,
readings: [day: string, debtSeconds: number][],
config: SleepDebtConfig = DEFAULT_CONFIG
) {
const byDay = new Map(readings);
const pairs: { day: string; observed: number; predicted: number; errorMinutes: number }[] = [];
for (const [day, debtSeconds] of readings) {
const previousDay = shiftDay(day, -1);
const previousDebt = byDay.get(previousDay);
if (previousDebt === undefined) continue;
const predicted =
sleepDebtForDay(totals, day, config).rawSeconds -
sleepDebtForDay(totals, previousDay, config).rawSeconds;
const observed = debtSeconds - previousDebt;
pairs.push({ day, observed, predicted, errorMinutes: (predicted - observed) / 60 });
}
const worstMinutes = pairs.reduce((worst, p) => Math.max(worst, Math.abs(p.errorMinutes)), 0);
return { pairs, worstMinutes, looksCorrect: pairs.length > 0 && worstMinutes <= 10 };
}
// ---------------------------------------------------------------------------
// Example: OURA_TOKEN=... node sleep-debt.ts
// ---------------------------------------------------------------------------
if (import.meta.filename === process.argv[1]) {
const token = process.env.OURA_TOKEN;
if (!token) {
console.error("Set OURA_TOKEN to a personal access token from cloud.ouraring.com");
process.exit(1);
}
const today = new Date().toISOString().slice(0, 10);
const day = shiftDay(today, -1); // yesterday — the app's "Yesterday" tab
const totals = dailySleepTotals(await fetchSessions(token, day));
const debt = sleepDebtForDay(totals, day);
console.log(`Sleep debt for ${day}: ${formatDuration(debt.seconds)} (${debt.level})`);
console.log(` against a sleep need of ${formatDuration(debt.needSeconds)}`);
console.log(` from ${debt.validDays}/${DEFAULT_CONFIG.windowDays} nights`);
if (!debt.isValid) console.log(" NOT ENOUGH DATA — the app would show nothing here");
if (debt.missingDays) {
console.log(` ${debt.missingDays} night(s) absent from the API — likely understated`);
}
console.log("\nIf this disagrees with your app, calibrate needSeconds. See the writeup.");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment