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. It's never shown again by list or retrieve. Lose it and you have to rotate by deleting and re-creating the endpoint.

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).