Staying current
Webhooks
A signed POST to your endpoint for every change-feed event that matches its filters: a new measure, a status or deadline change, a closing-soon reminder, a match on a watchlist. Read the feed when there is something in it instead of polling it.
Pro and aboveWebhooks need the webhooks capability: 5 endpoints on Pro, 10 on Business, 50 on Enterprise.
Create an endpoint
From the dashboard, or with a key. The URL must be https:// on a public host name or address: no credentials in it, no loopback, private or link-local address, at most 2,048 characters. Filters are optional; without them every event reaches the 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"]}}'The answer is 201 with the endpoint and its signing secret (whsec_…), once. Store it with your other secrets; no read returns it again.
{
"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
Each filter is a list (or a comma-separated string). An absent or empty filter does not restrict; values compare case-insensitively; an event must pass every filter given. Every name is checked when the endpoint is created, so a typo is a 400 now rather than an endpoint that silently never fires.
| Filter | Meaning |
|---|---|
| event_types | Event types. An event matches when its primary type or any of its categories is in the list, so subsidy.deadline_changed also hears a version whose primary type is subsidy.status_changed but which moved the deadline too. |
| sources | Source ids (GET /v1/sources), checked when the endpoint is created. Matches measure events from those sources and the sources’ own freshness events; match events carry no source and do not pass. |
| regions | flanders, brussels, wallonia. A measure without a regional restriction (national and EU measures) passes a region filter, because it applies everywhere. |
| topics | Topic ids (GET /v1/taxonomy). The event’s measure must share at least one, so a topic filter lets no source or match event through. |
| subsidy_ids | Measure ids (not slugs). Only events about these measures, match events included. |
| watchlists | Watchlist ids (wl_…) of your own. Only match events carry a watchlist, so this filter lets match.* events through and nothing else. |
Managing endpoints
| Route | What it does |
|---|---|
| GET /v1/webhooks | Your endpoints, paged, without their secrets. |
| DELETE /v1/webhooks/{id} | Removes the endpoint. Nothing queued for it is sent. |
| POST /v1/webhooks/{id}/rotate | A new secret, returned once; the old one stops working at once. Deliveries are signed when they are sent, so every delivery from now on, retries included, carries the new signature. |
| POST /v1/webhooks/{id}/enable | Switches an endpoint back on after repeated failures switched it off, and resets its failure count. Deliveries given up on while it was off are not resent. |
| POST /v1/webhooks/{id}/test | Queues a signed ping to the endpoint. The worker sends it within about 30 seconds. |
| GET /v1/webhooks/{id}/deliveries | The last 50 deliveries, newest first: id, event_type, status (pending, delivered, failed), attempts, last_status, last_error, created_at, delivered_at, next_attempt_at. |
What a delivery carries
One delivery per endpoint per event, never a batch. The body is the change-feed event with its differences as { from, to } pairs:
{
"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"
}
}
}| Field | Meaning |
|---|---|
| id | The event’s id (chg_…), the same as in GET /v1/changes and the same on every retry: deduplicate on it. |
| event | The event type. Ignore types you do not know, so a new one is not an outage. |
| created_at | When the event occurred, in Brussels time. |
| data.subject, data.id | subsidy and the measure’s id; source and the source’s id; match and <watchlist>:<reference>:<measure>; or webhook for a ping. |
| data.version, data.categories | The measure version the event produced (null otherwise), and every category it touched. |
| data.diff | Field path to { from, to }, for every field that changed. Empty for events that are not versions. |
| data.summary | The feed’s summary: title, status, instrument type, issuer, deadline, regions, topics, official URL, source. For a match event: watchlist_id, company_reference, subsidy_id, match_status, previous_status and the measure’s summary. |
| data.subsidy_id, data.links | For measure events: the id again, and the API links to the measure and its versions. |
A test delivery is an event of 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."
}
}
}Verify every delivery
Each delivery is a POST with Content-Type: application/json and these headers:
| Header | Value |
|---|---|
| Subsido-Signature | t=<unix seconds>,v1=<hex>: the hex is HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint’s secret. |
| Subsido-Event | The event type, as in the body. |
| Subsido-Delivery | The delivery’s id, the same on every retry. Quote it when you write to us. |
| User-Agent | Subsido/<version> (+https://subsido.be/nl/bronnen) |
Sign the bytes, not the JSON
t. The examples allow 5 minutes of clock difference.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 "", 204To check your code, POST /v1/webhooks/{id}/test and look at GET /v1/webhooks/{id}/deliveries: last_status is what your endpoint answered.
Delivery and retries
- Answer with any
2xxwithin 10 seconds. Anything else is a failure: a timeout, a4xxor5xx, and a redirect, which is not followed. - A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: 6 attempts in all, after which it is marked
failed. - An endpoint that fails 20 deliveries in a row is switched off (
active: false), and what was still queued for it is marked failed. Fix the receiver, then switch it back on withPOST /v1/webhooks/{id}/enableor from the dashboard. Deliveries given up on are not resent, but nothing is lost for good: the change feed keeps every event for 400 days, so catch up from the last event you processed. - Deliveries can repeat and can arrive out of order. Deduplicate on the event’s
id, and treat the change feed, not the order of deliveries, as the record. - Deliveries go to public addresses only. The host name is resolved at delivery time and a name that resolves only to private, loopback or link-local addresses is refused.
- An account whose plan no longer includes webhooks keeps its endpoints, and they stop receiving until the plan does again.
