Skip to content

Webhook payloads and signatures

Each delivery is a POST of this JSON body:

{
"id": "kmevt_1a2b3c...",
"type": "run.failed",
"created": "2026-10-10T12:00:00.000Z",
"summary": "company.resolve is failing for this workspace: KingMinos storage is temporarily unavailable.",
"data": {
"workspace_id": "ws_1a2b3c...",
"capability": "company.resolve",
"source_run_id": "run_9f8e7d...",
"external_ref": null,
"consecutive_failures": 3,
"reason": "KingMinos storage is temporarily unavailable, so the run failed before any vendor call.",
"remediation": "KingMinos had a storage outage. Retry the same Idempotency-Key after backoff (503 store_unavailable is retryable; do not mint a new key). See https://kingminos.com/docs/api/"
}
}
  • summary is the human-readable line (≤240 chars, emails redacted).
  • external_ref is whatever your capability call sent, for correlation.
  • test.ping deliveries carry data.message instead.
Header Meaning
KingMinos-Event-Id Event id (kmevt_…)
KingMinos-Delivery-Id Delivery attempt id (kmwhd_…)
KingMinos-Signature t=<unix>,v1=<hex> HMAC signature

The signature scheme is the same as OutboundSync’s product webhooks — v1 is HMAC-SHA256(secret, "<t>.<raw request body>"), hex-encoded. Compare with a constant-time check and reject timestamps outside a tolerance window (for example 5 minutes).

  1. Read the raw request body bytes exactly as received (do not parse and re-serialize JSON before verifying).
  2. Parse t and v1 from KingMinos-Signature.
  3. Compute HMAC-SHA256 over "${t}.${rawBody}" with your kmwhsec_… secret.
  4. Reject if the timestamp is stale or the hex digests differ.
const crypto = require('crypto');
// rawBody: the exact bytes received (Buffer or string), before any JSON parsing.
// header: the KingMinos-Signature value. secret: the whole kmwhsec_… string.
function verify(rawBody, header, secret, toleranceSec = 300) {
if (typeof header !== 'string') return false;
const parts = {};
for (const kv of header.split(',')) {
const i = kv.indexOf('=');
if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!parts.t || !parts.v1 || !Number.isInteger(t)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}

With Express, capture the bytes with express.raw({ type: 'application/json' }) and pass req.body (a Buffer) as rawBody.

POST /v1/webhooks/{id}/rotate-secret returns a new secret shown once. The previous secret stops working immediately (there is no grace window), so have your receiver ready, rotate, and update its secret at once.