Skip to content
StableAudienceDevOwner@partners-teamDernière revue2026-05-24

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

http
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"
  }
}
HeaderDescription
Nex-Signaturet=<unix_seconds>,v1=<hmac_hex> — voir Règle 1
Nex-EventNom typé de l'event (order.paid, connection.activated, …)
Nex-Event-IdUUID v4 unique par event — clé d'idempotence (voir Règle 3)
User-AgentToujours 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

  1. Parser le header Nex-Signature pour extraire t et v1
  2. Recalculer HMAC_SHA256(secret, "${t}.${rawBody}")
  3. Comparer en temps constant (crypto.timingSafeEqual) — JAMAIS === (vulnérable à un timing attack)
  4. Si différent → return 400 Bad Request immédiatement, ne pas traiter le body

Anti-pattern à éviter

js
// ❌ 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 possible

Rè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.

js
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

  1. À l'arrivée d'un webhook, lire Nex-Event-Id
  2. Tenter d'insérer dans une table webhook_events_seen
  3. Si violation d'unicité (event déjà vu) → return 200 OK immédiatement sans rejouer la logique métier
  4. Sinon → traiter normalement (idéalement en async via une queue)
sql
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é :

  1. Vérifier signature + idempotence (Règles 1+2+3) → si KO, return 4xx
  2. Enqueue le payload dans votre queue interne (BullMQ, SQS, Sidekiq, etc.)
  3. Return 200 OK immédiatement
  4. 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)

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

js
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_seen créée + INSERT idempotent
  • [ ] Endpoint répond < 500 ms p99 (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 ?

  1. Notifier Nex immédiatement : nous régénérons le secret côté CMMS (spec-06)
  2. Recevoir le nouveau secret par un canal sécurisé (pas email plaintext)
  3. 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)
  4. 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.

Nex — Plateforme fintech CEMAC