@chrono-os/checkout-webhooks
v0.1.1
Published
Webhooks de checkout de infoproduto (Hotmart, Kirvano, Kiwify, Greenn, Lightforms): validação fail-closed e timing-safe, evento normalizado, dedupe por (plataforma, transação, evento) e log sem PII. Zero dependências de runtime.
Maintainers
Readme
@chrono-os/checkout-webhooks
Recebe webhooks de checkout de infoproduto (Hotmart, Kirvano, Kiwify, Greenn e Lightforms), autentica cada requisição e devolve um evento no mesmo formato para todas as plataformas. Zero dependências de runtime (usa node:crypto).
Três garantias:
- Fail-closed. Sem segredo configurado, toda requisição é recusada. Aceitar sem validar exige um opt-in com valor literal e é proibido com
NODE_ENV=production. - Comparação em tempo constante para token e HMAC.
- Nada de PII no log. O pacote só loga plataforma, evento e id da transação.
yarn add @chrono-os/checkout-webhooksUso
import { criarHotmart, processarUmaVez, liberaAcesso, revogaAcesso, dadosParaLog } from '@chrono-os/checkout-webhooks'
const hotmart = criarHotmart({
segredo: process.env.HOTMART_HOTTOK, // vazio = recusa tudo
logger: (nivel, msg, dados) => log[nivel](dados, msg), // dados nunca têm PII
})
// Next App Router
export async function POST(req: Request) {
const r = hotmart.receber({
corpoBruto: await req.text(), // corpo CRU
headers: req.headers,
url: req.url,
})
if (!r.ok) {
return Response.json({ ok: false }, { status: r.motivo === 'corpo_invalido' ? 400 : 401 })
}
const ev = r.evento
const s = await processarUmaVez(ev, { reivindicar, liberar }, async () => {
if (revogaAcesso(ev.evento)) return revogar(ev) // negativo primeiro
if (liberaAcesso(ev.evento)) return matricular(ev)
})
return Response.json({ ok: true, duplicado: !s.processado })
}receber é o caminho seguro: valida, parseia e só então normaliza. validar e normalizar existem separados para casos específicos. normalizar não autentica e serve para reprocessar payload já guardado no banco (backfill).
Em Express, Fastify e Nest, configure o raw body (express.raw, addContentTypeParser com parseAs: 'string', rawBody: true no Nest) e passe headers e query da requisição. Corpo re-serializado depois do parser não serve para HMAC.
Rota única por plataforma na URL:
import { criarAdaptador, ehPlataforma } from '@chrono-os/checkout-webhooks'
if (!ehPlataforma(params.plataforma)) return 404
const adaptador = criarAdaptador(params.plataforma, { segredo: segredos[params.plataforma] })Autenticação por plataforma
| Plataforma | Mecanismo | Onde o pacote lê | Fonte | Status |
|---|---|---|---|---|
| Hotmart | hottok fixo por conta | header X-HOTMART-HOTTOK | developers.hotmart.com, Webhook 2.0.0 (eventos de compra e cancelamento de assinatura): "Este campo será enviado com o nome X-HOTMART-HOTTOK no cabeçalho HTTP de todas as requisições" | confirmado |
| Kiwify | signature = HMAC-SHA1 hex de JSON.stringify(body) com o token do webhook | query ?signature= | Notion oficial "Webhooks (pt-br)" da Kiwify, seção Segurança nos webhooks | confirmado |
| Kirvano | token fixo cadastrado no webhook | header security-token | headers reais gravados no checkout_webhook_log do dashboard-nairio-api em 2026-07-23. A central de ajuda só diz "Token: opcional, usado para autenticação das mensagens" | confirmado pelo parque |
| Greenn | token compartilhado definido por você | header x-webhook-token ou query ?token= (configuráveis) | a documentação não diz como o webhook se autentica | não confirmado |
| Lightforms | token compartilhado definido por você | header x-webhook-token ou query ?token= (configuráveis) | sem documentação pública | não confirmado |
Detalhes:
- Hotmart:
aceitarHottokNoCorpo: trueaceita o hottok no corpo (hottokoudata.hottok) quando o header falta, formato do webhook 1.0. O default éfalse. - Kiwify: o exemplo oficial assina o objeto re-serializado. O pacote aceita esse HMAC e também o do corpo cru; os dois dependem do segredo.
- Kirvano: o token é opcional na plataforma e obrigatório aqui. A validação HMAC
x-kirvano-signatureque o dashboard-nairio-api mantinha como fallback nunca foi observada em produção e não entrou. - Greenn e Lightforms:
criarGreenn({ segredo, cabecalho: 'x-webhook-signature', parametroQuery: false })muda onde o token é lido. Token na URL aparece em log de proxy e de APM; prefira header quando a plataforma deixar configurar. Antes de ligar em produção, confirme com a plataforma se ela oferece algo melhor.
Vários segredos (duas contas, rotação): segredo: { ebooks: '...', raiox: '...' }. O resultado traz em conta qual conferiu.
Evento normalizado
{
plataforma: 'hotmart' | 'kirvano' | 'kiwify' | 'greenn' | 'lightforms',
transacao: string | null, // null em evento sem venda (carrinho da Hotmart)
idEvento: string | null, // Hotmart `id`
evento: EventoCheckout,
eventoOriginal: string, // nome cru: PURCHASE_APPROVED, order_approved...
produto: { id, nome, ofertaId },
itens: [{ id, nome, ofertaId, valor, orderBump }],
comprador: { email, nome, telefone?, documento? },
valor: { centavos, moeda } | null,
assinaturaId: string | null,
recebidoEm: Date,
bruto: unknown, // payload inteiro, com PII
}evento: aprovada, concluida, pendente, recusada, expirada, cancelada, reembolsada, chargeback, disputa, carrinho_abandonado, assinatura_renovada, assinatura_atrasada, assinatura_cancelada, formulario_respondido, formulario_incompleto, desconhecido.
O evento sai do nome do evento, não do status da compra. Na Hotmart, PURCHASE_REFUNDED pode chegar com purchase.status = "APPROVED", e ler o status primeiro já transformou reembolso em venda (e em Purchase no Meta) no dashboard do Cris.
EVENTOS_QUE_LIBERAM/liberaAcesso():aprovada,concluida,assinatura_renovada.EVENTOS_QUE_REVOGAM/revogaAcesso():reembolsada,chargeback,disputa,cancelada,assinatura_cancelada.- Hotmart manda
PURCHASE_APPROVEDe depoisPURCHASE_COMPLETE(fim da garantia) para a mesma venda. Conte receita por transação, nunca por evento. - Lightforms é formulário. Envio incompleto sai como
formulario_incompletoe não libera nada. - Greenn: contrato em
trialingsaidesconhecido(o app decide se teste grátis libera acesso).
Mapeamento completo:
| Normalizado | Hotmart | Kirvano | Kiwify (webhook_event_type) | Greenn (type:currentStatus) |
|---|---|---|---|---|
| aprovada | PURCHASE_APPROVED | SALE_APPROVED | order_approved | sale:paid |
| concluida | PURCHASE_COMPLETE | | | |
| pendente | PURCHASE_BILLET_PRINTED | BANK_SLIP_GENERATED, PIX_GENERATED | billet_created, pix_created | sale:waiting_payment |
| recusada | | SALE_REFUSED | order_rejected | sale:refused |
| expirada | PURCHASE_EXPIRED | BANK_SLIP_EXPIRED, PIX_EXPIRED | | |
| cancelada | PURCHASE_CANCELED | | | |
| reembolsada | PURCHASE_REFUNDED | SALE_REFUNDED | order_refunded | sale:refunded |
| chargeback | PURCHASE_CHARGEBACK | SALE_CHARGEBACK | chargeback | sale:chargedback |
| disputa | PURCHASE_PROTEST | | | |
| carrinho_abandonado | PURCHASE_OUT_OF_SHOPPING_CART | ABANDONED_CART | (sem webhook_event_type e sem order_status) | lead / checkoutAbandoned |
| assinatura_renovada | | SUBSCRIPTION_RENEWED | subscription_renewed | contract:paid |
| assinatura_atrasada | PURCHASE_DELAYED | SUBSCRIPTION_EXPIRED | subscription_late | contract:unpaid, contract:pending_payment |
| assinatura_cancelada | SUBSCRIPTION_CANCELLATION | SUBSCRIPTION_CANCELED | subscription_canceled | contract:canceled |
Kiwify sem webhook_event_type cai no order_status (paid, waiting_payment, refused, refunded, chargedback).
Valores sempre em centavos inteiros:
| Plataforma | Campo | Unidade na origem |
|---|---|---|
| Hotmart | data.purchase.price.value + currency_value | reais (1600.2) |
| Kirvano | total_price, products[].price | string BRL ("R$ 1.234,56", com NBSP) |
| Kiwify | Commissions.charge_amount + currency | centavos ("12424"), segundo a doc |
| Greenn | sale.amount / currentSale.amount | reais, não confirmado |
Dedupe
As plataformas reenviam (a Kiwify até 5 vezes), e duas entregas do mesmo evento podem chegar ao mesmo tempo. Um SELECT antes do INSERT não protege: as duas leem "não existe" e as duas processam. processarUmaVez pede ao app uma escrita condicional atômica e roda o trabalho só para quem ganhou.
Chave: plataforma:transacao:evento (sem transação, usa idEvento; sem os dois, devolve sem_chave).
model WebhookProcessado {
id String @id // plataforma:transacao:evento
plataforma String
transacao String
evento String
criadoEm DateTime @default(now())
}const dedupe = {
async reivindicar(c) {
try {
await prisma.webhookProcessado.create({ data: { id: c.id, plataforma: c.plataforma, transacao: c.transacao, evento: c.evento } })
return true
} catch (e) {
if (e?.code === 'P2002') return false // outra entrega já reivindicou
throw e
}
},
async liberar(c) {
await prisma.webhookProcessado.delete({ where: { id: c.id } }).catch(() => {})
},
}liberar roda quando o trabalho lança, para a retentativa da plataforma conseguir processar. Sem ele, uma falha no meio perde o evento: a chave fica marcada e toda retentativa vira duplicado. criarReivindicadorEmMemoria() existe só para teste e processo único.
Log
O logger recebe (nivel, mensagem, dados). Em dados só entram plataforma, motivo, evento, eventoOriginal, transacao e conta. Para logar um evento no app, use dadosParaLog(ev). ev.bruto e ev.comprador têm e-mail, telefone e documento e não devem ir para log.
Fixtures dos testes
- Hotmart (
PURCHASE_APPROVED,PURCHASE_BILLET_PRINTED,PURCHASE_OUT_OF_SHOPPING_CART): amostras reais dos cenários Make do Cris, anonimizadas (nome, e-mail, CPF, telefone, endereço, produtor, ids de produto e oferta). Reembolso, chargeback, disputa e cancelamento são derivados da aprovada trocando só oevent, eSUBSCRIPTION_CANCELLATIONfoi montado com os campos da doc oficial. - Lightforms (
completeeincomplete): amostras reais do Make do Cris, anonimizadas. - Kirvano: exemplos da central de ajuda (aprovada recorrente com order bump, reembolso, carrinho, assinatura cancelada), com comprador trocado. O preço com NBSP e milhar vem do contrato do dashboard-nairio-api.
- Kiwify: exemplo da doc oficial, com comprador trocado.
- Greenn: estrutura da doc oficial (que publica os campos vazios), preenchida com dados fictícios.
Adoção
- Hotmart: o header é
x-hotmart-hottok. O institucional liax-hottok/hottoke recusaria toda entrega real. - Kiwify: a assinatura vem na query e cobre o corpo. Passe
queryouurl. - Kirvano: valor é string BRL. Não multiplique por 100.
- Evento negativo antes do positivo:
revogaAcessoprimeiro.
