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.
| Filtre | Signification |
|---|---|
| event_types | Types 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. |
| sources | Id 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. |
| regions | flanders, 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. |
| topics | Id 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_ids | Id de mesures (pas de slugs). Uniquement les événements sur ces mesures, événements de matching compris. |
| watchlists | Id 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
| Route | Ce qu’elle fait |
|---|---|
| GET /v1/webhooks | Vos 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}/rotate | Un 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}/enable | Ré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}/test | Met en file un ping signé vers l’endpoint. Il part dans les 30 secondes environ. |
| GET /v1/webhooks/{id}/deliveries | Les 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"
}
}
}| Champ | Signification |
|---|---|
| id | L’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. |
| event | Le type d’événement. Ignorez les types que vous ne connaissez pas, pour qu’un nouveau type ne provoque pas de panne. |
| created_at | Quand l’événement s’est produit, à l’heure de Bruxelles. |
| data.subject, data.id | subsidy 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.categories | La version de la mesure produite par l’événement (null sinon), et chaque catégorie touchée. |
| data.diff | Chemin du champ vers { from, to }, pour chaque champ modifié. Vide pour les événements qui ne sont pas des versions. |
| data.summary | Le 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.links | Pour 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ête | Valeur |
|---|---|
| Subsido-Signature | t=<unix seconds>,v1=<hex> : l’hex est le HMAC-SHA256 de <t>.<raw body>, avec le secret de l’endpoint comme clé. |
| Subsido-Event | Le type d’événement, comme dans le corps. |
| Subsido-Delivery | L’id de la livraison, le même à chaque nouvelle tentative. Mentionnez-le si vous nous écrivez. |
| User-Agent | Subsido/<version> (+https://subsido.be/nl/bronnen) |
Vérifiez les octets, pas le JSON
t. Les exemples tolèrent 5 minutes d’écart d’horloge.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
});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 "", 204Pour 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
2xxdans les 10 secondes. Tout le reste est un échec : un dépassement de délai, un4xxou5xx, 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 avecPOST /v1/webhooks/{id}/enableou 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’
idde 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.
