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
- Vai a /dashboard/settings/webhook.
- Define a URL (deve começar por
https://). - Activa o toggle.
- Clica Testar webhook para confirmar que o endpoint responde.
Eventos suportados
| Event | Descrição |
|---|---|
payment.success | Pagamento confirmado pelo banco |
payment.failed | Pagamento falhou (MB WAY rejeitado, Multibanco expirado, etc.) |
payment.cancelled | Cliente cancelou ou transacção expirou |
webhook.test | Enviado 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
| Header | Valor |
|---|---|
Content-Type | application/json |
X-Xpend-Signature | sha256=<hmac> |
X-Xpend-Event | payment.success · payment.failed · … |
X-Xpend-Delivery-Id | ID único da entrega (útil para idempotência) |
User-Agent | Xpend-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.idpara 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.
