Hex.pm Hex Docs Build Status Coverage Status Last Updated License

An Elixir client for the Oura API, leveraging the OpenAPI v1.27 specification.

An Elixir library for interacting with the Oura API with a base client generated using OpenAPI Code Generator from Oura OpenAPI specs v1.27. It supports basic functionality for tertrieving data from Oura, such as activity, readiness, and sleep metrics.

Features

  • OAuth2 authentication support (recommended approach)
  • Personal Access Token support (deprecated - to be removed by end of 2025)
  • Fetch data such as activity, readiness, and sleep metrics
  • Built on the robust Elixir ecosystem
  • Compatible with OpenAPI v1.27

Installation

Add ex_oura to your list of dependencies in mix.exs:

def deps do
  [
    {:ex_oura, "~> 2.0.0"}
  ]
end

Developer Integration Guide

API Module Reference

ExOura provides dedicated modules for each type of Oura data:

Core Data Modules

Specialized Data Modules

Core Infrastructure Modules

Getting Started

  1. 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://localhost as you will get 403 error)
  2. Install ExOura

    # In mix.exs
    def deps do
      [
        {:ex_oura, "~> 2.0.0"}
      ]
    end
  3. 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
      ]

OAuth2 Integration Examples

Basic OAuth2 Flow

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
end

Token Refresh Handling

defmodule 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
end

Data Retrieval Examples

Comprehensive Health Dashboard

defmodule 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
end

Streaming Large Datasets

defmodule 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
end

Error Handling Best Practices

defmodule 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

Production Considerations

Rate Limiting Management

# 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
end

Background Data Sync

defmodule 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

Quick Reference

# 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)

Pagination Support

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 data

Pagination Options

You 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)

Rate Limiting and Retry Logic

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])

Rate Limiting Features

  • 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

Retry Logic Features

  • 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

Advanced Usage

# 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}"

License

This project is licensed under the MIT License.