Skip to content
Subsido

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.

FilterMeaning
event_typesEvent 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.
sourcesSource 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.
regionsflanders, brussels, wallonia. A measure without a regional restriction (national and EU measures) passes a region filter, because it applies everywhere.
topicsTopic 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_idsMeasure ids (not slugs). Only events about these measures, match events included.
watchlistsWatchlist ids (wl_…) of your own. Only match events carry a watchlist, so this filter lets match.* events through and nothing else.

Managing endpoints

RouteWhat it does
GET /v1/webhooksYour endpoints, paged, without their secrets.
DELETE /v1/webhooks/{id}Removes the endpoint. Nothing queued for it is sent.
POST /v1/webhooks/{id}/rotateA 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}/enableSwitches 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}/testQueues a signed ping to the endpoint. The worker sends it within about 30 seconds.
GET /v1/webhooks/{id}/deliveriesThe 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"
    }
  }
}
FieldMeaning
idThe event’s id (chg_…), the same as in GET /v1/changes and the same on every retry: deduplicate on it.
eventThe event type. Ignore types you do not know, so a new one is not an outage.
created_atWhen the event occurred, in Brussels time.
data.subject, data.idsubsidy and the measure’s id; source and the source’s id; match and <watchlist>:<reference>:<measure>; or webhook for a ping.
data.version, data.categoriesThe measure version the event produced (null otherwise), and every category it touched.
data.diffField path to { from, to }, for every field that changed. Empty for events that are not versions.
data.summaryThe 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.linksFor 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:

HeaderValue
Subsido-Signaturet=<unix seconds>,v1=<hex>: the hex is HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint’s secret.
Subsido-EventThe event type, as in the body.
Subsido-DeliveryThe delivery’s id, the same on every retry. Quote it when you write to us.
User-AgentSubsido/<version> (+https://subsido.be/nl/bronnen)

Sign the bytes, not the JSON

Compute the HMAC over the raw request body exactly as received. Parsing it and serialising it again changes whitespace and key order, and the signature will not match. The timestamp is inside the signature, so reject old ones: a captured delivery cannot be replayed with a fresh t. The examples allow 5 minutes of clock difference.
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

To 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 2xx within 10 seconds. Anything else is a failure: a timeout, a 4xx or 5xx, 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 with POST /v1/webhooks/{id}/enable or 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.