RevenueCat logo

RevenueCat is a focused Elixir client for common RevenueCat backend flows.

It currently targets:

  • active entitlement checks
  • customer deletion
  • webhook ingestion with Ecto-backed deduplication
  • optional Ecto webhook-event changeset helpers

Usage

Use RevenueCat.has_active_entitlement/2 and RevenueCat.delete_customer/1 as the primary API.

Reusable client struct example

client =
  RevenueCat.new(
    secret_api_key: System.fetch_env!("REVENUECAT_SECRET_API_KEY"),
    project_id: System.fetch_env!("REVENUECAT_PROJECT_ID"),
    req_options: [receive_timeout: 5_000]
  )

RevenueCat.has_active_entitlement(client, "user_123", "pro")

Per-call options example

RevenueCat.has_active_entitlement("user_123", "pro",
  secret_api_key: System.fetch_env!("REVENUECAT_SECRET_API_KEY"),
  project_id: System.fetch_env!("REVENUECAT_PROJECT_ID"),
  req_options: [receive_timeout: 5_000]
)

Entitlement check example

case RevenueCat.has_active_entitlement("user_123", "pro") do
  {:ok, true} ->
    :grant_access

  {:ok, false} ->
    :deny_access

  {:error, reason} ->
    {:error, reason}
end

The second argument can be:

  • an entitlement ID (for example, entl123...)
  • a lookup key
  • a display name

Customer deletion example

case RevenueCat.delete_customer("user_123") do
  :ok ->
    :ok

  {:error, :upstream_unavailable} ->
    {:retry_later, :revenuecat_unavailable}

  {:error, reason} ->
    {:error, reason}
end

Real controller-style example

def sync_subscription(conn, %{"app_user_id" => app_user_id}) do
  case RevenueCat.has_active_entitlement(app_user_id, "pro") do
    {:ok, true} ->
      json(conn, %{subscription_tier: "pro"})

    {:ok, false} ->
      json(conn, %{subscription_tier: "free"})

    {:error, {:not_configured, _key}} ->
      send_resp(conn, 503, "RevenueCat is not configured")

    {:error, :upstream_unavailable} ->
      send_resp(conn, 503, "RevenueCat is temporarily unavailable")

    {:error, :upstream_error} ->
      send_resp(conn, 502, "RevenueCat returned an unexpected response")

    {:error, :invalid_response} ->
      send_resp(conn, 502, "RevenueCat response was invalid")

    {:error, reason} ->
      send_resp(conn, 502, "Billing check failed: #{inspect(reason)}")
  end
end

Optional Ecto helper example

RevenueCat.Ecto.WebhookEvent provides reusable changesets for a host schema.

defmodule MyApp.Billing.RevenueCatWebhookEvent do
  use Ecto.Schema

  schema "revenuecat_webhook_events" do
    field :external_id, :string
    field :event_type, :string
    field :app_user_id, :string
    field :payload, :map
    field :processed_at, :utc_datetime
    field :sync_failed_at, :utc_datetime
    field :sync_error, :string
    field :last_sync_attempt_at, :utc_datetime
    field :sync_attempt_count, :integer, default: 0

    timestamps(updated_at: false)
  end

  def create_changeset(event, attrs),
    do: RevenueCat.Ecto.WebhookEvent.create_changeset(event, attrs)

  def synced_changeset(event, attrs),
    do: RevenueCat.Ecto.WebhookEvent.synced_changeset(event, attrs)

  def failed_changeset(event, attrs),
    do: RevenueCat.Ecto.WebhookEvent.failed_changeset(event, attrs)
end

Webhook ingestion with deduplication

Use RevenueCat.Webhook.process/2 to validate payloads, deduplicate by event ID, and run your event handler exactly once.

def revenuecat_webhook(conn, params) do
  case RevenueCat.Webhook.process(params,
         repo: MyApp.Repo,
         schema: MyApp.Billing.RevenueCatWebhookEvent,
         handle_event_fun: &sync_users_from_revenuecat_event/2
       ) do
    {:ok, :processed} ->
      send_resp(conn, 204, "")

    {:ok, :duplicate} ->
      send_resp(conn, 204, "")

    {:error, :invalid_payload} ->
      send_resp(conn, 400, "Invalid RevenueCat payload")

    {:error, reason} ->
      send_resp(conn, 503, "Webhook processing failed: #{inspect(reason)}")
  end
end

If your webhook-events unique index has a non-default name, pass it explicitly:

RevenueCat.Webhook.process(params,
  repo: MyApp.Repo,
  schema: MyApp.Billing.RevenueCatWebhookEvent,
  handle_event_fun: &sync_users_from_revenuecat_event/2,
  external_id_constraint_name: "revenuecat_webhook_events_external_id_index"
)

Webhook ingestion is currently Ecto-backed:

Handler contract:

def sync_users_from_revenuecat_event(event, event_meta) do
  # event is payload["event"]
  # event_meta includes :event_id, :event_type, :app_user_id
  :ok
end

Security

Verify webhook authenticity before calling RevenueCat.Webhook.process/2.

At minimum, validate your shared secret header (for example, Authorization / X-RevenueCat-Auth) in your controller or plug and reject unauthorized requests with 401.

Why This Exists

RevenueCat provides official SDKs for mobile and frontend clients, but Elixir backends often still need to implement provider calls and webhook idempotency glue themselves.

This package focuses on the backend primitives most teams need first.

What It Does

  • Calls RevenueCat V2 API for active entitlement checks.
  • Deletes RevenueCat customers.
  • Resolves configured entitlement values by ID, lookup key, or display name.
  • Handles paginated list responses.
  • Ingests webhooks with deduplication by external_id.
  • Tracks webhook sync attempts and processed/failed status fields.
  • Provides optional Ecto webhook-event changeset helpers.
  • Returns predictable error atoms for HTTP mapping.

What It Does Not Do

  • No full RevenueCat API surface.
  • No webhook signature/auth middleware.
  • No persistence or migration generation.
  • No background job orchestration.

This package is a composable primitive you integrate into your own billing workflow.

Installation

def deps do
  [
    {:revenue_cat, "~> 0.1.0"}
  ]
end

If you use webhook Ecto changeset helpers, add Ecto in the host app:

def deps do
  [
    {:revenue_cat, "~> 0.1.0"},
    {:ecto, "~> 3.13"}
  ]
end

Configuration

config :revenue_cat, :revenuecat,
  secret_api_key: System.fetch_env!("REVENUECAT_SECRET_API_KEY"),
  project_id: System.fetch_env!("REVENUECAT_PROJECT_ID"),
  # optional
  base_url: "https://api.revenuecat.com/v2",
  req_options: [receive_timeout: 5_000]

API

Return shape:

  • entitlement check: {:ok, boolean} or {:error, reason_atom_or_tuple}
  • customer delete: :ok or {:error, reason_atom_or_tuple}
  • webhook process: {:ok, :processed | :duplicate} or {:error, reason_atom_or_tuple}

Error Atoms

Common errors returned by RevenueCat:

  • {:not_configured, :revenuecat_secret_api_key}
  • {:not_configured, :revenuecat_project_id}
  • {:not_configured, :revenuecat_entitlement_id}
  • :upstream_unavailable
  • :upstream_error
  • :invalid_response
  • :invalid_payload
  • :webhook_store_unavailable
  • {:missing_option, key}
  • :invalid_handle_event_fun
  • {:invalid_handle_event_result, value}
  • {:handler_crash, crash}

Notes

  • delete_customer/1 treats RevenueCat 404 as successful idempotent delete.
  • Entitlement matching is case-insensitive for lookup key and display name resolution.
  • The package does not read your app-specific entitlement defaults; pass the desired entitlement value explicitly per call.
  • Webhook handler exceptions/throws/exits are captured as failed attempts and returned as {:handler_crash, ...}.

Testing

  • Unit tests:
mix test
  • Postgres integration test (exactly-once webhook locking):
REVENUE_CAT_TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/revenue_cat_test \
  mix test --include integration test/revenue_cat/webhook_postgres_integration_test.exs
  • No-Ecto runtime smoke test (consumer app):
mix test --include integration test/revenue_cat/no_ecto_runtime_smoke_test.exs