Pagali API

API REST para pagamentos, eventos, doações e loja em Cabo Verde. Sem SDKs obrigatórios.

📍 Cabo Verde 💳 SISP · Vinti4 · Visa · Mastercard 🔒 HMAC-SHA256 🌐 REST + Webhooks

✨ 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.cancelled com reason: "timeout".
  • Validação de amount — comparar amount (centavos) do webhook com o valor esperado.
  • Registo externo de bilhetesPOST /v1/public/tickets/register permite 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.

💳Pagamentos E-Commerce/v1/public/ecommerce/
🎟️Eventos & Bilhetes/v1/public-events/
🎫Registo Externo de Bilhetes/v1/public/tickets/
❤️Doações — iFundHere/v1/public/donations/
🛒Loja Online/v1/public/shop/
📄CMS — Conteúdo Público/v1/public/cms/

Ambientes

AmbienteBase URLQuando usar
Sandboxhttps://europe-west1-pagali-sandbox.cloudfunctions.net/apiDesenvolvimento e testes
Produçãohttps://europe-west1-pagali-prod.cloudfunctions.net/apiPagamentos 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>.

PrefixoAutenticaçãoQuem usa
/v1/public/*públicaQualquer site/app
/v1/public-events/*públicaQualquer site/app
/v1/client/*Firebase tokenApp Pagali autenticada
/v1/admin/*Firebase token + roleAdmin 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.

MétodoEndpointDescrição
POST/v1/public/ecommerce/paymentIniciar pagamento (form ou JSON)
GET/v1/public/ecommerce/paymentIniciar pagamento via querystring
GET/v1/public/ecommerce/pay/:refConfirmar e redirecionar para SISP/Vinti4
GET/v1/public/ecommerce/status/:refConsultar estado de um pagamento
GET/v1/public/ecommerce/template?entity=IDObter template activo de uma entidade
⚠️
Use sempre o webhook como fonte de verdade. O return URL é apenas UX — o cliente pode fechar o browser antes do redirect.

Parâmetros

ParâmetroTipoDescrição
id_ent obrigatóriostring (UUID)UUID da entidade Pagali
id_temp obrigatóriostring (UUID)UUID da página de pagamento (template)
order_id obrigatóriostringReferência única da encomenda. Ex: ORD-1744800000-AB12
total obrigatóriostringValor em CVE como string de inteiro. Ex: "5000" (não 5000)
return obrigatórioURL HTTPSURL de retorno do cliente
notify obrigatórioURL HTTPSURL do webhook para receber resultado
currency_code opcionalstring132 = CVE (padrão)
item_name[], quantity[], amount[], total_item[] opcionalarraysDetalhe dos items (arrays paralelos)
firstname, lastname, email, mobile opcionalstringDados do comprador — pré-preenchem o form

Padrão Auto-Submit Bridge ⭐

🚫
Não use iframe (X-Frame-Options bloqueia) nem popup.document.write() (Chrome interrompe). Gere o HTML no servidor e sirva-o como uma página que faz auto-submit.

Exemplos

Node.js

javascript
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
  ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[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

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

http
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.

http headers
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
json body
{
  "order_id": "ORD-1744800000-AB12",
  "payment_status": "completed",   // "completed" | "failed" | "cancelled"
  "amount": 550000,                  // centavos (5500,00 CVE)
  "reference": "PG-ABC123",
  "status": "PAID"
}
ℹ️
O webhook pode chegar como 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 timingSafeEqual (Node) / hash_equals (PHP) / hmac.compare_digest (Python) — nunca ===.
Usa o raw body recebido, não o objecto já parseado (re-serializar invalida a assinatura).

Node.js

javascript
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

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

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.

javascript
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:

json
{ "order_id": "ORD-...", "payment_status": "cancelled", "status": "EXPIRED", "reason": "timeout" }
// Header: X-Pagali-Event: payment.cancelled

Handler Completo ⭐

typescript — Next.js App Router
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

MétodoEndpointDescrição
GET/Lista eventos publicados. Query: page limit categoria cidade search dataInicio dataFim orderBy
GET/featuredEventos em destaque (mais vendidos, futuros). Query: limit
GET/upcomingPróximos eventos (data_inicio > agora). Query: page limit categoria cidade
GET/discoverEventos por trending score (vendas recentes). Query: page limit categoria cidade
GET/categoriesCategorias de eventos com contagem
GET/citiesCidades com eventos publicados

Detalhe do Evento

MétodoEndpointDescrição
GET/:idDetalhe completo — por ID numérico ou slug. Inclui tipos_bilhete e estatisticas
GET/by-code/:codeResolver short code (slug) — retorna evento + tipos de bilhete + URLs de partilha
GET/:id/embedJSON minimal para widget embeddable — CORS * aberto
GET/:id/reg-questionsPerguntas de registo personalizadas do organizador

Tipos de Bilhete

MétodoEndpointDescrição
GET/:id/ticket-typesTipos de bilhete disponíveis, com stock, preços e período de vendas
POST/:id/unlockValidar código de acesso para bilhetes secretos/privados. Body: { codigo }
POST/:id/validate-promoValidar código promocional. Body: { codigo, subtotal }. Retorna desconto calculado.

Checkout

MétodoEndpointDescrição
POST/:id/checkoutCriar pedido de bilhetes. Retorna pedido_id, QR codes, total. Eventos gratuitos confirmam imediatamente.
POST/:eventId/waitlistEntrar na lista de espera. Body: { email, nome, bilhete_tipo_id, quantidade }
json — POST /public-events/:id/checkout
{
  "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

MétodoEndpointDescrição
POST/submitauthSubmeter novo evento para aprovação (requer Firebase token). Suporta recorrência 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

MétodoEndpointDescrição
POST/registerRegistar bilhete(s) de evento externo. Cria conta Pagali automaticamente se o email do comprador não existir.

Parâmetros do Body (JSON)

CampoTipoObrigatórioDescrição
apiKeystringsimChave de API fornecida pela Pagali para o serviço parceiro
buyerEmailstringsimEmail do comprador — usado para associar o bilhete à conta Pagali
buyerNamestringnãoNome do comprador (usado no email de boas-vindas se conta for criada)
qrCodestringsimCódigo único do bilhete — gerado pelo serviço parceiro. Usado para validação na entrada do evento.
eventNamestringsimNome do evento (ex: "CVMA 2026")
ticketNamestringsimTipo/categoria do bilhete (ex: "VIP", "Geral")
eventDatestring ISO 8601nãoData do evento (ex: "2026-06-20T21:00:00.000Z")
quantitynumbernãoQuantidade de bilhetes (default: 1)
totalAmountnumbernãoValor total pago em CVE (ex: 3000)
entidadestringnãoNome curto do serviço parceiro (ex: "CVMA")
pagamentoIdstringnãoID do pagamento no sistema parceiro — para rastreabilidade

Comportamento

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"
}
Integração recomendada: Chamar este endpoint no webhook de pagamento confirmado (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

MétodoEndpointDescrição
GET/campaignsLista campanhas públicas activas. Query: categoria tipo page limit
GET/campaigns/:idOrSlugDetalhe de campanha — inclui recompensas, doações recentes, actualizações, média
GET/featuredCampanhas em destaque, trending e "quase lá" (70–99% da meta)
GET/searchPesquisa de campanhas. Query: q categoria tipo ordenar (recentes/populares/quase_la)
GET/categoriesCategorias com contagem de campanhas activas
GET/statsEstatísticas globais: total campanhas, valor arrecadado, doadores, campanhas financiadas
GET/campaigns/:id/commentsComentários públicos de uma campanha
GET/campaigns/:id/donorsLista de doadores (respeita anonimato). Query: ordenar (recentes/valor)
GET/campaigns/:id/qrcodeGerar QR Code para link de doação
GET/campaigns/:id/embedWidget embeddable — retorna HTML + dados da campanha. Query: theme=light|dark
POST/campaigns/:id/shareRegistar partilha de campanha. Body: { plataforma }

Efectuar Doação

MétodoEndpointDescrição
POST/donateCriar doação (guest ou autenticado). Retorna id da doação — depois pagar via Pagali E-Commerce.
POST/:id/confirm-paymentConfirmar pagamento de uma doação. Actualiza campanha e envia email ao organizador.
GET/:id/statusVerificar estado de uma doação
json — POST /public/donations/donate
{
  "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

MétodoEndpointDescrição
GET/storesLista lojas activas. Query: page limit search categoria
GET/store/:idDetalhe de uma loja
GET/store/:id/productsProdutos de uma loja. Query: page limit categoria search
GET/product/:idDetalhe de um produto — preço, stock, imagens, variantes
POST/checkoutCriar checkout de loja. Retorna order_id para enviar ao E-Commerce Pagali.

📄 CMS — Conteúdo Público

Base URL: {BASE_URL}/v1/public/cms

MétodoEndpointDescrição
GET/pages/:slugPágina CMS por slug (ex: sobre-nos, termos)
GET/blog/postsPosts do blog. Query: page limit categoria
GET/blog/posts/featuredPosts em destaque
GET/blog/posts/:slugPost do blog por slug
GET/blog/categoriesCategorias do blog
GET/configConfigurações públicas da plataforma (nome, logo, contactos)
GET/testimonialsTestemunhos/depoimentos
GET/faqPerguntas frequentes
GET/footerConteúdo do rodapé

Estados de Pagamento

payment_statusX-Pagali-EventAcção
completedpayment.completedConfirmar pedido, libertar bilhetes/produtos, enviar email
failedpayment.failedLibertar reserva, notificar cliente
cancelledpayment.cancelledLibertar reserva. Se reason: "timeout" → expirado após 30min

Segurança

Erros Comuns

ErroCausaSolução
Página em brancopopup.document.write()Usar Auto-Submit Bridge (HTML servido pelo backend)
Webhook não chegaURL sem HTTPS ou timeout > 10sVerificar HTTPS, timeout do handler, logs
Pedidos duplicadosWebhook entregue múltiplas vezesUsar X-Pagali-Idempotency-Key
401 no webhookSecret errado ou não configuradoVerificar PAGALI_WEBHOOK_SECRET
Pedido fica PENDINGCliente fechou browser ou timeout SISPAguardar webhook de expiração após 30min
amount mismatchUnderpayment ou valor manipuladoComparar 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