Skip to content

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

Webhook events
TypeWhendata holds
mark.completedA 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.failedA 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.blockedA 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
pingA 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"
}
Headers of a delivery
HeaderValue
Kennmarq-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>: what to verify before trusting the body.
Kennmarq-Event-IdThe event's ID (evt_…), the same on every retry: use it to handle each event once.
Kennmarq-Event-TypeThe event's type.
Content-Typeapplication/json
User-AgentKennmarq-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-Id once.
  • 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.