Skip to content

Webhooks and usage

Subscribe to inference events with HMAC-signed deliveries, and read your usage.

Webhooks push events to a URL you control, so you do not have to poll. Each registration belongs to the consumer that owns the calling key, and deliveries are HMAC-signed with a secret you choose.

Register an endpoint

POST/api/v1/webhooks
url
uriRequired
Where deliveries are POSTed. Must be reachable over HTTPS.
event_types
string[]Required
At least one event, for example ["inference.completed", "key.expiring"].
secret
stringRequired
16 to 128 characters, generated by you. Used to HMAC-sign every delivery. It is never returned by any endpoint afterwards.
bash
curl -X POST "https://api-diagnostics.karmat-hq.com/api/v1/webhooks" \
  -H "X-API-Key: $DIAGNOSTICS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://clinic.example.com/hooks/karmat",
    "event_types": ["inference.completed", "key.expiring"],
    "secret": "'"$(openssl rand -hex 32)"'"
  }'
json
{
  "id": "wh_01HQ8ZK3M4",
  "url": "https://clinic.example.com/hooks/karmat",
  "event_types": ["inference.completed", "key.expiring"],
  "is_active": true,
  "created_at": "2026-08-27T09:21:44Z"
}

Store the secret on your side

The signing secret is write-only: you supply it at registration and the API never gives it back. If you lose it you cannot verify deliveries, and the only fix is to delete the webhook and register a new one.

Verify every delivery

An unverified webhook endpoint is an open API that anyone who learns the URL can post to. Compute the HMAC over the raw body, because parsing first and re-serialising will change the bytes and break the signature.

py
import hmac, hashlib

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    # Constant-time: a plain == leaks timing that can forge a signature.
    return hmac.compare_digest(expected, signature)
app/api/hooks/karmat/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const raw = await request.text(); // raw text, not request.json()
  const signature = request.headers.get("X-Karmat-Signature") ?? "";

  const expected = createHmac("sha256", process.env.KARMAT_WEBHOOK_SECRET!)
    .update(raw)
    .digest("hex");

  const ok =
    signature.length === expected.length &&
    timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!ok) return new Response("invalid signature", { status: 400 });

  await enqueue(JSON.parse(raw)); // hand off, respond fast
  return new Response(null, { status: 204 });
}

Acknowledge fast, work later

Return a 2xx as soon as you have verified and persisted the event. Doing the actual work inside the handler makes deliveries time out, and a timed-out delivery is a retried delivery.

List and delete

GET/api/v1/webhooks

Returns your registrations. Secrets are omitted.

bash
curl "https://api-diagnostics.karmat-hq.com/api/v1/webhooks" \
  -H "X-API-Key: $DIAGNOSTICS_API_KEY"
DELETE/api/v1/webhooks/{webhook_id}
bash
curl -X DELETE "https://api-diagnostics.karmat-hq.com/api/v1/webhooks/wh_01HQ8ZK3M4" \
  -H "X-API-Key: $DIAGNOSTICS_API_KEY"

Usage analytics

GET/api/v1/analytics/usage

Aggregate request counts and latency for the calling key. This is where you check what you are actually consuming, and the limits attached to your key.

bash
curl "https://api-diagnostics.karmat-hq.com/api/v1/analytics/usage" \
  -H "X-API-Key: $DIAGNOSTICS_API_KEY"

Pair this with your own logging. Record the response model_version alongside your request id on every inference call. When a clinician questions a result months later, that pairing is the difference between answering and guessing.