[{"data":1,"prerenderedAt":827},["ShallowReactive",2],{"paleta":3,"post-2026-10-pix-idempotente":31},{"projetos":4,"posts":27},[5,9,13,16,20,23],{"path":6,"title":7,"contexto":8},"\u002Fprojetos\u002Fkravva","Kravva","Produto próprio",{"path":10,"title":11,"contexto":12},"\u002Fprojetos\u002Fonboarding-erp","Onboarding digital de um ERP financeiro","Consultoria PJ",{"path":14,"title":15,"contexto":12},"\u002Fprojetos\u002Fpainel-erp","Painel administrativo do ERP",{"path":17,"title":18,"contexto":19},"\u002Fprojetos\u002Fligafest","Ingresso em grupo da LigaFest","LigaMagic · CLT",{"path":21,"title":22,"contexto":19},"\u002Fprojetos\u002Fcircuitos-ligamagic","Circuitos LigaMagic e LigaPokémon",{"path":24,"title":25,"contexto":26},"\u002Fprojetos\u002Fblippy","Blippy","Projeto pessoal",[28],{"path":29,"titulo":30},"\u002Fblog\u002F2026-10-pix-idempotente","Como evitei cobrar um PIX duas vezes",{"id":32,"title":33,"body":34,"data":816,"description":817,"extension":818,"meta":819,"navigation":208,"path":29,"resumo":820,"seo":821,"stem":822,"tags":823,"titulo":30,"__hash__":826},"blog\u002Fblog\u002F2026-10-pix-idempotente.md","2026 10 Pix Idempotente",{"type":35,"value":36,"toc":808},"minimark",[37,49,52,55,63,68,82,85,113,285,288,292,298,301,314,317,574,581,588,592,601,609,612,643,690,697,701,704,707,711,714,777,781,804],[38,39,40,41,48],"p",{},"Um usuário paga um PIX no app do banco. Meu servidor não participa disso: ele só descobre o pagamento quando o provedor de pagamento (o PSP) faz uma chamada HTTP para uma URL minha. Essa chamada é o ",[42,43,44],"strong",{},[45,46,47],"termo",{"id":47},"webhook",". E o PSP só para de insistir quando recebe um 2xx de volta.",[38,50,51],{},"Isso cria um problema que todo sistema de pagamento tem: o mesmo aviso pode chegar duas vezes, ou duas ao mesmo tempo, em workers diferentes. No Kravva, uma confirmação faz quatro coisas: marca a transação como paga, libera o acesso, lança a venda no financeiro e credita a comissão de um parceiro. O usuário pagou uma vez, mas se eu rodar essas quatro coisas duas vezes, contabilizo duas vendas e pago comissão em dobro. É esse o \"cobrar duas vezes\" do título: o efeito do meu lado, não o débito na conta do cliente.",[38,53,54],{},"Tem um segundo problema: a URL é pública. Qualquer pessoa pode enviar um JSON dizendo \"paguei\" e, se eu acreditar, libero acesso pago de graça.",[38,56,57,58,62],{},"Resolvi em três camadas: autenticar a chamada, torná-la ",[45,59,61],{"id":60},"idempotencia","idempotente"," e registrar o dinheiro de um jeito que não se reescreve.",[64,65,67],"h2",{"id":66},"camada-1-provar-que-o-aviso-veio-do-psp","Camada 1: provar que o aviso veio do PSP",[38,69,70,76,77,81],{},[42,71,72],{},[45,73,75],{"id":74},"hmac","HMAC"," é um hash que mistura uma chave: ",[78,79,80],"code",{},"HMAC(chave, mensagem)",". Sem a chave, ninguém calcula o resultado certo. Pense num lacre de cera com um anel que só o PSP tem. Ele assina o corpo da requisição e manda o resultado num header; eu recalculo e comparo.",[38,83,84],{},"Dois detalhes que custam caro quando esquecidos:",[86,87,88,99],"ul",{},[89,90,91,94,95,98],"li",{},[42,92,93],{},"A assinatura vale para os bytes exatos que o PSP enviou."," Se eu deixar o framework converter o JSON em objeto e reserializar, espaços e ordem das chaves podem mudar e a assinatura \"falha\". Por isso o NestJS sobe com ",[78,96,97],{},"rawBody: true"," e eu valido o corpo cru.",[89,100,101,104,105,108,109,112],{},[42,102,103],{},"A comparação precisa ser em tempo constante."," Um ",[78,106,107],{},"==="," para no primeiro caractere diferente, e um atacante que mede o tempo de resposta consegue descobrir a assinatura aos poucos (timing attack). O ",[78,110,111],{},"timingSafeEqual"," do Node compara tudo sempre, mas exige buffers do mesmo tamanho, então checo o tamanho antes.",[114,115,120],"pre",{"className":116,"code":117,"language":118,"meta":119,"style":119},"language-ts shiki shiki-themes github-light github-dark-dimmed","\u002F\u002F Simplificado do código real\nconst esperada = createHmac('sha256', chave)\n  .update(Buffer.from(corpoCru, 'utf8')) \u002F\u002F bytes originais\n  .digest('base64');\n\nconst a = Buffer.from(esperada);\nconst b = Buffer.from(assinaturaDoHeader);\n\n\u002F\u002F tamanho primeiro, depois comparação em tempo constante\nreturn a.length === b.length && timingSafeEqual(a, b);\n","ts","",[78,121,122,131,160,187,203,210,228,245,250,256],{"__ignoreMap":119},[123,124,127],"span",{"class":125,"line":126},"line",1,[123,128,130],{"class":129},"sDN9O","\u002F\u002F Simplificado do código real\n",[123,132,134,138,142,145,149,153,157],{"class":125,"line":133},2,[123,135,137],{"class":136},"s7YZ4","const",[123,139,141],{"class":140},"sviXB"," esperada",[123,143,144],{"class":136}," =",[123,146,148],{"class":147},"sPO5f"," createHmac",[123,150,152],{"class":151},"sYgZi","(",[123,154,156],{"class":155},"s-HuK","'sha256'",[123,158,159],{"class":151},", chave)\n",[123,161,163,166,169,172,175,178,181,184],{"class":125,"line":162},3,[123,164,165],{"class":151},"  .",[123,167,168],{"class":147},"update",[123,170,171],{"class":151},"(Buffer.",[123,173,174],{"class":147},"from",[123,176,177],{"class":151},"(corpoCru, ",[123,179,180],{"class":155},"'utf8'",[123,182,183],{"class":151},")) ",[123,185,186],{"class":129},"\u002F\u002F bytes originais\n",[123,188,190,192,195,197,200],{"class":125,"line":189},4,[123,191,165],{"class":151},[123,193,194],{"class":147},"digest",[123,196,152],{"class":151},[123,198,199],{"class":155},"'base64'",[123,201,202],{"class":151},");\n",[123,204,206],{"class":125,"line":205},5,[123,207,209],{"emptyLinePlaceholder":208},true,"\n",[123,211,213,215,218,220,223,225],{"class":125,"line":212},6,[123,214,137],{"class":136},[123,216,217],{"class":140}," a",[123,219,144],{"class":136},[123,221,222],{"class":151}," Buffer.",[123,224,174],{"class":147},[123,226,227],{"class":151},"(esperada);\n",[123,229,231,233,236,238,240,242],{"class":125,"line":230},7,[123,232,137],{"class":136},[123,234,235],{"class":140}," b",[123,237,144],{"class":136},[123,239,222],{"class":151},[123,241,174],{"class":147},[123,243,244],{"class":151},"(assinaturaDoHeader);\n",[123,246,248],{"class":125,"line":247},8,[123,249,209],{"emptyLinePlaceholder":208},[123,251,253],{"class":125,"line":252},9,[123,254,255],{"class":129},"\u002F\u002F tamanho primeiro, depois comparação em tempo constante\n",[123,257,259,262,265,268,271,274,276,279,282],{"class":125,"line":258},10,[123,260,261],{"class":136},"return",[123,263,264],{"class":151}," a.",[123,266,267],{"class":140},"length",[123,269,270],{"class":136}," ===",[123,272,273],{"class":151}," b.",[123,275,267],{"class":140},[123,277,278],{"class":136}," &&",[123,280,281],{"class":147}," timingSafeEqual",[123,283,284],{"class":151},"(a, b);\n",[38,286,287],{},"Uma ressalva honesta: no meu PSP, a chave usada na assinatura é publicada na documentação deles. Ou seja, a assinatura confere integridade e formato, mas não basta sozinha. Por isso exijo um segundo fator: um segredo meu, só conhecido por mim e pelo PSP, anexado à URL cadastrada. Sem os dois, a resposta é 401.",[64,289,291],{"id":290},"camada-2-confirmar-uma-única-vez","Camada 2: confirmar uma única vez",[38,293,294,297],{},[42,295,296],{},"Idempotência"," é a propriedade de uma operação que, repetida, tem o mesmo efeito de executada uma vez. Webhook é entrega \"pelo menos uma vez\": exatamente uma vez não existe na rede. Então a idempotência tem que estar do meu lado.",[38,299,300],{},"O instinto é \"coloco numa transação\". Só que uma transação sozinha não impede a corrida. No nível padrão do Postgres, duas entregas simultâneas leem \"pendente\", ambas decidem que precisam confirmar e ambas gravam \"pago\".",[38,302,303,304,309,310,313],{},"A saída é o ",[42,305,306],{},[45,307,308],{"id":308},"lock pessimista",": assumir que o conflito vai acontecer e travar antes. ",[78,311,312],{},"SELECT ... FOR UPDATE"," lê a linha e a mantém travada até o fim da transação. Quem chega depois espera. Analogia: banheiro com chave na recepção. A segunda pessoa não tenta a porta e briga com a primeira; ela espera a chave voltar e, quando entra, vê que já foi limpo.",[38,315,316],{},"Escolhi o pessimista em vez do otimista (versão na linha, quem perde tenta de novo) porque o conflito é provável, já que PSPs reenviam em rajada, e a seção crítica é curta.",[114,318,320],{"className":116,"code":319,"language":118,"meta":119,"style":119},"\u002F\u002F TypeORM, simplificado\nreturn this.dataSource.transaction(async (manager) => {\n  \u002F\u002F vira SELECT ... FOR UPDATE\n  const tx = await manager.findOne(Transacao, {\n    where: { id: transacaoId },\n    lock: { mode: 'pessimistic_write' },\n  });\n  if (!tx) throw new Error('Transação não encontrada');\n\n  const jaEstavaPago = tx.statusPagamento === 'pago';\n  if (!jaEstavaPago) {\n    tx.statusPagamento = 'pago';\n    await manager.save(tx);\n    await this.razao.registrarVendaSeAusente(manager, tx);\n    await this.creditarComissao(manager, tx); \u002F\u002F só na 1ª vez\n  }\n\n  \u002F\u002F roda sempre: repara acesso que ficou faltando\n  await this.garantirAcessos(manager, tx);\n});\n",[78,321,322,327,361,366,388,393,404,409,438,442,462,474,487,501,517,536,542,547,553,568],{"__ignoreMap":119},[123,323,324],{"class":125,"line":126},[123,325,326],{"class":129},"\u002F\u002F TypeORM, simplificado\n",[123,328,329,331,334,337,340,342,345,348,352,355,358],{"class":125,"line":133},[123,330,261],{"class":136},[123,332,333],{"class":140}," this",[123,335,336],{"class":151},".dataSource.",[123,338,339],{"class":147},"transaction",[123,341,152],{"class":151},[123,343,344],{"class":136},"async",[123,346,347],{"class":151}," (",[123,349,351],{"class":350},"stnAF","manager",[123,353,354],{"class":151},") ",[123,356,357],{"class":136},"=>",[123,359,360],{"class":151}," {\n",[123,362,363],{"class":125,"line":162},[123,364,365],{"class":129},"  \u002F\u002F vira SELECT ... FOR UPDATE\n",[123,367,368,371,374,376,379,382,385],{"class":125,"line":189},[123,369,370],{"class":136},"  const",[123,372,373],{"class":140}," tx",[123,375,144],{"class":136},[123,377,378],{"class":136}," await",[123,380,381],{"class":151}," manager.",[123,383,384],{"class":147},"findOne",[123,386,387],{"class":151},"(Transacao, {\n",[123,389,390],{"class":125,"line":205},[123,391,392],{"class":151},"    where: { id: transacaoId },\n",[123,394,395,398,401],{"class":125,"line":212},[123,396,397],{"class":151},"    lock: { mode: ",[123,399,400],{"class":155},"'pessimistic_write'",[123,402,403],{"class":151}," },\n",[123,405,406],{"class":125,"line":230},[123,407,408],{"class":151},"  });\n",[123,410,411,414,416,419,422,425,428,431,433,436],{"class":125,"line":247},[123,412,413],{"class":136},"  if",[123,415,347],{"class":151},[123,417,418],{"class":136},"!",[123,420,421],{"class":151},"tx) ",[123,423,424],{"class":136},"throw",[123,426,427],{"class":136}," new",[123,429,430],{"class":147}," Error",[123,432,152],{"class":151},[123,434,435],{"class":155},"'Transação não encontrada'",[123,437,202],{"class":151},[123,439,440],{"class":125,"line":252},[123,441,209],{"emptyLinePlaceholder":208},[123,443,444,446,449,451,454,456,459],{"class":125,"line":258},[123,445,370],{"class":136},[123,447,448],{"class":140}," jaEstavaPago",[123,450,144],{"class":136},[123,452,453],{"class":151}," tx.statusPagamento ",[123,455,107],{"class":136},[123,457,458],{"class":155}," 'pago'",[123,460,461],{"class":151},";\n",[123,463,465,467,469,471],{"class":125,"line":464},11,[123,466,413],{"class":136},[123,468,347],{"class":151},[123,470,418],{"class":136},[123,472,473],{"class":151},"jaEstavaPago) {\n",[123,475,477,480,483,485],{"class":125,"line":476},12,[123,478,479],{"class":151},"    tx.statusPagamento ",[123,481,482],{"class":136},"=",[123,484,458],{"class":155},[123,486,461],{"class":151},[123,488,490,493,495,498],{"class":125,"line":489},13,[123,491,492],{"class":136},"    await",[123,494,381],{"class":151},[123,496,497],{"class":147},"save",[123,499,500],{"class":151},"(tx);\n",[123,502,504,506,508,511,514],{"class":125,"line":503},14,[123,505,492],{"class":136},[123,507,333],{"class":140},[123,509,510],{"class":151},".razao.",[123,512,513],{"class":147},"registrarVendaSeAusente",[123,515,516],{"class":151},"(manager, tx);\n",[123,518,520,522,524,527,530,533],{"class":125,"line":519},15,[123,521,492],{"class":136},[123,523,333],{"class":140},[123,525,526],{"class":151},".",[123,528,529],{"class":147},"creditarComissao",[123,531,532],{"class":151},"(manager, tx); ",[123,534,535],{"class":129},"\u002F\u002F só na 1ª vez\n",[123,537,539],{"class":125,"line":538},16,[123,540,541],{"class":151},"  }\n",[123,543,545],{"class":125,"line":544},17,[123,546,209],{"emptyLinePlaceholder":208},[123,548,550],{"class":125,"line":549},18,[123,551,552],{"class":129},"  \u002F\u002F roda sempre: repara acesso que ficou faltando\n",[123,554,556,559,561,563,566],{"class":125,"line":555},19,[123,557,558],{"class":136},"  await",[123,560,333],{"class":140},[123,562,526],{"class":151},[123,564,565],{"class":147},"garantirAcessos",[123,567,516],{"class":151},[123,569,571],{"class":125,"line":570},20,[123,572,573],{"class":151},"});\n",[38,575,576,577,580],{},"Repare em dois detalhes. A checagem ",[78,578,579],{},"jaEstavaPago"," acontece com a linha travada, então a segunda entrega enxerga o estado já commitado pela primeira. E a concessão de acesso roda sempre, de propósito: se um estado parcial ficou para trás, a reentrega conserta o acesso sem recreditar a comissão.",[38,582,583,584,587],{},"Há ainda uma regra sobre efeitos colaterais: o aviso em tempo real ao usuário só sai ",[42,585,586],{},"depois do commit",". Se a transação desfizer, ninguém é avisado de um acesso que não existe.",[64,589,591],{"id":590},"camada-3-um-ledger-que-só-cresce","Camada 3: um ledger que só cresce",[38,593,594,595,600],{},"O dinheiro vai para um ",[42,596,597],{},[45,598,599],{"id":599},"ledger"," (razão): uma tabela em que linhas nunca são editadas nem apagadas. Uma correção é uma linha nova que compensa a anterior. Estorno é um lançamento negativo, e o saldo é a soma.",[114,602,607],{"className":603,"code":605,"language":606,"meta":119},[604],"language-text","tipo    | valor_centavos | competência\nvenda   |      +4990     | 2026-08\nestorno |      -4990     | 2026-09   (linha nova, negativa)\n","text",[78,608,605],{"__ignoreMap":119},[38,610,611],{},"Três decisões aqui:",[86,613,614,631,637],{},[89,615,616,619,620,623,624,627,628,526],{},[42,617,618],{},"Centavos inteiros."," ",[78,621,622],{},"0.1 + 0.2"," em JavaScript dá ",[78,625,626],{},"0.30000000000000004",". Em dinheiro o erro acumula. R$ 49,90 é ",[78,629,630],{},"4990",[89,632,633,636],{},[42,634,635],{},"Estorno na competência do evento."," Se ele reescrevesse agosto, um mês já apurado mudaria de valor. Com a linha negativa em setembro, \"quanto vendi em agosto?\" tem a mesma resposta hoje e daqui a um ano.",[89,638,639,642],{},[42,640,641],{},"Constraint única como rede de segurança."," O código evita a duplicidade, mas o banco é a última barreira se o código falhar.",[114,644,648],{"className":645,"code":646,"language":647,"meta":119,"style":119},"language-sql shiki shiki-themes github-light github-dark-dimmed","-- no máximo uma venda e um estorno por transação\nCREATE UNIQUE INDEX uq_lancamento_transacao_tipo\n  ON lancamentos_financeiros (transacao_id, tipo)\n  WHERE deleted_at IS NULL;\n","sql",[78,649,650,655,666,674],{"__ignoreMap":119},[123,651,652],{"class":125,"line":126},[123,653,654],{"class":129},"-- no máximo uma venda e um estorno por transação\n",[123,656,657,660,663],{"class":125,"line":133},[123,658,659],{"class":136},"CREATE",[123,661,662],{"class":136}," UNIQUE INDEX",[123,664,665],{"class":147}," uq_lancamento_transacao_tipo\n",[123,667,668,671],{"class":125,"line":162},[123,669,670],{"class":136},"  ON",[123,672,673],{"class":151}," lancamentos_financeiros (transacao_id, tipo)\n",[123,675,676,679,682,685,688],{"class":125,"line":189},[123,677,678],{"class":136},"  WHERE",[123,680,681],{"class":151}," deleted_at ",[123,683,684],{"class":136},"IS",[123,686,687],{"class":136}," NULL",[123,689,461],{"class":151},[38,691,692,693,696],{},"Os escritores do ledger são \"se ausente\" (inserem só se o lançamento ainda não existe) e recebem o ",[78,694,695],{},"EntityManager"," do chamador. Assim vender e lançar acontecem na mesma transação: juntos ou nenhum.",[64,698,700],{"id":699},"o-que-testar","O que testar",[38,702,703],{},"Teste de idempotência com mock não prova nada, porque mock não reproduz lock. Os meus rodam contra um Postgres real e cobrem reentrega, falha no meio e duas entregas concorrentes.",[38,705,706],{},"Para a concorrência, não torço para o escalonador sobrepor as chamadas. Uso um \"portão\": uma conexão separada trava a linha, disparo as duas entregas, espero as duas ficarem bloqueadas, solto o portão e verifico o resultado: um acesso, uma comissão, um evento de tempo real.",[64,708,710],{"id":709},"o-que-eu-faria-diferente","O que eu faria diferente",[38,712,713],{},"Olhando com honestidade para o meu código:",[86,715,716,731,745,751,771],{},[89,717,718,724,725,727,728,526],{},[42,719,720,721,526],{},"O histórico de webhooks não tem ",[78,722,723],{},"UNIQUE"," A dedupe ali é \"consulta e insere\", e duas entregas simultâneas passam pela consulta antes de qualquer insert. O efeito financeiro está protegido pelo lock, mas o histórico pode duplicar. O certo é um ",[78,726,723],{}," com ",[78,729,730],{},"ON CONFLICT DO NOTHING",[89,732,733,744],{},[42,734,735,736,739,740,743],{},"O ",[78,737,738],{},"FOR UPDATE"," também busca por ",[78,741,742],{},"gateway_id",", que não tem índice."," Com a tabela crescendo, a busca vira varredura e o lock dura mais.",[89,746,747,750],{},[42,748,749],{},"Transação inexistente responde 500."," O PSP reenvia sem parar. Falha permanente deve ser registrada e respondida com 2xx.",[89,752,753,756,757,760,761,764,765,767,768,526],{},[42,754,755],{},"A imutabilidade do ledger é só convenção."," Nada no banco impede um ",[78,758,759],{},"UPDATE",", e o ",[78,762,763],{},"deleted_at"," herdado abre uma brecha para o índice parcial deixar de enxergar uma linha. Eu colocaria um trigger que recusa ",[78,766,759],{}," e ",[78,769,770],{},"DELETE",[89,772,773,776],{},[42,774,775],{},"Comissão fica fora do ledger."," Hoje é um incremento num saldo mutável, então o ledger não fecha contra o caixa. Partidas dobradas resolveriam.",[64,778,780],{"id":779},"resumo-para-levar","Resumo para levar",[86,782,783,786,792,798,801],{},[89,784,785],{},"Webhook é entrega \"pelo menos uma vez\": o mesmo aviso chega repetido ou em paralelo, e o seu código precisa aguentar.",[89,787,788,789,791],{},"Valide o HMAC sobre o corpo cru, com ",[78,790,111],{},", e não confie numa assinatura cuja chave seja pública sem um segundo fator.",[89,793,794,795,797],{},"Transação não basta: use ",[78,796,312],{},", cheque o estado com a linha travada e deixe constraints únicas como última barreira.",[89,799,800],{},"Dinheiro em ledger append-only, centavos inteiros, estorno como linha negativa; só avise o usuário depois do commit.",[89,802,803],{},"Teste a concorrência contra Postgres real, forçando a sobreposição.",[805,806,807],"style",{},"html pre.shiki code .sDN9O, html code.shiki .sDN9O{--shiki-default:#6A737D;--shiki-dark:#768390}html pre.shiki code .s7YZ4, html code.shiki .s7YZ4{--shiki-default:#D73A49;--shiki-dark:#F47067}html pre.shiki code .sviXB, html code.shiki .sviXB{--shiki-default:#005CC5;--shiki-dark:#6CB6FF}html pre.shiki code .sPO5f, html code.shiki .sPO5f{--shiki-default:#6F42C1;--shiki-dark:#DCBDFB}html pre.shiki code .sYgZi, html code.shiki .sYgZi{--shiki-default:#24292E;--shiki-dark:#ADBAC7}html pre.shiki code .s-HuK, html code.shiki .s-HuK{--shiki-default:#032F62;--shiki-dark:#96D0FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .stnAF, html code.shiki .stnAF{--shiki-default:#E36209;--shiki-dark:#F69D50}",{"title":119,"searchDepth":133,"depth":162,"links":809},[810,811,812,813,814,815],{"id":66,"depth":133,"text":67},{"id":290,"depth":133,"text":291},{"id":590,"depth":133,"text":591},{"id":699,"depth":133,"text":700},{"id":709,"depth":133,"text":710},{"id":779,"depth":133,"text":780},"2026-10-14","Um usuário paga um PIX no app do banco. Meu servidor não participa disso: ele só descobre o pagamento quando o provedor de pagamento (o PSP) faz uma chamada HTTP para uma URL minha. Essa chamada é o webhook. E o PSP só para de insistir quando recebe um 2xx de volta.","md",{},"Webhook de PIX chega duplicado. Como autentiquei com HMAC, confirmei com lock pessimista no Postgres e registrei tudo num ledger append-only.",{"description":817},"blog\u002F2026-10-pix-idempotente",[824,825,60],"nestjs","postgres","X1oczZDAxVOhmYlSrR5qM3L4b4gRmpt2XzN7SRtXoc0",1792078526680]