Webhook v2: traduz payload de evento de compra (Hotmart) para criar/atualizar filiação
TLDR: Novo
POST /api/v2/webhookrecebe o payload de compra no formato de evento da Hotmart (PURCHASE_APPROVED/PURCHASE_REFUNDED/PURCHASE_DELAYED), enviado por outro sistema (não a Hotmart diretamente), traduz os campos para o formato queWebhookMembershipServicejá entende, e reaproveita 100% da lógica de criação/atualização de filiação existente — sem alterar o v1.
Contexto
Hoje POST /api/v1/webhook (app/controllers/api/v1/webhook_controller.rb) só entende o payload que o checkout-api envia (via CbtrgService/Cbtrg#webhook_send_payload, ver checkout-api/app/services/cbtrg_service.rb): { payment: { id, status, billing_type, checkout_id, installment_count, customer: { id, email, doc_number, name, phone_number } } }.
Precisamos aceitar um segundo formato de payload — o formato de evento de notificação da Hotmart (event, data.purchase, data.product, data.buyer) — enviado por outro sistema, para criar/atualizar a mesma Membership sem duplicar a lógica de negócio que já existe em WebhookMembershipService.
Exemplo de payload recebido (evento PURCHASE_APPROVED):
json
{
"id": "9f1c2a34-7b8d-4e5f-a1b2-c3d4e5f6a7b8",
"order_id": 18342,
"creation_date": 1755741600000,
"event": "PURCHASE_APPROVED",
"version": "2.0.0",
"data": {
"purchase": {
"transaction": "f4708f72-89a0-4976-a51a-5068c481f296",
"status": "APPROVED",
"payment": { "type": "PIX", "installments_number": 1 },
"offer": { "code": "6cikhiz8", "name": "Formação Completa" }
},
"product": { "id": 42, "name": "Formação em Terapias Integrativas" },
"buyer": {
"id": 9021,
"name": "Maria Aparecida Silva",
"email": "maria@example.com",
"checkout_phone": "+5511999998888",
"document": "123.456.789-00",
"document_type": "CPF"
}
}
}
Mesmo payload para o evento PURCHASE_REFUNDED (mesma estrutura, data.purchase.status: "REFUNDED", data.purchase.refund_date preenchido).
Payload para o evento PURCHASE_DELAYED (parcela/boleto em atraso — equivalente ao PAYMENT_OVERDUE que hoje chega via Asaas para o fluxo do v1, mas aqui no formato Hotmart):
json
{
"id": "9f1c2a34-7b8d-4e5f-a1b2-c3d4e5f6a7b8",
"order_id": 18342,
"creation_date": 1755741600000,
"event": "PURCHASE_DELAYED",
"version": "2.0.0",
"data": {
"purchase": {
"transaction": "f4708f72-89a0-4976-a51a-5068c481f296",
"status": "PARTIALLY_PAID",
"approved_date": null,
"payment": { "type": "PIX", "installments_number": 1 },
"offer": { "code": "6cikhiz8", "name": "Formação Completa" }
},
"product": { "id": 42, "name": "Formação em Terapias Integrativas" },
"buyer": {
"id": 9021,
"name": "Maria Aparecida Silva",
"email": "maria@example.com",
"checkout_phone": "+5511999998888",
"document": "123.456.789-00",
"document_type": "CPF"
}
}
}
Mapeamento de campos (decidido com o time)
params legado (WebhookMembershipService) |
origem no payload novo | observação |
|---|---|---|
id |
data.purchase.transaction |
id da transação, estável entre reenvios do mesmo evento — não o id de nível raiz, que é o id da notificação (mesmo padrão já usado no checkout-api: gateway_id = gateway_payment[:id], não o id do envelope do webhook) |
status |
mapeado do event |
PURCHASE_APPROVED → "paid"; PURCHASE_REFUNDED → "refunded"; PURCHASE_DELAYED → "overdue"; qualquer outro event não é processado nesta versão |
billing_type |
data.purchase.payment.type |
|
checkout_id |
data.purchase.offer.code |
resolve o MembershipGroup via gateway_product_id, igual ao v1 — offer.code distingue a oferta (equivalente a Black/Blue/White), product.id não distinguiria |
installment_count |
data.purchase.payment.installments_number |
|
customer.id |
data.buyer.id |
|
customer.email |
data.buyer.email |
|
customer.doc_number |
data.buyer.document, só dígitos |
mesmo padrão de normalização do Customer#phone_number_flat/#parse_doc_number no checkout-api |
customer.name |
data.buyer.name |
|
customer.phone_number |
data.buyer.checkout_phone, só dígitos |
idem |
Autenticação
Mesmo token do v1 — params[:auth_token_verification] == Rails.application.credentials.WEBHOOK_AUTH_TOKEN_VERIFICATION. Nenhuma credencial nova. Quem chama este endpoint é outro sistema (não a Hotmart diretamente), então o token compartilhado por query string já é suficiente.
Objetivos
- Criar
POST /api/v2/webhook, aceitando o payload de evento acima. - Tratar
PURCHASE_APPROVED(→status: "paid", cria/atualiza filiação),PURCHASE_REFUNDED(→status: "refunded") ePURCHASE_DELAYED(→status: "overdue") — os dois últimos só atualizam filiação existente, nunca criam (mesma regra do v1:status != "paid"→find_by, nuncafind_or_initialize_by). PURCHASE_DELAYEDreproduz para essa origem o mesmo efeito quePAYMENT_OVERDUE(Asaas) já tem hoje no fluxo v1: gravapayment_status: "overdue"na filiação, o que já é suficiente paraMembership#paid?/#apolo_access_permitted?revogarem o acesso — sem necessidade de replicar a carência de 5 dias doCbtrgService(isso é uma decisão docheckout-api/gateway antes de notificar; aqui o evento só chega quando o “outro sistema” decidir que está de fato atrasado).- Qualquer outro
eventretorna200 OKsem processar, com mensagem indicando que o evento não é suportado (mesmo padrão de resposta do v1, que sempre responde200/bad_requestsem nunca estourar erro 500 para o remetente). - Traduzir o payload para os params do
WebhookMembershipService, reaproveitando-o sem alterações. - Reaproveitar
WebhookHistorypara auditoria do payload recebido, igual ao v1. Api::V2::WebhookControllernão deve herdar deApi::V2::ApplicationController(que exige headerapolo-access-token) — herda direto deApplicationController, como oApi::V1::WebhookControllerjá faz.- A lógica de tradução/validação/resolução da filiação vira um use case com a gem
u-case(Micro::Case, já usada no projeto — verAuth::Login,Onboarding::Moderate,Membership::FutureApprove), mantendo o controller fino (result.on_success/result.on_failure), no mesmo padrão doApi::V2::AuthController.
Fora de escopo
- Não altera
Api::V1::WebhookController,WebhookMembershipServicenem o payload/contrato do v1 — ocheckout-apicontinua chamando o v1 exatamente como hoje. - Não trata outros eventos de compra da Hotmart (
PURCHASE_CANCELED,PURCHASE_CHARGEBACK,PURCHASE_PROTEST, etc.) — fica para uma spec futura, se necessário. - Não implementa nenhuma carência/reprocessamento automático (equivalente ao
PlatformRunIntegrationSyncJob.set(wait: 5.days)doCbtrgService) paraPURCHASE_DELAYED— ocitrg-apisó reage ao evento recebido; se o “outro sistema” reenviar um evento de recuperação (ex: pagamento do boleto atrasado sendo confirmado depois), isso chega como um novoPURCHASE_APPROVEDpara a mesmatransaction, queset_membership/update_membershipjá tratam corretamente (volta apayment_status: "paid"). - Não implementa verificação de assinatura própria da Hotmart (ex:
hottok/HMAC) — a autenticação é o token compartilhado do v1, porque o payload chega via outro sistema, não da Hotmart diretamente. - Não corrige a conversão de
reference_id(colunaintegeremWebhookHistory) para ids não numéricos (transactioné um UUID) — o v1 já tem essa mesma limitação compayment.id(ex:"payment_92839288432"), e não é o escopo desta mudança alterar esse comportamento herdado.
Mudanças
app/models/membership/process_purchase_webhook.rb (novo — use case)
Toda a tradução do payload, validação de campos mínimos, resolução do evento suportado, resolução/criação da Membership e a chamada ao WebhookMembershipService (sem alterá-lo) ficam concentradas neste Micro::Case, no mesmo padrão de Auth::Login/Onboarding::Moderate/Membership::FutureApprove:
```ruby class Membership::ProcessPurchaseWebhook < Micro::Case EVENT_STATUS_MAP = { “PURCHASE_APPROVED” => “paid”, “PURCHASE_REFUNDED” => “refunded”, “PURCHASE_DELAYED” => “overdue” }.freeze
attributes :payload
def call! return event_not_supported unless mapped_status return missing_fields unless minimal_fields_present?
create_webhook_history
return membership_not_found unless membership
WebhookMembershipService.new(membership, membership_params)
Success result: { message: "Webhook processed" } end
private
def event_not_supported Rails.logger.error “Purchase webhook received unsupported event: #{event}”
Failure :event_not_supported, result: { message: I18n.t("api.errors.webhook.event_not_supported", event: event) } end
def missing_fields Rails.logger.error “Purchase webhook missing required fields (event: #{event}, transaction: #{purchase[:transaction]})”
Failure :missing_fields, result: { message: I18n.t("api.errors.webhook.missing_fields") } end
def membership_not_found Rails.logger.error “Purchase webhook membership not found for payment_reference #{membership_params[:id]}”
Failure :membership_not_found, result: { message: I18n.t("api.errors.webhook.membership_not_found", id: membership_params[:id]) } end
def event payload[:event] end
def purchase payload.dig(:data, :purchase) || {} end
def buyer payload.dig(:data, :buyer) || {} end
def mapped_status EVENT_STATUS_MAP[event] end
def minimal_fields_present? membership_params[:customer][:doc_number].present? && membership_params[:customer][:email].present? && membership_params[:checkout_id].present? && membership_params[:id].present? end
def membership @membership ||= begin finder = (mapped_status != “paid”) ? :find_by : :find_or_initialize_by
Membership.send(finder, payment_reference: membership_params[:id])
end end
def membership_params @membership_params ||= { id: purchase[:transaction], status: mapped_status, billing_type: purchase.dig(:payment, :type), checkout_id: purchase.dig(:offer, :code), installment_count: purchase.dig(:payment, :installments_number), customer: { id: buyer[:id], email: buyer[:email], doc_number: only_numbers(buyer[:document]), name: buyer[:name], phone_number: only_numbers(buyer[:checkout_phone]) } } end
def create_webhook_history WebhookHistory.create( reference_id: membership_params[:id], reference_type: “membership”, webhook_type: “payment”, payload: payload.to_json ) end
def only_numbers(value) value.to_s.gsub(/\D/, “”) end end ```
Observação: citrg-api não tem um StringUtils::OnlyNumbers equivalente ao do checkout-api — normaliza inline com .gsub(/\D/, "") (único ponto de uso, não justifica extrair um utilitário novo).
O use case recebe o payload bruto (o params inteiro da requisição, sem permit) como único atributo — não campos pré-extraídos pelo controller. Isso mantém o WebhookHistory gravando o payload bruto (igual ao v1, params.to_json), e concentra toda a extração/parsing do formato Hotmart dentro do use case, não no controller. Como o use case só faz leitura ([]/dig) pra montar membership_params campo a campo — nunca mass-assignment direto num model a partir do payload —, não precisa de permit para ser seguro.
app/controllers/api/v2/webhook_controller.rb (novo — controller fino)
```ruby class Api::V2::WebhookController < ApplicationController skip_before_action :verify_authenticity_token before_action :verify_auth_token
def create result = Membership::ProcessPurchaseWebhook.call(payload: params)
result.on_success { render json: {}, status: :ok }
result.on_failure { |r| render json: { message: r[:message] }, status: :ok } end
private
def verify_auth_token render json: { message: I18n.t(“api.errors.webhook.invalid_auth_token”) }, status: :ok and return unless auth_token_validity end
def auth_token_validity params[:auth_token_verification] == Rails.application.credentials.WEBHOOK_AUTH_TOKEN_VERIFICATION end end ```
A checagem de autenticação (auth_token_verification) fica no controller — é uma preocupação de HTTP/autenticação, não regra de negócio, e replica exatamente o que Api::V1::WebhookController#verify_auth_token já faz. O controller não faz mais permit/parsing nenhum — só repassa params pro use case.
config/routes.rb
Dentro de namespace :v2, adicionar:
ruby
resources :webhook, only: [ :create ]
config/locales/{pt-BR,en,es}.yml
Reaproveita as chaves já existentes em api.errors.webhook (missing_fields, membership_not_found, invalid_auth_token). Adiciona uma chave nova:
yaml
webhook:
event_not_supported: "Evento '%{event}' não é suportado"
(equivalente em en.yml/es.yml)
test/fixtures/membership_groups.yml
Nova fixture representando a oferta usada nos testes (ex: hotmart_offer_group, gateway_product_id: "6cikhiz8"), para testar a resolução de checkout_id a partir de offer.code sem colidir com a fixture test_group existente.
test/models/membership/process_purchase_webhook_test.rb (novo — TDD, unit test)
Cobertura principal do use case (Triple-A, fixtures, sem HTTP), no mesmo padrão de test/models/auth/login_test.rb:
- evento não suportado (ex:
PURCHASE_CREATED) →Failure(:event_not_supported), nenhumaWebhookHistory/Membershipcriada - campos mínimos ausentes (
buyer.document/buyer.email/offer.code/purchase.transaction) →Failure(:missing_fields) PURCHASE_APPROVEDcom dados válidos →Success, criaMembershipcompayment_status: "paid",payment_reference: data.purchase.transaction,membership_groupresolvido poroffer.codePURCHASE_APPROVEDnormalizadoc_number/phone_numberpara só dígitosPURCHASE_REFUNDEDpara filiação existente →Success, atualizapayment_statuspara"refunded", não cria filiação novaPURCHASE_REFUNDEDsem filiação existente →Failure(:membership_not_found), nenhuma criaçãoPURCHASE_DELAYEDpara filiação existente →Success, atualizapayment_statuspara"overdue", não cria filiação novaPURCHASE_DELAYEDsem filiação existente →Failure(:membership_not_found), nenhuma criaçãoWebhookHistoryé criado com opayloadbruto recebido em uma chamada válida- as 3 falhas (
event_not_supported,missing_fields,membership_not_found) logam viaRails.logger.error
test/controllers/api/v2/webhook_controller_test.rb (novo — TDD, integration test)
Cobertura fina do controller (HTTP + autenticação), no mesmo padrão de test/controllers/api/v2/auth_controller_test.rb — não repete os casos de regra de negócio já cobertos no teste do use case:
- token de autenticação inválido/ausente →
200com mensageminvalid_auth_token,Membership::ProcessPurchaseWebhooknunca é chamado - token válido + evento válido →
200, delega corretamente pro use case e persiste aMembership - token válido +
Failuredo use case (ex: evento não suportado) →200com a mensagem doresult[:message]
Como verificar
bash
make run.test path=test/models/membership/process_purchase_webhook_test.rb
make run.test path=test/controllers/api/v2/webhook_controller_test.rb
Manual:
bash
curl -X POST http://localhost:3000/api/v2/webhook \
-H "Content-Type: application/json" \
-d '{
"auth_token_verification": "TOKEN_DO_V1",
"id": "9f1c2a34-7b8d-4e5f-a1b2-c3d4e5f6a7b8",
"event": "PURCHASE_APPROVED",
"data": {
"purchase": {
"transaction": "f4708f72-89a0-4976-a51a-5068c481f296",
"status": "APPROVED",
"payment": { "type": "PIX", "installments_number": 1 },
"offer": { "code": "6cikhiz8", "name": "Formação Completa" }
},
"buyer": {
"id": 9021,
"name": "Maria Aparecida Silva",
"email": "maria@example.com",
"checkout_phone": "+5511999998888",
"document": "123.456.789-00"
}
}
}'
Confirmar que uma Membership é criada com payment_reference: "f4708f72-89a0-4976-a51a-5068c481f296" e payment_status: "paid", e que reenviar o mesmo PURCHASE_APPROVED não duplica a filiação (idempotência via payment_reference único).
Documentação
.project/docs/reference/api/public_endpoints.md— adicionarPOST /api/v2/webhookna tabela e um contrato de exemplo, junto do v1..project/docs/reference/membership/membership_activation_flow.md— nota linkando para o novo formato de origem (v2), sem duplicar o fluxo (a lógica de ativação em si não muda)..project/docs/README.md— nova linha na tabela despecs/.
Checklist de implementação
- Setup
- [x]
git pull --rebase origin main - [x]
git checkout -b feature/webhook_v2_purchase_membership
- [x]
- Fixture (
test/fixtures/membership_groups.yml)- [x] Adicionar
hotmart_offer_group(nomeadapurchase_offer_group) comgateway_product_idde teste, sem colidir comtest_group
- [x] Adicionar
- Locale (
config/locales/{pt-BR,en,es}.yml)- [x] Adicionar
api.errors.webhook.event_not_supportednos 3 arquivos
- [x] Adicionar
- Use case — TDD (
Membership::ProcessPurchaseWebhook)- [x] Escrever
test/models/membership/process_purchase_webhook_test.rb(vermelho) com os casos: evento não suportado, campos mínimos ausentes,PURCHASE_APPROVEDcria filiação,PURCHASE_APPROVEDnormaliza doc/telefone,PURCHASE_REFUNDEDatualiza existente,PURCHASE_REFUNDEDsem filiação,PURCHASE_DELAYEDatualiza existente,PURCHASE_DELAYEDsem filiação,WebhookHistorycriado - [x] Implementar
app/models/membership/process_purchase_webhook.rbaté os testes ficarem verdes - [x] Refatorar se necessário (sem alterar comportamento coberto pelos testes)
- [x] Escrever
- Controller — TDD (
Api::V2::WebhookController)- [x] Adicionar rota em
config/routes.rb(namespace :v2 { resources :webhook, only: [:create] }) - [x] Escrever
test/controllers/api/v2/webhook_controller_test.rb(vermelho): token inválido/ausente, token válido delega e persiste,Failuredo use case retorna a mensagem - [x] Implementar
app/controllers/api/v2/webhook_controller.rbaté os testes ficarem verdes
- [x] Adicionar rota em
- Documentação
- [x] Atualizar
.project/docs/reference/api/public_endpoints.md - [x] Atualizar
.project/docs/reference/membership/membership_activation_flow.md - [x] Atualizar
.project/docs/README.md(linha nova emspecs/) - [x] Atualizar o status deste arquivo de spec:
proposed→in_progress→done
- [x] Atualizar
- Verificação
- [x]
make run.test path=test/models/membership/process_purchase_webhook_test.rb - [x]
make run.test path=test/controllers/api/v2/webhook_controller_test.rb - [x]
make run.lint - [x]
make run.test(suíte completa, sem regressão) - [x] Coberto pelo teste de integração (
ActionDispatch::IntegrationTestexercita a stack real via HTTP) — não subimos o servidor de dev pra rodar o curl manual à parte
- [x]
- Commit
- [x] Confirmar com você antes de commitar
- [x] Commits pequenos e atômicos, um por contexto (ex: fixture+locale, use case+teste, controller+teste, docs)