
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}
endThe 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}
endReal 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
endOptional 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)
endWebhook 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
endIf 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:
RevenueCat.Webhook.process/2expects an Ecto repo module and Ecto schema.- Default persistence callbacks use
RevenueCat.Ecto.WebhookEventchangesets. - The core API client (
RevenueCat.has_active_entitlement/*,RevenueCat.delete_customer/*) works without Ecto.
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
endSecurity
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"}
]
endIf you use webhook Ecto changeset helpers, add Ecto in the host app:
def deps do
[
{:revenue_cat, "~> 0.1.0"},
{:ecto, "~> 3.13"}
]
endConfiguration
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/2RevenueCat.has_active_entitlement/3(per-call options or client struct)RevenueCat.delete_customer/1RevenueCat.delete_customer/2(per-call options or client struct)RevenueCat.new/1RevenueCat.Webhook.process/2RevenueCat.Ecto.WebhookEvent.create_changeset/2RevenueCat.Ecto.WebhookEvent.create_changeset/3RevenueCat.Ecto.WebhookEvent.synced_changeset/2RevenueCat.Ecto.WebhookEvent.failed_changeset/2
Return shape:
- entitlement check:
{:ok, boolean}or{:error, reason_atom_or_tuple} - customer delete:
:okor{: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/1treats RevenueCat404as 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