Développeurs

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

  1. Ouvrez les réglages Paramètres → votre organisation → API & webhooksAjouter un webhook. Réservé au propriétaire.
  2. 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.
  3. Choisissez les événements Vous ne recevez que ceux que vous cochez.
  4. 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énementDéclenché quand
ao.matchedUne nouvelle correspondance est calculée entre votre profil et un appel d'offres.
bc.matchedIdem pour un bon de commande.
ao.deadline_soonLa date limite d'un AO sur lequel vous travaillez arrive dans moins de 3 jours.
decompte.paidUn décompte de vos marchés passe au statut payé.
adjudication.publishedLe 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.

ao.matched
{
  "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" }
  }
}
ao.deadline_soon
{
  "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
  }
}
decompte.paid
{
  "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"
    }
  }
}
adjudication.published
{
  "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êteContenu
X-Ogerant-EventLe type d'événement.
X-Ogerant-DeliveryIdentifiant stable de la livraison. Un rejeu renvoie le même identifiant : servez-vous-en pour dédupliquer.
X-Ogerant-TimestampHorodatage de la signature, en secondes Unix.
X-Ogerant-Signaturet=<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.

Node : Express
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
<?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

Rejeu et désactivation

Un échec n'est pas définitif, mais il n'est pas éternel non plus.

Réponse de votre endpointCe qu'il se passe
2xxLivré. Le compteur d'échecs consécutifs repart à zéro.
5xx, 429, délai dépassé, erreur réseauRejeu à 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écutifsL'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é