Webhooks
Register an endpoint and Legalize signs and POSTs to it. Signatures are constant-time HMAC-SHA256, delivery is retried on failure, and the SDKs ship a one-line verifier.
Create an endpoint
list
or retrieve. If you lose it, or want to retire it,
rotate — the endpoint and its delivery
history stay where they are.
Rotating the signing secret
One key at a time, relieved the instant you ask. There is no overlap window and you do not need one:
- Rotate.
POST /api/v1/webhooks/{id}/rotate, or the Rotate signing key button on the webhooks dashboard. Both answer with the new secret once. The old one stops verifying immediately. - Deploy it. Whatever is in between — a minute, an afternoon — your handler rejects the signature, so we record a failure and retry.
- Nothing is lost. Deliveries go out once a day and the ladder is five attempts spread over about five days. When your new secret is live the retry verifies and the event lands. You see failures for a while, with an obvious cause; you do not see a gap in the feed.
Rotate whenever the old secret has been somewhere it should not have been — a shared log, a screenshot, a chat transcript. It is a symmetric key: anyone holding it can sign a payload that looks exactly like ours.
create_webhook answers with a
secret_url instead; you collect the key in the dashboard, once, and rotate it
there.
An account may hold up to 20 endpoints. Past that,
a create is refused with too_many_endpoints until one is deleted.
Narrow it, or you will stop reading it
An endpoint with no filter receives every change in every country. That is a few dozen a day across the whole corpus — survivable, and almost never what anyone wants. Four filters narrow it, and they compose as AND: adding one can only ever reduce what arrives.
event_types— the only one with no “any”: an endpoint subscribed to no event could never fire.countries— two-letter codes. Omit for all of them.law_ids— watch specific norms and nothing else, up to 200. This is the precise subscription: a live matter, a compliance mapping, the acts one client is exposed to. Over the API and the connector only, where naming a norm by its id is unambiguous; the dashboard form narrows by words instead.match_query— words that must appear in the title, the short title or the official subject headings. Read by the same engine as the search box, so endings fold in and filler words are dropped.
400 at create time. The alternative is an endpoint that
looks healthy in the dashboard, fires nothing, ever, and gives you nothing to diagnose.
match_query is matched against the words actually stored: proteccion de
datos finds the Spanish act and not the Portuguese protecao de dados. To follow
one topic across jurisdictions, create one endpoint per language rather than one endpoint with
several countries.
Try the rule before you commit to it
POST /api/v1/webhooks/preview takes the same fields as create and answers what
they would have delivered over the last 30 days — a count, a per-day rate and a
sample — without writing anything. It runs the identical matcher the dispatcher runs, so
a preview showing nothing is a promise that the endpoint would show nothing.
The dashboard runs it for you: the create form previews itself as you type, on every change to
the words, the events and the countries. The connector has it as preview_webhook.
Or ask an assistant to create it
The same endpoint can be registered through the MCP connector, without
writing any of the code above. Three tools do it — create_webhook,
list_webhooks and delete_webhook — and, together with the
email digest's three, they are the only tools on the
connector that write anything. If nobody is going to run a server for the deliveries,
create_email_digest takes the identical rule and sends the day's changes to your
own inbox instead.
It answers with the endpoint, a link to collect the signing key, and how many events the rule would have delivered in the last month — so a filter that catches nothing is visible immediately. The key itself is not in that reply, and that is the point: pick it up on the dashboard, where it is shown once and can be rotated.
Delivery format
Each delivery is a POST with these headers:
X-Legalize-Signature: v1=<hex_hmac_sha256>— signature overtimestamp + "." + raw_body, keyed by the endpoint secret. Multiplev1=…entries can be comma-joined.X-Legalize-Timestamp— Unix seconds at the moment we signed the payload.X-Legalize-Event— the event type (redundant with the body but handy for fast routing).Content-Type: application/json.
Verify in your handler
Use the raw request bytes. Re-serializing the JSON
changes whitespace and breaks the signature. Every framework has
an escape hatch for this (Express: express.raw(),
Flask: request.get_data(), FastAPI: await request.body()).
Event types
test.ping— synthetic event from the dashboard's "Send test event" button. Delivered immediately, and the only one that is.law.created— a law the corpus did not have before.law.updated— a law we already had was re-ingested because its file changed.law.repealed— the law leftin_force. The payload carries bothstatusandprevious_status, so you can tellrepealedfromexpired,annulledorpartially_repealed.reform.created— a reform record that was not in the history before, with its date, source id and subject.
An endpoint only receives events created after it was registered: subscribing today is not a request for last week's changes. Events are dropped if they cannot be delivered within 7 days.
Your SDK accepts any string — we may add event types in future releases; forward compatibility is intentional.
Retries, delivery receipts, replay
A delivery that fails (non-2xx from your server, a timeout, a TLS error)
is retried on the next dispatch run, up to 5 attempts, and is then marked
failed. Since dispatch runs with the daily sync, those
attempts are normally a day apart. List past deliveries via
webhooks.deliveries(endpoint_id) and retry one immediately
with webhooks.retry(endpoint_id, delivery_id).
What arrives
Every event has the same envelope. data is what differs by type.
The sha is the commit in the country repository, so you can read the
exact text the reform produced straight from
raw.githubusercontent.com/legalize-dev/legalize-{country}/<sha>/…
without asking us again. A law.* event carries
title, status and last_updated instead, plus
previous_status on a repeal.
The body of the law is never in the payload. Fetch it with the sha, or
from GET /api/v1/{country}/laws/{id}.
What the delivery guarantees are
- At least once, not exactly once. The same event can arrive
twice — a dispatch run that dies after your server answered, or a manual
retry. Deduplicate on the
idin the payload; it is stable across redeliveries of the same event. - No ordering guarantee. Events from one run are delivered in no
particular order, so a
law.createdand thereform.createdfor the same law can arrive either way round. Treat each event as a signal to re-read the law, not as a delta to apply in sequence. - A disabled endpoint does not accumulate. While every endpoint on your account is disabled, events are not recorded for you at all — disabling and re-enabling loses that interval rather than queueing it. Delete an endpoint you no longer want; disable one only while you are fixing it.
tolerance= in Python, tolerance option
in Node, WithTolerance(...) in Go).