Webhooks
A fonte de verdade das suas integrações: cada mudança de estado é-lhe enviada, assinada, com reenvio automático.
Criar um endpoint
POST /v1/webhooks
{
"url": "https://oseusite.com/webhook/neopays",
"description": "produção",
"subscribed_events": ["payment.success", "refund.success"]
}
A resposta traz o segredo de assinatura. subscribed_events vazio = todos os eventos; um evento
fora do catálogo é recusado em vez de guardado, porque esperaria por uma entrega que nada emite.
Esta chamada garante, nem sempre cria. Um URL já registado volta com
200em vez de ser duplicado — um script de aprovisionamento executado duas vezes deixa UM endpoint, não dois a receber cada evento em duplicado. O segredo é devolvido em ambos os casos, porque a razão legítima para voltar a chamar é tê-lo perdido.
GET /v1/webhooks — os seus endpoints e os seus contadores
GET /v1/webhooks/{id} — um endpoint
PATCH /v1/webhooks/{id} — modificação parcial
DELETE /v1/webhooks/{id} — eliminação definitiva
GET /v1/webhooks/{id}/secret — reler o segredo
POST /v1/webhooks/test — entrega de teste a todos os seus endpoints activos
O PATCH só muda aquilo que envia: omitir is_active não desactiva o endpoint. Para parar as
entregas sem perder o endpoint nem o seu segredo, envie is_active: false em vez de eliminar.
Dez endpoints por conta no máximo. O painel faz tudo isto também, e só ele sabe reenviar uma entrega falhada e rodar um segredo.
Os eventos
| Evento | Quando |
|---|---|
payment.initiated |
Cobrança criada |
payment.pending |
À espera da confirmação do pagador |
payment.success |
Cobrada — o evento que conta |
payment.failed |
Falha |
payout.success / payout.failed / payout.cancelled |
Ciclo de vida de um pagamento |
payout.refunded |
Pagamento devolvido pelo rail à posteriori |
refund.success / refund.failed |
Ciclo de vida de um reembolso |
O envelope é sempre o mesmo:
{
"event": "payment.success",
"created": "2026-08-24T12:00:00Z",
"data": {
"id": "op_01J8Z9K2QW",
"reference": "NP-...",
"merchant_reference": "encomenda-1042",
"amount": "5000",
"currency": "XOF",
"status": "success"
}
}
O corpo do webhook não tem a mesma forma que a resposta da API. Traz
amountecurrencyonde a API trazamount_minoreasset_id, emerchant_referenceonde ela trazclient_ref. É um contrato distinto, escrito antes do outro e inalterado desde então: não presuma que um objecto de um se descodifica como um objecto do outro.
Verificar a assinatura
Cada entrega traz o cabeçalho:
X-NeoPays-Signature: t=<marca temporal unix>,v1=<hmac>
onde hmac = HMAC-SHA256(segredo, "<t>.<corpo em bruto>"), em hexadecimal. Verifique em três tempos:
const crypto = require('node:crypto')
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')))
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`).digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}
O corpo EM BRUTO, não o reserializado. Calcule o HMAC sobre os bytes recebidos, antes de qualquer análise JSON. Um
JSON.stringifyde volta pode reordenar as chaves e invalidar a assinatura.
Durante uma rotação de segredo o cabeçalho traz DUAS entradas
v1=, o segredo actual primeiro. Verifique-as todas, não apenas a primeira: no dia em que mudar a sua configuração para o segredo novo antes de terminar a mudança do lado da plataforma, a primeira assinatura é a do segredo antigo, e um verificador que pare na primeira recusa então 100 % das entregas.
Reenvio
Responda 2xx em menos de alguns segundos (acuse a recepção, processe depois). Qualquer outra
resposta dispara a escada de tentativas: 5 tentativas no total, após 60 s, 5 min, 30 min e 2 h.
Um endpoint que falha 20 entregas consecutivas fica suspenso uma hora (as entregas retomam sozinhas); um único sucesso — incluindo uma entrega de teste a partir do painel — reabilita-o de imediato.
Idempotência do lado do receptor
As entregas são at-least-once: o mesmo evento pode chegar duas vezes. Deduplique pelo par
(event, data.id) ou registe os identificadores de eventos já processados.