Xpend

Webhooks

Os webhooks são a forma mais fiável de saberes quando uma transacção muda de estado. Em vez de fazeres polling em GET /api/v1/payments/status, configura um endpoint no teu servidor e o Xpend envia-te um POST sempre que algo acontece.

Polling vs webhook. O polling em GET /payments/status lê a base de dados já actualizada pelo webhook SIBS — útil para UX no checkout (ex.: MB WAY a aguardar confirmação), mas não confirma pagamentos. A fonte de verdade é o webhook payment.success.

Configurar o endpoint

  1. Vai a /dashboard/settings/webhook.
  2. Define a URL (deve começar por https://).
  3. Activa o toggle.
  4. Clica Testar webhook para confirmar que o endpoint responde.

Eventos suportados

EventDescrição
payment.successPagamento confirmado pelo banco
payment.failedPagamento falhou (MB WAY rejeitado, Multibanco expirado, etc.)
payment.cancelledCliente cancelou ou transacção expirou
webhook.testEnviado quando clicas "Testar webhook" no backoffice

Payload

{
  "event": "payment.success",
  "timestamp": "2026-05-10T14:32:00Z",
  "data": {
    "id": "txn_42",
    "externalOrderId": "ORDER-456",
    "amount": 49.90,
    "currency": "EUR",
    "paymentMethod": "MBWAY",
    "status": "paid",
    "paidAt": "2026-05-10T14:32:00Z"
  }
}

Headers

HeaderValor
Content-Typeapplication/json
X-Xpend-Signaturesha256=<hmac>
X-Xpend-Eventpayment.success · payment.failed · …
X-Xpend-Delivery-IdID único da entrega (útil para idempotência)
User-AgentXpend-Webhook/1.0

Verificar autenticidade (HMAC-SHA256)

Todos os webhooks são assinados com o teu webhookSecret (visível em Webhook). Verifica sempre a assinatura antes de processar — caso contrário um atacante pode simular eventos.

import crypto from "crypto"

export function POST(req) {
  const raw = await req.text()
  const signature = req.headers.get("x-xpend-signature") || ""

  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.XPEND_WEBHOOK_SECRET)
    .update(raw)
    .digest("hex")

  const sigBuf = Buffer.from(signature)
  const expBuf = Buffer.from(expected)
  if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
    return new Response("Invalid signature", { status: 401 })
  }

  const event = JSON.parse(raw)
  if (event.event === "payment.success") {
    // Marca o pedido como pago na tua BD
    await markOrderPaid(event.data.externalOrderId)
  }
  return Response.json({ ok: true })
}

Sistema de retry

Se o teu endpoint não responder com 2xx em 8 segundos, retentamos:

  • 1ª tentativa imediata
  • 2ª tentativa após 200ms
  • 3ª tentativa após 1s
  • 4ª tentativa após 5s

Após 4 tentativas falhadas o webhook é marcado como não-entregue. Vê o estado em Transacções → drawer da transacção.

Boas práticas

  • Idempotência: usa o data.id para deduplicar — o mesmo evento pode chegar mais que uma vez.
  • Responde rápido: processamento pesado deve ser assíncrono (queue). Responde 200 em <1s.
  • HTTPS: só aceitamos URLs com https://.
  • Verifica a assinatura antes de qualquer parsing.