n8n-nodes-conversor-plataformas
v0.9.4
Published
Community node n8n que normaliza payloads de webhooks de plataformas de venda (Hotmart, Guru, Eduzz, Virtu, Greenn, TMB, Kiwify) para um schema canonico de compra.
Maintainers
Readme
n8n-nodes-conversor-plataformas
Community node Kokopelli ADTK Convert — um node, duas actions:
- Converter Gateway — normaliza webhooks de plataformas de venda
(Hotmart, Guru, Eduzz, Virtu, Greenn, TMB, Kiwify) para um schema canonico de compra, pronto para
alimentar
upsert_purchase_data()sem remapear campos a cada plataforma. - Converter GTM — filtra eventos internos do GTM e normaliza os eventos de negocio para o schema de eventos (todos os campos em string).
Ambas aceitam o item do Webhook node ({ headers, body }) e o de fila
RabbitMQ/AMQP em qualquer forma: { content: { body } } (envelope), { content: <body> }
(body cru direto no content), com content/body como objeto, array ou string JSON
nao-parseada (RabbitMQ sem "JSON Parse Body"). Em todos os casos o body real e
desembrulhado antes do mapeamento.
Status
🟢 7 plataformas implementadas e testadas: Hotmart, Guru, Eduzz, Virtu, Greenn, TMB
e Kiwify — todas com compra aprovada e reembolso/estorno cobertos. Auto-deteccao por
user-agent + assinatura do body. Os mapas de status seguem o enum oficial de
cada plataforma (developers.hotmart.com Webhook 2.0.0; docs.digitalmanager.guru;
Greenn/pagar.me; Virtu; docs.kiwify.com.br). PagTrust entra quando houver payload real.
Convencao de status: reembolso/chargeback consumado -> rejected; contestacao
ainda em aberto (Hotmart DISPUTE/protesto, Guru dispute/"Reembolso Sol.",
Kiwify pending_refund/refund_requested) -> pending.
Action: Converter Gateway — schema canonico (saida)
| campo | tipo | observacao |
|---|---|---|
| nome | string | null | |
| email | string | null | lowercase/trim |
| telefone | string | null | so digitos |
| valor | number | null | total da compra, na moeda de moeda |
| moeda | string | null | BRL/EUR/USD (Hotmart internacional pode vir EUR/USD) |
| id_transacao | string | null | id de dedup/upsert |
| status | pending | approved | rejected | null | |
| nome_produto | string | null | produto principal |
| id_produto | string | null | id do produto principal |
| plataforma | enum | hotmart|guru|eduzz|virtu|greenn|tmb|kiwify |
| utm_source | string | null | Guru source.utm_*, Eduzz data.utm.*, TMB utm_*, Virtu utm/firstTouchUtm, Greenn utm.* (defensivo), Kiwify TrackingParameters.utm_* |
| utm_medium | string | null | |
| utm_campaign | string | null | |
| utm_content | string | null | |
| utm_term | string | null | so Guru envia |
| xcod | string | null | rastreamento Hotmart (data.purchase.origin.xcod) |
| sck | string | null | 2o codigo de rastreamento: Hotmart (data.purchase.origin.sck) e Kiwify (TrackingParameters.sck) |
| itens | ItemCompra[] | detalhamento por produto |
ItemCompra: { nome, id, valor, quantidade, tipo }, onde tipo ∈ principal |
order_bump | upsell | downsell | desconhecido. A matriz carrega todos os
produtos do payload (inclusive bump/downsell); nome_produto/id_produto apontam
pro primeiro item principal (fallback: primeiro item, ex.: transacao 100% bump).
Classificacao do tipo, em ordem de precedencia:
- Rotulo nativo do item quando presente e reconhecido (
bump/upsell/downsell/main, ignorando caixa e os separadores espaco/tab/quebra de linha,_e-; token exato pos-normalizacao). Rotulos genericos de catalogo (product,plan) NAO classificam. Nenhuma plataforma suportada documenta upsell/downsell no payload hoje (pesquisa 2026-07-16) — chegam como compra normal separada; o reconhecimento e defensivo/futuro. - Flag de bump da transacao — classifica todos os itens sem rotulo nativo
reconhecido (rotulo explicito prevalece) nos mappers multi-item: Guru
is_order_bumpna raiz (bump = transacao separada), Eduzzdata.orderBump.isMainSale === false(bump = fatura separada), Virtuproducts[].isOrderBumppor produto (defensivo, sem doc publica). Hotmart (purchase.order_bump.is_order_bump) e Greenn (sale.bump_idpreenchido) entregam 1 produto por evento — a flag classifica esse unico item. - Heuristica de posicao (duas etapas): o 1º item sem tipo explicito =
principal(a menos que outro item ja sejaprincipalpor rotulo), demais =order_bump. Item rotulado bump/downsell na posicao 0 nao rouba o posto do item nao rotulado. Rotulos sao comparados por token exato pos-normalizacao (non-bump/upsell-disablednao classificam).
Atribuicao por plataforma — nao ha regra "ou UTM ou codigo"; cada uma manda o que o payload dela tem (fonte entre parenteses):
| Plataforma | utm_* | xcod | sck |
|---|:---:|:---:|:---:|
| Hotmart | — | ✓ (data.purchase.origin.xcod) | ✓ (data.purchase.origin.sck) |
| Kiwify | ✓ (TrackingParameters.*) | — | ✓ (TrackingParameters.sck) |
| Guru, Eduzz, TMB, Virtu, Greenn | ✓ | — | — |
Semantica:
xcodescksao campos SEPARADOS e independentes — na Hotmart moram no mesmo blocoorigine qualquer um pode vir sozinho (venda de funil costuma trazer so osck); oxcodpode encodar varios valores separados por_. O irmaosrcda Kiwify nao tem campo canonico e fica de fora.
Ausencia, camada a camada (vale pra
scke pra qualquer campo vazio):
- schema canonico (
PurchaseData): a chave existe SEMPRE —emptyPurchaseinicializa emnull.- saida do node: o
nullcai junto com os demais vazios (regra "chaves vazias omitidas", mais abaixo) — a chave nao aparece no item.- destino: insert que hoje nao tem coluna
scksegue funcionando sem mudanca; pra PERSISTIR o valor, o destino precisa da coluna correspondente.
Formatos de entrada: o node desembrulha o item do Webhook node (
{ headers, body }) e o de fila RabbitMQ/AMQP em qualquer forma —contentcom envelope ({ content: { body } }) ou body cru ({ content: <body> }), econtent/bodycomo objeto, array ou string JSON. Fila e webhook produzem a mesma saida. Obs: em fila com body cru nocontentnao ha headers HTTP — e, desde 0.9.3, o header nao e fonte de IP nem no webhook (a menos que Usar IP Do Header seja ligado), entao oipvem do payload: o produtor precisa injetarip_overrideno body.
Moeda: o
valorpermanece na moeda original da transacao; quem soma receita downstream deve agrupar/converter pormoeda. Sem alteracao de banco.
Kiwify — valores em CENTAVOS:
Commissions.charge_amount: 6277= R$ 62,77. E a unica plataforma suportada com essa escala; o mapper divide por 100 (centavosParaReais), entaovalorsai em reais como o das outras. O contrato e ESTRITO: centavo e inteiro ou string de digitos — formato monetario (1.234,56) e fracao de centavo (6277.5) viramnull, nao valor convertido (pelo parser generico1.234,56daria 12.3456, 100x menor). Campo vazio e visivel; valor errado e silencioso. A base do valor echarge_amount(total cobrado do cliente), com fallbackproduct_base_price—settlement_amount/my_commissionsao repasse ao produtor e ficam de fora. A deteccao e so por body (order_id+ objetos capitalizadosCustomer+Product/Commissions): o user-agent da Kiwify eaxios/<versao>, generico demais pra ser assinatura. A Kiwify entrega 1 produto por evento (webhook configurado POR produto), entao o order bump vem em evento proprio ecommissioned_stores(split produtor/coprodutor/afiliado) NAO vira item.
Parametros da action Converter Gateway:
- Plataforma —
Auto-detectar(pela estrutura do JSON) ou plataforma fixa. Fila com plataformas misturadas (ex: purchase_data) exigeAuto-detectar— plataforma fixa aplica SEMPRE aquele mapper. Desde 0.8.1, payload cuja assinatura casa com OUTRA plataforma gera erro acionavel em vez de linha lixo silenciosa no banco; payload que nenhuma assinatura reconhece segue processando (o override manual existe pra isso). - Itens Como JSON — serializa
itenscomo string JSON em vez de array. Ligue quando o destino for uma colunajson/jsonbno node Postgres nativo: com array JS o driver enviatext[]e o insert falha (column "itens" is of type jsonb but expression is of type text[]); com a string JSON o Postgres faz o cast automaticotext -> jsonb. Dispensa o node Edit Fields intermediario. Desligado (default),itenssegue array de objetos (consumo em fluxo n8n, ex: Split Out). - Manter Payload Original — anexa o JSON bruto em
_original.
Action: Converter GTM
Processa eventos vindos do GTM (server-side). Filtra os eventos internos do
GTM (sem event_name) e normaliza os de negocio para a linha de eventos, com
todos os campos em string (numero -> string, objeto -> JSON, ausente -> "").
Parametros:
- Eventos Permitidos — allowlist de
event_nameseparada por virgula. Vazio = todos os eventos passam (inclusive sem nome). Use para restringir aos eventos de negocio. - Eventos Excluidos — blocklist de
event_nameseparada por virgula. Vazio = nenhum excluido. Tem prioridade sobre "Eventos Permitidos" (um evento que esteja nas duas listas e excluido). Use quando for mais facil listar os poucos eventos a descartar (ex: internos do GTMgtm.dom,gtm.load,gtm.js) do que escolher todos os permitidos. - Campos De Saida — escolha quais campos manter no output. Vazio = todos.
- Campos A Excluir — campos a remover do output. Aplica depois de "Campos De
Saida". Vazio = nao remove nada. Use para descartar os poucos campos que a tabela destino
nao tem (ex:
event_name) sem precisar escolher manualmente todos os que ficam — evita o erroColumn 'event_name' does not exist in selected tableno insert downstream. - Usar IP Do Header (desde 0.9.3, default desligado) — le
x-real-ip/x-forwarded-forcomo ultima fonte da colunaip, so quando nenhuma das quatro fontes do payload (ip_override,ip,user_data.ip_override,user_data.ip) trouxe valor. Ligue apenas se o n8n recebe o webhook DIRETO, sem proxy na frente. Atras de proxy (sGTM/Cloud Run, Traefik, Cloudflare) esses headers carregam o endereco da infraestrutura, nao o do lead: gravar isso contamina geo e atribuicao de todo evento com um IP unico. Desligado, sem IP no payload a coluna sai vazia (NULL no banco — melhor que IP errado). - IPs/Faixas De Proxy a Ignorar (desde 0.9.2; subordinado a "Usar IP Do Header" desde
0.9.3) — IPs ou faixas CIDR separados por virgula (
35.191.0.0/16, 130.211.0.0/22). Aparece na UI somente com o toggle ligado, porque atua exclusivamente no caminho do header. Faixas listadas sao puladas na cadeia dox-forwarded-for; se nada sobrar,ipsai vazio.Preferir o toggle desligado a manter esta lista. Foi a tentativa de 0.9.2 de salvar o caminho do header, mas lista de faixa de nuvem drifta: o provedor troca de bloco e o IP de proxy volta a entrar em silencio. Se ha proxy na frente do n8n, o caminho duravel e deixar Usar IP Do Header desligado e mandar o IP do lead no payload via
ip_override. Escopo (desde 0.9.4): SO esta lista e exclusiva do header. A checagem de endereco valido e o descarte de faixa privada/reservada (10.x,127.x,192.168.x,169.254.x,100.64/10,fe80::) valem para as duas origens — header e payload. Ja oipsProxycontinua so no header, porque nomeia a infra de transporte, que nao tem equivalente do lado do payload: o mesmo203.0.113.9sobrevive vindo emip_overridee e descartado vindo emx-real-ip. Ate 0.9.3 o payload passava sem filtro, sob o argumento de queip_overrideera uma declaracao EXPLICITA do produtor. O argumento caiu: o produtor e um script do GTM que interpola variavel e ja mandouundefined(dai o tratamento de placeholder da 0.9.2) e10.0.0.1. Agora um candidato invalido cede a vez ao proximo da cadeia em vez de virar coluna — mesmo padrao ja usado emgenero. A precedencia entre candidatos validos nao mudou. - Manter Payload Original — anexa o body bruto em
_original.
Allowlist x blocklist (eventos) e Saida x Excluir (campos): os dois pares sao complementares e combinaveis. Eventos: passa se
(permitidos vazio OU nome ∈ permitidos)enome ∉ excluidos. Campos: parte de todos → mantem so os de "Campos De Saida" (se preenchido) → remove os de "Campos A Excluir". Tudo vazio = comportamento original.
Cadeia de fontes (desde 0.9.2):
topo do body→user_data→user_data.address/url_parameters→ query dapage_location. O sGTM entrega o bloco de usuario aninhado emuser_data, e ate 0.9.1 o node lia so o topo do body — id, geo, user agent, data de nascimento e o IP real eram descartados em silencio. Cada fonte nova entra depois da que ja existia, entao nenhum payload que ja funcionava muda de resultado. Um alias presente-porem-vazio deixou de mascarar um preenchido mais adiante na cadeia.
Placeholder do produtor vira NULL (desde 0.9.2): quando a camada de tracking nao resolve uma variavel, o JavaScript do GTM interpola o texto
undefined/null/NaNe ele chega como valor do campo (payload real: sete colunas de UTM com a palavra"undefined"). Um campo cujo valor INTEIRO seja um desses (em qualquer caixa) e tratado como ausente → chave omitida →NULLno banco, em vez de sujeira que mente no relatorio e escapa de umIS NULL.
Mapeamento principal: event_name ← event_name;
senha_atendimento ← senha_de_atendimento;
st_user_id ← st_user_id e user_id ← user_id (id de tracking vs. id do usuario no
sistema — campos distintos desde 0.6.8; fallback user_data.* desde 0.9.2);
email ← email/email_address/user_data.email_address (normalizado: lowercase + trim);
telefone ← telefone/phone/phone_number/user_data.phone_number (normalizado: so digitos);
nome/sobrenome ← first_name/last_name (fallback user_data.address.*);
cidade/estado/pais ← campo homonimo em pt-BR no topo, fallback
user_data.address.city/.region/.state/.country (desde 0.9.2);
zipcode ← zipcode/cep ou user_data.address.postal_code/.zip/.zp (normalizado:
CEP brasileiro fica so com digitos, 26255-000 → 26255000; codigo estrangeiro alfanumerico
e preservado em UPPERCASE);
data_nascimento ← data_nascimento/birth ou user_data.birth/.birthdate/.db
(normalizado para ISO YYYY-MM-DD; aceita YYYYMMDD do sGTM, ISO e DD/MM/YYYY como
padrao BR; data impossivel e hash SHA-256 — o db do Meta CAPI chega hasheado — sao
descartados, nunca "corrigidos");
user_agent ← user_agent (fallback user_data.user_agent);
language ← language;
genero ← gender/genero (topo ou user_data.gender/user_data.ge — ge e o
campo do Meta Conversions API), decodificado (desde 0.6.9): as plataformas de
tracking enviam o genero hasheado em SHA-256 (lowercase+trim), entao os dois hashes
conhecidos sao revertidos pra letra canonica — sha256("f") → f, sha256("m") → m.
Tambem aceita o valor ja em claro (f/m/female/male/feminino/masculino);
desconhecido/vazio → chave omitida;
xcod ← xcod e sck ← sck (mesma hierarquia das UTMs no payload do GTM);
utm_*/ad_id/utm_id ← campo homonimo no topo, fallback url_parameters.* (desde 0.9.2);
fbc/fbp/ttp ← common_cookie._fbc/._fbp/._ttp (o common_cookie bruto nao vai pro
output — so serve de fonte; ttp e o cookie do TikTok Pixel, irmao do fbp, desde 0.9.2);
fbclid/gclid/gbraid/wbraid/ttclid/click_id ← campo direto, url_parameters ou query
da page_location. O fbclid e guardado cru: derivar fbc dele (fb.1.<ts>.<fbclid>)
produziria um valor que nao casa com o cookie que o pixel gravou no browser — pior que nao ter;
pagina_origem ← page_location.
ip— so o payload e fonte por padrao (desde 0.9.3). Ordem exata:ip_override(topo) →ip(topo) →user_data.ip_override→user_data.ip. As duas fontes que ja existiam ficam na frente das aninhadas, pra que um payload que ja resolvia poripnao mude de resultado. Nenhuma das quatro trouxe valor →ipvazio → NULL, que e melhor que um IP errado.Desde 0.9.4 cada candidato do payload precisa ser utilizavel — endereco valido e fora de faixa privada/reservada. Um candidato reprovado cede a vez ao proximo da cadeia (mesmo padrao do
genero), entaoip_override: "10.0.0.1"+ip: "189.4.20.7"grava189.4.20.7, e nao mais o endereco privado. Reprovados todos → NULL. A precedencia entre candidatos validos e exatamente a de antes.O header
x-real-ip/x-forwarded-fore opt-in pelo parametro Usar IP Do Header (desligado por padrao). Atras de sGTM/Cloud Run/Traefik ele carrega o IP do proxy, nao o do usuario: ate 0.9.1 todos os eventos desse arranjo gravavam o mesmo endereco de datacenter, contaminando geo e atribuicao; 0.9.2 tentou corrigir filtrando faixas, mas manter lista de bloco de nuvem drifta. Com o toggle ligado, o node percorre a cadeiacliente, proxy1, proxy2da esquerda pra direita e usa o primeiro endereco valido que nao seja privado/reservado nem casar as faixas de "IPs/Faixas De Proxy a Ignorar".Mudanca de comportamento em 0.9.3 (maior que a de 0.9.2): workflow que dependia do header para preencher
ippassa a gravar NULL — a chave e omitida do output. Quem recebe o webhook direto, sem proxy, deve ligar "Usar IP Do Header" ao atualizar; quem esta atras de proxy deve mandar o IP do lead no payload viaip_override(e o que a camada de tracking captura do usuario). Uma listaipsProxyherdada de 0.9.2 fica inerte enquanto o toggle estiver desligado — ela nao reativa o header.O
x-forwarded-fore um header controlado pelo cliente e oip_overridevem do mesmo payload — a colunaipe dado de atribuicao best-effort, nunca controle de seguranca. Desde 0.9.3 isso vale ainda mais forte: com o header fora da jogada por padrao, a coluna passa a ser 100% auto-declarada por quem POSTa o evento (o header ao menos era atribuido pela infra). Quem alcancar a URL do Webhook node escolhe o valor — logo, autenticacao/validacao de origem no webhook e responsabilidade da configuracao do n8n, eipnunca deve alimentar decisao de acesso, antifraude ou rate-limit. O crivo da 0.9.4 melhora a qualidade do dado (lixo e faixa privada param de entrar); ele nao e controle de seguranca e nao autentica nada — um endereco publico qualquer, forjado, continua passando por definicao. Os campos*_entrada(primeiro toque) vem dos campos homonimos do payload (utm_source_entrada,utm_medium_entrada,utm_campaign_entrada,utm_content_entrada,utm_term_entrada,utm_id_entrada,ad_id_entrada), persistidos pela camada de tracking (GTM/sGTM). Ficam vazios quando o payload nao os envia — desde 0.6.10 nao sao mais derivados da query dapage_location(que carrega o toque ATUAL/last-click, nao o primeiro toque; derivar de la duplicava os UTMs atuais nas colunas_entrada).
data_criacao←timestamp(desde 0.6.7; fuso de Sao Paulo desde 0.6.11): otimestampdo evento (epoch em segundos, ms ou microssegundos — detectado pela magnitude; ou uma string de data ISO com fuso explicito) e convertido para ISO 8601 no fuso America/Sao_Paulo, com offset explicito (YYYY-MM-DDTHH:MM:SS.sss-03:00), pronto para inserir numa colunatimestamp/timestamptzdo Postgres. Numa colunatimestamp(sem tz) o Postgres grava a hora local de Sao Paulo; numatimestamptzgrava o instante correto — nos dois casos o valor deixa de aparecer 3h adiantado (antes saia em UTC). Assim o registro guarda a data do evento, nao a do insert. Se nao houvertimestampvalido,data_criacaosai vazio → a chave e omitida → oDEFAULTda coluna no banco assume. Oidcontinua gerado pelo banco (auto-id). O campotimestampcru permanece no output (use Campos A Excluir se quiser so odata_criacao).
Colunas novas em 0.9.2 — o banco precisa acompanhar.
data_nascimento,zipcode,ttp,fbclid,click_idelanguagesao chaves novas no output. No modo "Map Automatically" do node Postgres, chave sem coluna correspondente derruba o insert inteiro. DDL pronto emdocs/sql/eventos-alter-0.9.2.sql; enquanto oALTER TABLEnao for aplicado, liste os campos em "Campos A Excluir".
LGPD — minimizacao na origem. A partir de 0.9.2 o node persiste dado pessoal que antes se perdia: data de nascimento, CEP, IP real e user agent, alem de geo e ids. Colete so o que tiver finalidade e base legal — o que nao for usar deve ser cortado em "Campos A Excluir" (descartar na origem sai mais barato que expurgar depois). A saida carrega PII em claro, apropriada pro banco analitico do proprio cliente; nao ligue esta saida direto em Meta CAPI / Google EC / TikTok Events / Stape sem aplicar SHA-256 nos campos de identificacao.
Saida tudo em texto + chaves vazias omitidas: todo campo sai como string ("para facilitar manipulacao") — numero/boolean viram texto, recursivamente (incl.
valor,quantidadee os campos deitensdos gateways). Campo ausente na entrada nao aparece no output (sem a chave, em vez de""/null), em qualquer nivel. Vale para GTM e para os 4 gateways. Quando o banco precisar do campo sempre presente, use COALESCE/?? ''no insert.
Desenvolvimento
npm install
npm run dev # n8n-node dev: sobe n8n com o node + hot-reload
npm test # vitest (fixtures sanitizadas)
npm run smoke -- caminho.json # roda os mappers contra um export real do n8n
npm run smoke:gtm -- evento.json # linha de evento GTM ja na forma final do output
# (IPS_PROXY="35.191.0.0/16" espelha o param do node)
npm run lint
npm run build
npm run release # publica no npmInstalacao (instancia unica self-hosted)
Settings→Community Nodes→Install- Digitar
n8n-nodes-conversor-plataformas - Aceitar o aviso de risco →
Install
Licenca
MIT
