Webhooks
Plutôt que d'interroger l'API en boucle pour savoir si quelque chose a bougé, laissez O'Gérant appeler votre serveur quand ça bouge. Vous enregistrez une URL, vous choisissez vos événements, et vous recevez une requête signée à chaque fois.
Déploiement en cours (août 2026). L'enregistrement d'un endpoint, la signature, le journal des livraisons et le rejeu sont en place ; la cadence d'envoi automatique est en cours de câblage côté infrastructure. Vous pouvez donc préparer et tester votre intégration dès maintenant, mais ne comptez pas encore sur ces webhooks en production : en attendant, interrogez l'API. Cette page sera mise à jour dès que l'envoi tourne en continu.
Enregistrer un endpoint
- Ouvrez les réglages Paramètres → votre organisation → API & webhooks → Ajouter un webhook. Réservé au propriétaire.
- Donnez l'URL de votre endpoint Elle doit être en HTTPS et joignable publiquement. Une adresse locale, privée ou de métadonnées cloud est refusée à l'enregistrement : et revérifiée après résolution DNS à chaque envoi.
- Choisissez les événements Vous ne recevez que ceux que vous cochez.
- Copiez le secret de signature Il s'affiche une seule fois (
whsec_…). C'est lui qui vous permettra de vérifier que la requête vient bien de nous.
Les événements disponibles
| Événement | Déclenché quand |
|---|---|
ao.matched | Une nouvelle correspondance est calculée entre votre profil et un appel d'offres. |
bc.matched | Idem pour un bon de commande. |
ao.deadline_soon | La date limite d'un AO sur lequel vous travaillez arrive dans moins de 3 jours. |
decompte.paid | Un décompte de vos marchés passe au statut payé. |
adjudication.published | Le résultat d'une consultation qui vous concerne est publié. |
La forme d'une livraison
Toujours un POST en application/json, avec la même enveloppe quel que soit l'événement : vous branchez sur event et vous lisez data.
{
"id": "evt_cmsnrowjd0000zuvyfxa9qh5w",
"event": "ao.matched",
"createdAt": "2026-08-10T14:32:11.104Z",
"organizationId": "cmqh97bhf0000m1qdhadgvv23",
"data": {
"tender": {
"uuid": "ctnyv8ruel8cr28l0sb0aky3",
"kind": "AO",
"reference": "15/2026",
"title": "Travaux d'élargissement de la route provinciale 3406"
},
"match": { "id": "51", "adequacyPct": 78, "riskLevel": "LOW" }
}
}
{
"id": "evt_…", "event": "ao.deadline_soon", "createdAt": "…", "organizationId": "…",
"data": {
"tender": {
"uuid": "ctnyv8ruel8cr28l0sb0aky3", "kind": "AO", "reference": "15/2026",
"title": "Travaux d'élargissement…",
"submissionDeadline": "2026-08-13T09:00:00.000Z"
},
"hoursRemaining": 66
}
}
{
"id": "evt_…", "event": "decompte.paid", "createdAt": "…", "organizationId": "…",
"data": {
"marche": { "uuid": "aaf24993-deec-4910-810b-bc15298c9e4e", "numero": "57/2025/BAT" },
"paiement": {
"uuid": "0198f2…", "montantRecu": 412350.75,
"referenceVirement": "VIR-2026-0881", "payeLe": "2026-08-09"
}
}
}
{
"id": "evt_…", "event": "adjudication.published", "createdAt": "…", "organizationId": "…",
"data": {
"tender": { "uuid": "…", "kind": "AO", "reference": "15/2026", "title": "…" },
"adjudication": { "uuid": "0198f2…", "date": "2026-08-05", "bidCount": 7, "isCanceled": false }
}
}
Les en-têtes
| En-tête | Contenu |
|---|---|
X-Ogerant-Event | Le type d'événement. |
X-Ogerant-Delivery | Identifiant stable de la livraison. Un rejeu renvoie le même identifiant : servez-vous-en pour dédupliquer. |
X-Ogerant-Timestamp | Horodatage de la signature, en secondes Unix. |
X-Ogerant-Signature | t=<secondes>,v1=<HMAC hexadécimal>. |
Vérifier la signature
C'est la partie à ne pas rater : sans elle, n'importe qui connaissant votre URL peut vous envoyer de faux événements. Recalculez HMAC-SHA256(secret, "<t>.<corps brut>"), comparez-le à v1 en temps constant, et refusez un horodatage vieux de plus de 5 minutes.
Sur le corps BRUT. Vérifiez avant de parser le JSON, et signez la chaîne reçue telle quelle. Re-sérialiser un objet change l'ordre des clés, et plus aucune signature ne correspond. C'est l'erreur numéro un.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.OGERANT_WEBHOOK_SECRET;
// `express.raw` : on a besoin du corps BRUT, pas d'un objet déjà parsé.
app.post("/ogerant", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Ogerant-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
const raw = req.body.toString("utf8");
// 1. Fenêtre de tolérance : une requête capturée ne doit pas être rejouable.
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(400).send("horodatage hors fenêtre");
}
// 2. Comparaison en temps constant.
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex");
const ok =
parts.v1 &&
expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!ok) return res.status(400).send("signature invalide");
// 3. Déduplication : un rejeu porte le MÊME X-Ogerant-Delivery.
const event = JSON.parse(raw);
if (alreadyHandled(req.get("X-Ogerant-Delivery"))) return res.sendStatus(200);
// 4. Répondez vite (2xx sous 5 s), traitez en arrière-plan.
enqueue(event);
res.sendStatus(200);
});
<?php
$secret = getenv('OGERANT_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // le corps BRUT
$header = $_SERVER['HTTP_X_OGERANT_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_pad(explode('=', $kv, 2), 2, null);
$parts[$k] = $v;
}
$t = isset($parts['t']) ? (int) $parts['t'] : 0;
// 1. Fenêtre de tolérance (5 minutes).
if ($t === 0 || abs(time() - $t) > 300) {
http_response_code(400);
exit('horodatage hors fenêtre');
}
// 2. Comparaison en temps constant.
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (empty($parts['v1']) || !hash_equals($expected, $parts['v1'])) {
http_response_code(400);
exit('signature invalide');
}
// 3. Déduplication sur l'identifiant de livraison.
$deliveryId = $_SERVER['HTTP_X_OGERANT_DELIVERY'] ?? '';
if (dejaTraite($deliveryId)) { http_response_code(200); exit; }
// 4. Répondez vite, traitez ensuite.
$event = json_decode($raw, true);
enfiler($event);
http_response_code(200);
Ce qu'on attend de votre endpoint
- Un 2xx en moins de 5 secondes. Au-delà, la requête est abandonnée et comptée comme un échec. Accusez réception, puis traitez en file d'attente.
- De l'idempotence. Un rejeu porte le même
X-Ogerant-Delivery: traitez-le une fois. - Pas de redirection. Nous ne suivons jamais un 3xx ; il compte comme un échec.
Rejeu et désactivation
Un échec n'est pas définitif, mais il n'est pas éternel non plus.
| Réponse de votre endpoint | Ce qu'il se passe |
|---|---|
2xx | Livré. Le compteur d'échecs consécutifs repart à zéro. |
5xx, 429, délai dépassé, erreur réseau | Rejeu à 1 min, 5 min, 30 min, 2 h puis 6 h. |
4xx (hors 429) | Aucun rejeu : votre serveur a déjà jugé la requête. L'échec est compté. |
| 6 échecs consécutifs | L'endpoint est désactivé, sa file en attente est annulée, et une notification arrive dans l'application. |
Pour le réactiver : corrigez votre endpoint, puis rebasculez l'interrupteur dans API & webhooks : ce qui remet aussi le compteur d'échecs à zéro.
Pourquoi annuler la file d'attente ? Pour ne pas vous envoyer demain matin, d'un coup, une rafale d'événements d'hier soir au moment où vous réactivez.
Le journal des livraisons
Dans API & webhooks, chaque endpoint a un dépliant Livraisons : pour chaque envoi, l'événement, la date, le statut, le code HTTP renvoyé par votre serveur, le début de sa réponse et l'erreur éventuelle. C'est la première chose à ouvrir quand « ça ne marche pas » : la réponse y est presque toujours (401 = votre vérification rejette, 404 = mauvaise URL, délai dépassé = traitement synchrone trop lent).
Le bouton Renvoyer remet une livraison en file pour un nouvel essai immédiat. Le compteur de tentatives repart de zéro : un rejeu manuel est une décision, pas la suite d'une courbe épuisée.
Sécurité
- Votre endpoint doit être en HTTPS : la charge utile contient vos données d'affaires.
- Les adresses privées, locales et de métadonnées cloud sont refusées : à l'enregistrement et après résolution DNS, à chaque tentative.
- Le secret ne s'affiche qu'à la création. Perdu ? Supprimez le webhook et recréez-le.
- Un endpoint qui ne vérifie pas la signature accepte n'importe quel envoi de n'importe qui. Vérifiez.