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.

Delivery is daily, not instant. Events are created by the sync that ingests changed laws, which runs once a day (Mon–Sat, shortly after 11:00 UTC), and they are delivered at the end of that same run. So a law that changes today reaches your endpoint in tomorrow's batch, not within seconds. Use the dashboard's "Send test event" button to verify your endpoint and signature handling immediately.

Create an endpoint

endpoint = client.webhooks.create( url="https://yourapp.example/hooks/legalize", event_types=["law.updated", "reform.created"], description="Prod receiver", ) print(endpoint.id, endpoint.secret) # secret shown ONCE
const endpoint = await client.webhooks.create({ url: "https://yourapp.example/hooks/legalize", eventTypes: ["law.updated", "reform.created"], description: "Prod receiver", }); console.log(endpoint.id, endpoint.secret); // secret shown ONCE
endpoint, _ := client.Webhooks().Create(ctx, legalize.WebhookCreateOptions{ URL: "https://yourapp.example/hooks/legalize", EventTypes: []string{"law.updated", "reform.created"}, Description: "Prod receiver", }) fmt.Println(endpoint.ID, endpoint.Secret) // secret shown ONCE
curl -X POST "https://legalize.dev/api/v1/webhooks" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://yourapp.example/hooks/legalize","event_types":["law.updated"]}'
Store the secret. It's returned exactly once in the create response, and never shown again by 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:

  1. 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.
  2. Deploy it. Whatever is in between — a minute, an afternoon — your handler rejects the signature, so we record a failure and retry.
  3. 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.

Why the connector will not hand you the secret. The MCP connector can create an endpoint, but it never returns the signing key. Everything a tool returns is written into the model's context and into a transcript held by whoever runs the client — which is exactly where a key that can forge deliveries must not be. 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.
A rule that could never match is refused, not stored. An event type we do not emit, a country we do not serve, a law id the corpus does not hold, a search that will not parse — each is a 400 at create time. The alternative is an endpoint that looks healthy in the dashboard, fires nothing, ever, and gives you nothing to diagnose.
Words reach one language. Laws are written in their own, so 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.

curl -sX POST https://legalize.dev/api/v1/webhooks/preview \ -H "Authorization: Bearer $LEGALIZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_types":["law.updated","reform.created"], "countries":["es"], "match_query":"proteccion de datos"}'

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.

“Subscribe https://yourapp.example/hooks/legalize to Spanish data-protection changes, and tell me how many that would have been last month.”

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 over timestamp + "." + raw_body, keyed by the endpoint secret. Multiple v1=… 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()).

from fastapi import FastAPI, Request, HTTPException from legalize import Webhook, WebhookVerificationError app = FastAPI() @app.post("/hooks/legalize") async def receive(req: Request): body = await req.body() # raw bytes try: event = Webhook.verify( payload=body, sig_header=req.headers["X-Legalize-Signature"], timestamp=req.headers["X-Legalize-Timestamp"], secret=os.environ["LEGALIZE_WHSEC"], ) except WebhookVerificationError as e: logger.warning("webhook rejected: %s", e.reason) raise HTTPException(400) handle(event) # event.type, event.data return {}
import express from "express"; import { Webhook, WebhookVerificationError } from "@legalize-dev/sdk"; const app = express(); app.post( "/hooks/legalize", express.raw({ type: "application/json" }), // raw Buffer (req, res) => { try { const event = Webhook.verify({ payload: req.body, sigHeader: req.header("X-Legalize-Signature"), timestamp: req.header("X-Legalize-Timestamp"), secret: process.env.LEGALIZE_WHSEC, }); handle(event); res.status(204).send(); } catch (err) { if (err instanceof WebhookVerificationError) return res.status(400).send(); throw err; } } );
http.HandleFunc("/hooks/legalize", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) defer r.Body.Close() event, err := legalize.Verify( body, r.Header.Get("X-Legalize-Signature"), r.Header.Get("X-Legalize-Timestamp"), os.Getenv("LEGALIZE_WHSEC"), ) if err != nil { http.Error(w, "forbidden", http.StatusForbidden) return } handle(event) w.WriteHeader(http.StatusNoContent) })

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 left in_force. The payload carries both status and previous_status, so you can tell repealed from expired, annulled or partially_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.

{ "id": "evt_9f1c4a7b2e5d8036a1c4f9e2", "event_type": "reform.created", "created_at": "2026-08-27T11:04:12Z", "data": { "country": "es", "law_id": "BOE-A-1978-31229", "date": "2026-08-25", "source_id": "BOE-A-2026-14882", "subject": "[reform] Constitución Española — art. 49", "sha": "4f3a91c0e8b7…", // the commit this reform is "articles_affected": ["49"], "url": "https://legalize.dev/es/law/BOE-A-1978-31229" } }

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 id in 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.created and the reform.created for 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.
Replay protection. The verifier rejects any payload whose timestamp is more than 5 minutes off the server clock. Clock-skew tolerance is configurable (tolerance= in Python, tolerance option in Node, WithTolerance(...) in Go).