Pagali API
API REST para pagamentos, eventos, doações e loja em Cabo Verde. Sem SDKs obrigatórios.
✨ Novidades na v2.3
- Assinatura HMAC nos webhooks —
X-Pagali-Signature: sha256=<hex>. Fortemente recomendado em produção. - X-Pagali-Timestamp em todos os webhooks — protege contra replay attacks.
- Expiração automática de pedidos pendentes após 30 min — webhook
payment.cancelledcomreason: "timeout". - Validação de amount — comparar
amount(centavos) do webhook com o valor esperado. - Registo externo de bilhetes —
POST /v1/public/tickets/registerpermite que qualquer serviço de eventos registe bilhetes no Pagali após pagamento. Cria conta automaticamente se o comprador não tiver conta Pagali e envia email com QR code.
Visão Geral
A Pagali API expõe vários grupos de endpoints públicos. Todos usam a mesma base URL e devolvem JSON.
Ambientes
| Ambiente | Base URL | Quando usar |
|---|---|---|
| Sandbox | https://europe-west1-pagali-sandbox.cloudfunctions.net/api | Desenvolvimento e testes |
| Produção | https://europe-west1-pagali-prod.cloudfunctions.net/api | Pagamentos reais |
Todos os exemplos usam {BASE_URL} como abreviatura da base URL acima.
Autenticação
Os endpoints públicos (prefixo /public) não requerem autenticação. Os endpoints de cliente e admin requerem Firebase ID Token no header Authorization: Bearer <token>.
| Prefixo | Autenticação | Quem usa |
|---|---|---|
/v1/public/* | pública | Qualquer site/app |
/v1/public-events/* | pública | Qualquer site/app |
/v1/client/* | Firebase token | App Pagali autenticada |
/v1/admin/* | Firebase token + role | Admin Pagali |
💳 Pagamentos E-Commerce
Aceite pagamentos online via SISP/Vinti4 no teu site. O cliente é redirecionado para uma página segura Pagali e depois devolvido ao teu site.
return URL é apenas UX — o cliente pode fechar o browser antes do redirect.Parâmetros
| Parâmetro | Tipo | Descrição |
|---|---|---|
id_ent obrigatório | string (UUID) | UUID da entidade Pagali |
id_temp obrigatório | string (UUID) | UUID da página de pagamento (template) |
order_id obrigatório | string | Referência única da encomenda. Ex: ORD-1744800000-AB12 |
total obrigatório | string | Valor em CVE como string de inteiro. Ex: "5000" (não 5000) |
return obrigatório | URL HTTPS | URL de retorno do cliente |
notify obrigatório | URL HTTPS | URL do webhook para receber resultado |
currency_code opcional | string | 132 = CVE (padrão) |
item_name[], quantity[], amount[], total_item[] opcional | arrays | Detalhe dos items (arrays paralelos) |
firstname, lastname, email, mobile opcional | string | Dados do comprador — pré-preenchem o form |
Padrão Auto-Submit Bridge ⭐
popup.document.write() (Chrome interrompe). Gere o HTML no servidor e sirva-o como uma página que faz auto-submit.Exemplos
Node.js
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); app.post('/checkout', async (req, res) => { const { items, buyer } = req.body; const total = items.reduce((s, i) => s + i.amount * i.quantity, 0); const orderId = `ORD-${Date.now()}-${Math.random().toString(36).slice(2,8).toUpperCase()}`; // Guardar encomenda como PENDING await db.orders.create({ orderId, total, status: 'PENDING' }); const fields = { id_ent: process.env.PAGALI_ENTITY_ID, id_temp: process.env.PAGALI_PAGE_ID, order_id: orderId, total: String(total), currency_code: '132', 'return': `https://meusite.cv/sucesso?order=${orderId}`, notify: 'https://meusite.cv/api/pagali/webhook', email: buyer.email, 'item_name[]': items.map(i => i.name), 'quantity[]': items.map(i => String(i.quantity)), 'amount[]': items.map(i => String(i.amount)), 'total_item[]': items.map(i => String(i.amount * i.quantity)), }; let inputs = ''; for (const [k, v] of Object.entries(fields)) { if (Array.isArray(v)) { for (const x of v) inputs += `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(x)}">`; } else inputs += `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(String(v))}">`; } const PAGALI_URL = 'https://europe-west1-pagali-prod.cloudfunctions.net/api/v1/public/ecommerce/payment'; res.send(`<!DOCTYPE html><html><body><form id="f" method="POST" action="${PAGALI_URL}">${inputs}</form><script>document.getElementById('f').submit();</script></body></html>`); });
PHP
$orderId = 'ORD-' . time() . '-' . strtoupper(bin2hex(random_bytes(4))); $total = 5500; // calcular server-side! $PAGALI_URL = 'https://europe-west1-pagali-prod.cloudfunctions.net/api/v1/public/ecommerce/payment'; $fields = [ 'id_ent' => $_ENV['PAGALI_ENTITY_ID'], 'id_temp' => $_ENV['PAGALI_PAGE_ID'], 'order_id' => $orderId, 'total' => (string)$total, 'currency_code' => '132', 'return' => "https://meusite.cv/sucesso?order=$orderId", 'notify' => 'https://meusite.cv/api/pagali/webhook', ]; ?><!DOCTYPE html><html><body> <form id="f" method="POST" action="<?= $PAGALI_URL ?>"> <?php foreach ($fields as $k => $v): ?> <input type="hidden" name="<?= $k ?>" value="<?= htmlspecialchars($v) ?>"> <?php endforeach; ?> </form> <script>document.getElementById('f').submit();</script> </body></html>
Consultar Estado
GET {BASE_URL}/v1/public/ecommerce/status/ORD-1744800000-AB12
// Resposta
{
"order_id": "ORD-1744800000-AB12",
"payment_status": "completed",
"reference": "PG-ABC123",
"amount": 550000
}
Webhooks — Formato
Após o pagamento, a Pagali envia um POST ao teu notify URL com o resultado.
X-Pagali-Signature: sha256=a3f1b8...9c2d // HMAC-SHA256 do body X-Pagali-Timestamp: 1744800000 // unix seconds X-Pagali-Idempotency-Key: pagali-ORD-123-completed X-Pagali-Event: payment.completed X-Pagali-Attempt: 1 User-Agent: Pagali-Webhook/2.0
{
"order_id": "ORD-1744800000-AB12",
"payment_status": "completed", // "completed" | "failed" | "cancelled"
"amount": 550000, // centavos (5500,00 CVE)
"reference": "PG-ABC123",
"status": "PAID"
}
application/json ou application/x-www-form-urlencoded. Aceite ambos. Timeout: 10s. Até 3 retries (1s → 5s → 30s).Assinatura HMAC NOVO v2.3 🔐
O Pagali assina o body de cada webhook com HMAC-SHA256. Verifica sempre antes de processar.
===.Usa o raw body recebido, não o objecto já parseado (re-serializar invalida a assinatura).
Node.js
import { createHmac, timingSafeEqual } from 'crypto'; function verifyPagaliSignature(rawBody, sigHeader) { const secret = process.env.PAGALI_WEBHOOK_SECRET; if (!secret) return true; // sem secret → aceita (modo dev) if (!sigHeader) return false; const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); const provided = sigHeader.startsWith('sha256=') ? sigHeader.slice(7) : sigHeader; try { const a = Buffer.from(expected, 'hex'), b = Buffer.from(provided, 'hex'); return a.length === b.length && timingSafeEqual(a, b); } catch { return false; } }
PHP
$rawBody = file_get_contents('php://input'); $sig = $_SERVER['HTTP_X_PAGALI_SIGNATURE'] ?? ''; $provided = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig; if (!hash_equals(hash_hmac('sha256', $rawBody, getenv('PAGALI_WEBHOOK_SECRET')), $provided)) { http_response_code(401); exit; }
Python
import hmac, hashlib, os sig = request.headers.get('X-Pagali-Signature', '') provided = sig[7:] if sig.startswith('sha256=') else sig expected = hmac.new(os.environ['PAGALI_WEBHOOK_SECRET'].encode(), request.body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, provided): return Response(status=401)
Validação de Amount NOVO v2.3
O campo amount vem em centavos (ex: 550000 = 5500,00 CVE). Compara sempre com o valor esperado.
if (payment_status === 'completed') { const expectedCents = Math.round(Number(order.total) * 100); const providedCents = Math.round(Number(body.amount)); if (providedCents > 0 && Math.abs(expectedCents - providedCents) > 1) { await markFailed(order, 'AMOUNT_MISMATCH'); return res.json({ received: true, error: 'amount_mismatch' }); // 200! } }
Idempotência
O webhook pode ser entregue mais de uma vez. Usa X-Pagali-Idempotency-Key para detectar duplicados e ignorar sem re-processar.
Expiração de Pedidos NOVO v2.3
Pedidos PENDING/PROCESSING há mais de 30 minutos são expirados automaticamente. Recebes:
{ "order_id": "ORD-...", "payment_status": "cancelled", "status": "EXPIRED", "reason": "timeout" }
// Header: X-Pagali-Event: payment.cancelled
Handler Completo ⭐
import { NextRequest, NextResponse } from 'next/server'; import { createHmac, timingSafeEqual } from 'crypto'; function verify(raw: string, hdr: string|null) { const s = process.env.PAGALI_WEBHOOK_SECRET; if (!s) return true; if (!hdr) return false; const exp = createHmac('sha256',s).update(raw).digest('hex'); const prov = hdr.startsWith('sha256=') ? hdr.slice(7) : hdr; try { const a=Buffer.from(exp,'hex'),b=Buffer.from(prov,'hex'); return a.length===b.length&&timingSafeEqual(a,b); } catch{return false;} } export async function POST(req: NextRequest) { try { const raw = await req.text(); if (!verify(raw, req.headers.get('x-pagali-signature'))) return NextResponse.json({ error:'Invalid signature' }, { status:401 }); const body = JSON.parse(raw); const { order_id, payment_status, amount } = body; // Idempotência const iKey = req.headers.get('x-pagali-idempotency-key'); if (iKey) { const seen = await db.findIdemKey(iKey); if (seen) return NextResponse.json({ received:true, duplicate:true }); await db.saveIdemKey(iKey).catch(()=>{}); } const order = await db.orders.findOne({ orderId: order_id }); if (!order) return NextResponse.json({ error:'not found' }, { status:404 }); if (payment_status === 'completed') { // Validar amount const expected = Math.round(Number(order.total) * 100); const provided = Math.round(Number(amount)); if (provided > 0 && Math.abs(expected - provided) > 1) { await db.orders.update(order_id, { status:'FAILED', reason:'AMOUNT_MISMATCH' }); return NextResponse.json({ received:true, error:'amount_mismatch' }); } // Transacção com guard de race condition await db.$transaction(async (tx) => { const fresh = await tx.orders.findUnique(order_id); if (fresh?.status === 'COMPLETED') return; await tx.orders.update(order_id, { status:'COMPLETED' }); }); Promise.resolve().then(()=>sendEmail(order)).catch(console.error); } else { const status = payment_status === 'cancelled' ? 'CANCELLED' : 'FAILED'; await db.orders.update(order_id, { status, reason: body.reason || payment_status }); } return NextResponse.json({ received:true }); } catch (err) { // CRÍTICO: devolver 200 mesmo em erro — evita retry storm console.error(err); return NextResponse.json({ received:true, error: err instanceof Error ? err.message : 'internal' }); } }
🎟️ Eventos & Bilhetes
Base URL: {BASE_URL}/v1/public-events
Todos públicos, sem autenticação.
Listar e Pesquisar
page limit categoria cidade search dataInicio dataFim orderBylimitpage limit categoria cidadepage limit categoria cidadeDetalhe do Evento
tipos_bilhete e estatisticasTipos de Bilhete
{ codigo }{ codigo, subtotal }. Retorna desconto calculado.Checkout
pedido_id, QR codes, total. Eventos gratuitos confirmam imediatamente.{ email, nome, bilhete_tipo_id, quantidade }{
"itens": [{ "bilhete_tipo_id": 1, "quantidade": 2 }],
"comprador_nome": "João Silva",
"comprador_email": "joao@email.cv",
"comprador_telefone": "+2389876543"
}
// Resposta (evento pago)
{
"success": true,
"data": {
"pedido_id": 42, "codigo": "PED-ABC123", "total": 3000,
"status": "PENDING", "pagamento_status": "PENDING",
"expira_em": "2026-04-16T15:30:00.000Z",
"bilhetes": [{ "codigo": "BIL-XYZ", "qrCode": "data:image/png;base64,..." }]
}
}
Outros Endpoints
weekly/monthly.🎫 Registo Externo de Bilhetes NOVO v2.3
Permite que qualquer serviço de eventos externo (ex: CVMA, festivais, conferências) registe bilhetes comprados na sua plataforma directamente no Pagali, após confirmação de pagamento. O comprador passa a ver o bilhete em "Meus Bilhetes" na app Pagali, com QR code.
Base URL: {BASE_URL}/v1/public/tickets
Parâmetros do Body (JSON)
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
apiKey | string | sim | Chave de API fornecida pela Pagali para o serviço parceiro |
buyerEmail | string | sim | Email do comprador — usado para associar o bilhete à conta Pagali |
buyerName | string | não | Nome do comprador (usado no email de boas-vindas se conta for criada) |
qrCode | string | sim | Código único do bilhete — gerado pelo serviço parceiro. Usado para validação na entrada do evento. |
eventName | string | sim | Nome do evento (ex: "CVMA 2026") |
ticketName | string | sim | Tipo/categoria do bilhete (ex: "VIP", "Geral") |
eventDate | string ISO 8601 | não | Data do evento (ex: "2026-06-20T21:00:00.000Z") |
quantity | number | não | Quantidade de bilhetes (default: 1) |
totalAmount | number | não | Valor total pago em CVE (ex: 3000) |
entidade | string | não | Nome curto do serviço parceiro (ex: "CVMA") |
pagamentoId | string | não | ID do pagamento no sistema parceiro — para rastreabilidade |
Comportamento
- Email existe no Pagali — bilhete associado directamente à conta existente. Email de confirmação com QR code enviado.
- Email não existe no Pagali — conta criada automaticamente (pg_user + pg_cliente + Firebase Auth) com senha aleatória. Email de boas-vindas enviado com credenciais e QR code do bilhete.
- Login posterior — se o comprador criar conta Pagali mais tarde com o mesmo email, os bilhetes são auto-associados na primeira visita a "Meus Bilhetes".
Exemplo de Request
POST {BASE_URL}/v1/public/tickets/register
Content-Type: application/json
{
"apiKey": "pagali-tickets-2026",
"buyerEmail": "comprador@exemplo.cv",
"buyerName": "João Silva",
"qrCode": "CVMA-2026-ABC123XYZ",
"eventName": "Cabo Verde Music Awards 2026",
"ticketName": "VIP",
"eventDate": "2026-06-20T21:00:00.000Z",
"quantity": 2,
"totalAmount": 6000,
"entidade": "CVMA",
"pagamentoId": "ord_cmb9x1234"
}
Resposta
{
"success": true,
"registered": true,
"accountCreated": false, // true se conta Pagali foi criada automaticamente
"userId": "firebase-uid-abc",
"message": "Bilhete registado com sucesso"
}
payment_status: "completed"),
em modo fire-and-forget. Uma falha não deve bloquear a confirmação do pagamento — registar o erro em logs e continuar.
❤️ Doações — iFundHere
Base URL: {BASE_URL}/v1/public/donations
Campanhas
categoria tipo page limitq categoria tipo ordenar (recentes/populares/quase_la)ordenar (recentes/valor)theme=light|dark{ plataforma }Efectuar Doação
id da doação — depois pagar via Pagali E-Commerce.{
"campanha_id": "uuid-da-campanha",
"valor": 5000,
"doador_nome": "Maria Santos",
"doador_email": "maria@email.cv",
"anonimo": false,
"mensagem": "Força! Estou convosco."
}
// Resposta: { id, estado: "PENDENTE" } — depois redirecionar para pagamento
🛒 Loja Online
Base URL: {BASE_URL}/v1/public/shop
page limit search categoriapage limit categoria searchorder_id para enviar ao E-Commerce Pagali.📄 CMS — Conteúdo Público
Base URL: {BASE_URL}/v1/public/cms
sobre-nos, termos)page limit categoriaEstados de Pagamento
| payment_status | X-Pagali-Event | Acção |
|---|---|---|
completed | payment.completed | Confirmar pedido, libertar bilhetes/produtos, enviar email |
failed | payment.failed | Libertar reserva, notificar cliente |
cancelled | payment.cancelled | Libertar reserva. Se reason: "timeout" → expirado após 30min |
Segurança
- ✅ HTTPS obrigatório em todos os URLs
- ✅ Calcular total sempre server-side
- ✅ Verificar
X-Pagali-Signatureem produção - ✅ Validar
amountdo webhook (anti-underpayment) - ✅ Implementar idempotência com
X-Pagali-Idempotency-Key - ✅ Devolver 200 em erros transitórios (evita retry storm)
- 🚫 Não usar iframe para a página de pagamento
- 🚫 Não usar
popup.document.write() - 🚫 Não logar
PAGALI_WEBHOOK_SECRETem produção
Erros Comuns
| Erro | Causa | Solução |
|---|---|---|
| Página em branco | popup.document.write() | Usar Auto-Submit Bridge (HTML servido pelo backend) |
| Webhook não chega | URL sem HTTPS ou timeout > 10s | Verificar HTTPS, timeout do handler, logs |
| Pedidos duplicados | Webhook entregue múltiplas vezes | Usar X-Pagali-Idempotency-Key |
| 401 no webhook | Secret errado ou não configurado | Verificar PAGALI_WEBHOOK_SECRET |
| Pedido fica PENDING | Cliente fechou browser ou timeout SISP | Aguardar webhook de expiração após 30min |
| amount mismatch | Underpayment ou valor manipulado | Comparar amount (centavos) com total esperado × 100 |
Suporte
Email: suporte@pagali.cv · Admin: admin.pagali.cv
Pagali API v2.3 · Documentação actualizada em Abril 2026