Oura API Explorer
Build a local Flask app that connects to Oura and displays live sleep JSON.
Introduction
30 Second Summary
Your sleep data lives behind your account while most apps show only the view designed for you. Seeing the original response gives you a clearer starting point for deciding what to build next.
In this project, you will build a polished local Flask web app for your Oura account. The app uses OAuth 2.0 to request sleep data from the Oura API before displaying its current JSON response in a readable viewer.
What You'll Build
The finished explorer shows a green 200 badge above the formatted response from your own sleep request.
By the end of this project, you'll have:
- A polished connection screen where you can confirm the authentication state. You can also see the /v2/usercollection/sleep endpoint plus the requested daily scope.
- An account connection flow that sends you to Oura for consent using only the daily scope. Its Disconnect control removes the access token from the running local process.
- A live response viewer with an HTTP status badge plus formatted JSON. Styled empty and error states keep every result readable.
- Secret Mission: Inspect your real response and document its observed shape as the foundation for a future card-based dashboard.
Are there any prerequisites?
You need a Mac with internet access plus an Oura Ring linked to an Oura account with API-accessible data.
If your ring is Gen3 or later, API access requires an active Oura Membership.
Before We Start
Before any hands-on work begins, this step helps you commit to building a local explorer for your own Oura account. You will focus on the raw sleep response so you can inspect the data without assuming undocumented fields.
Set Up Python and the Project
Your explorer needs a supported Python runtime before any local web code can run.
A virtual environment keeps this project's packages separate from the rest of your Mac.
This setup adds Flask for the local web app.
Authlib prepares the OAuth flow. Requests prepares the protected API call.
In this step, get ready to:
- Install Python 3.14.8 with its macOS certificates.
- Create the isolated environment inside the project folder in VS Code.
- Install the pinned package set from requirements.txt.
Install Python 3.14.8
The app depends on Python 3.14.8. Start by checking the version currently available on your Mac.
- Check your current Python version by running this command in a terminal:
python3 --version
What does this check show?
The command reports which Python release your terminal uses. The result determines whether you can continue or need the macOS installer.
✔️ I see version 3.14.8 or higher
Your Python runtime meets the project's requirement. Continue with the certificate step below.
ⓧ I see an older version
Your current Python release is below the project version. Install Python 3.14.8 before continuing.
- Open the official Python macOS releases page in your browser.
- Select Download macOS installer for Python 3.14.8.
- Open the downloaded installer file.
- Select Continue in the installer.
- Select Agree when the licence appears.
- Select Install to add Python to your Mac.
- Complete the remaining macOS prompts.
ⓧ Command not found
Python is missing from your terminal. Install Python 3.14.8 from the official macOS release.
- Open the official Python macOS releases page in your browser.
- Select Download macOS installer for Python 3.14.8.
- Open the downloaded installer file.
- Select Continue in the installer.
- Select Agree when the licence appears.
- Select Install to add Python to your Mac.
- Complete the remaining macOS prompts.
The certificate helper connects this Python installation to trusted web certificates. This step is easy to miss because the helper sits inside the Python application folder.
- Open Finder from the macOS Dock.
- Select Applications in Finder's sidebar.
- Open the Python 3.14 folder.
- Double-click Install Certificates.command.
A terminal window completes the certificate setup. You should see Successfully installed certifi followed by update complete.
Certificate helper not completing?
- Confirm that you opened Install Certificates.command from /Applications/Python 3.14/.
- Allow the helper to finish downloading its certificate package before closing the terminal window.
Need help with the certificate helper?
Create the isolated project environment
The project folder gives every file one predictable home. The integrated terminal keeps your commands tied to that folder.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Visual Studio Code into the search field.
- Press Enter to open VS Code.
- Click View in the top menu.
- Select Terminal to open the integrated terminal.
- Move the terminal to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The cd command changes the terminal's current folder. The ~/Desktop path points to the Desktop inside your macOS home folder.
- Create oura-api-explorer on your Desktop by running these commands:
mkdir oura-api-explorer
cd oura-api-explorer
What do these commands do?
- The first command creates the oura-api-explorer folder.
- The second command moves the terminal into that folder.
Your terminal prompt now points to oura-api-explorer. This confirms that the project folder exists on your Desktop.
- Click File in the VS Code menu.
- Select Open Folder....
- Select Desktop in the folder dialog.
- Select the oura-api-explorer folder.
- Click Open.
- Confirm that you trust the folder if a workspace trust prompt appears.
You'll see oura-api-explorer at the top of the Explorer sidebar. VS Code now treats this folder as your workspace.
- Click View in the top menu.
- Select Terminal to open a terminal inside oura-api-explorer.
- Create the virtual environment by running these commands:
python3 -m venv .venv
source .venv/bin/activate
What do these commands do?
- The first command creates .venv with an isolated Python installation.
- The second command activates that environment for the current terminal.
- Future package installations now stay inside .venv.
- Confirm that the terminal prompt begins with (.venv).
- Confirm that .venv appears in the VS Code Explorer sidebar.
Virtual environment not activating?
- Confirm that the VS Code terminal is inside the oura-api-explorer folder.
- Check that .venv appears in the Explorer sidebar before running the activation command again.
Need help activating the environment?
Install the pinned dependencies
A pinned dependency file records the exact package versions used by the project. This makes the environment reproducible each time it is installed.
- Select the New File... button in the Explorer sidebar.
- Enter requirements.txt as the file name.
- Populate requirements.txt by pasting this exact package list:
Authlib==1.8.0
Flask==3.1.3
requests==2.34.2
What does this file define?
- Authlib 1.8.0 provides the OAuth client used later.
- Flask 3.1.3 serves the local explorer in your browser.
- Requests 2.34.2 supports HTTP response handling.
- Save requirements.txt with Cmd+S on macOS or Ctrl+S on Windows.
- Confirm that requirements.txt remains listed in the Explorer sidebar.
Package file looks different?
- Confirm that the file name is exactly requirements.txt.
- Check that each package occupies its own line.
- Remove any spaces surrounding the version markers.
Need help checking the dependency file?
✔️ Awesome, I've got everything!
Great. Double-check that requirements.txt is saved before installing the packages.
ⓧ I'd like to double check the full code
Authlib==1.8.0
Flask==3.1.3
requests==2.34.2
The activated environment directs pip to install these packages inside .venv.
- Install the pinned dependencies by running this command:
python3 -m pip install -r requirements.txt
What does this command do?
- The python3 -m pip portion runs the package installer through the active Python environment.
- The -r requirements.txt portion installs every pin from the dependency file.
The terminal downloads the three packages plus their supporting libraries. It returns to the (.venv) prompt when installation finishes.
Dependencies not installing?
- Confirm that the terminal prompt begins with (.venv).
- Confirm that requirements.txt is saved inside oura-api-explorer.
- Check that your Mac has an active internet connection.
Need help with the package installation?
Before you run the final check, decide whether the runtime plus all three imports are ready.
- Verify the Python version plus the installed dependencies by running these commands:
python3 --version
python3 -c "import flask, authlib, requests; print('Dependencies ready')"
What does this check prove?
- The first command confirms which Python runtime the activated terminal uses.
- The second command imports every pinned dependency.
- The final message appears only after all three imports succeed.
You'll see Python 3.14.8 followed by Dependencies ready.
That's the foundation locked in. Your isolated Python environment can import every package the explorer needs.
Final check not passing?
- Activate .venv again if the prompt no longer begins with (.venv).
- Run the package installation command again if the import check reports a missing dependency.
- Restart the VS Code terminal if the Python version still shows an older installation.
Need help passing the final environment check?
Your runtime plus dependencies are ready. Next, you'll turn this empty folder into the polished explorer shell.
Build the Polished Explorer Shell
Your Python environment is ready. That foundation now gives your explorer a reliable place to run.
A polished shell gives the protected data from the Oura API a clear place to appear. In this step, you will build the interface that exposes connection details and response states.
In this step, get ready to:
- Create the Flask entry point for the local explorer.
- Build the complete HTML response viewer.
- Style the shell before testing its locked state.
Create the Flask entry point
The entry point starts the local server and renders the page template. It also supplies the initial values that keep the explorer disconnected.
- Create app.py inside the oura-api-explorer folder by using the new-file control in the VS Code file sidebar.
- Create a templates folder inside oura-api-explorer by using the new-folder control in the file sidebar.
- Create a static folder inside oura-api-explorer by using the new-folder control in the file sidebar.
- Add the local route to app.py by copying this code:
from flask import Flask, render_template
app = Flask(__name__)
@app.get("/")
def index():
return render_template(
"index.html",
connected=False,
payload=None,
status_code=None,
error=None,
)
if __name__ == "__main__":
app.run()
What Does This Code Do?
- The Flask(__name__) call creates the web application.
- The index() route renders index.html when your browser requests the home page.
- The four template values describe the explorer before authentication or a live response exists.
- The app.run() call starts the local development server when you run the file directly.
- Save app.py.
- Confirm the file sidebar lists app.py beside the templates and static folders.
Missing a Project File?
Check that all three items sit directly inside oura-api-explorer. A nested folder would prevent Flask from finding the template or stylesheet.
Ask for help if the file layout differs: Help me check the file layout for my Flask project in VS Code.
✔️ Awesome, I've got everything!
Great. Save app.py before moving to the template.
ⓧ I'd like to double check the full code
from flask import Flask, render_template
app = Flask(__name__)
@app.get("/")
def index():
return render_template(
"index.html",
connected=False,
payload=None,
status_code=None,
error=None,
)
if __name__ == "__main__":
app.run()
Build the response viewer
The template uses conditional sections to represent locked, connected, successful, and failed requests. The initial values from app.py select the locked state.
- Create index.html inside the templates folder by using the new-file control in the VS Code file sidebar.
- Add the document structure and hero to templates/index.html by copying this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Oura API Explorer</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<nav class="nav">
<a class="brand" href="{{ url_for('index') }}">
<span class="brand-mark">O</span>
<span>Oura API Explorer</span>
</a>
<span class="local-badge">Local only</span>
</nav>
<section class="hero">
<p class="eyebrow">OAuth 2.0 + Oura API V2</p>
<h1>Your sleep API response,<br><span>beautifully exposed.</span></h1>
<p class="hero-copy">
Connect your own account, make one authenticated request, and inspect
exactly what the API returns without guessing its schema.
</p>
How Is the Page Framed?
The navigation identifies the explorer as a local application. The hero explains that the app will inspect the exact API response without assuming a fixed JSON structure.
- Save templates/index.html.
- Confirm the file now contains the page title and the hero section.
Hero Markup Looks Misaligned?
Check the indentation beneath <main class="shell">. Keep each nested element two spaces deeper than its parent.
Ask for help with the structure: Help me compare the opening HTML structure in my Oura explorer.
- Add the connection actions below the hero copy in templates/index.html by copying this code:
<div class="actions">
{% if connected %}
<a class="button button-primary" href="{{ url_for('explore') }}">Explore response</a>
<a class="button button-quiet" href="{{ url_for('disconnect') }}">Disconnect</a>
{% else %}
<a class="button button-primary" href="{{ url_for('login') }}">Connect Oura</a>
<span class="locked-action">Response locked until you grant access</span>
{% endif %}
</div>
</section>
How Do the Actions Change?
The connected value chooses between the response controls and the connection prompt. Your temporary route supplies False for the initial page state.
- Save templates/index.html.
- Confirm the actions section contains one complete conditional block.
Conditional Block Incomplete?
Make sure the block contains one {% if connected %} line and one matching {% endif %} line.
Ask for help with the template logic: Help me check the connected-state conditional in my template.
- Add the connection status cards below the hero section by copying this code:
<section class="status-grid" aria-label="Connection details">
<article class="status-card">
<span class="status-label">Authentication</span>
{% if connected %}
<span class="status-value"><span class="dot dot-live"></span>Connected</span>
{% else %}
<span class="status-value"><span class="dot"></span>Not connected</span>
{% endif %}
</article>
<article class="status-card">
<span class="status-label">Endpoint</span>
<code class="status-code">/v2/usercollection/sleep</code>
</article>
<article class="status-card">
<span class="status-label">Requested scope</span>
<span class="scope-pill">daily</span>
</article>
</section>
What Do the Status Cards Show?
- The authentication card changes with the connection state.
- The endpoint card identifies the protected sleep resource.
- The scope card makes the requested permission visible before consent.
- Save templates/index.html.
- Confirm the status grid contains three status-card articles.
Missing a Status Card?
Check that each card has its own closing </article> tag. Confirm the entire group closes with </section>.
Ask for help comparing the cards: Help me find the missing status card markup.
- Add the viewer header and successful-response window below the status grid by copying this code:
<section class="viewer-card">
<header class="viewer-header">
<div>
<p class="viewer-kicker">Live response</p>
<h2>JSON payload</h2>
</div>
<span class="http-badge {% if status_code == 200 %}http-success{% endif %}">
{% if status_code %}{{ status_code }}{% else %}Waiting{% endif %}
</span>
</header>
{% if payload %}
<div class="code-window">
<div class="window-bar">
<span></span><span></span><span></span>
<small>oura-response.json</small>
</div>
<pre><code>{{ payload }}</code></pre>
</div>
How Will a Successful Response Appear?
The badge displays the HTTP status supplied by the server. A successful payload appears inside a scrollable code window without relying on specific response fields.
- Save templates/index.html.
- Confirm the viewer contains the http-badge and code-window elements.
Viewer Tags Do Not Line Up?
Keep the code-window inside the {% if payload %} branch. Leave the conditional open for the remaining states.
Ask for help with this branch: Help me check the successful-response branch in my template.
- Complete the viewer states below the successful-response window by copying this code:
{% elif error %}
<div class="message-state error-state">
<span class="state-icon">!</span>
<div>
<h3>The request did not complete</h3>
<p>{{ error }}</p>
</div>
</div>
{% elif connected %}
<div class="message-state">
<span class="state-icon">200</span>
<div>
<h3>Connection ready</h3>
<p>Select Explore response to request the current sleep payload.</p>
</div>
</div>
{% else %}
<div class="message-state locked-state">
<span class="state-icon">{ }</span>
<div>
<h3>Your response is still private</h3>
<p>Connect through Oura before this local viewer can request it.</p>
</div>
</div>
{% endif %}
</section>
Why Are There Four Viewer States?
The viewer can show a payload or a request error. It can also show a connected empty state or the initial locked state.
- Save templates/index.html.
- Confirm the viewer conditional ends with one {% endif %} line.
Seeing an Extra Template Branch?
Check the branch order against the code above. The final {% else %} branch must contain the locked state.
Ask for help checking the states: Help me compare all four response viewer states.
- Finish templates/index.html with the privacy note and closing tags by copying this code:
<footer>
Credentials stay in terminal environment variables. The access token stays
only in this running Python process.
</footer>
</main>
</body>
</html>
Why Include the Footer?
The footer keeps credential and token handling visible in the interface. It sets the expectation that sensitive values remain outside the source files.
- Save templates/index.html.
- Confirm the final three lines close main and body before closing html.
Template Still Shows as Unsaved?
Save the file once more. Check that index.html sits inside templates instead of beside that folder.
Ask for help with the completed file: Help me compare my complete explorer template.
✔️ Awesome, I've got everything!
Your template now contains the complete shell and all four response states.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Oura API Explorer</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<nav class="nav">
<a class="brand" href="{{ url_for('index') }}">
<span class="brand-mark">O</span>
<span>Oura API Explorer</span>
</a>
<span class="local-badge">Local only</span>
</nav>
<section class="hero">
<p class="eyebrow">OAuth 2.0 + Oura API V2</p>
<h1>Your sleep API response,<br><span>beautifully exposed.</span></h1>
<p class="hero-copy">
Connect your own account, make one authenticated request, and inspect
exactly what the API returns without guessing its schema.
</p>
<div class="actions">
{% if connected %}
<a class="button button-primary" href="{{ url_for('explore') }}">Explore response</a>
<a class="button button-quiet" href="{{ url_for('disconnect') }}">Disconnect</a>
{% else %}
<a class="button button-primary" href="{{ url_for('login') }}">Connect Oura</a>
<span class="locked-action">Response locked until you grant access</span>
{% endif %}
</div>
</section>
<section class="status-grid" aria-label="Connection details">
<article class="status-card">
<span class="status-label">Authentication</span>
{% if connected %}
<span class="status-value"><span class="dot dot-live"></span>Connected</span>
{% else %}
<span class="status-value"><span class="dot"></span>Not connected</span>
{% endif %}
</article>
<article class="status-card">
<span class="status-label">Endpoint</span>
<code class="status-code">/v2/usercollection/sleep</code>
</article>
<article class="status-card">
<span class="status-label">Requested scope</span>
<span class="scope-pill">daily</span>
</article>
</section>
<section class="viewer-card">
<header class="viewer-header">
<div>
<p class="viewer-kicker">Live response</p>
<h2>JSON payload</h2>
</div>
<span class="http-badge {% if status_code == 200 %}http-success{% endif %}">
{% if status_code %}{{ status_code }}{% else %}Waiting{% endif %}
</span>
</header>
{% if payload %}
<div class="code-window">
<div class="window-bar">
<span></span><span></span><span></span>
<small>oura-response.json</small>
</div>
<pre><code>{{ payload }}</code></pre>
</div>
{% elif error %}
<div class="message-state error-state">
<span class="state-icon">!</span>
<div>
<h3>The request did not complete</h3>
<p>{{ error }}</p>
</div>
</div>
{% elif connected %}
<div class="message-state">
<span class="state-icon">200</span>
<div>
<h3>Connection ready</h3>
<p>Select Explore response to request the current sleep payload.</p>
</div>
</div>
{% else %}
<div class="message-state locked-state">
<span class="state-icon">{ }</span>
<div>
<h3>Your response is still private</h3>
<p>Connect through Oura before this local viewer can request it.</p>
</div>
</div>
{% endif %}
</section>
<footer>
Credentials stay in terminal environment variables. The access token stays
only in this running Python process.
</footer>
</main>
</body>
</html>
Style and test the locked explorer
The stylesheet turns the template into a dark developer console with a responsive layout. Each group below controls one visible part of the shell.
- Create style.css inside the static folder by using the new-file control in the VS Code file sidebar.
- Add the shared colours and sizing foundation to static/style.css by copying this code:
:root {
color-scheme: dark;
--bg: #07110f;
--panel: rgba(15, 31, 27, 0.78);
--panel-strong: #10231e;
--line: rgba(174, 225, 199, 0.14);
--text: #f3f8f5;
--muted: #9db1a8;
--mint: #82f2bd;
--mint-strong: #35d68c;
--amber: #f1c66d;
--danger: #ff8b8b;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
What Does the Foundation Control?
The variables keep the interface palette consistent. Universal border sizing prevents padding from making cards wider than expected.
- Save static/style.css.
- Confirm the file begins with :root and contains the mint and danger colours.
- Add the page background and grid texture below the foundation by copying this code:
body {
min-height: 100vh;
margin: 0;
overflow-x: hidden;
background:
radial-gradient(circle at top, rgba(46, 128, 91, 0.15), transparent 38%),
var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
body::before {
position: fixed;
inset: 0;
pointer-events: none;
content: "";
opacity: 0.22;
background-image:
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: linear-gradient(to bottom, black, transparent 75%);
}
How Is the Background Built?
The body combines the dark base with a soft radial glow. The fixed pseudo-element overlays a faint grid without blocking clicks.
- Save static/style.css.
- Confirm the body rules contain both gradient definitions.
Background Rules Look Broken?
Check every property for its closing semicolon. Keep the two gradient lines beneath their matching property.
Ask for help with the CSS foundation: Help me debug the background styles in my explorer.
- Add the ambient glows below the background rules by copying this code:
.ambient {
position: fixed;
z-index: -1;
width: 340px;
height: 340px;
border-radius: 50%;
filter: blur(90px);
opacity: 0.18;
}
.ambient-one {
top: 12%;
left: -120px;
background: var(--mint-strong);
}
.ambient-two {
right: -120px;
bottom: 10%;
background: #5c78ff;
}
What Creates the Ambient Light?
Two blurred circles sit beyond opposite page edges. Their low opacity adds depth without competing with the response viewer.
- Save static/style.css.
- Confirm both ambient variants include a position and background colour.
- Add the shell and navigation layout below the ambient rules by copying this code:
.shell {
width: min(1120px, calc(100% - 40px));
margin: 0 auto;
padding: 28px 0 48px;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 72px;
}
.brand {
display: inline-flex;
gap: 12px;
align-items: center;
color: var(--text);
font-weight: 700;
text-decoration: none;
}
How Does the Layout Stay Centred?
The shell limits the content width while preserving space at both edges. Flexbox keeps the brand and local badge on opposite sides of the navigation.
- Save static/style.css.
- Confirm the shell has automatic horizontal margins.
Navigation Rules Missing?
Check that .shell and .nav each have a closing brace before the next selector starts.
Ask for help with the layout: Help me debug the shell and navigation CSS.
- Add the brand mark and badge styles below the navigation rules by copying this code:
.brand-mark {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid rgba(130, 242, 189, 0.36);
border-radius: 50%;
background: rgba(130, 242, 189, 0.1);
color: var(--mint);
}
.local-badge,
.scope-pill,
.http-badge {
display: inline-flex;
align-items: center;
width: fit-content;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 8px 12px;
}
Why Share the Badge Rules?
The local badge and response badges use the same compact pill shape. Shared rules keep repeated interface details consistent.
- Save static/style.css.
- Confirm the grouped selector names all three badges.
- Add the hero typography below the badge rules by copying this code:
.hero {
max-width: 820px;
margin-bottom: 48px;
}
.eyebrow,
.viewer-kicker,
.status-label {
margin: 0 0 12px;
color: var(--mint);
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
max-width: 820px;
margin: 0;
font-size: clamp(3rem, 8vw, 6.7rem);
line-height: 0.94;
letter-spacing: -0.065em;
}
h1 span {
color: transparent;
background: linear-gradient(90deg, var(--mint), #c3ffe2 52%, #9ab4ff);
background-clip: text;
-webkit-background-clip: text;
}
How Does the Hero Stand Out?
Responsive sizing lets the headline scale with the browser width. The nested span clips a mint-to-blue gradient into the second line.
- Save static/style.css.
- Confirm the headline rules include the gradient background.
Gradient Text Missing?
Check that h1 span includes transparent text colour and both background clipping properties.
Ask for help with the heading: Help me debug the hero gradient CSS.
- Add the hero copy and button foundation below the heading rules by copying this code:
.hero-copy {
max-width: 650px;
margin: 28px 0 0;
color: var(--muted);
font-size: 1.08rem;
line-height: 1.75;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin-top: 32px;
}
.button {
display: inline-flex;
min-height: 48px;
align-items: center;
justify-content: center;
border-radius: 14px;
font-weight: 800;
padding: 0 20px;
text-decoration: none;
transition: transform 160ms ease, border-color 160ms ease;
}
How Do the Actions Adapt?
The action row wraps when horizontal space becomes limited. The shared button rule keeps both actions aligned and easy to select.
- Save static/style.css.
- Confirm the action row allows wrapping.
- Add the button variants and locked message below the button foundation by copying this code:
.button:hover {
transform: translateY(-2px);
}
.button-primary {
background: var(--mint);
color: #062017;
box-shadow: 0 12px 36px rgba(53, 214, 140, 0.18);
}
.button-quiet {
border: 1px solid var(--line);
color: var(--text);
}
.locked-action {
color: var(--muted);
font-size: 0.86rem;
}
What Separates the Actions?
The primary action uses a bright mint fill. The secondary action uses a quiet border while the locked message remains muted.
- Save static/style.css.
- Confirm the primary and quiet buttons have separate selector blocks.
Button Styles Blending Together?
Check that .button-primary closes before .button-quiet begins.
Ask for help with the actions: Help me debug the explorer button styles.
- Add the status grid and card surfaces below the action styles by copying this code:
.status-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-bottom: 14px;
}
.status-card,
.viewer-card {
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.status-card {
display: flex;
min-height: 124px;
flex-direction: column;
justify-content: space-between;
border-radius: 20px;
padding: 22px;
}
How Do the Cards Form a Console?
The grid gives each status equal width on larger screens. Translucent surfaces and blur separate the cards from the background.
- Save static/style.css.
- Confirm the grid defines three equal columns.
- Add the status values and indicators below the card styles by copying this code:
.status-value {
display: flex;
gap: 10px;
align-items: center;
font-size: 1rem;
font-weight: 750;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--amber);
box-shadow: 0 0 16px rgba(241, 198, 109, 0.52);
}
.dot-live {
background: var(--mint-strong);
box-shadow: 0 0 16px rgba(53, 214, 140, 0.7);
}
.status-code {
overflow-wrap: anywhere;
color: #cbe5d8;
font-size: 0.88rem;
}
.scope-pill {
color: var(--mint);
}
How Is Status Communicated?
The amber dot marks the disconnected state. A connected state can switch to the mint variant while long endpoint text remains contained.
- Save static/style.css.
- Confirm the disconnected and live indicators use different colours.
Status Indicator Missing?
Check that the base .dot rule has a width and height. The element cannot appear without both dimensions.
Ask for help with the status styles: Help me debug the status cards and indicator dots.
- Add the response viewer shell below the status indicators by copying this code:
.viewer-card {
overflow: hidden;
border-radius: 24px;
}
.viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 26px;
border-bottom: 1px solid var(--line);
}
.viewer-header h2 {
margin: 0;
font-size: 1.45rem;
}
.viewer-kicker {
margin-bottom: 7px;
}
.http-success {
border-color: rgba(130, 242, 189, 0.3);
background: rgba(130, 242, 189, 0.09);
color: var(--mint);
}
How Does the Viewer Signal Success?
The header separates response metadata from the content area. A successful status gains a mint border and background.
- Save static/style.css.
- Confirm the viewer hides overflowing content and rounds its corners.
- Add the response code window below the viewer header by copying this code:
.code-window {
margin: 18px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 16px;
background: #07100e;
}
.window-bar {
display: flex;
gap: 7px;
align-items: center;
min-height: 42px;
padding: 0 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.window-bar span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #355049;
}
.window-bar small {
margin-left: auto;
color: #6f8b82;
}
Why Use a Code Window?
The inner panel separates raw response text from the rest of the interface. Its top bar makes the payload feel like a readable local file.
- Save static/style.css.
- Confirm the window bar aligns its filename to the right.
Code Window Has Square Corners?
Check that .code-window includes both rounded corners and hidden overflow.
Ask for help with the viewer: Help me debug the response code window CSS.
- Add the payload text and message layout below the code window by copying this code:
pre {
max-height: 560px;
margin: 0;
overflow: auto;
padding: 22px;
color: #bdf9d8;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.85rem;
line-height: 1.7;
tab-size: 2;
}
.message-state {
display: flex;
min-height: 260px;
gap: 18px;
align-items: center;
justify-content: center;
padding: 40px;
text-align: left;
}
How Will Long Responses Behave?
The response area scrolls after reaching its maximum height. Message states use the same minimum height so the viewer does not jump between outcomes.
- Save static/style.css.
- Confirm the payload area allows scrolling.
- Add the message typography and state icon below the message layout by copying this code:
.message-state h3 {
margin: 0 0 8px;
font-size: 1.15rem;
}
.message-state p {
max-width: 480px;
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.state-icon {
display: grid;
width: 58px;
height: 58px;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--line);
border-radius: 17px;
background: rgba(255, 255, 255, 0.035);
color: var(--mint);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.86rem;
font-weight: 800;
}
How Are Empty States Kept Readable?
The message text has a controlled width and muted colour. The fixed-size icon gives every state a consistent visual anchor.
- Save static/style.css.
- Confirm the state icon has fixed width and height values.
Message State Looks Compressed?
Check that .message-state has its minimum height. Confirm .state-icon uses a non-growing flex value.
Ask for help with the empty state: Help me debug the response message layout.
- Add the error colour and footer styles below the state icon by copying this code:
.error-state .state-icon {
border-color: rgba(255, 139, 139, 0.3);
color: var(--danger);
}
footer {
padding: 24px 6px 0;
color: #6f847c;
font-size: 0.8rem;
line-height: 1.6;
text-align: center;
}
How Does an Error Stand Apart?
The error icon switches to the danger colour while preserving the shared state layout. The footer stays quiet beneath the main viewer.
- Save static/style.css.
- Confirm the error icon uses the danger variable.
- Complete the stylesheet with the mobile layout by copying this code:
@media (max-width: 760px) {
.shell {
width: min(100% - 24px, 1120px);
}
.nav {
padding-bottom: 52px;
}
.status-grid {
grid-template-columns: 1fr;
}
.message-state {
align-items: flex-start;
flex-direction: column;
}
}
What Changes on Smaller Screens?
The content gains tighter side spacing while the status cards become one column. Message icons move above their text to fit narrow screens.
- Save static/style.css.
- Confirm the final rule closes the mobile media block.
Stylesheet Ends Inside a Rule?
Check the final braces carefully. One closes .message-state while the last brace closes the media block.
Ask for help with the finished stylesheet: Help me compare the end of my explorer stylesheet.
✔️ Awesome, I've got everything!
Your complete stylesheet is saved. The explorer is ready for its first browser check.
ⓧ I'd like to double check the full code
:root {
color-scheme: dark;
--bg: #07110f;
--panel: rgba(15, 31, 27, 0.78);
--panel-strong: #10231e;
--line: rgba(174, 225, 199, 0.14);
--text: #f3f8f5;
--muted: #9db1a8;
--mint: #82f2bd;
--mint-strong: #35d68c;
--amber: #f1c66d;
--danger: #ff8b8b;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
overflow-x: hidden;
background:
radial-gradient(circle at top, rgba(46, 128, 91, 0.15), transparent 38%),
var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
body::before {
position: fixed;
inset: 0;
pointer-events: none;
content: "";
opacity: 0.22;
background-image:
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: linear-gradient(to bottom, black, transparent 75%);
}
.ambient {
position: fixed;
z-index: -1;
width: 340px;
height: 340px;
border-radius: 50%;
filter: blur(90px);
opacity: 0.18;
}
.ambient-one {
top: 12%;
left: -120px;
background: var(--mint-strong);
}
.ambient-two {
right: -120px;
bottom: 10%;
background: #5c78ff;
}
.shell {
width: min(1120px, calc(100% - 40px));
margin: 0 auto;
padding: 28px 0 48px;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 72px;
}
.brand {
display: inline-flex;
gap: 12px;
align-items: center;
color: var(--text);
font-weight: 700;
text-decoration: none;
}
.brand-mark {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid rgba(130, 242, 189, 0.36);
border-radius: 50%;
background: rgba(130, 242, 189, 0.1);
color: var(--mint);
}
.local-badge,
.scope-pill,
.http-badge {
display: inline-flex;
align-items: center;
width: fit-content;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 8px 12px;
}
.hero {
max-width: 820px;
margin-bottom: 48px;
}
.eyebrow,
.viewer-kicker,
.status-label {
margin: 0 0 12px;
color: var(--mint);
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
max-width: 820px;
margin: 0;
font-size: clamp(3rem, 8vw, 6.7rem);
line-height: 0.94;
letter-spacing: -0.065em;
}
h1 span {
color: transparent;
background: linear-gradient(90deg, var(--mint), #c3ffe2 52%, #9ab4ff);
background-clip: text;
-webkit-background-clip: text;
}
.hero-copy {
max-width: 650px;
margin: 28px 0 0;
color: var(--muted);
font-size: 1.08rem;
line-height: 1.75;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin-top: 32px;
}
.button {
display: inline-flex;
min-height: 48px;
align-items: center;
justify-content: center;
border-radius: 14px;
font-weight: 800;
padding: 0 20px;
text-decoration: none;
transition: transform 160ms ease, border-color 160ms ease;
}
.button:hover {
transform: translateY(-2px);
}
.button-primary {
background: var(--mint);
color: #062017;
box-shadow: 0 12px 36px rgba(53, 214, 140, 0.18);
}
.button-quiet {
border: 1px solid var(--line);
color: var(--text);
}
.locked-action {
color: var(--muted);
font-size: 0.86rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-bottom: 14px;
}
.status-card,
.viewer-card {
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.status-card {
display: flex;
min-height: 124px;
flex-direction: column;
justify-content: space-between;
border-radius: 20px;
padding: 22px;
}
.status-value {
display: flex;
gap: 10px;
align-items: center;
font-size: 1rem;
font-weight: 750;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--amber);
box-shadow: 0 0 16px rgba(241, 198, 109, 0.52);
}
.dot-live {
background: var(--mint-strong);
box-shadow: 0 0 16px rgba(53, 214, 140, 0.7);
}
.status-code {
overflow-wrap: anywhere;
color: #cbe5d8;
font-size: 0.88rem;
}
.scope-pill {
color: var(--mint);
}
.viewer-card {
overflow: hidden;
border-radius: 24px;
}
.viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 26px;
border-bottom: 1px solid var(--line);
}
.viewer-header h2 {
margin: 0;
font-size: 1.45rem;
}
.viewer-kicker {
margin-bottom: 7px;
}
.http-success {
border-color: rgba(130, 242, 189, 0.3);
background: rgba(130, 242, 189, 0.09);
color: var(--mint);
}
.code-window {
margin: 18px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 16px;
background: #07100e;
}
.window-bar {
display: flex;
gap: 7px;
align-items: center;
min-height: 42px;
padding: 0 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.window-bar span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #355049;
}
.window-bar small {
margin-left: auto;
color: #6f8b82;
}
pre {
max-height: 560px;
margin: 0;
overflow: auto;
padding: 22px;
color: #bdf9d8;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.85rem;
line-height: 1.7;
tab-size: 2;
}
.message-state {
display: flex;
min-height: 260px;
gap: 18px;
align-items: center;
justify-content: center;
padding: 40px;
text-align: left;
}
.message-state h3 {
margin: 0 0 8px;
font-size: 1.15rem;
}
.message-state p {
max-width: 480px;
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.state-icon {
display: grid;
width: 58px;
height: 58px;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--line);
border-radius: 17px;
background: rgba(255, 255, 255, 0.035);
color: var(--mint);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.86rem;
font-weight: 800;
}
.error-state .state-icon {
border-color: rgba(255, 139, 139, 0.3);
color: var(--danger);
}
footer {
padding: 24px 6px 0;
color: #6f847c;
font-size: 0.8rem;
line-height: 1.6;
text-align: center;
}
@media (max-width: 760px) {
.shell {
width: min(100% - 24px, 1120px);
}
.nav {
padding-bottom: 52px;
}
.status-grid {
grid-template-columns: 1fr;
}
.message-state {
align-items: flex-start;
flex-direction: column;
}
}
Before you run the explorer, what do you expect the response viewer to show without authorization?
- Start the local Flask server in the activated VS Code terminal by running this command:
python3 app.py
- Open http://127.0.0.1:5000/ in your browser.
You will see the dark gradient explorer with Not connected and endpoint /v2/usercollection/sleep.
The requested scope shows daily. The response viewer remains locked because the explorer has no authorization yet.
Why Is the Locked State Useful?
The polished interface now has a clear place for connection and response feedback. Its locked panel makes the missing consent step visible before protected data can appear.
Explorer Not Loading?
- Confirm the terminal still shows the activated .venv environment.
- Check that index.html is inside templates.
- Check that style.css is inside static.
Ask for help with the local server: Help me debug why my Oura explorer is not loading locally.
You have built the full visual shell and proved that its protected response stays locked. Next, you will connect the interface to Oura through OAuth consent.
Connect the Explorer to Oura
The shell from the last step proves your local Flask interface is ready. Its locked panel marks the boundary around health data because the Oura API requires explicit consent.
This step registers your app for OAuth 2.0 access. Authlib completes the flow with PKCE while the access token stays in process memory.
In this step, get ready to:
- Register the local explorer as an Oura API application.
- Export the OAuth credentials through terminal environment variables.
- Add the authorization routes that connect your Oura account.
Register your Oura API application
Oura needs a registered callback address before it can return your browser to the local explorer. The redirect URL must match exactly during registration and authorization.
Why use Authlib for OAuth?
Oura recommends using a ready-made OAuth2 client library. Authlib handles the authorization redirect plus the token exchange for your Flask app.
The app requests only the daily scope. This gives the explorer the limited permission it needs for daily summaries.
- Open a new browser tab.
- Go to Oura's official API support article.
- Select the newest developer portal link in the article.
- Sign in with your Oura account.
- Start creating an API application through the portal's application creation flow.
- Enter http://127.0.0.1:5000/callback as the application's redirect URL.
- Complete the application registration.
You should see the new application listed in the portal. This confirms that Oura recognizes your local callback address.
Check Your Membership Access
Gen3 and later users need an active Oura Membership to access their data through the Oura API. Confirm your membership is active before continuing if your ring is Gen3 or later.
This is the sensitive part: the Client Secret stays out of your source files. It exists only in your current terminal session.
- Leave the developer portal tab available.
- Switch back to the activated VS Code terminal from earlier.
- Stop the Flask server with Control-C if it is still running.
- Replace your-client-id-here in the command below with the Client ID from your application.
- Replace your-client-secret-here with the Client Secret from your application.
- Export the local configuration by running the edited commands:
export OURA_CLIENT_ID="your-client-id-here"
export OURA_CLIENT_SECRET="your-client-secret-here"
export FLASK_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
What do these exports do?
- OURA_CLIENT_ID identifies the API application that requests access.
- OURA_CLIENT_SECRET proves that the authorization request belongs to that application.
- FLASK_SECRET_KEY receives a random value that Flask uses to protect the temporary OAuth state.
- The exported values become available to programs started from this terminal.
Your terminal should return to its prompt without displaying the credential values. The current shell now holds the configuration required by the Flask server.
Configure Authlib and in-memory state
A startup check makes missing configuration visible before the authorization flow begins. The Flask secret key also protects the temporary browser state used during OAuth.
- In app.py, replace the current import lines plus the existing app = Flask(__name__) line with this code:
import json
import os
from authlib.integrations.flask_client import OAuth
from flask import Flask, redirect, render_template, url_for
required_variables = (
"OURA_CLIENT_ID",
"OURA_CLIENT_SECRET",
"FLASK_SECRET_KEY",
)
missing_variables = [name for name in required_variables if not os.environ.get(name)]
if missing_variables:
missing_list = ", ".join(missing_variables)
raise RuntimeError(f"Missing environment variables: {missing_list}")
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]
What does this code do?
- required_variables lists the three values the app needs before it can start.
- missing_variables finds any value that is absent from the current terminal environment.
- The RuntimeError stops startup with a useful list when configuration is missing.
- app.secret_key gives Flask the random value needed to protect OAuth state.
- Save app.py.
Before you run the app, do you expect the startup check to find any missing variables?
- Test the exported configuration by starting the Flask server:
python3 app.py
What does this check prove?
A successful startup proves that all three environment variables are available to Python. The server should start on the same local address as before.
You should see the Flask development server start without a missing-variable error. Your exported configuration has reached the app.
Seeing a missing-variable error?
- Confirm that you ran all three export commands in the same activated terminal.
- Check that you replaced both credential placeholders before running the commands.
- Keep using this terminal because a new terminal does not inherit these exported values.
Help me check my Oura environment variables without exposing their values.
- Stop the server with Control-C.
The OAuth registry holds Oura's authorization settings in one client object. Its configuration requests the daily scope through S256 PKCE.
- In app.py, add this code directly below app.secret_key = os.environ["FLASK_SECRET_KEY"]:
oauth = OAuth(app)
oura = oauth.register(
name="oura",
client_id=os.environ["OURA_CLIENT_ID"],
client_secret=os.environ["OURA_CLIENT_SECRET"],
authorize_url="https://developer.ouraring.com/authorize",
access_token_url="https://api.ouraring.com/oauth/token",
client_kwargs={
"scope": "daily",
"code_challenge_method": "S256",
},
)
token_store = {"token": None}
How is the OAuth client configured?
- OAuth(app) creates the registry attached to your Flask application.
- oura stores the authorization endpoint plus the token endpoint for this provider.
- client_kwargs limits the request to daily summaries while enabling S256 PKCE.
- token_store starts empty because the learner has not granted access yet.
- Save app.py.
- Check the OAuth registry by restarting the server:
python3 app.py
What should this confirm?
The server should start without a configuration or syntax error. This proves that Authlib accepted the Oura client registration.
- Refresh the existing explorer tab.
- Confirm that the polished shell still displays Not connected.
- Stop the server with Control-C.
Server failing after registration?
- Check that the OAuth registration appears below the Flask secret key assignment.
- Compare the endpoint strings with the code block above.
- Check that the closing parentheses align with their opening calls.
Help me debug my Authlib Oura client registration.
A shared rendering helper lets every route display the current connection state. It reads the in-memory token instead of hard-coding the explorer as disconnected.
- In app.py, replace the current index() route with this helper plus the updated route:
def render_explorer(payload=None, status_code=None, error=None):
return render_template(
"index.html",
connected=token_store["token"] is not None,
payload=payload,
status_code=status_code,
error=error,
)
@app.get("/")
def index():
return render_explorer()
How does the connection state reach the template?
- render_explorer() collects the values used by the existing template states.
- connected becomes true only when token_store contains a token.
- index() now uses the helper to render the home page.
- Save app.py.
- Test the rendering helper by restarting the server:
python3 app.py
What should the explorer show?
The home page should load with Not connected because token_store still contains None. This proves the template now reads live process state.
- Refresh http://127.0.0.1:5000/ in your browser.
- Confirm that the locked response panel still appears.
- Stop the server with Control-C.
Explorer no longer loading?
- Confirm that render_explorer() appears before the index() route.
- Check that the template name remains index.html.
- Check that each template argument ends with a comma.
Help me debug the render_explorer helper in my Flask app.
Complete the authorization flow
Three routes complete the connection flow. One starts consent. One exchanges the returned authorization code for a token. One clears that token from memory.
- In app.py, add these routes directly above the if __name__ == "__main__": block:
@app.get("/login")
def login():
redirect_uri = url_for("callback", _external=True)
return oura.authorize_redirect(redirect_uri)
@app.get("/callback")
def callback():
token_store["token"] = oura.authorize_access_token()
return redirect(url_for("explore"))
@app.get("/disconnect")
def disconnect():
token_store["token"] = None
return redirect(url_for("index"))
What do the authorization routes do?
- login() builds the registered callback URL before sending your browser to Oura.
- callback() exchanges Oura's returned authorization code for an access token.
- token_store keeps the token only inside the running Python process.
- disconnect() clears the token before returning to the locked home page.
- Save app.py.
Use the tabs below to compare your completed file before testing the connection.
✔️ Awesome, I've got everything!
Your OAuth configuration plus the three authorization routes are ready. Make sure app.py is saved before continuing.
ⓧ I'd like to double check the full code
import json
import os
from authlib.integrations.flask_client import OAuth
from flask import Flask, redirect, render_template, url_for
required_variables = (
"OURA_CLIENT_ID",
"OURA_CLIENT_SECRET",
"FLASK_SECRET_KEY",
)
missing_variables = [name for name in required_variables if not os.environ.get(name)]
if missing_variables:
missing_list = ", ".join(missing_variables)
raise RuntimeError(f"Missing environment variables: {missing_list}")
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]
oauth = OAuth(app)
oura = oauth.register(
name="oura",
client_id=os.environ["OURA_CLIENT_ID"],
client_secret=os.environ["OURA_CLIENT_SECRET"],
authorize_url="https://developer.ouraring.com/authorize",
access_token_url="https://api.ouraring.com/oauth/token",
client_kwargs={
"scope": "daily",
"code_challenge_method": "S256",
},
)
token_store = {"token": None}
def render_explorer(payload=None, status_code=None, error=None):
return render_template(
"index.html",
connected=token_store["token"] is not None,
payload=payload,
status_code=status_code,
error=error,
)
@app.get("/")
def index():
return render_explorer()
@app.get("/login")
def login():
redirect_uri = url_for("callback", _external=True)
return oura.authorize_redirect(redirect_uri)
@app.get("/callback")
def callback():
token_store["token"] = oura.authorize_access_token()
return redirect(url_for("explore"))
@app.get("/disconnect")
def disconnect():
token_store["token"] = None
return redirect(url_for("index"))
if __name__ == "__main__":
app.run()
What should match?
Your file should contain the environment checks followed by the Authlib registry. The rendering helper plus all four current routes should appear before the final server startup block.
- Start the updated server from the activated terminal:
python3 app.py
What does this start?
The server starts with the exported credentials plus the OAuth routes. Keep this terminal running while your browser moves through Oura's consent flow.
Before you connect, which permission do you expect Oura to ask you to review?
- Return to the explorer tab at http://127.0.0.1:5000/.
- Select Connect Oura.
- Review the requested daily permission on Oura's consent screen.
- Approve access using the consent control shown by Oura.
Seeing a routing error after consent?
The callback stores your token before its final redirect. That redirect targets the /explore route that you build in the next step.
This interim version can therefore show a routing error after Oura returns your browser. The token still remains in token_store for the running process.
- Enter http://127.0.0.1:5000/ in the browser address bar.
- Confirm that the Authentication card now shows Connected.
- Confirm that the explorer now displays Explore response plus Disconnect.
You made the consent flow work. Your access token now lives only in the running Python process.
Connection not completing?
- Confirm that the registered redirect URL is exactly http://127.0.0.1:5000/callback.
- Confirm that the Flask server is still running in the terminal that holds the exported variables.
- Restart the flow from the home page if you denied access on the consent screen.
Help me troubleshoot my Oura OAuth callback without exposing my Client Secret.
That's the consent barrier cleared: your explorer can now connect to your Oura account while keeping its token in memory. Next, you'll use that token to request the live sleep response.
Display the Live Sleep Response
Your explorer now completes OAuth 2.0 authorization. It keeps the access token in process memory.
The explorer becomes useful when it sends that token to the protected Oura API sleep endpoint. A readable panel then exposes exactly what the current API returned.
In this step, get ready to:
- Call the protected sleep endpoint with the saved token.
- Render the HTTP status with readable API output.
- Prove that disconnect clears the token from memory.
Call the protected sleep endpoint
Authlib can attach the saved token to a protected request. The new route also needs a fixed endpoint for the current Oura sleep response.
- In the Explorer sidebar in Visual Studio Code, select app.py.
- Find token_store = {"token": None} below the OAuth registration.
- Add the sleep endpoint above the existing token store by replacing that line with these two lines:
SLEEP_ENDPOINT = "https://api.ouraring.com/v2/usercollection/sleep"
token_store = {"token": None}
What Does This Endpoint Constant Do?
- SLEEP_ENDPOINT keeps the protected URL in one named value.
- token_store continues to hold the current token only in the running process.
- Locate the callback() function that ends with return redirect(url_for("explore")).
- Add the new /explore route immediately below callback() by pasting this code:
@app.get("/explore")
def explore():
token = token_store["token"]
if token is None:
return redirect(url_for("index"))
try:
response = oura.get(SLEEP_ENDPOINT, token=token)
status_code = response.status_code
response.raise_for_status()
payload = json.dumps(response.json(), indent=2, ensure_ascii=False)
return render_explorer(payload=payload, status_code=status_code)
except Exception as error:
return render_explorer(error=str(error), status_code="Error")
What Does This Route Do?
- token_store["token"] retrieves the token created during authorization.
- if token is None redirects an unauthenticated visitor to the locked home screen.
- oura.get(SLEEP_ENDPOINT, token=token) applies the token to the protected request.
- response.raise_for_status() stops the success path when the server returns a failed response.
- json.dumps(response.json(), indent=2, ensure_ascii=False) converts the decoded response into readable text.
- except Exception as error passes a failure detail to the template's styled error state.
✔️ Awesome, I've got everything!
Your live-response route is in place.
ⓧ I'd like to double check the full code
import json
import os
from authlib.integrations.flask_client import OAuth
from flask import Flask, redirect, render_template, url_for
required_variables = (
"OURA_CLIENT_ID",
"OURA_CLIENT_SECRET",
"FLASK_SECRET_KEY",
)
missing_variables = [name for name in required_variables if not os.environ.get(name)]
if missing_variables:
missing_list = ", ".join(missing_variables)
raise RuntimeError(f"Missing environment variables: {missing_list}")
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]
oauth = OAuth(app)
oura = oauth.register(
name="oura",
client_id=os.environ["OURA_CLIENT_ID"],
client_secret=os.environ["OURA_CLIENT_SECRET"],
authorize_url="https://developer.ouraring.com/authorize",
access_token_url="https://api.ouraring.com/oauth/token",
client_kwargs={
"scope": "daily",
"code_challenge_method": "S256",
},
)
SLEEP_ENDPOINT = "https://api.ouraring.com/v2/usercollection/sleep"
token_store = {"token": None}
def render_explorer(payload=None, status_code=None, error=None):
return render_template(
"index.html",
connected=token_store["token"] is not None,
payload=payload,
status_code=status_code,
error=error,
)
@app.get("/")
def index():
return render_explorer()
@app.get("/login")
def login():
redirect_uri = url_for("callback", _external=True)
return oura.authorize_redirect(redirect_uri)
@app.get("/callback")
def callback():
token_store["token"] = oura.authorize_access_token()
return redirect(url_for("explore"))
@app.get("/explore")
def explore():
token = token_store["token"]
if token is None:
return redirect(url_for("index"))
try:
response = oura.get(SLEEP_ENDPOINT, token=token)
status_code = response.status_code
response.raise_for_status()
payload = json.dumps(response.json(), indent=2, ensure_ascii=False)
return render_explorer(payload=payload, status_code=status_code)
except Exception as error:
return render_explorer(error=str(error), status_code="Error")
@app.get("/disconnect")
def disconnect():
token_store["token"] = None
return redirect(url_for("index"))
if __name__ == "__main__":
app.run()
- Save app.py.
- In the activated terminal from earlier, stop the running Flask server by pressing Control+C.
- Restart the local server by running this command:
python3 app.py
What Happens When the Server Restarts?
Flask starts with the new route loaded. Restarting also clears the old process-local token.
Before you reconnect, do you expect the callback to stop at the ready state or request the sleep endpoint immediately?
- Return to http://127.0.0.1:5000/ in your browser.
- Select Connect Oura.
- Complete Oura's consent screen for the daily permission if it appears.
The callback sends you directly to /explore. You should see a green 200 badge with formatted output or a styled error card with the server's failure detail.
That closes the request loop. Your saved token now reaches the protected sleep endpoint.
Response Request Not Completing?
- Reconnect through Connect Oura if the viewer returns to its locked state after the server restart.
- Check that SLEEP_ENDPOINT appears directly above token_store in app.py.
- Confirm that your Oura account has API-accessible data. Gen3 and later accounts also require active Oura Membership for API access.
Help me diagnose my live Oura response request.
Inspect the rendered response states
The response viewer preserves the current JSON instead of assuming which fields exist. The status cards make the connection context visible before each request.
- Select the Oura API Explorer brand link to return to the home screen.
- Confirm that the Authentication card shows Connected.
- Confirm that the Endpoint card shows /v2/usercollection/sleep.
- Confirm that the Requested scope card shows daily.
- Confirm that the response viewer shows Connection ready.
Before you request the response again, what status do you expect the viewer to display?
- Select Explore response.
A successful request shows a green 200 badge. The panel displays the current response with indentation preserved.
Why Preserve the Raw Response?
The current sleep response can evolve without matching a schema guessed in advance. Rendering the decoded response directly keeps your explorer grounded in what the API actually returned.
The formatted panel gives you evidence for future interface decisions. Your next design can start from observed fields instead of assumptions.
Verify disconnect and reconnect
The disconnect route removes the token from token_store. The interface should return to its locked state as soon as that in-memory value is cleared.
Before you disconnect, which connection details do you expect to change?
- Select Disconnect.
You should see Not connected in the authentication card. The response badge should show Waiting while the panel returns to its locked state.
Your disconnect control now proves that the running process releases its access token.
- Select Connect Oura.
- Complete Oura's consent screen if it appears.
- Select the Oura API Explorer brand link to return to the home screen.
Before the final check, do you expect the explorer to show the locked panel or use the new token?
- Select Explore response.
You should see a green 200 badge with formatted JSON returned by https://api.ouraring.com/v2/usercollection/sleep or a styled error card with the server's failure detail.
You now have the complete local integration working. The explorer can authorize access, request live sleep data, expose the current response, and clear its token.
Secret mission
Become a Response Schema Detective
Turn your live Oura sleep response into an evidence-based schema note without exposing personal values. You will map the observed structure and define the checks a future visual card needs before it trusts one field.
Clean Up Your Resources
Clean Up Your Resources
Your explorer runs locally on your Mac. No paid cloud resource was created during this project.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your existing Oura account or membership costs remain unchanged.
Resources you used:
- Local files: The oura-api-explorer folder stores your source files. It also contains the .venv virtual environment and schema-notes.md.
- Local process: The running Flask server holds the temporary access token in memory.
- Terminal configuration: The current Visual Studio Code terminal holds OURA_CLIENT_ID, OURA_CLIENT_SECRET, and FLASK_SECRET_KEY.
- External registration: The Oura API application remains registered with http://127.0.0.1:5000/callback as its redirect URL.
Keep everything running
No action is needed. Choose this option if you are still inspecting responses or developing the explorer.
- Leave the current Flask server running.
- Keep the current Visual Studio Code terminal open so its exported configuration remains available.
- Leave the Oura API application registered with http://127.0.0.1:5000/callback.
- Keep the oura-api-explorer folder for future development.
Pause - I'll come back to this later
Pausing shuts down the local server to free system resources. Your application registration and project files stay available.
- Return to the current Visual Studio Code terminal.
- Press Control-C to stop the Flask server.
- Refresh http://127.0.0.1:5000/ in your browser.
Your browser can no longer reach the explorer. The process-local access token has also disappeared.
- Keep the terminal open if you want its exported configuration to remain available.
- Close the terminal if you want to discard the exported configuration.
What Remains After Pausing?
The Oura API application remains registered. The oura-api-explorer folder also stays on your Mac.
A future server process starts without an access token. You authorize through Oura again when you need another response.
Delete - I don't want to use this again
Removing the application and local folder is permanent. Your Oura account remains intact.
- Return to the current Visual Studio Code terminal.
- Press Control-C to stop the Flask server.
The server has stopped. Its process-local access token is now gone.
- Open the official Oura API support article.
- Follow the newest developer portal link.
- Select the application registered with http://127.0.0.1:5000/callback.
- Use the portal's application-deletion control to remove the application.
- Confirm that the application no longer appears among your registered applications.
Portal Labels Can Differ
The authenticated portal can use different labels as its interface changes. Identify the correct application by its registered redirect URL before deleting it.
- Return to the current Visual Studio Code terminal.
- Delete the local project from its parent folder by running these commands:
cd ..
rm -rf oura-api-explorer
What Do These Commands Remove?
The first command moves into the folder that contains oura-api-explorer.
The second command permanently removes the project folder. This also removes .venv and schema-notes.md.
- Verify that the project folder is gone by listing the remaining items:
ls
What Should You See?
The terminal lists the remaining items in the parent folder. You should not see oura-api-explorer in the output.
Project Folder Still Listed?
- Check that the folder name is exactly oura-api-explorer.
- Confirm that your terminal moved into the folder containing oura-api-explorer before you ran the removal command.
Help me remove the remaining local project folder safely.
- Close the current Visual Studio Code terminal session.
Closing the terminal discards its three exported environment variables. Your local files and application registration are now removed.
Nice Work!
Nice Work!
You did it! Your polished local Flask explorer now connects to your Oura account through OAuth 2.0.
You've learned how to:
- Build a polished local Flask developer console with clear views for every connection state.
- Complete the OAuth 2.0 authorization-code flow with S256 PKCE while limiting consent to the daily scope.
- Inspect a live Oura API sleep response as readable JSON without assuming undocumented fields.
- Secret Mission: Documented your account's observed response schema in schema-notes.md with validation rules for a future dashboard card.
Ready to quiz yourself?