Skip to main content

README.md

# RevenueCat

<p align="left">
  <img src="https://raw.githubusercontent.com/metacircu1ar/revenue_cat/main/logo.png" alt="RevenueCat logo" width="180">
</p>

`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

```elixir
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

```elixir
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

```elixir
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

```elixir
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

```elixir
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.

```elixir
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.

```elixir
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:

```elixir
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:
- `RevenueCat.Webhook.process/2` expects an Ecto repo module and Ecto schema.
- Default persistence callbacks use `RevenueCat.Ecto.WebhookEvent` changesets.
- The core API client (`RevenueCat.has_active_entitlement/*`, `RevenueCat.delete_customer/*`) works without Ecto.

Handler contract:

```elixir
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

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

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

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

## Configuration

```elixir
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

- `RevenueCat.has_active_entitlement/2`
- `RevenueCat.has_active_entitlement/3` (per-call options or client struct)
- `RevenueCat.delete_customer/1`
- `RevenueCat.delete_customer/2` (per-call options or client struct)
- `RevenueCat.new/1`
- `RevenueCat.Webhook.process/2`
- `RevenueCat.Ecto.WebhookEvent.create_changeset/2`
- `RevenueCat.Ecto.WebhookEvent.create_changeset/3`
- `RevenueCat.Ecto.WebhookEvent.synced_changeset/2`
- `RevenueCat.Ecto.WebhookEvent.failed_changeset/2`

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:

```bash
mix test
```

- Postgres integration test (exactly-once webhook locking):

```bash
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):

```bash
mix test --include integration test/revenue_cat/no_ecto_runtime_smoke_test.exs
```