Webhooks | API Reference Guide | CourtMesh
    Skip to main content

    Webhooks

    Receive each monitoring event as a signed HTTPS POST. Delivery, retries and webhook management are free.

    Signed event delivery
    HMAC SHA256 signatures, eight attempts over about two days, and a delivery log you can read.
    POST

    /webhooks

    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

    FieldTypeRequiredNotes
    urlhttps URLYesMust 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.
    eventsstring[]NoEvent 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.
    watchlistIdsstring[]NoOnly events from these watchlists. Default: all watchlists; an empty list also means all.
    descriptionstring (max 200)NoA 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"
      }'

    Payload and headers

    The body is the event object, identical to what GET /changes returns. Every delivery carries these headers:

    Headers

    FieldTypeRequiredNotes
    Content-TypeheaderNoapplication/json
    User-AgentheaderNoCourtMesh-Webhooks/1
    CourtMesh-Event-IdheaderNoThe event id. Use it to dedupe.
    CourtMesh-Event-TypeheaderNoFor example case.order_added.
    CourtMesh-Delivery-IdheaderNoThe delivery attempt record, visible in the deliveries list.
    CourtMesh-SignatureheaderNot=<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 }
      }
    }

    Verify the signature

    Anyone who learns your URL can POST to it, so check the signature before you trust a delivery. The recipe:

    1. Split the CourtMesh-Signature header on commas. Take t and every v1.
    2. Reject the delivery if the difference between now and t is more than 300 seconds.
    3. Compute an HMAC SHA256 with your secret over t + "." + rawBody, using the raw body bytes exactly as received.
    4. Compare it in constant time against each 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);
      });
    }

    Retries and auto disable

    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:

    AttemptWhen
    1Immediately
    21 minute after the failure
    35 minutes
    430 minutes
    52 hours
    66 hours
    712 hours
    824 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.

    Rotating the secret

    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.

    Testing and deliveries

    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"

    Best practices

    • Dedupe on the event id. Delivery is at least once, so the same event can arrive twice. Store CourtMesh-Event-Id and skip repeats.
    • Verify the raw body. Read the bytes before any JSON parsing and keep the 300 second tolerance to blunt replays.
    • Answer fast, work later. Return a 2xx within 10 seconds and put the real work on a queue.
    • Reconcile with the feed. Poll GET /changes with a stored cursor now and then. An empty page is free, and it closes any gap from downtime on your side.
    • Keep the secret secret. Load it from your environment, never from source control, and rotate it if it leaks.
    • Expect new fields. Ignore fields you do not recognise, and handle event types you did not subscribe to without failing.

    All webhook endpoints

    All webhook management calls are free. They need the monitoring:write scope to change anything and monitoring:read to read.

    MethodPathPurpose
    POST/webhooksCreate (HTTP 201). Returns the secret once.
    GET/webhooksList endpoints.
    GET/webhooks/:idRead one. Never returns the secret.
    PATCH/webhooks/:idChange url, events, watchlistIds, description or enabled.
    DELETE/webhooks/:idDelete the endpoint and its delivery history. Returns { deleted: true }.
    POST/webhooks/:id/rotate-secretIssue a new secret. The old one keeps signing for 24 hours.
    POST/webhooks/:id/testSend a webhook.test event now, once, and return the delivery.
    GET/webhooks/:id/deliveriesDelivery log, cursor paginated.
    POST/webhooks/:id/deliveries/:deliveryId/retryRe-queue one delivery now.

    Errors: WEBHOOK_NOT_FOUND (404), WEBHOOK_LIMIT_REACHED (403), INVALID_WEBHOOK_URL (400) and DELIVERY_NOT_FOUND (404).