An Elixir client for the Oura API, leveraging the OpenAPI v1.42 specification.
An Elixir library for interacting with the Oura API with a base client generated using OpenAPI Code Generator from Oura OpenAPI specs v1.42. It supports Oura collection APIs including activity, readiness, sleep, workouts, heart-rate time series, and webhook management.
- OAuth2 authentication support (recommended approach)
- Fetch data such as activity, readiness, and sleep metrics
- Built on the robust Elixir ecosystem
- Compatible with OpenAPI v1.42
The public ExOura functions and response structs remain compatible. This update adds
live_third_party_heart_rate and live_oura_heart_rate workout sources and the optional
heart_health OAuth2 scope. Request it explicitly with
ExOura.authorization_url(scopes: ["daily", "heart_health"]); existing OAuth2 grants
need new user consent to include it. Default scopes remain personal and daily.
The SpO2 scope is spo2.
Oura no longer lists meal as a supported webhook subscription data type. Sleep
ring_id is deprecated and now returns nil; the field remains in the response struct.
Add ex_oura to your list of dependencies in mix.exs:
def deps do
[
{:ex_oura, "~> 4.0"}
]
endExOura provides dedicated modules for each type of Oura data:
ExOura.DailyActivity- Daily activity metrics (steps, calories, activity score)ExOura.DailySleep- Sleep data (sleep score, stages, duration, quality)ExOura.DailyReadiness- Readiness scores and recovery metricsExOura.Workout- Exercise sessions and workout dataExOura.PersonalInfo- User demographics and physical information
ExOura.HeartRate- Time-series heart rate dataExOura.DailySp02- Blood oxygen saturation data during sleepExOura.DailyStress- Daily stress levels and patternsExOura.DailyResilience- Resilience scores and stress recoveryExOura.EnhancedTag- User tags and annotations (recommended)ExOura.Tag- Legacy tag system (deprecated by Oura)ExOura.Session- Guided sessions and breathing exercisesExOura.Sleep- Detailed sleep session dataExOura.SleepTime- Sleep timing preferencesExOura.RingBatteryLevel- Time-series ring battery level eventsExOura.Vo2Max- VO2 Max measurementsExOura.WebhookSubscription- Webhook management
- ExOura.Client - Base HTTP client with authentication
ExOura.OAuth2- OAuth2 flow managementExOura.Pagination- Automatic pagination handlingExOura.RateLimiter- API rate limit management
-
Register Your Application (OAuth2 - Recommended)
- Visit Oura OAuth Applications
- Create a new application and note your client credentials
- Configure your redirect URI (it has to be a valid one not
http://localhostas you will get 403 error)
-
Install ExOura
# In mix.exs def deps do [ {:ex_oura, "~> 4.0"} ] end
-
Configure Your Application
# In config/config.exs config :ex_oura, timeout: 10_000, oauth2: [ client_id: "your_client_id", client_secret: "your_client_secret", redirect_uri: "https://yourapp.com/oauth/callback" ], rate_limiting: [ enabled: true, daily_limit: 5_000, per_minute_limit: 300 ]
defmodule MyApp.OuraController do
use MyApp, :controller
# Step 1: Redirect user to Oura for authorization
def authorize(conn, _params) do
state = generate_csrf_token() # Your CSRF token generation
store_state_in_session(conn, state) # Store for verification
auth_url = ExOura.authorization_url([
scopes: ["daily", "heartrate", "personal"],
state: state
])
redirect(conn, external: auth_url)
end
# Step 2: Handle the OAuth callback
def callback(conn, %{"code" => code, "state" => state}) do
with {:ok, stored_state} <- get_state_from_session(conn),
true <- secure_compare(state, stored_state),
{:ok, tokens} <- ExOura.get_token(code) do
# Store tokens securely (database, encrypted session, etc.)
store_user_tokens(current_user(conn), tokens)
# Start the ExOura client with tokens
{:ok, _client} = ExOura.Client.start_link([
access_token: tokens.access_token,
refresh_token: tokens.refresh_token
])
redirect(conn, to: "/dashboard")
else
{:error, reason} ->
conn
|> put_flash(:error, "OAuth authorization failed: #{inspect(reason)}")
|> redirect(to: "/")
end
end
enddefmodule MyApp.OuraService do
@doc "Ensures we have valid tokens before making API calls"
def ensure_valid_tokens(user) do
tokens = get_user_tokens(user)
if ExOura.token_expired?(tokens) do
case ExOura.refresh_token(tokens.refresh_token) do
{:ok, new_tokens} = result ->
update_user_tokens(user, new_tokens)
restart_client_with_tokens(new_tokens)
result
{:error, reason} ->
# Token refresh failed - user needs to re-authorize
{:error, :reauthorization_required}
end
else
{:ok, tokens}
end
end
defp restart_client_with_tokens(tokens) do
# Restart client with new tokens
ExOura.Client.start_link([
access_token: tokens.access_token,
refresh_token: tokens.refresh_token
])
end
enddefmodule MyApp.HealthDashboard do
@doc "Fetches comprehensive health data for dashboard"
def fetch_user_health_data(user, date_range) do
with {:ok, _tokens} <- MyApp.OuraService.ensure_valid_tokens(user) do
{start_date, end_date} = date_range
# Fetch data in parallel using Task.async
tasks = [
Task.async(fn -> ExOura.all_daily_activity(start_date, end_date) end),
Task.async(fn -> ExOura.all_daily_sleep(start_date, end_date) end),
Task.async(fn -> ExOura.all_workouts(start_date, end_date) end),
Task.async(fn -> ExOura.single_personal_info() end)
]
# Wait for all tasks to complete
[activity_result, sleep_result, workout_result, personal_result] =
Task.await_many(tasks, 30_000)
case {activity_result, sleep_result, workout_result, personal_result} do
{{:ok, activities}, {:ok, sleep_data}, {:ok, workouts}, {:ok, personal_info}} ->
{:ok, %{
activities: activities,
sleep: sleep_data,
workouts: workouts,
personal_info: personal_info,
summary: generate_health_summary(activities, sleep_data, workouts)
}}
_ ->
{:error, :data_fetch_failed}
end
end
end
defp generate_health_summary(activities, sleep_data, workouts) do
%{
avg_steps: avg_field(activities, :steps),
avg_sleep_score: avg_field(sleep_data, :score),
total_workouts: length(workouts),
avg_workout_duration: avg_field(workouts, :duration)
}
end
defp avg_field([] =_ data, _field), do: 0
defp avg_field(data, field) when is_list(data) do
sum = data |> Enum.map(&Map.get(&1, field, 0)) |> Enum.sum()
sum / length(data)
end
enddefmodule MyApp.DataAnalyzer do
@doc "Analyzes large datasets using streaming for memory efficiency"
def analyze_yearly_activity(user, year) do
start_date = Date.new!(year, 1, 1)
end_date = Date.new!(year, 12, 31)
with {:ok, _tokens} <- MyApp.OuraService.ensure_valid_tokens(user) do
results = ExOura.stream_daily_activity(start_date, end_date)
|> Stream.filter(&(&1.score > 0)) # Valid scores only
|> Stream.map(&extract_activity_metrics/1)
|> Enum.reduce(%{total_steps: 0, active_days: 0, high_activity_days: 0}, &accumulate_metrics/2)
{:ok, %{
year: year,
total_steps: results.total_steps,
active_days: results.active_days,
high_activity_days: results.high_activity_days,
avg_daily_steps: results.total_steps / max(results.active_days, 1)
}}
end
end
defp extract_activity_metrics(activity) do
%{
steps: activity.steps || 0,
high_activity: (activity.score || 0) >= 80
}
end
defp accumulate_metrics(day_metrics, acc) do
%{
total_steps: acc.total_steps + day_metrics.steps,
active_days: acc.active_days + 1,
high_activity_days: acc.high_activity_days + if(day_metrics.high_activity, do: 1, else: 0)
}
end
enddefmodule MyApp.OuraAPI do
@doc "Robust API call with comprehensive error handling"
def safe_fetch_sleep_data(user, date_range, opts \\ []) do
max_retries = Keyword.get(opts, :max_retries, 3)
with_retry(fn -> fetch_sleep_data(user, date_range) end, max_retries)
end
defp fetch_sleep_data(user, {start_date, end_date}) do
case MyApp.OuraService.ensure_valid_tokens(user) do
{:ok, _tokens} ->
ExOura.multiple_daily_sleep(start_date, end_date)
{:error, :reauthorization_required} ->
{:error, :user_needs_reauth}
{:error, _reason} = error ->
error
end
end
defp with_retry(func, retries_left) when retries_left > 0 do
case func.() do
{:ok, _result} = result ->
result
{:error, %{status: status}} when status in [429, 500, 502, 503, 504] ->
# Retryable errors
:timer.sleep(exponential_backoff(3 - retries_left))
with_retry(func, retries_left - 1)
{:error, _reason} = error ->
# Non-retryable error
error
end
end
defp with_retry(func, 0), do: func.()
defp exponential_backoff(attempt) do
base_delay = 1000 # 1 second
:rand.uniform(base_delay * :math.pow(2, attempt)) |> round()
end
end# Start rate limiter in your application supervisor
children = [
{ExOura.RateLimiter, []},
# ... other children
]
# Monitor rate limit status
defmodule MyApp.RateLimitMonitor do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def init(state) do
# Check rate limits every minute
:timer.send_interval(60_000, :check_rate_limits)
{:ok, state}
end
def handle_info(:check_rate_limits, state) do
case ExOura.RateLimiter.get_status() do
%{remaining: remaining} when remaining < 100 ->
# Alert when approaching daily limit
Logger.warning("Oura API daily limit approaching: #{remaining} requests remaining")
%{per_minute_remaining: per_min} when per_min < 10 ->
# Alert when approaching per-minute limit
Logger.warning("Oura API per-minute limit approaching: #{per_min} requests remaining")
_ ->
:ok
end
{:noreply, state}
end
enddefmodule MyApp.OuraSync do
use Oban.Worker, queue: :oura_sync, max_attempts: 3
@doc "Background job to sync user's Oura data"
def perform(%Oban.Job{args: %{"user_id" => user_id, "sync_date" => sync_date}}) do
user = MyApp.Accounts.get_user!(user_id)
date = Date.from_iso8601!(sync_date)
with {:ok, _tokens} <- MyApp.OuraService.ensure_valid_tokens(user),
{:ok, data} <- sync_user_data_for_date(user, date) do
MyApp.HealthData.store_user_data(user, date, data)
:ok
else
{:error, :user_needs_reauth} ->
# Schedule notification to user
MyApp.Notifications.schedule_reauth_reminder(user)
{:snooze, 3600} # Retry in 1 hour
{:error, reason} ->
Logger.error("Failed to sync Oura data for user #{user_id}: #{inspect(reason)}")
{:error, reason}
end
end
defp sync_user_data_for_date(user, date) do
# Fetch yesterday's data (typically available by 10 AM)
with {:ok, activity} <- ExOura.multiple_daily_activity(date, date),
{:ok, sleep} <- ExOura.multiple_daily_sleep(date, date),
{:ok, workouts} <- ExOura.multiple_workout(date, date) do
{:ok, %{
activity: List.first(activity.data),
sleep: List.first(sleep.data),
workouts: workouts.data
}}
end
end
end# Most common operations
{:ok, activities} = ExOura.multiple_daily_activity(~D[2025-01-01], ~D[2025-01-31])
{:ok, sleep_data} = ExOura.multiple_daily_sleep(~D[2025-01-01], ~D[2025-01-31])
{:ok, workouts} = ExOura.multiple_workout(~D[2025-01-01], ~D[2025-01-31])
{:ok, personal_info} = ExOura.single_personal_info()
# Pagination helpers (automatically handles multiple pages)
{:ok, all_activities} = ExOura.all_daily_activity(~D[2024-01-01], ~D[2024-12-31])
{:ok, all_sleep} = ExOura.all_daily_sleep(~D[2024-01-01], ~D[2024-12-31])
# Memory-efficient streaming for large datasets
ExOura.stream_daily_activity(~D[2024-01-01], ~D[2024-12-31])
|> Stream.filter(&(&1.score > 80))
|> Enum.take(100)For large date ranges, the API returns paginated results. ExOura provides convenient functions to automatically handle pagination:
# Fetch ALL daily activity data across multiple pages
{:ok, all_activities} = ExOura.all_daily_activity(~D[2024-01-01], ~D[2024-12-31])
IO.inspect(length(all_activities)) # All activities for the year
# Fetch ALL workouts across multiple pages
{:ok, all_workouts} = ExOura.all_workouts(~D[2024-01-01], ~D[2024-12-31])
# Stream data for memory-efficient processing of large datasets
ExOura.stream_daily_activity(~D[2024-01-01], ~D[2024-12-31])
|> Stream.filter(& &1.score > 80)
|> Stream.take(10)
|> Enum.to_list()
# Available pagination helpers
ExOura.all_daily_activity/3 # All daily activity data
ExOura.all_daily_readiness/3 # All daily readiness data
ExOura.all_daily_sleep/3 # All daily sleep data
ExOura.all_workouts/3 # All workout data
ExOura.all_sleep/3 # All sleep data
ExOura.stream_daily_activity/3 # Stream daily activity data
ExOura.stream_workouts/3 # Stream workout dataYou can control pagination behavior with options:
# Limit maximum pages to prevent runaway requests
{:ok, activities} = ExOura.all_daily_activity(
~D[2024-01-01],
~D[2024-12-31],
[max_pages: 10]
)
# Manual pagination if you need more control
{:ok, page1} = ExOura.multiple_daily_activity(~D[2024-01-01], ~D[2024-01-31])
{:ok, page2} = ExOura.multiple_daily_activity(~D[2024-01-01], ~D[2024-01-31], page1.next_token)ExOura automatically handles Oura API rate limits and implements intelligent retry logic:
Configuration Options:
Rate limiting is enabled by default with the standard Oura API limits. You can customize or disable it:
config :ex_oura,
rate_limiting: [
enabled: true, # Set to false to disable rate limiting entirely
daily_limit: 5000, # Customize daily limit (default: 5000)
per_minute_limit: 300 # Customize per-minute limit (default: 300)
]Behavior:
- When enabled (default): Tracks and enforces rate limits proactively
- When disabled: No rate limit tracking, but still handles API rate limit responses
- Automatic parsing of rate limit headers from API responses
- Uses Req's built-in retry logic with exponential backoff and jitter
Usage:
# Start the rate limiter (optional - provides better rate limit management)
{:ok, _pid} = ExOura.RateLimiter.start_link()
# All API requests automatically:
# - Respect rate limits (5000/day, 300/minute) if enabled
# - Parse rate limit headers from responses
# - Use Req's built-in retry with exponential backoff
# - Handle network errors and server errors gracefully
# Example: This will automatically retry on server errors and rate limits
{:ok, activities} = ExOura.multiple_daily_activity(~D[2024-01-01], ~D[2024-01-31])- Automatic Rate Limit Detection: Parses
X-RateLimit-*headers from API responses - Proactive Throttling: Prevents hitting rate limits before they occur
- Smart Delays: Adds small delays when approaching rate limits
- Exponential Backoff: Automatically increases delay between retry attempts
- Smart Error Detection: Only retries on appropriate errors (5xx, network issues, rate limits)
- Jitter: Adds randomness to prevent thundering herd problems
- Configurable: Customize max attempts, delays, and backoff factors
# Custom retry configuration
alias ExOura.Retry
request_fn = fn ->
ExOura.multiple_daily_activity(~D[2024-01-01], ~D[2024-01-31])
end
{:ok, result} = Retry.with_retry(request_fn, [
max_attempts: 5,
base_delay: 2000, # Start with 2 second delay
max_delay: 30_000, # Cap at 30 seconds
backoff_factor: 2.5 # More aggressive backoff
])
# Monitor rate limit status
status = ExOura.RateLimiter.get_status()
IO.puts "Daily remaining: #{status.remaining}"
IO.puts "Per-minute remaining: #{status.per_minute_remaining}"Merging a release-please PR into master creates the GitHub release and runs the
publish job in release-please.yml. The job
checks out the released commit, compiles it, checks formatting, runs the
cassette-backed tests, and publishes the package and HexDocs using
Hex Workload Identity.
If the publish job fails before submission, fix the cause and re-run failed jobs on the original workflow run. Re-running the entire workflow can cause release-please to find no new release and skip publishing. Check for an existing staged release before retrying if the upload may already have succeeded.
This project is licensed under the MIT License.