Hoje, muito SaaS indie roda sobre um merchant of record. A Paddle e a Lemon Squeezy cuidam de imposto sobre vendas, IVA e chargebacks, uma troca realmente boa para um fundador solo que não quer pensar nos limites de IVA da União Europeia.
Os webhooks não têm o mesmo formato dos da Stripe, e as diferenças são do tipo que só aparece sob carga. Se você portou um handler da Stripe e só trocou a verificação de assinatura, há dois números que vale conhecer.
TL;DR
- A Paddle quer uma resposta
200em até cinco segundos; a Stripe não publica número nenhum - No modo live, a Paddle faz até 60 tentativas ao longo de cerca de 2 dias, cerca de 10 na primeira hora e cerca de 45 no primeiro dia
- O sandbox da Paddle se comporta de um jeito completamente diferente: 3 tentativas em cerca de 2 minutos
- A Lemon Squeezy assina com o cabeçalho
X-Signature, um digest HMAC-SHA256 em hex sobre o corpo bruto, e permite reenviar eventos pelo dashboard - Um handler lento é punido com muito mais força aqui do que na Stripe, porque as novas tentativas (retries) da Paddle começam com um minuto de intervalo e se espaçam devagar
Cinco segundos, por escrito
A Stripe nunca publica um número, e a comunidade trabalha com cerca de vinte segundos. A Paddle publica: seu endpoint "Responde 200 em até cinco segundos após receber uma requisição."
Cinco segundos colocam a Paddle na mesma faixa da Shopify, e não da Stripe, e isso é mais ou menos um quarto do prazo para o qual seu handler da Stripe foi escrito. Um handler de liberação que gera uma chave de licença, grava no seu banco de dados e envia um e-mail de boas-vindas por uma API de terceiros cabe com folga em vinte segundos e fica com folga fora de cinco.
O formato que sobrevive é o mesmo que sobrevive em todo lugar: verificar, persistir, confirmar o recebimento, processar depois.
export async function POST(req: Request) {
const raw = await req.text(); // raw bytes, before parsing
if (!verifyPaddleSignature(raw, req.headers.get("paddle-signature"))) {
return new Response("unauthorized", { status: 401 });
}
await queue.publish(raw); // durable, ~10ms
return new Response(null, { status: 200 }); // inside 5s
}
A curva de novas tentativas é a diferença real
O cronograma da Paddle no modo live: "fazemos até 60 tentativas no total, ao longo de cerca de dois dias. Cerca de 10 tentativas acontecem na primeira hora, e cerca de 45 no primeiro dia."
A Stripe distribui um punhado de tentativas por até três dias, com backoff exponencial. As esperas da Paddle começam em um minuto e crescem devagar, então a maioria das sessenta tentativas chega logo no começo.
Pense no que isso significa para um handler que não está quebrado, só lento. Ele leva seis segundos porque seu banco de dados está num mau momento. A Paddle dá timeout em cinco e tenta de novo. Na primeira hora, isso acontece cerca de dez vezes, e cada uma inicia mais uma execução de seis segundos do seu handler, contra um banco de dados que já é a causa do problema.
Lado a lado com os outros remetentes da nossa referência de provedores de webhook, a Paddle é o ponto fora da curva. O backoff exponencial existe para dar a um sistema em dificuldade espaço para se recuperar. A Paddle também chama o próprio cronograma de backoff exponencial, mas uma curva tão concentrada no início aplica a maior pressão exatamente quando seu endpoint tem menos condição de aguentar. A Paddle não está sendo irracional, porque, para eventos de cobrança, você quer uma entrega insistente, mas isso significa que o custo de um handler lento cresce muito mais rápido do que o do mesmo handler na Stripe.
Também significa que um handler não idempotente pode fazer o mesmo trabalho cerca de dez vezes em uma hora, em vez de algumas vezes em um dia. Se esse trabalho é "gerar uma licença" ou "enviar um recibo", seu cliente percebe.
O sandbox não vai mostrar isso
"Para contas sandbox, fazemos 3 tentativas no total, todas em cerca de dois minutos."
Três tentativas em cerca de dois minutos é um sistema diferente de sessenta em cerca de dois dias. Toda premissa de tempo que você valida no sandbox está errada em produção, e na direção mais segura, que é o pior tipo de erro. Seu handler vai parecer ótimo até o momento em que estiver lidando com dinheiro de verdade.
Se você quer testar o comportamento de novas tentativas que importa, responda 500 de propósito no modo live, contra um destino de teste, e acompanhe a primeira hora.
Verificação de assinatura
A Paddle assina com um cabeçalho Paddle-Signature que leva um timestamp e uma assinatura H1, da mesma família geral do esquema t=/v1= da Stripe. A Lemon Squeezy assina com o cabeçalho X-Signature, um digest HMAC-SHA256 em hex do corpo bruto da requisição, calculado com o segredo de assinatura do seu webhook.
As duas têm a mesma armadilha de todo provedor: o HMAC é calculado sobre os bytes exatos que chegaram. Faça o parse do JSON primeiro e serialize de novo, e o digest muda, enquanto o payload nos seus logs parece perfeitamente correto. É o bug de webhook mais comum em todos os provedores, e a mecânica de cada um está em Como verificar assinaturas de webhook da Stripe, do GitHub e da Shopify.
import crypto from "node:crypto";
function verifyLemonSqueezy(raw: string, header: string | null, secret: string) {
if (!header) return false;
const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
A checagem de tamanho antes de timingSafeEqual importa, porque a função lança um erro quando os tamanhos diferem, em vez de retornar false, e um cabeçalho malformado vindo de um atacante não deveria produzir um 500.
No App Router do Next.js, await req.text() antes de qualquer outra coisa. Em serverless de modo geral, Como receber webhooks na Vercel sem perder nenhum trata do body parser que consome o stream do corpo em silêncio.
Idempotência, com uma particularidade de merchant of record
A Paddle envia um identificador de evento que você pode usar como chave, e o padrão é a restrição de unicidade de sempre:
INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'paddle') ON CONFLICT (event_id) DO NOTHING;
Zero linhas afetadas significa que você já viu esse evento. Responda 200, e não um erro, ou você terá construído um loop com sessenta tentativas.
A particularidade de um merchant of record: eventos do ciclo de vida da assinatura de um mesmo cliente podem chegar muito próximos, e uma nova tentativa pode mudar a ordem deles. Um evento subscription.updated tentado de novo uma hora depois pode chegar depois de um evento subscription.canceled e deixar a assinatura ativa de novo. Deduplicar não ajuda, porque os dois eventos são distintos e os dois são legítimos. Você precisa de uma checagem de versão baseada no timestamp do próprio evento, que é o mesmo padrão descrito em Você recebeu o mesmo webhook duas vezes.
Com a densidade de novas tentativas da Paddle, um evento desatualizado tem muito mais chances do que na Stripe de chegar depois de um mais novo. Aqui, isso não é uma preocupação teórica.
Vindo da Stripe: o que mudar
Divida por quatro o prazo do seu handler. Tudo o que não for verificar, persistir e confirmar o recebimento vai para um worker.
Parta do princípio de que duplicatas são comuns, e não raras. Cerca de dez tentativas em uma hora mudam a conta de quão cuidadosa sua idempotência precisa ser.
Adicione checagens de versão a tudo que tem ciclo de vida. Principalmente assinaturas.
Não ajuste nada com base no sandbox. O comportamento de novas tentativas dele não tem nada a ver com o de produção.
Mantenha um log dos payloads brutos. A Lemon Squeezy permite reenviar eventos pelo dashboard e a Paddle guarda o histórico de entregas, mas as duas são a cópia do provedor, com a retenção do provedor, e nenhuma responde "o que nosso handler fez de fato com isso".
Onde o AnyHook entra
Cinco segundos é uma restrição de transporte, e sessenta tentativas são um comportamento de transporte. As duas coisas deixam de se aplicar ao seu código quando outra coisa está segurando a conexão.
Aponte o destino de notificações para in.anyhook.net/you/paddle. O AnyHook verifica e persiste na edge e responde 200 em cerca de um segundo, então o prazo de cinco segundos é cumprido por um worker que só faz duas coisas, e a curva de sessenta tentativas nunca entra em ação, porque não há nada para tentar de novo.
- Seu handler ganha de 60 a 300 segundos, conforme o plano e dentro da franquia de computação dele, e um banco de dados lento não atrai mais uma nova tentativa a cada poucos minutos
- Armazenamos cada evento antes da entrega, com criptografia em repouso, então fazer o replay (um novo envio de um evento armazenado, feito por você) da janela de um deploy ruim é uma seleção e um clique
- As novas tentativas de entrega contra seu endpoint usam backoff exponencial, que é a curva certa para um sistema em recuperação
- Os alertas disparam com 1, 5 e 20 falhas consecutivas, e a entrega é pausada automaticamente em 20, para que um handler quebrado não seja martelado
Seu handler ainda precisa de idempotência e de checagens de versão, porque as novas tentativas e o replay existem.
Conclusão
Cobrança via merchant of record é uma boa troca para um SaaS indie, e o contrato de webhooks é mais rígido do que aquele de onde você provavelmente está migrando. Cinco segundos, até sessenta tentativas em cerca de dois dias, a maioria logo no começo. Verifique sobre os bytes brutos, confirme o recebimento antes de trabalhar, deduplique pelo ID de evento do provedor e proteja as gravações de assinatura com o timestamp do próprio evento.
O handler que funciona bem na Stripe há dois anos não funciona bem aqui automaticamente, e não é o sandbox que vai avisar você.