Guide implémentation receiver
Cette page donne le code complet à implémenter côté votre serveur pour recevoir les webhooks Nex en toute sûreté. Le respect strict des 4 règles évite 3 classes de bugs (rejeu, doublon, signature forgée) qui peuvent coûter cher en environnement marchand.
À transmettre intégralement à votre équipe dev. Les snippets Node.js (Express et Fastify) en bas de page peuvent être copiés tels quels. Pour la vue d'ensemble et les garanties, voir Recevoir les webhooks.
Format d'un webhook entrant
POST /webhooks/nex HTTP/1.1
Host: api.maishapay.cd
Content-Type: application/json
User-Agent: Nex-Webhooks/1.0
Nex-Signature: t=1716553200,v1=5e7c1a3f4b8d2c9e6f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3
Nex-Event: order.paid
Nex-Event-Id: 4dd5e991-d853-41ac-a35d-f578cf846a85
Content-Length: 287
{
"event": "order.paid",
"id": "4dd5e991-d853-41ac-a35d-f578cf846a85",
"createdAt": "2026-05-24T12:05:30.000Z",
"data": {
"orderId": "cdefc6f6-acd1-44f8-a725-fa1b62d79ba8",
"transactionId": "tx-uuid-...",
"amount": 2500,
"currencyCode": "XAF",
"paidAt": "2026-05-24T12:05:30.000Z",
"externalReference": "MAISHA-ORDER-42",
"description": "Baguettes ×3"
}
}| Header | Description |
|---|---|
Nex-Signature | t=<unix_seconds>,v1=<hmac_hex> — voir Règle 1 |
Nex-Event | Nom typé de l'event (order.paid, connection.activated, …) |
Nex-Event-Id | UUID v4 unique par event — clé d'idempotence (voir Règle 3) |
User-Agent | Toujours préfixé Nex-Webhooks/ — utile pour filtrer côté WAF |
Le body est l'objet JSON complet (event, id, createdAt, data). Le id du payload est identique au header Nex-Event-Id — par contrat, vous pouvez utiliser n'importe lequel comme clé de dédup.
Règle 1 — Vérifier la signature HMAC
Le header Nex-Signature a la forme t=<timestamp>,v1=<signature_hex>. La signature est calculée :
HMAC_SHA256(webhook_secret, "${timestamp}.${rawBody}")Important : rawBody est le payload avant tout parsing JSON (octets bruts du body HTTP). Si votre framework parse le JSON avant que vous calculiez la signature, vous obtiendrez un résultat différent à cause des espaces/ordre des clés.
Étapes
- Parser le header
Nex-Signaturepour extrairetetv1 - Recalculer
HMAC_SHA256(secret, "${t}.${rawBody}") - Comparer en temps constant (
crypto.timingSafeEqual) — JAMAIS===(vulnérable à un timing attack) - Si différent → return
400 Bad Requestimmédiatement, ne pas traiter le body
Anti-pattern à éviter
// ❌ NE FAITES PAS ÇA
if (signature !== expected) { return 400 } // timing attack
const body = JSON.parse(req.body) // perte du rawBody
const sig = req.headers['x-nex-signature'] // mauvaise casse possibleRègle 2 — Rejeter les requêtes anciennes (anti-rejeu)
Un attaquant pourrait capturer un webhook valide (logs proxy, MITM transient, etc.) et le rejouer N minutes plus tard pour déclencher l'action métier une seconde fois. La signature reste valide tant qu'elle n'a pas de TTL implicite.
Mitigation : vérifier que t est récent.
const now = Math.floor(Date.now() / 1000);
const MAX_AGE_SECONDS = 5 * 60; // 5 min — fenêtre standard Stripe
if (now - parsedTimestamp > MAX_AGE_SECONDS) {
return res.status(400).send('Webhook trop ancien');
}Pourquoi 5 minutes : assez large pour absorber dérive d'horloge et retry network, assez court pour éviter qu'un attaquant ait le temps d'exploiter une capture.
Règle 3 — Idempotence via Nex-Event-Id
Nex applique une dédup côté serveur (spec-07 partie B) qui garantit qu'un event_id ne sera pas POST 2× pendant 24h. Mais cette garantie suppose Redis up : en cas d'incident infra Nex, le dedup store fail-open et vous pourriez recevoir un doublon. Vous devez donc aussi maintenir une idempotence côté receiver.
Recette
- À l'arrivée d'un webhook, lire
Nex-Event-Id - Tenter d'insérer dans une table
webhook_events_seen - Si violation d'unicité (event déjà vu) → return
200 OKimmédiatement sans rejouer la logique métier - Sinon → traiter normalement (idéalement en async via une queue)
CREATE TABLE webhook_events_seen (
event_id UUID PRIMARY KEY,
event_type VARCHAR(80) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Optionnel : purge des events > 30 jours (cron quotidien)
CREATE INDEX idx_webhook_events_seen_processed_at
ON webhook_events_seen(processed_at);Pourquoi pas "vérifier puis insérer" ?
Race condition : deux instances de votre receiver peuvent recevoir le même webhook simultanément (load balancer + retry Nex), exécuter le check en même temps, voir "pas encore vu", puis tous les deux insérer. Une INSERT ... ON CONFLICT DO NOTHING (PostgreSQL) ou INSERT IGNORE (MySQL) règle ça.
Règle 4 — Répondre 200 vite, traiter en async
Nex timeout les requêtes à 5 secondes. Si votre receiver fait du travail synchrone (créer une commande, débloquer un terminal), vous risquez de tomber dans le timeout sur un pic de trafic — Nex considérera l'envoi échoué.
Pattern recommandé :
- Vérifier signature + idempotence (Règles 1+2+3) → si KO, return 4xx
- Enqueue le payload dans votre queue interne (BullMQ, SQS, Sidekiq, etc.)
- Return
200 OKimmédiatement - Le worker async traite la logique métier sans timer Nex sur le dos
V1 (rotation seulement) : pour spec-09, Nex ajoutera une queue Outbox + retry exponentiel sur les 5xx. Tant que vous renvoyez 200, vous êtes safe.
Snippet complet — Express (Node.js)
import express from 'express';
import crypto from 'crypto';
import { Pool } from 'pg';
const app = express();
const pool = new Pool({ /* ... */ });
const WEBHOOK_SECRET = process.env.NEX_WEBHOOK_SECRET;
const MAX_AGE_SECONDS = 5 * 60;
// IMPORTANT : raw body parser (PAS express.json) pour préserver les octets bruts
app.post(
'/webhooks/nex',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body.toString('utf8');
const sigHeader = req.header('Nex-Signature') ?? '';
const eventId = req.header('Nex-Event-Id') ?? '';
// Règle 1 — parse signature header
const parts = Object.fromEntries(
sigHeader.split(',').map((p) => p.split('=')),
);
if (!parts.t || !parts.v1) {
return res.status(400).send('Missing Nex-Signature');
}
const t = parseInt(parts.t, 10);
// Règle 2 — rejet anti-rejeu
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > MAX_AGE_SECONDS) {
return res.status(400).send('Webhook trop ancien');
}
// Règle 1 (suite) — verify HMAC timing-safe
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${t}.${rawBody}`, 'utf8')
.digest('hex');
const provided = Buffer.from(parts.v1, 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
if (
provided.length !== expectedBuf.length ||
!crypto.timingSafeEqual(provided, expectedBuf)
) {
return res.status(400).send('Bad signature');
}
// Règle 3 — idempotence via INSERT ON CONFLICT
const event = JSON.parse(rawBody);
try {
const { rowCount } = await pool.query(
`INSERT INTO webhook_events_seen (event_id, event_type)
VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING`,
[eventId, event.event],
);
if (rowCount === 0) {
// Déjà vu — return 200 immédiat sans rejouer
return res.status(200).send('Already processed');
}
} catch (err) {
console.error('Idempotence check failed', err);
// Préférer renvoyer 500 pour que Nex re-tente (spec-09)
return res.status(500).send('Idempotence check failed');
}
// Règle 4 — enqueue + ack rapide
await myQueue.enqueue('process-nex-event', event);
return res.status(200).send('OK');
},
);Snippet complet — Fastify
import fastify from 'fastify';
import crypto from 'crypto';
const app = fastify();
const WEBHOOK_SECRET = process.env.NEX_WEBHOOK_SECRET;
const MAX_AGE_SECONDS = 5 * 60;
// Préserver le rawBody — équivalent express.raw
app.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => done(null, body),
);
app.post('/webhooks/nex', async (req, reply) => {
const rawBody = (req.body as Buffer).toString('utf8');
const sigHeader = req.headers['nex-signature'] as string ?? '';
const eventId = req.headers['nex-event-id'] as string ?? '';
const parts = Object.fromEntries(
sigHeader.split(',').map((p) => p.split('=')),
);
if (!parts.t || !parts.v1) return reply.code(400).send('Missing Nex-Signature');
const t = parseInt(parts.t, 10);
if (Math.abs(Math.floor(Date.now() / 1000) - t) > MAX_AGE_SECONDS) {
return reply.code(400).send('Webhook trop ancien');
}
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${t}.${rawBody}`, 'utf8')
.digest('hex');
const provided = Buffer.from(parts.v1, 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
if (
provided.length !== expectedBuf.length ||
!crypto.timingSafeEqual(provided, expectedBuf)
) {
return reply.code(400).send('Bad signature');
}
const event = JSON.parse(rawBody);
const isFirst = await markEventSeen(eventId, event.event); // INSERT ... ON CONFLICT
if (!isFirst) return reply.code(200).send('Already processed');
await myQueue.enqueue('process-nex-event', event);
return reply.code(200).send('OK');
});Checklist de mise en production
Avant d'activer la réception de webhooks Nex en prod :
- [ ] Endpoint en HTTPS uniquement (Nex refuse les URL
http://) - [ ] Secret webhook stocké dans un secret manager (pas dans le repo)
- [ ] Raw body preserved côté framework (pas de json parser global)
- [ ] Vérification signature HMAC implémentée + timing-safe
- [ ] Rejet anti-rejeu (window 5 min)
- [ ] Table
webhook_events_seencréée + INSERT idempotent - [ ] Endpoint répond
< 500 msp99 (traitement métier en async) - [ ] Logs structurés :
event_id,event_type,decision(processed | duplicate | rejected) - [ ] Alerte sur taux d'erreur 4xx (signature invalide = clé compromise ou rotation manquée)
- [ ] Rotation testée : déployer un nouveau secret, valider qu'un message signé avec l'ancien échoue
Que faire en cas de compromission de secret ?
- Notifier Nex immédiatement : nous régénérons le secret côté CMMS (spec-06)
- Recevoir le nouveau secret par un canal sécurisé (pas email plaintext)
- Déployer simultanément sur tous vos workers receveurs (le nouveau secret est actif immédiatement côté Nex — rotation hard V1, pas de grace period)
- Confirmer à Nex que vous avez déployé — sinon vos webhooks vont fail à signature invalide
Questions fréquentes
Q : Que faire si je reçois un event dont je n'ai pas la commande locale ? A : Renvoyer 200 OK quand même (sinon Nex retry et vous accumulez des erreurs). Loguer en interne et investiguer offline.
Q : Le paidAt est dans le futur (dérive d'horloge serveur) ? A : Ne vous fiez pas à paidAt comme source de vérité — c'est un timestamp informatif. La signature seule garantit l'authenticité.
Q : Mon secret peut-il avoir des caractères spéciaux ? A : Format actuel nex_whsec_<32_chars_url-safe> — uniquement A-Za-z0-9_-. Le format peut évoluer ; traitez-le comme une chaîne opaque, pas de parsing.
Q : Combien de temps Nex retient-il les events après envoi ? A : V1, pas de persistance (best-effort). À partir de spec-09 (Outbox), 30 jours. Endpoint admin de rejeu sera exposé côté CMMS.
Contact
Support intégration : partners@paywithnex.com — SLA 1 jour ouvré pour les questions de signature/idempotence.