Partner API v1 · Draft, not yet live

Webhooks

Draft — not yet live. This describes the proposed v1 partner contract (version 1.0.0-rc.1). No endpoint on this page is deployed, and no API key can be issued yet.

Webhooks tell your system when a register you depend on changed, when a watched entry changed or when a record on your watch list was amended. Every delivery is signed so that you can check it came from Crown Intelligence.

Events

Webhook events
EventSent when
list.updatedA register in a collection was loaded with changes.
monitor.hitA watched entity, record or collection was listed, delisted, amended or merged, in a way its notify_on covers.
record.changedA record on your watch list was amended, retired or merged.
webhook.testSent when you call POST /v1/webhooks/{id}/test. You cannot subscribe to it.

Creating an endpoint

The required scope is webhooks:manage. The URL must be https://. Private, loopback and link-local addresses are refused. The signing secret is returned once, in the creation response. Store it in your secret manager.

curl https://api.crownintelligence.net/v1/webhooks \
  -H "X-Api-Key: $CI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: hook-setup-0001" \
  -d '{"url": "https://partner.example.com/crown/events",
       "events": ["list.updated", "monitor.hit", "record.changed"],
       "description": "Re-screening queue"}'

The response has the endpoint id, its status and the secret. Later reads show only secret_prefix.

{
  "id": "whk_01J9Z3K4M6AB",
  "url": "https://partner.example.com/crown/events",
  "events": ["list.updated", "monitor.hit", "record.changed"],
  "status": "active",
  "secret_prefix": "whsec_9f",
  "secret": "whsec_9f2c...shown once..."
}

Endpoints are changed with PATCH (URL, events, and active or paused). Stop one with POST /v1/webhooks/{id}/retire, which keeps its delivery log. Rotate the secret with POST /v1/webhooks/{id}/rotate-secret. The new secret is shown once. For 24 hours both secrets are sent, so you can switch without losing an event.

Verifying the signature

Each delivery carries the Crown-Signature header, t=<unix seconds>,v1=<hex>. The hex value is the HMAC-SHA256, keyed with the endpoint secret, of the string <t>.<raw body>. Verify in this order: parse t and the v1 values; refuse if t is more than 300 seconds from your clock; compute the HMAC; compare in constant time; only then parse the body.

Crown-Signature: t=1760364000,v1=<hex HMAC-SHA256>
Crown-Event-Id: evt_01J9Z4...
Crown-Delivery-Id: dlv_01J9Z5...   (one per attempt; the event id is the same on retries)
Crown-Event-Type: list.updated

Node

import crypto from "node:crypto";

// rawBody must be the exact bytes received, as a string. Do not re-serialise the JSON first.
export function verifyCrownSignature(rawBody, signatureHeader, secret) {
  const parts = signatureHeader.split(",").map(p => p.trim().split("="));
  const t = Number((parts.find(([k]) => k === "t") || [])[1]);
  const v1 = parts.filter(([k]) => k === "v1").map(([, v]) => v);   // two values during a secret rotation
  if (!Number.isFinite(t) || v1.length === 0) return false;

  // 1. Replay protection: refuse anything more than 300 seconds from now.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;

  // 2. HMAC-SHA256 over "<t>.<raw body>", keyed with the endpoint secret.
  const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest();

  // 3. Constant-time comparison against each value.
  return v1.some(hex => {
    const given = Buffer.from(hex, "hex");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

Use the bytes as they arrived. Many frameworks parse JSON before your code runs, which changes the bytes and breaks the signature. Read the raw body first.

Receiving events

Delivery is at least once and not in order. The same event can arrive more than once, with the same Crown-Event-Id on each retry. Use the event id to avoid handling it twice, and do not rely on arrival order.

// Verify first, then parse. Deduplicate on the event id: delivery is at least once and unordered.
const seen = new Set();   // use a durable store in production

export async function receive(req, rawBody) {
  const ok = verifyCrownSignature(rawBody, req.headers["crown-signature"], process.env.CROWN_WEBHOOK_SECRET);
  if (!ok) return 401;
  const eventId = req.headers["crown-event-id"];
  if (seen.has(eventId)) return 200;            // a retry of an event already handled
  const event = JSON.parse(rawBody);            // only now parse the body
  seen.add(eventId);
  // ... queue event for re-screening using your own subject data ...
  return 200;                                   // any 2xx within 10 seconds counts as delivered
}

Retries and the delivery log

  • Success is any 2xx response within 10 seconds.
  • Any other response, or a timeout, is retried. Retries come at 1, 5, 30, 120, 360, 720 and 1,440 minutes after the first attempt: eight attempts in all, the last about 45 hours after the first.
  • A 410 Gone response pauses the endpoint and stops the retries. Crown Intelligence notifies the account owner.
  • Every attempt is listed at GET /v1/webhooks/{id}/deliveries, with its status (pending, succeeded, retrying or failed), the response code, the last error and the next attempt time. The log is append-only.

Test an endpoint with POST /v1/webhooks/{id}/test. It sends a webhook.test event through the same signing and delivery path.

Signature and retry details follow section 8 of the draft contract. Example values are illustrative.