Skip to content
GridCapacityAPI.com

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.

FilterNotifies only for changes…
countriesin these ISO country codes.
operatorsfrom these operators (slug or id).
sourcesfrom these source ids.
change_typesof 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.

FieldMeaning
idThe event's id. The same on every retry of the delivery: deduplicate on it.
typecapacity.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_idThe source whose import published the changes, and the run.
data.counts, data.totalHow many of the run's changes matched this endpoint's filters, per change type.
data.first_change_id, data.last_change_idThe first and last matching change-feed ids.
data.sincePass it to GET /v1/changes?since= to read exactly the changes after it.
data.countries, data.operatorsWhere 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:

HeaderValue
GridCapacity-Signaturet=<unix seconds>,v1=<hex>: the hex is HMAC-SHA256 of “<t>.<raw body>”, keyed with your endpoint's secret.
GridCapacity-EventThe event type, capacity.changes.
GridCapacity-DeliveryThe delivery's id, for our logs and yours.

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.
Node.js (Express)
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
});
Python (Flask)
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 "", 204

Delivery 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.since 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.
  • Endpoints are reached on public addresses only. A URL that resolves to a private, loopback or link-local address is refused at delivery time.