Source: https://legiscore.in/docs/webhooks

# Webhooks

Give a callback_url and we POST the finished search to you, signed.

Add callback_url to a search, or once at the top of a batch. It must be https with no username or password. Without one, your workspace's webhook subscriptions receive the same events.

Events: search.completed (succeeded), search.failed (failed or cancelled).

```json
{
  "delivery_id": "dlv_7f3a...",
  "event": "search.completed",
  "api_version": "2026-04-26",
  "occurred_at": "2026-10-05T10:03:12Z",
  "data": {
    "search": {
      "id": "SRCH-2026-A1B2C3D4",
      "status": "succeeded",
      "found": true
    },
    "links": {
      "self": "/api/v1/search/SRCH-2026-A1B2C3D4",
      "documents": [
        "/api/v1/search/SRCH-2026-A1B2C3D4/documents/result.pdf"
      ]
    }
  }
}
```

Links are paths, not signed URLs; call them with your API key.

## Verify the signature

Headers: X-LegiScore-Event, X-LegiScore-Delivery (unique, de-duplicate on it), X-LegiScore-Timestamp, X-LegiScore-Signature = "sha256=" + hex HMAC-SHA256 of "<timestamp>.<raw body>" keyed with your webhook secret. Verify against the raw body before parsing. Reject old timestamps.

```js
import crypto from "node:crypto";

export function verify(rawBody, headers, secret) {
  const timestamp = headers["x-legiscore-timestamp"];
  const received = headers["x-legiscore-signature"];
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Delivery and retries

- Answer any 2xx within 30 seconds.
- Otherwise retried after 60, 120, 240, 480 and 960 seconds (6 attempts).
- The same delivery can arrive twice. De-duplicate on X-LegiScore-Delivery.
- Polling GET /api/v1/search/{id} always gives the truth.
