Naar de inhoud
Subsido

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.

FilterBetekenis
event_typesEventtypes. 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.
sourcesBron-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.
regionsflanders, brussels, wallonia. Een maatregel zonder beperking tot een gewest (federale en Europese maatregelen) komt door een gewestfilter, omdat hij overal geldt.
topicsThema-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_idsMaatregel-id’s (geen slugs). Alleen events over deze maatregelen, match-events inbegrepen.
watchlistsId’s van je eigen watchlists (wl_…). Alleen match-events hebben een watchlist, dus deze filter laat match.*-events door en verder niets.

Endpoints beheren

RouteWat ze doet
GET /v1/webhooksJe endpoints, gepagineerd, zonder hun geheimen.
DELETE /v1/webhooks/{id}Verwijdert het endpoint. Wat er nog voor klaarstond, wordt niet verstuurd.
POST /v1/webhooks/{id}/rotateEen 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}/enableZet 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}/testZet een ondertekende ping naar het endpoint klaar. Die vertrekt binnen ongeveer 30 seconden.
GET /v1/webhooks/{id}/deliveriesDe 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"
    }
  }
}
VeldBetekenis
idHet id van het event (chg_…), hetzelfde als in GET /v1/changes en hetzelfde bij elke nieuwe poging: ontdubbel erop.
eventHet eventtype. Negeer types die je niet kent, zodat een nieuw type geen storing wordt.
created_atWanneer het event plaatsvond, in Brusselse tijd.
data.subject, data.idsubsidy 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.categoriesDe versie van de maatregel die het event opleverde (anders null), en elke categorie die het raakte.
data.diffVeldpad naar { from, to }, voor elk gewijzigd veld. Leeg voor events die geen versie zijn.
data.summaryDe 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.linksVoor 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:

HeaderWaarde
Subsido-Signaturet=<unix seconds>,v1=<hex>: de hex is HMAC-SHA256 van <t>.<raw body>, met het geheim van het endpoint als sleutel.
Subsido-EventHet eventtype, zoals in de body.
Subsido-DeliveryHet id van de levering, hetzelfde bij elke nieuwe poging. Vermeld het als je ons schrijft.
User-AgentSubsido/<version> (+https://subsido.be/nl/bronnen)

Controleer de bytes, niet de JSON

Bereken de HMAC over de ruwe body van de request, precies zoals je hem ontving. Hem parsen en opnieuw serialiseren verandert witruimte en de volgorde van de velden, en dan klopt de handtekening niet. Het tijdstempel zit in de handtekening, dus weiger oude: een onderschepte levering kan niet opnieuw afgespeeld worden met een verse t. De voorbeelden laten 5 minuten klokverschil toe.
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

Om 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, een 4xx of 5xx, 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 failed gemarkeerd.
  • 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 met POST /v1/webhooks/{id}/enable of 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 id van 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.