RateGrid

source.changed

Delivered when a document RateGrid cites for one of your tariffs changes · signed · free to register and free to receive

Read this before you build on it: the event is called source.changed, not rate.changed, and the difference is the whole design.

What RateGrid establishes at detection time is that bytes moved in a document it watches. Whether any rate moved is exactly what has not been determined yet. Most of the time none did.

Of the 42 changes reviewed so far, 24 were not rate changes — 57% re-renders, public notices and formatting. That is the expected behaviour of this event, not a fault in it.

So your handler should not re-price anything on receipt. It should tell you to look, or wait for the review field to carry a verdict. A handler written on the assumption that this event means a rate moved will be wrong most times it fires.

Why it is worth having anyway

A citation says a figure is traceable to a filed document. It does not say the document is still the one in force — section 6.3 of the terms is explicit that it cannot. Currency is a separate claim, and this is how it is delivered. Before this existed the monitor had been fetching every watched source daily for weeks, archiving what moved, and telling nobody but the operator.

Register an endpoint

curl -X POST https://rategrid.dev/webhooks \
  -H "X-API-Key: $RATEGRID_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/rategrid"}'

The response carries your signing secret. It is shown once and cannot be retrieved afterwards, because RateGrid does not keep it. The secret is derived when a delivery is signed, from a master key held outside the database and your endpoint's id — so there is no secret column, nothing to leak from a backup, and no way for support to read it back to you. If you lose it, delete the endpoint and make another.

HTTPS only. A signed payload sent over plain HTTP is a signed payload anyone on the path can read, which defeats the point of signing it.

Verifying a delivery

Every request carries this header:

X-RateGrid-Signature: t=1756640400,v1=9f86d081884c7d65...

v1 is HMAC-SHA256 over {timestamp}.{raw body}, keyed with your secret, hex encoded. The timestamp is inside the signed material, so a captured delivery cannot be replayed later against the same signature, and it is also sent in the clear so you can reject a stale one without parsing the body first.

Reject anything older than 300 seconds. Compare with a constant-time equality — == on a string leaks the correct prefix through timing.

Python

import hashlib, hmac, time

def verify(secret: str, body: bytes, header: str, window: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        ts = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - ts) > window:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Node

const crypto = require("crypto");

function verify(secret, body, header, window = 300) {
  const parts = Object.fromEntries(
    header.split(",").map(p => p.split("=", 2)));
  const ts = Number(parts.t);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > window) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(`${ts}.`).update(body).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Sign the raw bytes you received, not a re-serialised object. Most frameworks give you a parsed body by default; re-encoding it will reorder keys or change spacing and the signature will not match.

The payload

{
  "id": "evt_9f2c1a...",
  "type": "source.changed",
  "created": "2026-08-27T11:15:31+00:00",
  "data": {
    "source_id": "regulatory.html",
    "url": "https://www.firstenergycorp.com/.../regulatory.html",
    "detected_at": "2026-08-27T11:15:31+00:00",
    "previous_hash": "aaa...",
    "current_hash": "bbb...",
    "summary": "same size, digest moved",
    "affects": ["jcpl-rs-2026-07-15"],
    "review": null,
    "changes_url": "https://rategrid.dev/changes"
  },
  "notice": "A watched source document changed. This is NOT a statement that
             any rate changed ..."
}

Two fields worth reading carefully

affects is the list of tariffs that cite this document. It is not a list of tariffs that changed — whether any of them did is the question the event does not answer.

review is null when nobody has looked yet, and that is a fact rather than a default. Once a change has been reviewed the verdict rides along. “Not yet examined” and “examined and found harmless” are different states and only one of them should let you go back to sleep, so they are not collapsed into the same value.

Key rotation, and what happens if a key is retired

Each endpoint records the key version its secret was derived under. When RateGrid rotates its signing key, a new version is added and the old one is kept, so your existing secret goes on verifying until you choose to re-register. Rotation is additive; nothing breaks on the day it happens.

If the version your endpoint was issued under is eventually removed, RateGrid stops sending to that endpoint rather than signing with a key you cannot check. The skipped delivery is recorded with the reason, visible on your delivery log, and it tells you to re-register. A mis-signed delivery would fail verification at your end with no explanation and look identical to a bug in your own code.

Delivery log

curl https://rategrid.dev/webhooks -H "X-API-Key: $RATEGRID_KEY"

Lists your live endpoints and the reason the most recent delivery failed, if one did — so “we sent it” is checkable rather than asserted, and you can see why nothing is arriving without asking. Every attempt is recorded with its status code and error, and kept for 90 days, then deleted.

Endpoints

CallWhat it doesCredits
POST /webhooks Register a URL; returns the signing secret once 0
GET /webhooks Your live endpoints and last error. Secrets are never returned 0
DELETE /webhooks/{id} Stop deliveries to one endpoint 0

Managing your endpoints costs no quota, for the same reason revoking a leaked key does not: an account that has exhausted its allowance must still be able to stop or redirect what is being sent to it.

Delivery behaviour

What this does not tell you. It fires on documents RateGrid watches for tariffs in your account's reach. It does not fire when a utility files something RateGrid does not watch, and it does not fire for a rate change that reaches customers through a document nobody publishes. The status page lists every watched source and when each was last read, including the ones that must be checked by hand.