Como receber webhooks do LimãoPay
O LimãoPay envia um POST HTTPS assinado com HMAC SHA256 pra cada evento (pagamento confirmado, assinatura ativada, reembolso). Você cria o endpoint no dashboard…
Resposta curta
O LimãoPay envia um POST HTTPS assinado com HMAC SHA-256 pra cada evento (pagamento confirmado, assinatura ativada, reembolso). Você cria o endpoint no dashboard, recebe um signing secret, valida a assinatura no seu servidor.
Pra que serve
Imagina que você tem um SaaS com plano Free e Pro, e usa o LimãoPay pra cobrar o Pro. Quando alguém paga, você precisa liberar o Pro pro usuário automaticamente. É exatamente pra isso que webhooks existem:
- Cliente paga no LimãoPay
- LimãoPay envia POST
order.paidpro seu servidor - Seu servidor recebe, valida a assinatura, libera o Pro
Sem polling, sem latência, sem precisar consultar a API toda hora.
Passo 1 — Criar o endpoint
- Vai em Dashboard → Developers → Webhooks
- Clica em Novo endpoint
- Escolhe o modo:
- Test: pra desenvolvimento. Não recebe eventos reais — só os que você dispara manualmente pelo botão "Sandbox"
- Live: pra produção. Recebe eventos de pagamentos reais
- Cola a URL HTTPS pública do seu servidor (ex:
https://api.seusaas.com/webhooks/limaopay) - Marca os eventos que quer receber (pelo menos
order.paid) - Clica em Criar webhook
O LimãoPay mostra o signing secret (whsec_...). Copie agora — não dá pra ver de novo. Se perder, tem que rotacionar.
Passo 2 — Validar a assinatura
Cada POST chega com esse header:
LimaoPay-Signature: t=1737686400,v1=<hmac_sha256_hex>
A assinatura é o HMAC SHA-256 da string <timestamp>.<rawBody> usando seu signing secret.
Exemplo em Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.LIMAOPAY_WEBHOOK_SECRET!;
// IMPORTANTE: usar raw body — JSON.parse antes invalida a assinatura
app.post(
"/webhooks/limaopay",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
const sig = req.headers["limaopay-signature"] as string;
if (!verify(rawBody, sig)) return res.status(401).send("invalid");
const event = JSON.parse(rawBody);
if (event.type === "order.paid") {
// Liberar acesso pro buyer_email ou external_customer_id
console.log("Pago:", event.data.object.buyer_email);
}
res.status(200).send("ok");
},
);
function verify(body: string, sigHeader: string): boolean {
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.split("=")),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
// Anti-replay: rejeita timestamps fora de 5min
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", SECRET)
.update(`${t}.${body}`)
.digest("hex");
if (v1.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"));
}
Por que validar assim?
- HMAC: garante que o POST veio do LimãoPay (só quem tem o secret consegue gerar a assinatura)
- Timing-safe compare: impede que um atacante descubra o secret medindo o tempo de comparação
- Timestamp tolerance: impede ataque de replay (alguém pegar um POST antigo e reenviar)
Estrutura do payload
Formato estilo Stripe Event Object — o campo type discrimina, e data.object muda de forma conforme o evento. Exemplo de um order.paid:
{
"id": "evt_2k4m9x1abc",
"type": "order.paid",
"created": 1737686400,
"livemode": true,
"api_version": "2026-01-01",
"data": {
"object": {
"id": "ord_8f3a1b2c",
"object": "order",
"tenant_id": "tnt_seller_xyz",
"offer_id": "off_pro_plan",
"product_id": "prod_pro",
"status": "paid",
"amount_total": 4900,
"currency": "brl",
"payment_method": "pix",
"buyer_email": "comprador@example.com",
"buyer_name": "Maria Silva",
"external_customer_id": "user_42",
"external_metadata": { "plan": "pro" },
"product_subscription_id": null,
"paid_at": "2026-05-21T14:30:00.000Z",
"refunded_at": null
}
}
}
Onde achar o que você precisa:
| Quero saber… | Caminho no payload |
|---|---|
| Email do comprador | data.object.buyer_email |
| Nome do comprador | data.object.buyer_name |
| Seu ID interno do user | data.object.external_customer_id |
| Metadata que você passou na URL | data.object.external_metadata |
| Valor pago (em centavos) | data.object.amount_total |
| Moeda | data.object.currency (lowercase: brl, usd) |
| Método de pagamento | data.object.payment_method (pix, stripe, mp) |
| Quando foi pago | data.object.paid_at (ISO 8601) |
| ID único do evento (pra dedupe) | header LimaoPay-Event-Id ou campo id |
Eventos de assinatura (subscription.activated, subscription.renewed, etc.) têm data.object com formato diferente — incluem billing_cycle, current_period_start/end, price_at_creation e também buyer_email.
Passo 3 — Idempotência
O LimãoPay pode enviar o mesmo evento duas vezes (em caso de retry). Use o header LimaoPay-Event-Id pra dedupe:
const eventId = req.headers["limaopay-event-id"] as string;
if (await alreadyProcessed(eventId)) {
return res.status(200).send("ok (duplicate)");
}
// ... processa
await markProcessed(eventId);
Passo 4 — Testar antes de ir pra produção
No dashboard, dentro do endpoint test mode, tem o botão Sandbox:
- Escolhe o tipo de evento (ex:
order.paid) - Clica em Disparar evento mock
- O LimãoPay envia um POST com payload realista pro seu servidor
- Você vê o resultado em "Últimas entregas" — incluindo response status e tempo
Use isso pra validar a integração ponta-a-ponta antes de criar o endpoint live.
Passo 5 — Casar o pagamento com o usuário do seu SaaS
Quando o cliente do seu SaaS for pagar, gere o link da página LimãoPay anexando o ID dele:
https://limaopay.com.br/sualoja/produto?external_customer_id=user_42&metadata[plan]=pro
Estrutura da URL:
| Parte | Significado |
|---|---|
limaopay.com.br | Domínio fixo (ou limaopay.app em en-US) |
sualoja | Slug da loja do vendedor (escolhido no cadastro) |
produto | Slug da página de produto |
?external_customer_id=user_42 | ID do usuário no SEU sistema (volta em data.object.external_customer_id) |
&metadata[chave]=valor | Metadata livre — múltiplas keys aceitas (volta em data.object.external_metadata) |
Como funciona internamente: a página guarda esses valores em sessionStorage e envia ao backend quando o cliente clica em pagar. O backend persiste em Order.externalCustomerId + Order.externalMetadata. O webhook entrega ambos no payload.
Limites (alinhados com Stripe Metadata):
- Máx 50 chaves de metadata
- Chave: até 40 chars, valor: até 500 chars
external_customer_id: até 255 chars
Funciona em todos os métodos de pagamento — Stripe, PIX (V1 e V2 inline) e Mercado Pago.
Passo 6 — Redirect pós-pagamento (opcional)
Por padrão, depois que o comprador paga, o LimãoPay mostra um modal de "Pagamento confirmado" e fica nele. Pra SaaS externos que querem trazer o comprador de volta pro seu app (mostrar página de "obrigado", liberar acesso na hora, etc.), configure duas URLs no webhook:
- Vai em Dashboard → Developers → Webhooks → [seu endpoint]
- Preencha URL de sucesso (ex:
https://seusaas.com/limaopay/success) e opcionalmente URL de cancelamento
Depois disso, todo pagamento confirmado pra esse vendedor exibe o modal de confirmação por 2,5 segundos com um spinner ("Redirecionando você de volta…") e em seguida joga o comprador na sua URL de sucesso.
Como casar a Order que voltou
A URL de sucesso é única (não muda por checkout). Pra saber qual Order o comprador acabou de fechar, use:
- O webhook
order.paid(que chega em paralelo comLimaoPay-Event-Id): tem todos os dados —order.id,buyer_email,external_customer_id,external_metadata - Se você precisar identificar no momento do redirect (antes do webhook chegar), passe o ID interno como
external_customer_idna URL da página LimãoPay:?external_customer_id=user_42. Você pode persistir esse ID no seu lado em "aguardando pagamento" e correlacionar quando o webhook chegar
Validação de segurança
- HTTPS obrigatório (HTTP só em dev)
- Hostnames
localhost,.local,.internale IPs privados são bloqueados (defesa anti-SSRF) - Limite de 2048 caracteres por URL
- A URL fica snapshotada na Order no momento da criação. Mudanças posteriores no webhook config não afetam orders em andamento
Quando o redirect NÃO acontece
- Webhook em modo test — só endpoints em modo live entram na resolução
- Webhook pausado (
status: pausedoudisabled_by_failures) - Comprador fechou a aba durante o polling — não há browser pra redirecionar
- Sua URL não passou na validação HTTPS — vendedor recebe erro ao criar/editar o webhook, redirect não é configurado
Política de retry
Se seu servidor responde algo diferente de 2xx, o LimãoPay tenta de novo:
| Tentativa | Delay |
|---|---|
| 1 | imediata |
| 2 | 1 minuto |
| 3 | 5 minutos |
| 4 | 30 minutos |
| 5 | 2 horas |
| 6 | 8 horas |
| 7 | 24 horas |
| 8 | 48 horas |
Depois disso, o evento é marcado como exhausted (esgotou). Após 30 falhas consecutivas, o endpoint é auto-pausado e você recebe e-mail.
Rotacionar o secret
Se suspeitar de vazamento, rotacione no dashboard. O secret antigo continua válido por 24h (janela pra atualizar seu receptor). Durante a janela, o LimãoPay envia 2 assinaturas: v1 (novo) e v1_prev (antigo).
Checklist
- Endpoint criado com URL HTTPS pública
- Signing secret copiado e salvo em variável de ambiente
- Validação HMAC com timing-safe compare
- Validação de timestamp (anti-replay, tolerância 5min)
- Idempotência usando
LimaoPay-Event-Id - Testado no modo Sandbox antes de criar endpoint live
- Endpoint responde 2xx em até 10 segundos
- Não segue redirects (responda 2xx final, não 3xx)