Features
Webhooks
A signed notification to your endpoint whenever an import publishes changes you care about, so you read the change feed when there is something in it instead of polling. Business plan and above.
Create an endpoint
From the dashboard, or with a key. The URL must be https:// on a public host. Filters are optional; without them every published change reaches the endpoint.
curl -X POST "https://api.gridcapacityapi.com/v1/webhooks" \ -H "Authorization: Bearer gc_live_..." \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/hooks/grid", "filters": {"countries": ["GB"], "change_types": ["changed", "added"]}}'The answer carries the signing secret, once. Store it with your other secrets; it cannot be shown again. GET /v1/webhooks lists your endpoints, POST /v1/webhooks/{id}/rotate replaces the secret (the new one is shown once, and the old one stops signing immediately), and DELETE /v1/webhooks/{id} removes the endpoint.
| Filter | Notifies only for changes… |
|---|---|
| countries | in these ISO country codes. |
| operators | from these operators (slug or id). |
| sources | from these source ids. |
| change_types | of these types: added, changed, removed, restored. |
Every name in a filter is checked when the endpoint is created, so a typo is a 400 now rather than an endpoint that silently never fires.
What a delivery carries
One delivery per endpoint per published import run, not one per change: a run that moves 4,000 values is one event that says so. The event counts what changed and gives you the change-feed cursor to read it from. It carries no capacity values, so it can never carry a figure the source’s rights withhold; the values come from GET /v1/changes, which applies the same rights as every other route.
{
"id": "evt_…",
"type": "capacity.changes",
"created_at": "2026-09-25T04:13:02Z",
"data": {
"import_run_id": 1842,
"source_id": "gb_…",
"counts": {
"added": 0,
"changed": 12,
"removed": 1,
"restored": 0
},
"total": 13,
"first_change_id": 184455,
"last_change_id": 184467,
"since": 184454,
"countries": [
"GB"
],
"operators": [
"op_…"
]
}
}Placeholder values; the shape is the contract.
| Field | Meaning |
|---|---|
| id | The event's id. The same on every retry of the delivery: deduplicate on it. |
| type | capacity.changes, the only event type today. Ignore types you do not know, so a new one is not an outage. |
| data.source_id, data.import_run_id | The source whose import published the changes, and the run. |
| data.counts, data.total | How many of the run's changes matched this endpoint's filters, per change type. |
| data.first_change_id, data.last_change_id | The first and last matching change-feed ids. |
| data.since | Pass it to GET /v1/changes?since= to read exactly the changes after it. |
| data.countries, data.operators | Where the matching changes are, for routing the event without a second request. |
curl "https://api.gridcapacityapi.com/v1/changes?since=184454&source=gb_…" \ -H "Authorization: Bearer gc_live_..."Verify every delivery
Each delivery is a POST with a JSON body and three headers:
| Header | Value |
|---|---|
| GridCapacity-Signature | t=<unix seconds>,v1=<hex>: the hex is HMAC-SHA256 of “<t>.<raw body>”, keyed with your endpoint's secret. |
| GridCapacity-Event | The event type, capacity.changes. |
| GridCapacity-Delivery | The delivery's id, for our logs and yours. |
Sign the bytes, not the JSON
t.import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.GRID_WEBHOOK_SECRET;
const TOLERANCE_S = 300;
function verify(header, rawBody) {
// "t=1727237582,v1=5257a869..."
const parts = Object.fromEntries(
(header ?? "").split(",").map((p) => p.trim().split("=", 2)),
);
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), Buffer.from(expected))
);
}
// express.raw keeps the body as bytes; express.json() here would break verification.
app.post("/hooks/grid", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.get("GridCapacity-Signature"), req.body)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(204); // answer first, work after
enqueue(event); // dedupe on event.id, then read /v1/changes?since=event.data.since
});import hashlib, hmac, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["GRID_WEBHOOK_SECRET"].encode()
TOLERANCE_S = 300
def verify(header: str, raw_body: bytes) -> bool:
# "t=1727237582,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/grid")
def grid_hook():
# get_data() is the raw body, before any JSON parsing.
if not verify(request.headers.get("GridCapacity-Signature", ""), request.get_data()):
abort(400)
enqueue(request.get_json()) # dedupe on event["id"]; do the work elsewhere
return "", 204Delivery and retries
- Answer with any
2xx, quickly. Anything else, a redirect included, is a failure: redirects are not followed. - A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 12 h: 6 attempts in all. The dashboard shows each endpoint’s last success and its failures in a row.
- An endpoint that fails 20 deliveries in a row is switched off. Nothing is lost: the change feed still has every change, and you catch up from the last
data.sinceyou 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. - Endpoints are reached on public addresses only. A URL that resolves to a private, loopback or link-local address is refused at delivery time.