API da LulaPay
Esta documentação é para quem vai plugar o PIX da LulaPay em alguma coisa: o checkout da sua loja, a sua plataforma, ou uma automação — um bot de Telegram que vende e entrega acesso VIP, por exemplo.
http://localhost:3000 · todos os caminhos começam com /v1Visão geral
A LulaPay é um gateway de pagamentos PIX. O caminho é sempre o mesmo, independente de onde você esteja integrando:
- Seu sistema cria a cobrançaUma chamada
POST /v1/chargesfeita pelo seu servidor. Volta o código copia e cola e o QR Code. - Você mostra o PIX ao clienteNo checkout, numa mensagem do bot, num e-mail — onde fizer sentido.
- O cliente pagaA confirmação vem da instituição financeira, não de um webhook que qualquer um poderia forjar.
- A LulaPay te avisaPor webhook assinado (recomendado) ou pelo seu polling. O saldo entra no mesmo instante.
- Você entregaLibera o acesso, marca o pedido como pago, manda o link do grupo VIP.
O que muda entre um checkout e um bot? Só os passos 2 e 5. Os passos 1, 3 e 4 são idênticos — mesma API, mesmas chaves, mesmo webhook.
Comece em 5 minutos
- Crie a conta em
/cadastro. Em ambiente local a conta já nasce liberada para produção. - Pegue a chave em Painel → API. Chaves
lp_test_…são sandbox;lp_live_…movimentam dinheiro de verdade. - Configure um adquirente em Painel → Adquirentes. Sem isso, cobranças em produção falham com
503 no_provider— no modo teste não precisa. - Crie a primeira cobrança e mostre o
pix.copy_pasteao cliente. - Cadastre um webhook em Painel → Integrações → Webhooks para ser avisado quando o PIX cair.
curl -X POST http://localhost:3000/v1/charges \
-H "Authorization: Bearer lp_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-1042" \
-d '{
"amount_cents": 2990,
"description": "VIP 30 dias",
"external_id": "pedido-1042",
"customer": { "name": "Maria Silva", "email": "[email protected]" },
"metadata": { "telegram_user_id": "884412037", "plano": "vip_30d" }
}'
Autenticação
Toda chamada leva a chave no header Authorization, no formato Bearer:
Authorization: Bearer lp_live_9f2c...
| Prefixo | Ambiente | O que acontece |
|---|---|---|
lp_test_ | Teste | PIX de mentira, dinheiro de mentira. Dá para marcar como paga pelo painel, sem pagar nada. |
lp_live_ | Produção | PIX real, cobrado do cliente de verdade, entra no seu saldo de verdade. |
Os dois ambientes são completamente separados: cobranças, clientes, saldo e webhooks de teste não enxergam nada de produção, e vice-versa. Dá para deixar os dois configurados ao mesmo tempo e trocar só a variável de ambiente.
A chave secreta só vive no seu servidor. Nunca coloque lp_live_ em JavaScript de página, em app de celular, em repositório público ou num arquivo de configuração que você compartilha. Quem tem a chave cria cobranças e enxerga todas as suas vendas.
Regras da API
- Valores são sempre centavos inteiros.
2990= R$ 29,90. Nunca mande29.90. - Datas em ISO-8601 UTC:
2026-10-22T22:14:03.117Z. - Corpo e resposta em JSON, sempre com
Content-Type: application/json. - Limite de requisições: 300 por minuto por chave. Estourou, volta
429. - Valor por cobrança: mínimo R$ 1,00, máximo R$ 50.000,00.
Idempotência
Esse é o ponto que mais dá problema em integração de bot, então vale ler com atenção.
Um usuário no Telegram clica duas vezes no botão "Quero o VIP". Ou a rede cai no meio da chamada e o seu código tenta de novo. Nos dois casos, sem proteção, você cria duas cobranças para a mesma venda — e o cliente recebe dois PIX diferentes.
Para evitar, mande o header Idempotency-Key com um valor que identifique aquela intenção de compra:
Idempotency-Key: tg-884412037-vip30-1757630000
| Situação | Resposta |
|---|---|
| Primeira chamada com aquela chave | 201 com a cobrança criada |
| Repetiu a chave, mesmo corpo | A mesma cobrança, com o header Idempotent-Replayed: true |
| Repetiu a chave, corpo diferente | 409 idempotency_conflict |
| Repetiu enquanto a primeira processa | 409 idempotency_in_progress — tente em 1s |
Como montar uma boa chave: estável para a mesma intenção, diferente para intenções diferentes. Em bot, {plataforma}-{user_id}-{plano}-{janela_de_tempo} funciona bem; em loja, use o número do pedido. Evite usar só o user_id: assim a segunda compra do mesmo cliente seria bloqueada para sempre.
Criar cobrança
| Campo | Tipo | Descrição |
|---|---|---|
| amount_cents obrigatório | inteiro | Valor em centavos. Entre 100 e 5000000. Opcional se você mandar product_id. |
| description opcional | texto | Até 140 caracteres. Aparece para o cliente e no seu painel. |
| external_id opcional | texto | Até 100 caracteres. O identificador do pedido no seu sistema. Use para casar a venda de volta. |
| expires_in opcional | inteiro | Segundos até expirar. Entre 300 (5 min) e 604800 (7 dias). Padrão: 3600. |
| customer.name opcional | texto | Nome do pagador. |
| customer.document opcional | texto | CPF ou CNPJ. Validado — se for inválido, a chamada falha. |
| customer.email opcional | texto | Validado como e-mail. |
| customer.phone opcional | texto | Só dígitos, até 15. |
| metadata opcional | objeto | Até 20 chaves, 4 KB. Volta inteiro no webhook. É o campo mais útil para bot. |
| tracking opcional | objeto | utm_source, utm_medium, utm_campaign, src, sck. Alimenta os relatórios de origem. |
| product_id opcional | texto | Um produto cadastrado no painel. Preenche valor e descrição sozinho. |
Resposta 201
{
"id": "ch_mvk64kniwDdt4CEw9WUyCjVtTYqO",
"object": "charge",
"livemode": false,
"status": "pending",
"amount_cents": 2990,
"fee_cents": 338,
"net_cents": 2652,
"currency": "BRL",
"description": "VIP 30 dias",
"external_id": "pedido-1042",
"customer": { "name": "Maria Silva", "document": null, "email": "[email protected]", "phone": null },
"product": null,
"origin": "api",
"tracking": {},
"metadata": { "telegram_user_id": "884412037", "plano": "vip_30d" },
"pix": {
"copy_paste": "00020126580014br.gov.bcb.pix...6304AB12",
"qr_code_base64": "data:image/png;base64,iVBORw0KGgo..."
},
"payer_name": null,
"end_to_end_id": null,
"expires_at": "2026-10-22T23:14:03.117Z",
"paid_at": null,
"created_at": "2026-10-22T22:14:03.117Z"
}
| Campo | O que fazer com ele |
|---|---|
| id | Guarde. É por ele que você consulta e que o webhook identifica a venda. |
| pix.copy_paste | O código copia e cola — é isso que o cliente cola no banco. |
| pix.qr_code_base64 | PNG pronto em data URL. Em bot, dá para mandar direto como foto. |
| fee_cents | Taxa já calculada. |
| net_cents | O que de fato entra no seu saldo. |
| expires_at | Depois disso a cobrança vira expired sozinha e não pode mais ser paga. |
O QR Code só vem em cobrança pendente. Depois de paga ou expirada, qr_code_base64 volta null; o copy_paste continua lá para histórico.
Estados de uma cobrança
| status | Significado |
|---|---|
pending | PIX gerado, esperando o pagamento. |
paid | Pago e confirmado na fonte. Só entregue neste estado. |
expired | Passou do expires_at sem pagamento. |
failed | Não foi possível gerar o PIX no adquirente. |
refunded | Estornada. |
Consultar cobrança
Devolve o mesmo objeto acima, com o QR Code quando ainda estiver pendente. É o que o polling usa.
curl http://localhost:3000/v1/charges/ch_mvk64kniwDdt4CEw9WUyCjVtTYqO \
-H "Authorization: Bearer lp_test_..."
Listar cobranças
| Parâmetro | Descrição |
|---|---|
| status | Filtra por pending, paid, expired… |
| external_id | Busca pelo seu identificador de pedido. |
| limit | Quantas por página. |
| starting_after | ID da última da página anterior, para paginar. |
{ "object": "list", "data": [ { "id": "ch_...", "status": "paid", ... } ], "has_more": false }
Metadata: o campo que resolve a vida do bot
O metadata é um objeto livre (até 20 chaves, 4 KB) que a LulaPay guarda junto da cobrança e devolve inteiro no webhook. Ele existe para você não precisar manter uma tabela só para lembrar de quem era aquele PIX.
Numa venda de VIP por bot, o que você precisa saber na hora de entregar é: qual usuário do Telegram, em qual chat, qual plano. Guarde tudo ali:
"metadata": {
"telegram_user_id": "884412037",
"chat_id": "-1002145887733",
"message_id": "4471",
"plano": "vip_30d",
"bot": "sharkbot",
"workspace": "ws_8f2a"
}
Quando o webhook charge.paid chegar, você lê data.metadata.telegram_user_id e já sabe para quem mandar o link do grupo — sem consultar banco nenhum.
Não coloque segredo em metadata. Nada de token de bot, senha ou chave de API. É um campo de anotação, não um cofre.
Saldo e saques
{ "object": "balance", "livemode": true, "available_cents": 1481410, "pending_cents": 0, "currency": "BRL" }
curl -X POST http://localhost:3000/v1/withdrawals \
-H "Authorization: Bearer lp_live_..." \
-H "Idempotency-Key: saque-2026-10-22-01" \
-d '{ "amount_cents": 50000, "payout_account_id": "pa_..." }'
A conta de destino precisa estar cadastrada antes em Painel → Configurações → Contas de saque (o cadastro pede a sua senha). Se você configurar uma lista de IPs autorizados em Painel → API, saques pela API só saem desses IPs — recomendado.
Webhook ou polling?
Você tem duas formas de saber que o PIX caiu. Escolha pela sua infraestrutura, não por preferência:
| Critério | Webhook | Polling |
|---|---|---|
| Como funciona | A LulaPay chama a sua URL | Você pergunta de tempos em tempos |
| Precisa de | Um endereço HTTPS público | Nada — só sair para a internet |
| Latência | Segundos | O intervalo que você escolher |
| Bom para | Loja, plataforma, API hospedada | Bot em casa, VPS sem domínio, script local |
| Custo | Nenhum | Consome do seu limite de 300 req/min |
Dá para usar os dois. A configuração mais robusta é webhook como caminho principal e um polling lento (a cada 60s, só nas cobranças pendentes das últimas horas) como rede de segurança, para o caso de o webhook ter falhado.
Webhooks
Cadastre a URL em Painel → Integrações → Webhooks. Você recebe um segredo whsec_… — ele aparece uma vez só, guarde na hora.
Eventos
| Evento | Quando dispara |
|---|---|
charge.created | Cobrança criada (PIX gerado). Útil para marcar "aguardando pagamento". |
charge.paid | Pagamento confirmado. É aqui que você entrega. |
charge.expired | Expirou sem pagamento. Bom gancho para remarketing. |
withdrawal.paid | Saque concluído. |
withdrawal.failed | Saque recusado. |
Formato
POST https://seu-sistema.com/webhooks/lulapay
Content-Type: application/json
User-Agent: LulaPay-Webhooks/1.0
LulaPay-Signature: t=1761172919,v1=c544a141d69d3f00...
LulaPay-Event-Id: evt_7Kd2...
LulaPay-Event-Type: charge.paid
{
"id": "evt_7Kd2...",
"type": "charge.paid",
"livemode": true,
"created_at": "2026-10-22T22:21:59.392Z",
"data": {
"id": "ch_mvk64kniwDdt4CEw9WUyCjVtTYqO",
"status": "paid",
"amount_cents": 2990,
"fee_cents": 338,
"net_cents": 2652,
"external_id": "pedido-1042",
"metadata": { "telegram_user_id": "884412037", "plano": "vip_30d" },
"payer_name": "MARIA S SILVA",
"end_to_end_id": "E00000000202610222221...",
"paid_at": "2026-10-22T22:21:59.392Z"
}
}
Regras de quem recebe
- Responda
200rápido — em até 10 segundos, ou a entrega conta como falha. Se o seu processamento é lento, responda primeiro e processe numa fila depois. - Trate repetição. O mesmo evento pode chegar mais de uma vez. Guarde o
LulaPay-Event-Ide ignore os repetidos — senão você libera o VIP duas vezes. - Reenvio automático: em caso de falha tentamos de novo em 30s, 2min, 10min, 30min, 1h, 3h, 6h e 12h. Depois disso a entrega é marcada como falhada e aparece no painel.
- Só aceitamos HTTPS público. IP interno,
localhoste rede privada são recusados no cadastro (proteção contra SSRF). Para testar local, use um túnel (ngrok, cloudflared) ou o polling. - Não seguimos redirecionamento. Cadastre a URL final.
Validar a assinatura
Qualquer um pode mandar um POST para a sua URL dizendo "esse PIX foi pago". A assinatura é o que separa o webhook de verdade de uma tentativa de fraude. Valide sempre, antes de entregar qualquer coisa.
LulaPay-Signature: t=1761172919,v1=c544a141d69d3f00...
Onde v1 = HMAC_SHA256(segredo, "{t}.{corpo_bruto}"). O corpo bruto é o texto exato que chegou — se o framework fizer o parse do JSON e você serializar de novo, a assinatura não bate.
const crypto = require('node:crypto');
const express = require('express');
const app = express();
// IMPORTANTE: corpo bruto, sem parse
app.post('/webhooks/lulapay', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('LulaPay-Signature') || '';
const t = /t=(\d+)/.exec(header)?.[1];
const v1 = /v1=([0-9a-f]+)/.exec(header)?.[1];
if (!t || !v1) return res.sendStatus(400);
// rejeita evento velho demais (proteção contra replay)
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
const esperado = crypto.createHmac('sha256', process.env.LULAPAY_WEBHOOK_SECRET)
.update(`${t}.${req.body}`).digest('hex');
// comparação em tempo constante
const ok = esperado.length === v1.length &&
crypto.timingSafeEqual(Buffer.from(esperado), Buffer.from(v1));
if (!ok) return res.sendStatus(401);
const evt = JSON.parse(req.body);
res.sendStatus(200); // responde primeiro
if (evt.type === 'charge.paid') processar(evt); // processa depois
});
import hmac, hashlib, time, re, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["LULAPAY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/lulapay")
def lulapay():
header = request.headers.get("LulaPay-Signature", "")
t = re.search(r"t=(\d+)", header)
v1 = re.search(r"v1=([0-9a-f]+)", header)
if not t or not v1:
abort(400)
# rejeita evento velho demais (proteção contra replay)
if abs(time.time() - int(t.group(1))) > 300:
abort(400)
bruto = request.get_data() # bytes, sem parse
esperado = hmac.new(SECRET,
f"{t.group(1)}.".encode() + bruto,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(esperado, v1.group(1)):
abort(401)
evt = request.get_json()
if evt["type"] == "charge.paid":
liberar_acesso(evt["data"]) # idealmente numa fila
return "", 200
<?php
$segredo = getenv('LULAPAY_WEBHOOK_SECRET');
$header = $_SERVER['HTTP_LULAPAY_SIGNATURE'] ?? '';
$bruto = file_get_contents('php://input'); // corpo bruto, sem parse
preg_match('/t=(\d+)/', $header, $mt);
preg_match('/v1=([0-9a-f]+)/', $header, $mv);
if (!$mt || !$mv) { http_response_code(400); exit; }
// rejeita evento velho demais (proteção contra replay)
if (abs(time() - (int) $mt[1]) > 300) { http_response_code(400); exit; }
$esperado = hash_hmac('sha256', $mt[1] . '.' . $bruto, $segredo);
if (!hash_equals($esperado, $mv[1])) { http_response_code(401); exit; }
$evt = json_decode($bruto, true);
http_response_code(200);
if ($evt['type'] === 'charge.paid') {
liberar_acesso($evt['data']);
}
Três erros clássicos: (1) usar o JSON já parseado em vez do corpo bruto; (2) comparar as strings com == em vez de comparação em tempo constante; (3) não checar o t, deixando alguém reenviar um webhook antigo válido.
Polling
Se o seu bot roda num lugar sem endereço público — VPS sem domínio, máquina em casa, script local — você não consegue receber webhook. Nesse caso, pergunte:
import os, time, requests
BASE = "http://localhost:3000"
H = {"Authorization": "Bearer " + os.environ["LULAPAY_KEY"]}
def esperar_pagamento(charge_id, timeout=3600):
"""Consulta com intervalo crescente até pagar, expirar ou estourar o tempo."""
inicio, intervalo = time.time(), 3
while time.time() - inicio < timeout:
r = requests.get(f"{BASE}/v1/charges/{charge_id}", headers=H, timeout=15)
if r.status_code == 429: # respeite o limite
time.sleep(30)
continue
r.raise_for_status()
ch = r.json()
if ch["status"] in ("paid", "expired", "failed", "refunded"):
return ch
time.sleep(intervalo)
intervalo = min(intervalo * 1.5, 30) # 3s, 4.5s, 6.7s… até 30s
return None
Não consulte de 1 em 1 segundo em loop infinito. São 300 requisições por minuto no total da conta — com 20 clientes esperando ao mesmo tempo, um polling de 1s estoura o limite e derruba a integração inteira. Comece em 3s, aumente até 30s, e pare assim que a cobrança sair de pending.
Para conferir várias de uma vez, uma chamada resolve:
GET /v1/charges?status=paid&limit=50
Receita: bot de Telegram vendendo VIP
Esse é o caso mais comum de quem integra a LulaPay: um bot que vende acesso a um grupo ou canal e entrega sozinho quando o PIX cai. O desenho vale igual para SharkBot, NinjaBotX, SilverBot, RavenBot ou um bot próprio.
- O usuário clica em "Quero o VIP"Seu handler recebe o callback com o
user_ide ochat_id. - Seu servidor cria a cobrançaCom
Idempotency-Key(contra clique duplo) emetadataguardando quem é. - O bot manda o QR e o copia e colaQR como foto; copia e cola numa mensagem separada em
<code>, para o usuário tocar e copiar. - O pagamento confirmaWebhook
charge.paid, ou polling se você não tem URL pública. - O bot entregaGera um link de convite de uso único, manda ao usuário e registra a validade do acesso.
Passos 2 e 3 — criar a cobrança e mandar o PIX
import os, time, base64, httpx
from aiogram import types
BASE = "http://localhost:3000"
KEY = os.environ["LULAPAY_KEY"]
PLANOS = {"vip_30d": (2990, "VIP 30 dias"), "vip_vitalicio": (9700, "VIP vitalício")}
async def vender(cb: types.CallbackQuery, plano: str):
valor, nome = PLANOS[plano]
uid = cb.from_user.id
# janela de 10 min: clique duplo reaproveita o mesmo PIX,
# mas uma compra nova daqui a meia hora gera outro
janela = int(time.time()) // 600
chave = f"tg-{uid}-{plano}-{janela}"
async with httpx.AsyncClient(timeout=20) as http:
r = await http.post(f"{BASE}/v1/charges",
headers={"Authorization": f"Bearer {KEY}", "Idempotency-Key": chave},
json={
"amount_cents": valor,
"description": nome,
"external_id": chave,
"expires_in": 1800, # 30 min
"customer": {"name": cb.from_user.full_name},
"metadata": {
"telegram_user_id": str(uid),
"chat_id": str(cb.message.chat.id),
"plano": plano,
},
})
r.raise_for_status()
ch = r.json()
# QR como foto
png = base64.b64decode(ch["pix"]["qr_code_base64"].split(",", 1)[1])
await cb.message.answer_photo(
types.BufferedInputFile(png, "pix.png"),
caption=f"<b>{nome}</b>\nR$ {valor/100:.2f}".replace(".", ","))
# copia e cola em mensagem separada: o usuário toca e copia
await cb.message.answer(
"Toque no código para copiar:\n\n"
f"<code>{ch['pix']['copy_paste']}</code>\n\n"
"Assim que o PIX cair eu te mando o acesso aqui mesmo. ⚡")
return ch["id"]
const PLANOS = { vip_30d: [2990, 'VIP 30 dias'], vip_vitalicio: [9700, 'VIP vitalício'] };
async function vender(ctx, plano) {
const [valor, nome] = PLANOS[plano];
const uid = ctx.from.id;
// janela de 10 min: clique duplo reaproveita o mesmo PIX
const janela = Math.floor(Date.now() / 600000);
const chave = `tg-${uid}-${plano}-${janela}`;
const r = await fetch(`${process.env.LULAPAY_BASE}/v1/charges`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.LULAPAY_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': chave,
},
body: JSON.stringify({
amount_cents: valor,
description: nome,
external_id: chave,
expires_in: 1800,
customer: { name: `${ctx.from.first_name ?? ''} ${ctx.from.last_name ?? ''}`.trim() || null },
metadata: { telegram_user_id: String(uid), chat_id: String(ctx.chat.id), plano },
}),
});
if (!r.ok) throw new Error(`LulaPay ${r.status}: ${await r.text()}`);
const ch = await r.json();
const png = Buffer.from(ch.pix.qr_code_base64.split(',')[1], 'base64');
await ctx.replyWithPhoto({ source: png }, {
caption: `<b>${nome}</b>\nR$ ${(valor / 100).toFixed(2).replace('.', ',')}`,
parse_mode: 'HTML',
});
await ctx.reply(
`Toque no código para copiar:\n\n<code>${ch.pix.copy_paste}</code>\n\n` +
'Assim que o PIX cair eu te mando o acesso aqui mesmo. ⚡',
{ parse_mode: 'HTML' });
return ch.id;
}
Passos 4 e 5 — receber e entregar
async def liberar_acesso(data):
"""Chamado pelo webhook charge.paid, com a assinatura já validada."""
uid = int(data["metadata"]["telegram_user_id"])
plano = data["metadata"]["plano"]
# 1) proteção contra entrega duplicada — o mesmo evento pode chegar 2x
if ja_entregue(data["id"]):
return
marcar_entregue(data["id"])
# 2) convite de uso único e com validade
link = await bot.create_chat_invite_link(
chat_id=GRUPO_VIP, member_limit=1,
expire_date=int(time.time()) + 3600)
# 3) registra a assinatura ANTES de mandar o link
salvar_assinatura(uid, plano, dias=30 if plano == "vip_30d" else None)
await bot.send_message(uid,
"✅ <b>Pagamento confirmado!</b>\n\n"
f"Seu acesso está liberado: {link.invite_link}\n"
"O convite vale por 1 hora e só pode ser usado uma vez.")
Por que marcar como entregue antes de mandar? Se o processo cair no meio, é melhor o cliente reclamar que não recebeu (e você reenviar manualmente) do que dois eventos gerarem dois convites e um deles vazar para fora do grupo.
Se o seu bot não tem URL pública
Troque o passo 4 pelo polling: depois de mandar o PIX, dispare uma tarefa que acompanha aquela cobrança e chama o mesmo liberar_acesso() quando o status virar paid.
import asyncio
async def acompanhar(charge_id):
intervalo = 3
for _ in range(600):
async with httpx.AsyncClient(timeout=15) as http:
r = await http.get(f"{BASE}/v1/charges/{charge_id}",
headers={"Authorization": f"Bearer {KEY}"})
if r.status_code == 429:
await asyncio.sleep(30); continue
ch = r.json()
if ch["status"] == "paid":
return await liberar_acesso(ch)
if ch["status"] in ("expired", "failed", "refunded"):
return
await asyncio.sleep(intervalo)
intervalo = min(intervalo * 1.5, 30)
# logo depois de mandar o PIX:
asyncio.create_task(acompanhar(charge_id))
Receita: checkout externo
Para uma loja, plataforma ou checkout próprio o desenho é o mesmo — muda só que a tela é uma página web.
- O cliente finaliza o pedidoSeu backend cria a cobrança com o número do pedido em
external_id. - A página mostra o PIXQR Code, botão de copiar e um contador até o
expires_at. - A página pergunta ao SEU backend se já pagouNunca direto à LulaPay — a chave não pode ir para o navegador.
- O webhook confirma no backendMarque o pedido como pago e dispare o que tiver que disparar (nota, e-mail, separação).
- A página redirecionaPara a tela de obrigado assim que o seu backend responder "pago".
Backend — criar a cobrança
app.post('/pedidos/:id/pix', async (req, res) => {
const pedido = await db.pedido(req.params.id);
const r = await fetch(`${process.env.LULAPAY_BASE}/v1/charges`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.LULAPAY_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `pedido-${pedido.id}`, // o número do pedido já é único
},
body: JSON.stringify({
amount_cents: pedido.total_cents,
description: `Pedido #${pedido.numero}`,
external_id: String(pedido.id),
expires_in: 1800,
customer: {
name: pedido.cliente_nome,
document: pedido.cliente_cpf,
email: pedido.cliente_email,
},
tracking: { utm_source: pedido.utm_source, utm_campaign: pedido.utm_campaign },
}),
});
const ch = await r.json();
await db.salvarCobranca(pedido.id, ch.id);
// só o necessário vai para o navegador — nunca a chave
res.json({ copy_paste: ch.pix.copy_paste, qr: ch.pix.qr_code_base64, expires_at: ch.expires_at });
});
Frontend — perguntar ao seu próprio backend
let intervalo = 3000;
async function conferir() {
const r = await fetch(`/pedidos/${pedidoId}/status`); // SUA rota, não a da LulaPay
const { pago } = await r.json();
if (pago) return location.assign(`/obrigado/${pedidoId}`);
intervalo = Math.min(intervalo * 1.4, 15000);
setTimeout(conferir, intervalo);
}
setTimeout(conferir, intervalo);
A chave nunca vai para o navegador. Se o seu frontend chamar /v1/charges direto, qualquer visitante abre o DevTools e leva a sua chave — e com ela consulta todas as suas vendas e cria cobranças em seu nome. O navegador fala com o seu servidor; só o seu servidor fala com a LulaPay.
Remarketing e expiração
O evento charge.expired é o gancho para recuperar quem gerou o PIX e não pagou. Com o metadata você sabe exatamente quem foi:
if evt["type"] == "charge.expired":
uid = int(evt["data"]["metadata"]["telegram_user_id"])
plano = evt["data"]["metadata"]["plano"]
# uma oferta de recuperação, normalmente mais barata que a original
await oferecer_desconto(uid, plano)
Ajuste o expires_in ao seu funil: 30 minutos costuma bastar para venda por impulso em bot, e deixa o remarketing começar rápido. Para compra de valor alto, algo entre 24h e 7 dias faz mais sentido.
Testar antes de produção
Com uma chave lp_test_ as cobranças são geradas normalmente, mas nenhum dinheiro se move. Para simular um pagamento, abra a cobrança em Painel → Vendas (com o seletor no modo Teste) e clique em Simular pagamento. O webhook charge.paid dispara igualzinho ao de produção.
O que vale exercitar antes de virar a chave:
- Clique duplo no botão de compra — tem que gerar um PIX, não dois.
- Webhook chegando duas vezes — tem que entregar uma vez.
- Webhook com assinatura errada — tem que ser recusado.
- Cobrança expirando — o seu lado precisa limpar o estado de "aguardando".
- A API fora do ar ou devolvendo erro — o usuário precisa ver uma mensagem decente, não um travamento.
Erros
Todo erro volta com o mesmo formato:
{ "error": { "code": "validation_error", "message": "Dados inválidos.",
"details": { "amount_cents": "Valor mínimo: R$ 1,00" } } }
| HTTP | code | O que fazer |
|---|---|---|
| 400 | invalid_request | Corrija o corpo. O details diz qual campo. |
| 401 | unauthorized | Chave ausente, errada ou revogada. |
| 403 | ip_not_allowed | Saque pedido de um IP fora da lista autorizada. |
| 404 | not_found | O recurso não existe nesse modo. Cobrança de teste não aparece com chave de produção. |
| 409 | idempotency_conflict | Mesma Idempotency-Key com corpo diferente. Use outra chave. |
| 409 | idempotency_in_progress | A primeira chamada ainda processa. Tente em 1s. |
| 422 | insufficient_funds | Saque maior que o saldo disponível. |
| 429 | rate_limited | Passou de 300/min. Espere e reduza a frequência do polling. |
| 502 | provider_unavailable | Todos os adquirentes falharam. Tente de novo em instantes. |
| 503 | no_provider | Nenhum adquirente configurado para produção. Configure em Painel → Adquirentes. |
Na sua integração, trate 429, 502 e 503 como temporários (vale repetir, com espera crescente) e 400, 401, 404 e 409 como definitivos — repetir não muda nada.
Segurança
- Chave só no servidor, em variável de ambiente, fora do git. Se uma chave for exposta em qualquer lugar — chat, print, repositório — considere comprometida e revogue em Painel → API.
- Valide a assinatura de todo webhook antes de entregar qualquer coisa, e cheque o
tcontra replay. - Nunca confie no cliente. Status de pagamento vem da API ou do webhook assinado — jamais de um parâmetro na URL de retorno.
- Lista de IPs para saque em Painel → API: com ela ativa, mesmo uma chave vazada não consegue sacar de fora da sua infraestrutura.
- Uma chave por integração. Bot, loja e painel interno com chaves separadas — aí você revoga uma sem derrubar as outras.
- Registre o
end_to_end_iddas vendas: é o identificador da transação no sistema do PIX, o que vale numa contestação.
Checklist de go-live
- Chave
lp_live_em variável de ambiente, fora do git - Adquirente configurado em Painel → Adquirentes
Idempotency-Keyem toda criação de cobrança- Webhook cadastrado, com assinatura validada e checagem de replay
- Entrega protegida contra evento duplicado (guarda o
LulaPay-Event-Id) - Polling, se usado, com intervalo crescente e teto de 30s
429,502e503tratados com repetição e esperaexternal_idapontando para o pedido no seu sistemametadatacom o que você precisa na hora de entregar- Conta de saque cadastrada e lista de IPs autorizados ativa
- Fluxo inteiro exercitado no modo teste, inclusive os caminhos ruins
Faltou alguma coisa aqui? Escreva para [email protected] que a gente documenta.