Aller au contenu
Subsido

Rester à jour

Webhooks

Un POST signé vers votre endpoint pour chaque événement du flux de modifications qui passe ses filtres : une nouvelle mesure, un changement de statut ou d’échéance, un rappel d’échéance proche, un résultat sur une liste de suivi. Vous lisez le flux quand il contient quelque chose, au lieu de l’interroger sans cesse.

À partir de ProLes webhooks demandent la fonction webhooks : 5 endpoints sur Pro, 10 sur Business, 50 sur Enterprise.

Créer un endpoint

Depuis le tableau de bord, ou avec une clé. L’URL doit être en https://, sur un nom d’hôte ou une adresse publics : pas d’identifiants dedans, pas d’adresse de bouclage, privée ou link-local, 2 048 caractères au plus. Les filtres sont facultatifs ; sans eux, chaque événement parvient à l’endpoint.

curl -X POST "https://api.subsido.be/v1/webhooks" \  -H "Authorization: Bearer sb_live_..." \  -H "Content-Type: application/json" \  -d '{"url": "https://example.com/hooks/subsidies", "filters": {"event_types": ["subsidy.created", "subsidy.deadline_changed", "subsidy.closing_soon"], "regions": ["flanders"], "topics": ["energy_efficiency", "renewable_energy"]}}'

La réponse est 201 avec l’endpoint et son secret de signature (whsec_…), une seule fois. Conservez-le avec vos autres secrets ; aucune lecture ne le renvoie plus.

{
  "data": {
    "id": "wh_06f8r1a3s5d7f9g1h3j5k7m9zx",
    "url": "https://example.com/hooks/subsidies",
    "filters": {
      "event_types": [
        "subsidy.created",
        "subsidy.deadline_changed",
        "subsidy.closing_soon"
      ],
      "regions": [
        "flanders"
      ],
      "topics": [
        "energy_efficiency",
        "renewable_energy"
      ]
    },
    "active": true,
    "created_at": "2026-09-27T09:30:12+02:00",
    "last_success_at": null,
    "consecutive_failures": 0,
    "secret": "whsec_3f9a…"
  },
  "meta": {
    "request_id": "req_0192d3a4b5c67d8e9f0a1b2c3d4e5f64",
    "warnings": []
  }
}

Filtres

Chaque filtre est une liste (ou une chaîne séparée par des virgules). Un filtre absent ou vide ne restreint rien ; les valeurs sont comparées sans tenir compte de la casse ; un événement doit passer chaque filtre donné. Chaque nom est vérifié à la création de l’endpoint : une faute de frappe donne tout de suite un 400, plutôt qu’un endpoint qui ne se déclenche jamais sans que vous le sachiez.

FiltreSignification
event_typesTypes d’événements. Un événement correspond si son type principal ou l’une de ses catégories figure dans la liste : subsidy.deadline_changed reçoit donc aussi une version de type principal subsidy.status_changed qui a également déplacé l’échéance.
sourcesId de sources (GET /v1/sources), vérifiés à la création. Correspond aux événements des mesures de ces sources et aux événements de fraîcheur des sources elles-mêmes ; les événements de matching n’ont pas de source et ne passent pas.
regionsflanders, brussels, wallonia. Une mesure sans restriction régionale (mesures fédérales et européennes) passe un filtre de région, puisqu’elle s’applique partout.
topicsId de thèmes (GET /v1/taxonomy). La mesure de l’événement doit en partager au moins un : un filtre de thème ne laisse donc passer aucun événement de source ou de matching.
subsidy_idsId de mesures (pas de slugs). Uniquement les événements sur ces mesures, événements de matching compris.
watchlistsId de vos propres listes de suivi (wl_…). Seuls les événements de matching portent une liste : ce filtre ne laisse passer que les événements match.*.

Gérer les endpoints

RouteCe qu’elle fait
GET /v1/webhooksVos endpoints, paginés, sans leurs secrets.
DELETE /v1/webhooks/{id}Supprime l’endpoint. Rien de ce qui était en attente pour lui n’est envoyé.
POST /v1/webhooks/{id}/rotateUn nouveau secret, renvoyé une seule fois ; l’ancien cesse aussitôt de fonctionner. Les livraisons sont signées à l’envoi : chaque livraison à partir de maintenant, nouvelles tentatives comprises, porte la nouvelle signature.
POST /v1/webhooks/{id}/enableRéactive un endpoint désactivé après des échecs répétés, et remet son compteur d’échecs à zéro. Les livraisons abandonnées pendant qu’il était désactivé ne sont pas renvoyées.
POST /v1/webhooks/{id}/testMet en file un ping signé vers l’endpoint. Il part dans les 30 secondes environ.
GET /v1/webhooks/{id}/deliveriesLes 50 dernières livraisons, de la plus récente à la plus ancienne : id, event_type, status (pending, delivered, failed), attempts, last_status, last_error, created_at, delivered_at, next_attempt_at.

Ce que contient une livraison

Une livraison par endpoint et par événement, jamais de lot. Le corps est l’événement du flux, avec ses différences sous forme de paires { from, to } :

{
  "id": "chg_06f8s2k1c9t4r7m3q5v0x8z2yw",
  "event": "subsidy.deadline_changed",
  "created_at": "2026-09-27T06:12:40+02:00",
  "data": {
    "subject": "subsidy",
    "id": "eu:HORIZON-EIC-2026-ACCELERATOR-01",
    "version": 4,
    "categories": [
      "subsidy.deadline_changed"
    ],
    "diff": {
      "application_window.closes_at": {
        "from": "2026-10-01T17:00:00+02:00",
        "to": "2026-10-15T17:00:00+02:00"
      }
    },
    "summary": {
      "title": {
        "nl": null,
        "fr": null,
        "en": "EIC Accelerator",
        "de": null
      },
      "status": "open",
      "instrument_type": "grant",
      "issuer": "European Commission",
      "closes_at": "2026-10-15T17:00:00+02:00",
      "regions": [],
      "topics": [
        "innovation",
        "growth_scaleup"
      ],
      "url": "https://ec.europa.eu/info/funding-tenders/opportunities/portal/screen/opportunities/topic-details/HORIZON-EIC-2026-ACCELERATOR-01",
      "source_id": "eu_funding_tenders"
    },
    "subsidy_id": "eu:HORIZON-EIC-2026-ACCELERATOR-01",
    "links": {
      "api": "https://api.subsido.be/v1/subsidies/eu:HORIZON-EIC-2026-ACCELERATOR-01",
      "versions": "https://api.subsido.be/v1/subsidies/eu:HORIZON-EIC-2026-ACCELERATOR-01/versions"
    }
  }
}
ChampSignification
idL’id de l’événement (chg_…), le même que dans GET /v1/changes et le même à chaque nouvelle tentative : dédoublonnez sur cet id.
eventLe type d’événement. Ignorez les types que vous ne connaissez pas, pour qu’un nouveau type ne provoque pas de panne.
created_atQuand l’événement s’est produit, à l’heure de Bruxelles.
data.subject, data.idsubsidy et l’id de la mesure ; source et l’id de la source ; match et <watchlist>:<reference>:<measure> ; ou webhook pour un ping.
data.version, data.categoriesLa version de la mesure produite par l’événement (null sinon), et chaque catégorie touchée.
data.diffChemin du champ vers { from, to }, pour chaque champ modifié. Vide pour les événements qui ne sont pas des versions.
data.summaryLe summary du flux : titre, statut, type d’instrument, organisme, échéance, régions, thèmes, URL officielle, source. Pour un événement de matching : watchlist_id, company_reference, subsidy_id, match_status, previous_status et le résumé de la mesure.
data.subsidy_id, data.linksPour les événements de mesure : à nouveau l’id, et les liens API vers la mesure et ses versions.

Une livraison de test est un événement de type ping :

{
  "id": "chg_06f8s2n7q1w3e5r7t9y1v3h5np",
  "event": "ping",
  "created_at": "2026-09-27T09:30:00+02:00",
  "data": {
    "subject": "webhook",
    "id": "wh_06f8r1a3s5d7f9g1h3j5k7m9zx",
    "version": null,
    "categories": [],
    "diff": {},
    "summary": {
      "message": "A test delivery. Verify its signature with your endpoint's secret."
    }
  }
}

Vérifiez chaque livraison

Chaque livraison est un POST avec Content-Type: application/json et ces en-têtes :

En-têteValeur
Subsido-Signaturet=<unix seconds>,v1=<hex> : l’hex est le HMAC-SHA256 de <t>.<raw body>, avec le secret de l’endpoint comme clé.
Subsido-EventLe type d’événement, comme dans le corps.
Subsido-DeliveryL’id de la livraison, le même à chaque nouvelle tentative. Mentionnez-le si vous nous écrivez.
User-AgentSubsido/<version> (+https://subsido.be/nl/bronnen)

Vérifiez les octets, pas le JSON

Calculez le HMAC sur le corps brut de la requête, exactement tel que reçu. Le parser puis le sérialiser à nouveau change les espaces et l’ordre des clés, et la signature ne correspondra plus. L’horodatage fait partie de la signature, donc refusez les anciens : une livraison interceptée ne peut pas être rejouée avec un nouveau t. Les exemples tolèrent 5 minutes d’écart d’horloge.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.SUBSIDY_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_S = 300;

function verify(header, rawBody) {
  // "t=1790000000,v1=5257a869..."
  const parts = Object.fromEntries(
    (header ?? "").split(",").map((p) => {
      const i = p.indexOf("=");
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    }),
  );
  const t = parts.t ?? "";
  if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${t}.`)
    .update(rawBody) // the raw Buffer, exactly as received
    .digest("hex");
  const got = parts.v1 ?? "";
  return (
    got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got, "utf8"), Buffer.from(expected, "utf8"))
  );
}

// express.raw keeps the body as bytes; express.json() here would break verification.
app.post("/hooks/subsidies", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.get("Subsido-Signature"), req.body)) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204); // answer first, work after
  enqueue(event); // deduplicate on event.id
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["SUBSIDY_WEBHOOK_SECRET"].encode()  # whsec_...
TOLERANCE_S = 300

def verify(header: str, raw_body: bytes) -> bool:
    # "t=1790000000,v1=5257a869..."
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t = parts.get("t", "")
    if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE_S:
        return False
    expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts.get("v1", ""), expected)

@app.post("/hooks/subsidies")
def subsidies_hook():
    # get_data() is the raw body, before any JSON parsing.
    if not verify(request.headers.get("Subsido-Signature", ""), request.get_data()):
        abort(400)
    enqueue(request.get_json())  # deduplicate on event["id"]; do the work elsewhere
    return "", 204

Pour tester votre code, appelez POST /v1/webhooks/{id}/test puis consultez GET /v1/webhooks/{id}/deliveries : last_status est ce que votre endpoint a répondu.

Livraison et nouvelles tentatives

  • Répondez par un 2xx dans les 10 secondes. Tout le reste est un échec : un dépassement de délai, un 4xx ou 5xx, et une redirection, qui n’est pas suivie.
  • Une livraison échouée est retentée après 1 minute, 5 minutes, 30 minutes, 2 heures et 12 heures : 6 tentatives en tout, après quoi elle est marquée failed.
  • Un endpoint qui échoue 20 livraisons d’affilée est désactivé (active: false), et ce qui était encore en attente pour lui est marqué comme échoué. Corrigez le récepteur, puis réactivez l’endpoint avec POST /v1/webhooks/{id}/enable ou depuis le tableau de bord. Les livraisons abandonnées ne sont pas renvoyées, mais rien n’est perdu pour de bon : le flux de modifications conserve chaque événement pendant 400 jours, rattrapez donc à partir du dernier événement traité.
  • Les livraisons peuvent se répéter et arriver dans le désordre. Dédoublonnez sur l’id de l’événement, et considérez le flux, pas l’ordre des livraisons, comme la référence.
  • Les livraisons ne partent que vers des adresses publiques. Le nom d’hôte est résolu au moment de la livraison, et un nom qui ne pointe que vers des adresses privées, de bouclage ou link-local est refusé.
  • Un compte dont la formule n’inclut plus les webhooks garde ses endpoints, mais ils ne reçoivent plus rien tant que la formule ne les inclut pas à nouveau.