Webhooks
Add an endpoint in the dashboard and we POST each event your keys cause to it, signed with the endpoint's own secret. Every plan has them.
Events
| Type | When | data holds |
|---|---|---|
mark.completed | A file marked by POST /v1/mark (a clip when its job is done), or a manifest signed by POST /v1/sign (local mode). | mark_id, mode (cloud or local), project_id, kind (picture, song or clip), format, bytes, sha256, watermark_id, manifest, log_index, safety; a song's and a clip's seconds and marks |
mark.failed | A clip whose job failed: blocked by the safety check, over the marks left, a file we can't read, or a failure on our side. Nothing was marked. | mark_id, project_id, kind, error (code and message), and a blocked frame's safety |
check.blocked | A picture or a clip's frame the safety check blocked, on POST /v1/check or with check on POST /v1/mark. | request_id, endpoint, project_id, policy, reason, picture (file or input), and a clip's frame. Never the picture or its hash: we keep none |
ping | A test, sent from the dashboard to one endpoint. | message |
An endpoint takes the events of either live or test keys, never both, as keys do (environments). The body:
POST application/json
{
"created": 1793182831,
"data": {
"bytes": 1208033, "format": "image/png", "log_index": 4127, "manifest": "urn:c2pa:…",
"mark_id": "mk_4Q8oT1n6mWc2fLr0aZ9eYk", "mode": "cloud", "project_id": "prj_…",
"kind": "picture", "safety": null, "sha256": "…", "watermark_id": "1cfc5f99ffce3db9"
},
"id": "evt_9w1KQ0p3aZ8xLm2Tn4Rb6c",
"livemode": true,
"object": "event",
"type": "mark.completed"
}| Header | Value |
|---|---|
Kennmarq-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>: what to verify before trusting the body. |
Kennmarq-Event-Id | The event's ID (evt_…), the same on every retry: use it to handle each event once. |
Kennmarq-Event-Type | The event's type. |
Content-Type | application/json |
User-Agent | Kennmarq-Webhooks/1 |
Verify each delivery
The signature is an HMAC-SHA256 of the time, a full stop and the raw body, under the secret the dashboard showed when you added the endpoint (whsec_…). It's Stripe's scheme: the same few lines check both. Compare in constant time, and refuse a time more than five minutes off, so an old delivery can't be replayed.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header, now = Date.now() / 1000) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(now - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}Python
import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, header: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Verify the raw body as it arrived, before any JSON parser touches it: a re-serialised body won't match.
Delivery and retries
- A delivery counts when your endpoint answers 2xx within 10 seconds. Answer first, then do the work.
- Otherwise we try again after 1 and 5 minutes, half an hour, then 2, 6, 12 and 24 hours, and then give up. Events can arrive more than once and out of order: handle each
Kennmarq-Event-Idonce. - Endpoints are HTTPS on the standard port, on the public internet: we never follow a redirect, and never send to a private, loopback or link-local address, whatever a name resolves to.
- The dashboard shows each organisation's recent deliveries; we keep them 30 days.