RevenueCat (assinatura in-app)¶
App Flutter (ou nativo) que vende assinatura pela App Store e pelo Google Play quase sempre passa pelo RevenueCat. O app compra; o RevenueCat avisa o seu backend por webhook a cada mudança (compra, renovação, cancelamento, expiração...). O backend precisa de três coisas que todo serviço acabava escrevendo à mão:
- Autenticar o webhook — e recusar tudo quando nenhum segredo está configurado.
- Gravar o evento uma vez só, responder
200rápido e processar depois — o RevenueCat reenvia, e às vezes manda o mesmo evento duas vezes. - Desistir de um evento envenenado depois de N tentativas, em vez de tentar de novo a cada varredura, para sempre.
O SDK entrega as três: o adapter em
tempest_fastapi_sdk.integrations.subscriptions.revenuecat e o inbox
genérico WebhookInbox em tempest_fastapi_sdk.db, que serve para qualquer
provedor.
Instalação
Nenhum extra. O cliente usa o HTTPClient do SDK, e o httpx já vem no
pacote base.
De onde vem cada afirmação sobre o RevenueCat
Formato do evento, headers, tipos de evento e política de reenvio foram
lidos da documentação oficial, não medidos contra uma conta real:
Webhooks,
Event Types and Fields,
Sample Events
e a REST API v1. As três
páginas de webhook estão versionadas em vendor/ com o sha256 fixado por
teste, e a lista de tipos de evento do SDK é comparada com elas.
O caminho mínimo¶
Um arquivo com o modelo do inbox, a tabela de assinaturas do seu produto, o handler que aplica um evento e a rota:
# 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):
"""Banco + credenciais do RevenueCat, lidos do ambiente."""
class WebhookEventModel(BaseWebhookEventModel):
"""Inbox de webhooks: uma linha por evento distinto."""
__tablename__ = "webhook_events"
class SubscriptionModel(BaseModel):
"""Estado atual da assinatura de cada usuário do app."""
__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:
"""Grava o estado que o evento deixou, na mesma transação do inbox.
Args:
event (WebhookEventModel): A linha reivindicada do inbox.
session (AsyncSession): A sessão que também marca o evento como
processado.
"""
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,
)
)
E a varredura que processa o que chegou, a cada 5 minutos:
# 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:
"""Processa todo evento do inbox que já pode ser processado."""
await inbox.drain_once(apply_event)
No painel do RevenueCat, em Integrations → Webhooks, aponte a URL para
https://<seu-host>/webhooks/revenuecat e preencha o campo de
authorization header com o mesmo valor de REVENUECAT_WEBHOOK_SECRET.
O que acontece com uma entrega, rodado com o código acima (SQLite, três POSTs
de um INITIAL_PURCHASE em trial com um $email nos subscriber_attributes
— sem header, com header, de novo com header —, uma drain_once, e depois a
linha de subscriptions e a linha do inbox impressas: status, e se o payload
gravado tem 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
O reenvio respondeu 200 com recorded: False: o RevenueCat só precisa
parar de reenviar, e nada foi gravado de novo.
Peça por peça¶
1. A rota não faz regra de negócio¶
make_revenuecat_webhook_router autentica, grava o evento no inbox com
provider="revenuecat" e external_id=event.id, e responde 200. Só isso.
Por quê: segundo a documentação,
qualquer status diferente de 200 é falha, o RevenueCat reenvia "up to 5
times" (depois de 5, 10, 20, 40 e 80 minutos) e desconecta em 60 segundos. A
própria página recomenda adiar o processamento para depois da resposta.
Processar na hora, sem esperar a varredura
Passe on_recorded: ele recebe o id da linha do inbox de cada evento
novo (não do reenvio). Enfileire a task ali. Se o enfileiramento
falhar, a rota ainda responde 200 — o evento já está gravado e a
varredura pega.
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:
"""Processa um evento do inbox.
Args:
event_id (str): O id da linha do inbox.
"""
await inbox.process(UUID(event_id), apply_event)
async def enqueue(event_id: UUID) -> None:
"""Manda o evento recém-gravado para o worker.
Args:
event_id (UUID): O id da linha do inbox.
"""
await process_webhook_event.enqueue(str(event_id))
router = make_revenuecat_webhook_router(
inbox=inbox, secret="valor-do-painel", on_recorded=enqueue
)
2. Autenticação: header e, se quiser, HMAC¶
O RevenueCat oferece duas formas, as duas documentadas na página de webhooks:
| Forma | Header | O que prova |
|---|---|---|
| Authorization header (o valor que você digita no painel) | Authorization |
que quem mandou conhece o segredo |
| HMAC webhook signing (opcional, por integração) | X-RevenueCat-Webhook-Signature: t=<ts>,v1=<hex> |
isso e que o corpo não mudou |
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,
)
Um segredo está configurado quando tem valor. Cada segredo preenchido é conferido, e com os dois preenchidos a entrega precisa passar nos dois; o que estiver vazio só desliga a própria checagem. Por isso o mesmo código serve para "só header", "só HMAC" e "os dois", trocando apenas as variáveis de ambiente. Regras que o SDK aplica e que os testes fixam:
- comparação em tempo constante (
hmac.compare_digest); - o header
Authorizationvale com ou sem o prefixoBearer; - nenhum segredo preenchido recusa tudo (
401) — uma variável de ambiente esquecida não vira um endpoint aberto; secret=Noneesigning_secret=Nonejuntos levantamValueErrorna montagem: não existe rota sem autenticação;- a assinatura HMAC fora da janela de 5 minutos é recusada. Segundo a
documentação, o
té recalculado a cada tentativa, inclusive nos reenvios, então a janela só cobre relógio e latência.
Segredo por requisição
secret e signing_secret também aceitam uma função
Callable[[Request], str] — útil para rotação ou para um serviço com
vários projetos RevenueCat. O vazio é lido por requisição: a função que
devolve "" desliga aquela checagem só naquela entrega.
3. O inbox: uma vez, com teto de tentativas¶
WebhookInbox é genérico — não conhece o RevenueCat. Ele serve igual para
Stripe, OpenPix, Mercado Pago ou o zap-api: grave com o id de evento que o
provedor promete estável entre reenvios.
| Passo | O que faz | Por que assim |
|---|---|---|
record(provider, external_id, event_type, payload) |
INSERT ... ON CONFLICT DO NOTHING (PostgreSQL e SQLite) |
o reenvio devolve None em vez de levantar |
claim(id) |
UPDATE ... WHERE status = 'received' |
de dois workers, só um ganha |
process(id, handler) |
roda o handler na mesma transação que marca processed |
efeito no banco e marca entram juntos, ou nada entra |
fail(id, erro) |
volta para received com backoff exponencial |
dependência fora do ar ganha tempo para voltar |
max_attempts |
ao atingir, vira failed e sai da varredura |
evento envenenado não roda para sempre |
reclaim_stale() |
linha presa em processing volta (ou falha, sem orçamento) |
worker que morreu no meio não prende o evento |
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,
)
Efeito fora do banco não entra na transação
E-mail, push, chamada a outra API: se o handler faz isso e depois falha,
a próxima tentativa faz de novo. Torne esse efeito idempotente pelo
event.external_id.
Evento que chegou a failed fica na tabela para alguém olhar (last_error
diz o motivo). Para reprocessar depois de corrigir a causa, volte o status
para received e zere attempts.
4. De evento para estado: normalize¶
São 21 tipos de evento na documentação. normalize(event) devolve um
SubscriptionSnapshot com um SubscriptionStatusEnum e os campos que
importam (app_user_id, product_id, entitlement_ids, period_end,
grace_period_end, reason, event_at...):
| Evento | Status |
|---|---|
INITIAL_PURCHASE, RENEWAL, UNCANCELLATION, NON_RENEWING_PURCHASE, PRODUCT_CHANGE, SUBSCRIPTION_EXTENDED, REFUND_REVERSED |
TRIAL se period_type é TRIAL, senão ACTIVE |
CANCELLATION com CUSTOMER_SUPPORT |
REFUNDED |
CANCELLATION com BILLING_ERROR, BILLING_ISSUE |
BILLING_ISSUE |
CANCELLATION (outro motivo) |
CANCELLED — o período corre até o fim |
SUBSCRIPTION_PAUSED |
PAUSE_SCHEDULED — a documentação manda não cortar acesso aqui |
EXPIRATION com SUBSCRIPTION_PAUSED |
PAUSED |
EXPIRATION |
EXPIRED |
TEMPORARY_ENTITLEMENT_GRANT |
TEMPORARY_GRANT (até 24 h, durante falha da loja) |
TEST, TRANSFER, EXPERIMENT_ENROLLMENT, VIRTUAL_CURRENCY_TRANSACTION, SUBSCRIBER_ALIAS, PURCHASE_REDEEMED, INVOICE_ISSUANCE, PRICE_INCREASE_CONSENT_* |
NO_CHANGE |
| tipo que esta versão não conhece | UNKNOWN — nunca levanta |
O que cada status significa para o seu produto (qual plano libera o quê, quanto dura a carência, o que o reembolso corta) fica no seu serviço. O SDK entrega o estado, não o entitlement.
Entrega fora de ordem
snapshot.event_at é quando o RevenueCat gerou o evento. Guarde o último
aplicado por usuário e ignore um evento mais velho que chega atrasado.
5. Coleta mínima¶
subscriber_attributes é onde o app guarda $email, telefone e o que mais
anexou ao cliente — o exemplo de INITIAL_PURCHASE da documentação traz um
$email. Ele continua legível em event.subscriber_attributes, mas fica
fora do model_dump(), e é o model_dump() que a rota grava no inbox.
No exemplo acima, o processed False da última linha é isso: o campo não
chegou ao banco.
6. Reconciliação pela API¶
A página de webhooks
recomenda reler o cliente por GET /subscribers depois de qualquer webhook.
RevenueCatClient.get_subscriber faz essa chamada com uma chave secreta:
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:
"""Lê o estado atual de um usuário direto do 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())
O endpoint é Get or Create
A referência chama a rota de "Get or Create Customer": um app_user_id
que o RevenueCat nunca viu é criado. Reconcilie só ids que vieram de
um webhook ou da sua tabela de usuários. É também por isso que
tempest integrations verify revenuecat sai com código 2 sem chamar
nada: verificar a chave por essa rota criaria um cliente.
Para teste, FakeRevenueCatClient responde o que você semear e falha quando
você mandar:
import asyncio
import httpx
from tempest_fastapi_sdk.testing.fakes import FakeRevenueCatClient
async def main() -> None:
"""Semeia um cliente, lê, e força uma falha de rede."""
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("falhou:", exc)
asyncio.run(main())
Um app_user_id que ninguém semeou levanta httpx.HTTPStatusError (404),
o mesmo tipo que o cliente real levanta para resposta não-2xx — em vez de
criar o cliente como o endpoint real faria.
Configuração¶
| Variável | Para quê | Vazia significa |
|---|---|---|
REVENUECAT_WEBHOOK_SECRET |
valor do authorization header do painel | o header Authorization não é conferido; com REVENUECAT_WEBHOOK_SIGNING_SECRET também vazia, toda entrega é recusada (401) |
REVENUECAT_WEBHOOK_SIGNING_SECRET |
segredo do HMAC, se ativado | a assinatura não é conferida; com REVENUECAT_WEBHOOK_SECRET também vazia, toda entrega é recusada (401) |
REVENUECAT_API_KEY |
chave secreta (sk_...) para GET /v1/subscribers |
a chamada sai com Bearer vazio; a referência documenta 401 para chave inválida |
Composto com EnvironmentSettings, RevenueCatSettings derruba o boot com
ENV=production se nenhum dos dois segredos de webhook estiver
preenchido — em produção, isso significaria recusar toda compra e renovação:
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_routerautentica, grava no inbox e responde200; cada segredo preenchido é conferido, e sem nenhum preenchido toda entrega é recusada.WebhookInboxgrava cada evento uma vez, processa com claim, junta efeito e marca na mesma transação e para emmax_attempts— e serve para qualquer provedor.normalizetransforma os 21 tipos de evento em um estado; tipo novo viraUNKNOWN.subscriber_attributesnão é gravado.RevenueCatClient.get_subscriberreconcilia — lembrando que o endpoint cria o cliente se ele não existir.