npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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:

  1. 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.
  2. Comparação em tempo constante para token e HMAC.
  3. Nada de PII no log. O pacote só loga plataforma, evento e id da transação.
yarn add @chrono-os/checkout-webhooks

Uso

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: true aceita o hottok no corpo (hottok ou data.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-signature que 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_APPROVED e depois PURCHASE_COMPLETE (fim da garantia) para a mesma venda. Conte receita por transação, nunca por evento.
  • Lightforms é formulário. Envio incompleto sai como formulario_incompleto e não libera nada.
  • Greenn: contrato em trialing sai desconhecido (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ó o event, e SUBSCRIPTION_CANCELLATION foi montado com os campos da doc oficial.
  • Lightforms (complete e incomplete): 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 lia x-hottok/hottok e recusaria toda entrega real.
  • Kiwify: a assinatura vem na query e cobre o corpo. Passe query ou url.
  • Kirvano: valor é string BRL. Não multiplique por 100.
  • Evento negativo antes do positivo: revogaAcesso primeiro.