Receive each monitoring event as a signed HTTPS POST. Delivery, retries and webhook management are free.
Register an endpoint. An account can have up to 10. The response is HTTP 201 and includes the signing secret, which starts with whsec_ and is shown once, here and when you rotate it. Store it where your server can read it. This call honours the Idempotency-Key header. If you send no events, the response (and every later read) lists the full default set of case.* types; if you send no watchlistIds it shows [], which means all your watchlists.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
| url | https URL | Yes | Must be https, on port 443 or any non privileged port, and resolve to a public address. Private, loopback, link local and cloud metadata addresses are refused, checked at create time and again at every delivery. |
| events | string[] | No | Event types to receive, drawn from the seven case.* types (webhook.test is only sent by the test endpoint). Default: every case.* type; an empty list also means all. |
| watchlistIds | string[] | No | Only events from these watchlists. Default: all watchlists; an empty list also means all. |
| description | string (max 200) | No | A note for yourself. |
curl -X POST https://research.courtmesh.ai/api/v1/prod/webhooks \
-H "X-API-Key: cm-YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/courtmesh/webhook",
"events": ["case.order_added", "case.hearing_date_changed", "case.disposed"],
"watchlistIds": ["wl_8f3k2m"],
"description": "Collections team"
}'The body is the event object, identical to what GET /changes returns. Every delivery carries these headers:
Headers
| Field | Type | Required | Notes |
|---|---|---|---|
| Content-Type | header | No | application/json |
| User-Agent | header | No | CourtMesh-Webhooks/1 |
| CourtMesh-Event-Id | header | No | The event id. Use it to dedupe. |
| CourtMesh-Event-Type | header | No | For example case.order_added. |
| CourtMesh-Delivery-Id | header | No | The delivery attempt record, visible in the deliveries list. |
| CourtMesh-Signature | header | No | t=<unix seconds>,v1=<hex HMAC SHA256>. During a secret rotation it carries two v1 values. |
POST /courtmesh/webhook HTTP/1.1
Content-Type: application/json
User-Agent: CourtMesh-Webhooks/1
CourtMesh-Event-Id: evt_01hx7k...
CourtMesh-Event-Type: case.hearing_date_changed
CourtMesh-Delivery-Id: whd_9m2c...
CourtMesh-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_01hx7k...",
"type": "case.hearing_date_changed",
"createdAt": "2026-11-02T09:15:00.000Z",
"cnr": "DLHC010084212021",
"caseId": "65f2a9c4b1d3e8a7c6540012",
"watchlists": [{ "id": "wl_8f3k2m", "reference": "LOAN-42" }],
"data": {
"previous": "2026-11-10",
"current": "2026-12-04",
"snapshot": { "caseStatus": "Pending", "nextHearingDate": "2026-12-04", "orderCount": 7 }
}
}Anyone who learns your URL can POST to it, so check the signature before you trust a delivery. The recipe:
CourtMesh-Signature header on commas. Take t and every v1.t is more than 300 seconds.t + "." + rawBody, using the raw body bytes exactly as received.v1. Accept if any matches.The Node and Python clients do this for you. Both return the parsed event or throw WebhookSignatureError, and both accept a list of secrets so you can cover your side of a rotation.
import express from 'express';
import { verifyWebhookSignature, WebhookSignatureError } from '@courtmesh/sdk';
const app = express();
// Use the RAW body. Parsing it to an object and re-serialising it changes
// the bytes, and the signature will not match.
app.post('/courtmesh/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = verifyWebhookSignature(
req.body, // Buffer
req.get('CourtMesh-Signature'),
process.env.COURTMESH_WEBHOOK_SECRET, // whsec_...
{ toleranceSeconds: 300 },
);
} catch (err) {
if (err instanceof WebhookSignatureError) return res.sendStatus(400);
throw err;
}
if (await alreadyProcessed(event.id)) return res.sendStatus(200); // at least once
await enqueue(event); // do the real work after you answer
res.sendStatus(200);
});The SDK helpers are verifyWebhookSignature in Node and verify_webhook_signature in Python. Without the SDKs, the same check by hand:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret, tolerance = 300) {
const parts = header.split(',').map((p) => p.split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return signatures.some((sig) => {
const got = Buffer.from(sig, 'hex');
return got.length === expected.length && timingSafeEqual(got, expected);
});
}A delivery succeeds on any 2xx response within 10 seconds. Each attempt has one hard 10 second deadline and reads at most 64 KB of your response. Redirects are not followed. Anything else is a failure and is retried on this schedule, eight attempts in all, after which the delivery is marked failed:
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 1 minute after the failure |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours after attempt 7. If this one fails, the delivery is marked failed |
An endpoint whose deliveries only failed for 3 consecutive days is set to enabled: false with a disabledReason, and the person who created the endpoint is emailed. Fix the endpoint, then re-enable it with PATCH /webhooks/:id. Events stay readable from GET /changes for 90 days either way, so nothing is lost while an endpoint is down. A delivery that was never attempted and is more than 24 hours old is marked failed with lastError set to expired instead of being sent.
POST /webhooks/:id/rotate-secret returns a new secret. For the next 24 hours both secrets sign every delivery, so the header carries two v1 values and a receiver holding either secret verifies. Deploy the new secret on your side within that window, then drop the old one.
POST /webhooks/:id/test sends a webhook.test event immediately, as a single attempt that is never retried, and returns the delivery object. It is sent even to a switched off endpoint and does not count toward the endpoint's failure streak. The test event is not stored in the change feed. GET /webhooks/:id/deliveries lists every attempt with its status, attempt count, last response status and next attempt time, newest first, and keeps them for 30 days. POST /webhooks/:id/deliveries/:deliveryId/retry re-queues one now and returns the delivery object. An unknown delivery id is 404 DELIVERY_NOT_FOUND.
# Send a webhook.test event now and see the result
curl -X POST https://research.courtmesh.ai/api/v1/prod/webhooks/wh_4b9q7d/test \
-H "X-API-Key: cm-YOUR_KEY_HERE"
# Inspect deliveries
curl https://research.courtmesh.ai/api/v1/prod/webhooks/wh_4b9q7d/deliveries \
-H "X-API-Key: cm-YOUR_KEY_HERE"
# Re-queue one delivery now
curl -X POST https://research.courtmesh.ai/api/v1/prod/webhooks/wh_4b9q7d/deliveries/whd_9m2c.../retry \
-H "X-API-Key: cm-YOUR_KEY_HERE"{
"success": true,
"data": [
{
"id": "whd_9m2c...",
"eventId": "evt_01hx7k...",
"eventType": "case.hearing_date_changed",
"status": "failed",
"attempts": 3,
"lastAttemptAt": "2026-11-02T09:50:00.000Z",
"nextAttemptAt": "2026-11-02T11:50:00.000Z",
"lastResponseStatus": 503,
"lastError": null,
"createdAt": "2026-11-02T09:15:00.000Z"
}
],
"pagination": { "nextCursor": null, "hasMore": false }
}
// status is "pending", "succeeded" or "failed"CourtMesh-Event-Id and skip repeats.GET /changes with a stored cursor now and then. An empty page is free, and it closes any gap from downtime on your side.All webhook management calls are free. They need the monitoring:write scope to change anything and monitoring:read to read.
| Method | Path | Purpose |
|---|---|---|
| POST | /webhooks | Create (HTTP 201). Returns the secret once. |
| GET | /webhooks | List endpoints. |
| GET | /webhooks/:id | Read one. Never returns the secret. |
| PATCH | /webhooks/:id | Change url, events, watchlistIds, description or enabled. |
| DELETE | /webhooks/:id | Delete the endpoint and its delivery history. Returns { deleted: true }. |
| POST | /webhooks/:id/rotate-secret | Issue a new secret. The old one keeps signing for 24 hours. |
| POST | /webhooks/:id/test | Send a webhook.test event now, once, and return the delivery. |
| GET | /webhooks/:id/deliveries | Delivery log, cursor paginated. |
| POST | /webhooks/:id/deliveries/:deliveryId/retry | Re-queue one delivery now. |
Errors: WEBHOOK_NOT_FOUND (404), WEBHOOK_LIMIT_REACHED (403), INVALID_WEBHOOK_URL (400) and DELIVERY_NOT_FOUND (404).