RevenueCat (in-app subscriptions)¶
A Flutter (or native) app that sells subscriptions through the App Store and Google Play almost always goes through RevenueCat. The app buys; RevenueCat tells your backend through a webhook on every change (purchase, renewal, cancellation, expiration...). The backend needs three things every service ended up writing by hand:
- Authenticate the webhook — and refuse everything when no secret is configured.
- Record the event exactly once, answer
200fast and process later — RevenueCat retries, and sometimes sends the same event twice. - Give up on a poisoned event after N attempts, instead of retrying it on every sweep, forever.
The SDK ships all three: the adapter in
tempest_fastapi_sdk.integrations.subscriptions.revenuecat and the generic
WebhookInbox in tempest_fastapi_sdk.db, which works for any provider.
Installation
No extra. The client uses the SDK's HTTPClient, and httpx already
ships with the base package.
Where every claim about RevenueCat comes from
Event format, headers, event types and retry policy were read from the
official documentation, not measured against a live account:
Webhooks,
Event Types and Fields,
Sample Events
and the REST API v1. The three
webhook pages are vendored in vendor/ with their sha256 pinned by a
test, and the SDK's list of event types is compared against them.
The minimal path¶
One file with the inbox model, your product's subscription table, the handler that applies an event, and the route:
# src/billing.py
from datetime import datetime
from fastapi import FastAPI
from sqlalchemy import String, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import register_exception_handlers
from tempest_fastapi_sdk.db import (
AsyncDatabaseManager,
BaseModel,
BaseWebhookEventModel,
UtcDateTime,
WebhookInbox,
)
from tempest_fastapi_sdk.integrations.subscriptions.revenuecat import (
RevenueCatWebhook,
make_revenuecat_webhook_router,
normalize,
)
from tempest_fastapi_sdk.settings import DatabaseSettings, RevenueCatSettings
class Settings(DatabaseSettings, RevenueCatSettings):
"""Database + RevenueCat credentials, read from the environment."""
class WebhookEventModel(BaseWebhookEventModel):
"""Webhook inbox: one row per distinct event."""
__tablename__ = "webhook_events"
class SubscriptionModel(BaseModel):
"""Current subscription state of each app user."""
__tablename__ = "subscriptions"
app_user_id: Mapped[str] = mapped_column(String(255), unique=True)
status: Mapped[str] = mapped_column(String(32))
product_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
period_end: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
settings = Settings()
db = AsyncDatabaseManager(**settings.database_kwargs())
inbox: WebhookInbox[WebhookEventModel] = WebhookInbox(db, model=WebhookEventModel)
async def apply_event(event: WebhookEventModel, session: AsyncSession) -> None:
"""Store the state the event left, in the inbox's own transaction.
Args:
event (WebhookEventModel): The claimed inbox row.
session (AsyncSession): The session that also marks the event
processed.
"""
snapshot = normalize(RevenueCatWebhook.model_validate(event.payload).event)
if snapshot.app_user_id is None:
return
current = (
await session.execute(
select(SubscriptionModel).where(
SubscriptionModel.app_user_id == snapshot.app_user_id
)
)
).scalar_one_or_none()
if current is None:
current = SubscriptionModel(app_user_id=snapshot.app_user_id)
session.add(current)
current.status = snapshot.status.value
current.product_id = snapshot.product_id
current.period_end = snapshot.period_end
app = FastAPI()
register_exception_handlers(app)
app.include_router(
make_revenuecat_webhook_router(
inbox=inbox,
secret=settings.REVENUECAT_WEBHOOK_SECRET,
)
)
And the sweep that processes what arrived, every 5 minutes:
# src/tasks/billing.py
from tempest_fastapi_sdk.tasks import Cron, TaskQueue
from src.billing import apply_event, db, inbox
tq = TaskQueue.rabbitmq("amqp://guest:guest@localhost:5672/", resources=[db])
@tq.cron(Cron.EVERY_5_MINUTES)
async def sweep_webhook_inbox() -> None:
"""Process every inbox event that is due."""
await inbox.drain_once(apply_event)
In the RevenueCat dashboard, under Integrations → Webhooks, point the URL
at https://<your-host>/webhooks/revenuecat and fill the authorization
header field with the same value as REVENUECAT_WEBHOOK_SECRET.
What happens to a delivery, run with the code above (SQLite, three POSTs of a
trial INITIAL_PURCHASE carrying an $email in subscriber_attributes — no
header, with the header, with the header again —, one drain_once, then the
subscriptions row and the inbox row printed: status, and whether the stored
payload has subscriber_attributes):
401 {'detail': 'Invalid RevenueCat webhook credentials', 'code': 'UNAUTHORIZED', 'details': {}}
200 {'recorded': True, 'event_id': 'evt-1'}
200 {'recorded': False, 'event_id': 'evt-1'}
processed 1 retrying 0
user-1 trial monthly 2030-03-17 17:46:40+00:00
processed False
The repeat got 200 with recorded: False: RevenueCat only needs to stop
retrying, and nothing was stored twice.
Piece by piece¶
1. The route holds no business logic¶
make_revenuecat_webhook_router authenticates, records the event in the inbox
with provider="revenuecat" and external_id=event.id, and answers 200.
That is all.
Why: according to the documentation,
any status other than 200 is a failure, RevenueCat retries "up to 5 times"
(after 5, 10, 20, 40 and 80 minutes) and disconnects after 60 seconds. The
same page recommends deferring processing until after the response.
Process right away, without waiting for the sweep
Pass on_recorded: it receives the inbox row id of every new event
(not of a repeat). Enqueue the task there. If enqueueing fails, the route
still answers 200 — the event is already recorded and the sweep picks
it up.
from uuid import UUID
from tempest_fastapi_sdk.integrations.subscriptions.revenuecat import (
make_revenuecat_webhook_router,
)
from tempest_fastapi_sdk.tasks import TaskQueue
from src.billing import apply_event, inbox
tq = TaskQueue.rabbitmq("amqp://guest:guest@localhost:5672/")
@tq.task
async def process_webhook_event(event_id: str) -> None:
"""Process one inbox event.
Args:
event_id (str): The inbox row id.
"""
await inbox.process(UUID(event_id), apply_event)
async def enqueue(event_id: UUID) -> None:
"""Send the newly recorded event to the worker.
Args:
event_id (UUID): The inbox row id.
"""
await process_webhook_event.enqueue(str(event_id))
router = make_revenuecat_webhook_router(
inbox=inbox, secret="dashboard-value", on_recorded=enqueue
)
2. Authentication: the header and, optionally, HMAC¶
RevenueCat offers two ways, both documented on the webhooks page:
| Way | Header | What it proves |
|---|---|---|
| Authorization header (the value you type in the dashboard) | Authorization |
that the sender knows the secret |
| HMAC webhook signing (opt-in, per integration) | X-RevenueCat-Webhook-Signature: t=<ts>,v1=<hex> |
that and that the body is intact |
from tempest_fastapi_sdk.integrations.subscriptions.revenuecat import (
make_revenuecat_webhook_router,
)
from tempest_fastapi_sdk.settings import RevenueCatSettings
from src.billing import inbox
settings = RevenueCatSettings()
router = make_revenuecat_webhook_router(
inbox=inbox,
secret=settings.REVENUECAT_WEBHOOK_SECRET,
signing_secret=settings.REVENUECAT_WEBHOOK_SIGNING_SECRET,
)
A secret is configured when it holds a value. Every filled secret is checked, and with both filled a delivery must pass both; an empty one only turns its own check off. That is why the same code serves "header only", "HMAC only" and "both", changing nothing but the environment variables. Rules the SDK applies and the tests pin:
- constant-time comparison (
hmac.compare_digest); - the
Authorizationheader is accepted with or without aBearerprefix; - no filled secret refuses everything (
401) — a forgotten environment variable does not turn into an open endpoint; secret=Nonetogether withsigning_secret=NoneraisesValueErrorwhen the router is built: there is no unauthenticated route;- an HMAC signature outside the 5-minute window is refused. According to the
documentation,
tis recomputed on every attempt, retries included, so the window only covers clock skew and latency.
Per-request secret
secret and signing_secret also accept a Callable[[Request], str] —
useful for rotation or for a service with several RevenueCat projects.
Emptiness is read per request: a function returning "" turns that
check off for that delivery only.
3. The inbox: once, with an attempt budget¶
WebhookInbox is generic — it knows nothing about RevenueCat. It works the
same for Stripe, OpenPix, Mercado Pago or zap-api: record with the event id
the provider promises is stable across retries.
| Step | What it does | Why this way |
|---|---|---|
record(provider, external_id, event_type, payload) |
INSERT ... ON CONFLICT DO NOTHING (PostgreSQL and SQLite) |
a repeat returns None instead of raising |
claim(id) |
UPDATE ... WHERE status = 'received' |
of two workers, only one wins |
process(id, handler) |
runs the handler in the transaction that marks it processed |
the database effect and the mark commit together, or neither does |
fail(id, error) |
back to received with exponential backoff |
a dependency that is down gets time to come back |
max_attempts |
once reached, the row is failed and leaves the sweep |
a poisoned event does not run forever |
reclaim_stale() |
a row stuck in processing comes back (or fails, with no budget left) |
a worker that died mid-way does not strand the event |
from tempest_fastapi_sdk.db import WebhookInbox
from src.billing import WebhookEventModel, db
inbox: WebhookInbox[WebhookEventModel] = WebhookInbox(
db,
model=WebhookEventModel,
max_attempts=5,
backoff_base_seconds=60.0,
stale_after=600.0,
)
Effects outside the database are not in the transaction
E-mail, push, a call to another API: if the handler does that and then
fails, the next attempt does it again. Make that effect idempotent on
event.external_id.
An event that reached failed stays in the table for a person to look at
(last_error says why). To reprocess it after fixing the cause, set status
back to received and reset attempts.
4. From event to state: normalize¶
The documentation lists 21 event types. normalize(event) returns a
SubscriptionSnapshot with a SubscriptionStatusEnum and the fields that
matter (app_user_id, product_id, entitlement_ids, period_end,
grace_period_end, reason, event_at...):
| Event | Status |
|---|---|
INITIAL_PURCHASE, RENEWAL, UNCANCELLATION, NON_RENEWING_PURCHASE, PRODUCT_CHANGE, SUBSCRIPTION_EXTENDED, REFUND_REVERSED |
TRIAL when period_type is TRIAL, ACTIVE otherwise |
CANCELLATION with CUSTOMER_SUPPORT |
REFUNDED |
CANCELLATION with BILLING_ERROR, BILLING_ISSUE |
BILLING_ISSUE |
CANCELLATION (any other reason) |
CANCELLED — the period runs to its end |
SUBSCRIPTION_PAUSED |
PAUSE_SCHEDULED — the documentation says not to revoke access here |
EXPIRATION with SUBSCRIPTION_PAUSED |
PAUSED |
EXPIRATION |
EXPIRED |
TEMPORARY_ENTITLEMENT_GRANT |
TEMPORARY_GRANT (up to 24 h, during a store outage) |
TEST, TRANSFER, EXPERIMENT_ENROLLMENT, VIRTUAL_CURRENCY_TRANSACTION, SUBSCRIBER_ALIAS, PURCHASE_REDEEMED, INVOICE_ISSUANCE, PRICE_INCREASE_CONSENT_* |
NO_CHANGE |
| a type this release does not know | UNKNOWN — never raises |
What each status means for your product (which plan unlocks what, how long the grace period lasts, what a refund takes away) stays in your service. The SDK delivers the state, not the entitlement.
Out-of-order delivery
snapshot.event_at is when RevenueCat generated the event. Keep the last
one applied per user and ignore an older event that arrives late.
5. Minimal collection¶
subscriber_attributes is where the app puts $email, a phone number and
whatever else it attached to the customer — the documentation's
INITIAL_PURCHASE sample carries an $email. It is still readable as
event.subscriber_attributes, but it stays out of model_dump(), and
model_dump() is what the route stores in the inbox. In the run above, the
processed False on the last line is exactly that: the field never reached
the database.
6. Reconciliation through the API¶
The webhooks page
recommends re-reading the customer through GET /subscribers after any
webhook. RevenueCatClient.get_subscriber makes that call with a secret
key:
import asyncio
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.subscriptions.revenuecat import (
RevenueCatClient,
)
from tempest_fastapi_sdk.settings import RevenueCatSettings
async def main() -> None:
"""Read a user's current state straight from RevenueCat."""
settings = RevenueCatSettings()
async with HTTPClient(**settings.revenuecat_kwargs()) as http:
info = await RevenueCatClient(http).get_subscriber("user-1456")
for name, entitlement in info.subscriber.entitlements.items():
print(name, entitlement.expires_date)
asyncio.run(main())
The endpoint is Get or Create
The reference calls the route "Get or Create Customer": an app_user_id
RevenueCat has never seen is created. Reconcile only ids that came
from a webhook or from your own users table. That is also why
tempest integrations verify revenuecat exits with code 2 without calling
anything: verifying the key through that route would create a customer.
For tests, FakeRevenueCatClient answers what you seed and fails when told
to:
import asyncio
import httpx
from tempest_fastapi_sdk.testing.fakes import FakeRevenueCatClient
async def main() -> None:
"""Seed a customer, read it, and force a network failure."""
fake = FakeRevenueCatClient()
fake.set_subscriber("user-1", {"entitlements": {"pro": {}}})
info = await fake.get_subscriber("user-1")
print(list(info.subscriber.entitlements))
fake.fail_next(httpx.ConnectError("down"))
try:
await fake.get_subscriber("user-1")
except httpx.ConnectError as exc:
print("failed:", exc)
asyncio.run(main())
An app_user_id nobody seeded raises httpx.HTTPStatusError (404), the same
type the real client raises for a non-2xx answer — instead of creating the
customer as the real endpoint would.
Configuration¶
| Variable | What for | Empty means |
|---|---|---|
REVENUECAT_WEBHOOK_SECRET |
the dashboard's authorization header value | the Authorization header is not checked; with REVENUECAT_WEBHOOK_SIGNING_SECRET empty too, every delivery is refused (401) |
REVENUECAT_WEBHOOK_SIGNING_SECRET |
the HMAC secret, when enabled | the signature is not checked; with REVENUECAT_WEBHOOK_SECRET empty too, every delivery is refused (401) |
REVENUECAT_API_KEY |
secret key (sk_...) for GET /v1/subscribers |
the call goes out with an empty Bearer; the reference documents 401 for an invalid key |
Composed with EnvironmentSettings, RevenueCatSettings fails the boot under
ENV=production when neither webhook secret is set — in production that
would mean refusing every purchase and renewal:
ValidationError 1 validation error for Settings
Value error, ENV=production refuses development values:
- REVENUECAT_WEBHOOK_SECRET: empty, so every RevenueCat webhook is refused
Recap¶
make_revenuecat_webhook_routerauthenticates, records in the inbox and answers200; every filled secret is checked, and with none filled every delivery is refused.WebhookInboxrecords each event once, processes it under a claim, commits the effect with the mark, and stops atmax_attempts— and works for any provider.normalizeturns the 21 event types into one state; a new type becomesUNKNOWN.subscriber_attributesis not stored.RevenueCatClient.get_subscriberreconciles — remembering that the endpoint creates the customer when it does not exist.