Actueel blijven
Webhooks
Een ondertekende POST naar je endpoint voor elk event uit de change feed dat door de filters komt: een nieuwe maatregel, een gewijzigde status of deadline, een herinnering voor een nakende deadline, een match op een watchlist. Zo lees je de feed wanneer er iets in staat, in plaats van hem steeds te bevragen.
Pro en hogerWebhooks vragen de functie webhooks: 5 endpoints op Pro, 10 op Business, 50 op Enterprise.
Een endpoint aanmaken
Via het dashboard, of met een sleutel. De URL moet https:// zijn, op een publieke hostnaam of een publiek adres: geen inloggegevens erin, geen loopback-, privé- of link-local-adres, hoogstens 2.048 tekens. Filters zijn optioneel; zonder filters komt elk event bij het endpoint aan.
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"]}}'Het antwoord is 201 met het endpoint en zijn ondertekeningsgeheim secret (whsec_…), één keer. Bewaar het bij je andere geheimen; geen enkele leesopdracht geeft het nog terug.
{
"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": []
}
}Filters
Elke filter is een lijst (of een tekst met komma’s). Een ontbrekende of lege filter beperkt niets; waarden worden vergeleken zonder op hoofdletters te letten; een event moet door elke opgegeven filter komen. Elke naam wordt gecontroleerd bij het aanmaken van het endpoint, zodat een tikfout meteen een 400 geeft in plaats van een endpoint dat ongemerkt nooit afgaat.
| Filter | Betekenis |
|---|---|
| event_types | Eventtypes. Een event past als zijn hoofdtype of een van zijn categorieën in de lijst staat, dus subsidy.deadline_changed hoort ook een versie met hoofdtype subsidy.status_changed die ook de deadline verschoof. |
| sources | Bron-id’s (GET /v1/sources), gecontroleerd bij het aanmaken. Past op events over maatregelen uit die bronnen en op de actualiteitsevents van de bronnen zelf; match-events hebben geen bron en gaan er niet door. |
| regions | flanders, brussels, wallonia. Een maatregel zonder beperking tot een gewest (federale en Europese maatregelen) komt door een gewestfilter, omdat hij overal geldt. |
| topics | Thema-id’s (GET /v1/taxonomy). De maatregel van het event moet er minstens één mee delen, dus een themafilter laat geen bron- of match-events door. |
| subsidy_ids | Maatregel-id’s (geen slugs). Alleen events over deze maatregelen, match-events inbegrepen. |
| watchlists | Id’s van je eigen watchlists (wl_…). Alleen match-events hebben een watchlist, dus deze filter laat match.*-events door en verder niets. |
Endpoints beheren
| Route | Wat ze doet |
|---|---|
| GET /v1/webhooks | Je endpoints, gepagineerd, zonder hun geheimen. |
| DELETE /v1/webhooks/{id} | Verwijdert het endpoint. Wat er nog voor klaarstond, wordt niet verstuurd. |
| POST /v1/webhooks/{id}/rotate | Een nieuw geheim, één keer teruggegeven; het oude werkt meteen niet meer. Leveringen worden ondertekend op het moment van verzenden, dus elke levering vanaf nu, nieuwe pogingen inbegrepen, draagt de nieuwe handtekening. |
| POST /v1/webhooks/{id}/enable | Zet een endpoint weer aan nadat herhaalde mislukkingen het uitschakelden, en zet de teller van mislukkingen op nul. Leveringen die opgegeven werden terwijl het uit stond, worden niet opnieuw verstuurd. |
| POST /v1/webhooks/{id}/test | Zet een ondertekende ping naar het endpoint klaar. Die vertrekt binnen ongeveer 30 seconden. |
| GET /v1/webhooks/{id}/deliveries | De laatste 50 leveringen, de nieuwste eerst: id, event_type, status (pending, delivered, failed), attempts, last_status, last_error, created_at, delivered_at, next_attempt_at. |
Wat een levering bevat
Eén levering per endpoint per event, nooit een bundel. De body is het event uit de change feed, met de verschillen als paren { 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"
}
}
}| Veld | Betekenis |
|---|---|
| id | Het id van het event (chg_…), hetzelfde als in GET /v1/changes en hetzelfde bij elke nieuwe poging: ontdubbel erop. |
| event | Het eventtype. Negeer types die je niet kent, zodat een nieuw type geen storing wordt. |
| created_at | Wanneer het event plaatsvond, in Brusselse tijd. |
| data.subject, data.id | subsidy en het id van de maatregel; source en het id van de bron; match en <watchlist>:<reference>:<measure>; of webhook voor een ping. |
| data.version, data.categories | De versie van de maatregel die het event opleverde (anders null), en elke categorie die het raakte. |
| data.diff | Veldpad naar { from, to }, voor elk gewijzigd veld. Leeg voor events die geen versie zijn. |
| data.summary | De summary uit de feed: titel, status, soort instrument, verstrekker, deadline, gewesten, thema’s, officiële URL, bron. Voor een match-event: watchlist_id, company_reference, subsidy_id, match_status, previous_status en de samenvatting van de maatregel. |
| data.subsidy_id, data.links | Voor events over een maatregel: nogmaals het id, en de API-links naar de maatregel en haar versies. |
Een testlevering is een event van het 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."
}
}
}Controleer elke levering
Elke levering is een POST met Content-Type: application/json en deze headers:
| Header | Waarde |
|---|---|
| Subsido-Signature | t=<unix seconds>,v1=<hex>: de hex is HMAC-SHA256 van <t>.<raw body>, met het geheim van het endpoint als sleutel. |
| Subsido-Event | Het eventtype, zoals in de body. |
| Subsido-Delivery | Het id van de levering, hetzelfde bij elke nieuwe poging. Vermeld het als je ons schrijft. |
| User-Agent | Subsido/<version> (+https://subsido.be/nl/bronnen) |
Controleer de bytes, niet de JSON
t. De voorbeelden laten 5 minuten klokverschil toe.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 "", 204Om je code te testen: roep POST /v1/webhooks/{id}/test aan en kijk naar GET /v1/webhooks/{id}/deliveries: last_status is wat je endpoint antwoordde.
Levering en nieuwe pogingen
- Antwoord binnen 10 seconden met een
2xx. Al de rest is een mislukking: een time-out, een4xxof5xx, en een redirect, die niet gevolgd wordt. - Een mislukte levering wordt opnieuw geprobeerd na 1 minuut, 5 minuten, 30 minuten, 2 uur en 12 uur: 6 pogingen in totaal, daarna wordt ze als
failedgemarkeerd. - Een endpoint waarvoor 20 leveringen na elkaar mislukken, wordt uitgeschakeld (
active: false), en wat er nog voor klaarstond, wordt als mislukt gemarkeerd. Herstel de ontvanger en zet het endpoint weer aan metPOST /v1/webhooks/{id}/enableof vanuit het dashboard. Opgegeven leveringen worden niet opnieuw verstuurd, maar er gaat niets definitief verloren: de change feed bewaart elk event 400 dagen, dus haal je achterstand in vanaf het laatste event dat je verwerkte. - Leveringen kunnen dubbel en in een andere volgorde aankomen. Ontdubbel op de
idvan het event, en beschouw de change feed als de referentie, niet de volgorde van de leveringen. - Leveringen gaan alleen naar publieke adressen. De hostnaam wordt opgezocht op het moment van levering, en een naam die alleen naar privé-, loopback- of link-local-adressen verwijst, wordt geweigerd.
- Een account waarvan het plan geen webhooks meer bevat, houdt zijn endpoints, maar die ontvangen niets meer tot het plan ze weer bevat.
