Webhooks : suivre le statut DGI de vos factures

Plutôt que d'interroger l'API en boucle, recevez chaque changement de statut de vos factures sur votre propre URL. Chaque livraison est signée : vérifiez-la avant de lui faire confiance.

En bref

EFact envoie une requête POST JSON à votre URL HTTPS à chaque changement de statut d'une facture. Chaque requête est signée en HMAC-SHA256 avec un secret whsec_... propre à l'endpoint, généré par EFact et affiché une seule fois. Vérifiez la signature et l'horodatage, répondez 2xx en moins de 10 secondes, et dédoublonnez sur l'identifiant de livraison.

Comment créer un endpoint ?

Dans le dashboard, ouvrez Webhooks, saisissez une URL HTTPS publique et choisissez les événements. À la création, EFact génère le secret de signature et l'affiche une seule fois : copiez-le dans la configuration de votre serveur (par exemple EFACT_WEBHOOK_SECRET). Vous n'avez pas à le choisir vous-même : un secret généré par le serveur est toujours assez long et aléatoire. Les adresses privées et locales sont refusées. Jusqu'à 5 endpoints actifs par organisation.

Quels événements sont envoyés ?

Un endpoint sans sélection reçoit tous les événements.

ÉvénementDéclencheur
invoice.signedLa facture est normalisée (UBL 2.1) et signée (XAdES).
invoice.transmittedLa facture est soumise pour clairance.
invoice.acceptedClairance obtenue : référence DGI et QR code disponibles.
invoice.rejectedClairance refusée : errorCode et errorMessage renseignés.
invoice.failedErreur technique irrécupérable pendant le traitement.
invoice.cancelledLa facture a été annulée avant transmission.
test.pingEnvoyé par le bouton « Tester » du dashboard (data est null).
Corps de la requête
{
  "id": "3f6c2a9e-8d1b-4c7a-9e2f-1a2b3c4d5e6f",
  "type": "invoice.accepted",
  "apiVersion": "2026-07-01",
  "createdAt": "2026-09-30T14:05:12Z",
  "data": {
    "invoice": {
      "id": "b1e2c3d4-0000-4000-8000-000000000001",
      "invoiceNumber": "FAC-2026-0142",
      "status": "ACCEPTED",
      "previousStatus": "TRANSMITTED",
      "currency": "MAD",
      "amountTtc": 1200.00,
      "dgiReference": "DGI-REF-...",
      "dgiQrCode": "...",
      "errorCode": null,
      "errorMessage": null
    }
  }
}

Le corps ne contient jamais le XML signé : récupérez les artefacts via l'API Invoices. Les champs peuvent s'enrichir ; ignorez ceux que vous ne connaissez pas.

Comment vérifier la signature ?

Chaque livraison porte trois en-têtes. La signature est HMAC-SHA256(secret, id + "." + timestamp + "." + corps) encodée en hexadécimal, préfixée par v1=.

En-têtes
POST /webhooks/efact HTTP/1.1
Content-Type: application/json
efact-webhook-id: 3f6c2a9e-8d1b-4c7a-9e2f-1a2b3c4d5e6f
efact-webhook-timestamp: 1790777112
efact-webhook-signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  1. Lisez le corps brut, avant tout parsing JSON : re-sérialiser change les octets et casse la signature.
  2. Refusez un efact-webhook-timestamp à plus de 5 minutes de votre horloge (protection contre le rejeu).
  3. Calculez la signature attendue et comparez-la en temps constant à chaque entrée v1= de l'en-tête. Une seule correspondance suffit.
  4. En cas d'échec, répondez 400 et ne traitez pas l'événement.

Ces exemples n'utilisent que la bibliothèque standard de chaque langage : aucune dépendance à installer. Adaptez-les à votre framework en gardant les trois règles ci-dessus.

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.EFACT_WEBHOOK_SECRET!;

function verifyEfactSignature(rawBody: Buffer, headers: express.Request["headers"]): boolean {
  const id = headers["efact-webhook-id"];
  const timestamp = headers["efact-webhook-timestamp"];
  const header = headers["efact-webhook-signature"];
  if (typeof id !== "string" || typeof timestamp !== "string" || typeof header !== "string") {
    return false;
  }
  // Anti-rejeu : 5 minutes de tolérance.
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();
  // Une entrée "v1=" par secret valide (deux pendant une rotation).
  return header.split(",").some((entry) => {
    const [scheme, hex] = entry.trim().split("=");
    if (scheme !== "v1" || !hex) return false;
    const candidate = Buffer.from(hex, "hex");
    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
  });
}

const app = express();

// Corps BRUT obligatoire : la signature porte sur les octets reçus.
app.post("/webhooks/efact", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyEfactSignature(req.body, req.headers)) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));

  // Répondez 2xx tout de suite, traitez ensuite (file de jobs).
  res.sendStatus(200);
  queue.add("efact-event", event, { jobId: event.id }); // jobId = dédoublonnage
});

Comment changer le secret sans perdre d'événements ?

Le bouton Renouveler le secret du dashboard génère un nouveau secret. L'ancien reste valide 24 heures : pendant cette période, chaque livraison porte deux signatures, v1=<nouveau>,v1=<ancien>. Déployez le nouveau secret à votre rythme ; votre serveur accepte la livraison dès qu'une des deux correspond. Changez le secret immédiatement s'il a fuité.

Comment gérer les endpoints par API ?

Tout ce que permet le dashboard est disponible avec votre clé API (Authorization: Bearer efact_sk_...). Les endpoints appartiennent à l'organisation de la clé ; un identifiant d'une autre organisation renvoie 404. Réponses dans l'enveloppe standard décrite dans la vue d'ensemble de l'API.

MéthodeEndpointDescription
POST/v1/webhooksCréer un endpoint (201, secret renvoyé une seule fois)
GET/v1/webhooksLister les endpoints (paginé : page, size)
GET/v1/webhooks/:idRécupérer un endpoint
PATCH/v1/webhooks/:idModifier url, description, events ou active (champs fournis uniquement)
DELETE/v1/webhooks/:idSupprimer un endpoint (les livraisons en attente ne partent plus)
POST/v1/webhooks/:id/testEnvoyer un test.ping signé et renvoyer le code HTTP obtenu
POST/v1/webhooks/:id/rotate-secretGénérer un nouveau secret (l'ancien reste valide 24 h)
GET/v1/webhooks/:id/deliveriesJournal des livraisons (statut, tentatives, dernière erreur)
POST/v1/webhooks/deliveries/:deliveryId/retryRelancer une livraison immédiatement

events vide ou absent = tous les événements. Erreurs possibles à la création : 422 WEBHOOK_URL_INVALID / WEBHOOK_URL_FORBIDDEN (URL non HTTPS, privée ou locale) et 409 WEBHOOK_LIMIT_REACHED (5 endpoints actifs maximum).

Créer un endpoint
curl -X POST https://api.efact.ma/v1/webhooks \
  -H "Authorization: Bearer $EFACT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://erp.example.ma/webhooks/efact",
    "description": "ERP production",
    "events": ["invoice.accepted", "invoice.rejected", "invoice.failed"]
  }'
Réponse 201
{
  "code": "OK",
  "message": null,
  "data": {
    "id": "7c1d9b2e-4f3a-4e8b-9a6d-2b5c8e1f0a3d",
    "organizationId": "5e0b...",
    "url": "https://erp.example.ma/webhooks/efact",
    "description": "ERP production",
    "events": ["invoice.accepted", "invoice.rejected", "invoice.failed"],
    "active": true,
    "secret": "whsec_...",
    "createdAt": "2026-09-30T14:00:00Z",
    "previousSecretExpiresAt": null
  },
  "errors": null
}
Tester, désactiver, renouveler
# Tester le récepteur (réponse : { success, responseStatus, message })
curl -X POST https://api.efact.ma/v1/webhooks/$ENDPOINT_ID/test \
  -H "Authorization: Bearer $EFACT_SECRET_KEY"

# Désactiver temporairement
curl -X PATCH https://api.efact.ma/v1/webhooks/$ENDPOINT_ID \
  -H "Authorization: Bearer $EFACT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "active": false }'

# Changer le secret (data.secret, affiché une seule fois)
curl -X POST https://api.efact.ma/v1/webhooks/$ENDPOINT_ID/rotate-secret \
  -H "Authorization: Bearer $EFACT_SECRET_KEY"

# Consulter les dernières livraisons, puis en relancer une
curl "https://api.efact.ma/v1/webhooks/$ENDPOINT_ID/deliveries?page=0&size=20" \
  -H "Authorization: Bearer $EFACT_SECRET_KEY"
curl -X POST https://api.efact.ma/v1/webhooks/deliveries/$DELIVERY_ID/retry \
  -H "Authorization: Bearer $EFACT_SECRET_KEY"

Que se passe-t-il si mon serveur ne répond pas ?

Toute réponse autre que 2xx, un délai dépassé (5 s de connexion, 10 s de réponse) ou une redirection compte comme un échec. EFact relance jusqu'à 8 tentatives sur environ 26 heures (30 s, 2 min, 10 min, 30 min, 1 h, 4 h, 12 h, avec une variation aléatoire de ±20 %). Après 20 échecs consécutifs, l'endpoint est désactivé automatiquement ; réactivez-le depuis le dashboard. Chaque livraison peut aussi être relancée à la main depuis le journal.

Quelles sont les bonnes pratiques côté serveur ?

  • • Dédoublonnez : la livraison est « au moins une fois ». Stockez efact-webhook-id (égal à id dans le corps) et ignorez un identifiant déjà traité.
  • • Répondez vite : renvoyez 2xx dès la signature vérifiée, puis traitez l'événement en arrière-plan.
  • • Ne supposez pas l'ordre : utilisez status et createdAt plutôt que l'ordre d'arrivée ; en cas de doute, relisez la facture via l'API.
  • • Gardez le secret hors du code : variable d'environnement ou coffre-fort de secrets, jamais dans le dépôt ni dans les logs.
  • • Testez : le bouton « Tester » envoie un test.ping signé et affiche le code HTTP de votre serveur.

Étapes suivantes

Questions fréquentes

Quels événements de facture EFact envoie-t-il par webhook ?

EFact envoie invoice.signed, invoice.transmitted, invoice.accepted, invoice.rejected, invoice.failed et invoice.cancelled, plus test.ping depuis le bouton Tester du dashboard. Un endpoint créé sans liste d'événements les reçoit tous. invoice.accepted fournit la référence DGI et le QR code ; invoice.rejected fournit errorCode et errorMessage.

Combien d'endpoints webhook peut-on créer ?

Jusqu'à 5 endpoints actifs par organisation. Au-delà, la création renvoie 409 WEBHOOK_LIMIT_REACHED. Chaque URL doit être publique et en HTTPS : une URL privée, locale ou non HTTPS est refusée avec 422 WEBHOOK_URL_INVALID ou WEBHOOK_URL_FORBIDDEN.

Faut-il encore interroger l'API si l'on utilise les webhooks ?

Non pour le suivi courant : chaque changement de statut arrive sur votre URL. Gardez toutefois la lecture par API comme filet de sécurité, car la livraison est « au moins une fois » et l'ordre n'est pas garanti. En cas de doute sur un statut, relisez la facture via l'API plutôt que de vous fier à l'ordre d'arrivée.