Developers

The whole review flow,
over HTTP.

Everything the Chekr app does is an API: upload or import creatives, run scans with live SSE progress, read pinned findings, apply fixes, re-check and approve. Examples in curl, Python, JavaScript and Go.

Chekr API

Everything the Chekr web app does, you can do over HTTP. The API exposes the full review flow for AI-generated creatives:

upload → scan → findings (pinned, scored) → fix / edit → re-check → approve
   │        │            │                      │            │          │
POST     POST /…/scan   GET /creatives/{id}   POST /…/fix   POST      POST
/uploads  + SSE stream                         /…/fix-all    /…/rescan  /…/approve
                                               /…/edit

All examples below assume a local server:

BASE = http://localhost:8080/api        # JSON endpoints
FILES = http://localhost:8080/files     # stored images (creative + fix outputs)

Table of contents

  1. Authentication
  2. Conventions — errors, rate limits, ids, feature flags
  3. The review flow, end to end — curl, Python, JavaScript, Go
  4. Streaming scan progress (SSE) — in all four languages
  5. Endpoint reference
  6. Webhooks
  7. Generating typed clients

Authentication

The server has two independent gates; enable one, both, or neither (default: open, for local development).

Credential Where it comes from How to send it
Per-user API key (ck_…) Self-serve at chekr.io/keys (or POST /keys from a signed-in session) X-API-Key: ck_… header, or Authorization: Bearer ck_…
User JWT (Supabase) The web app's session Authorization: Bearer <supabase access token>
Service key API_KEY=<secret> env var (deployment-level) X-API-Key or Authorization: Bearer

Per-user keys are the credential for machine callers — plugins, CI, DAMs. A key acts as its owning user: same creatives, same quota, same tenancy. Only the SHA-256 hash is stored server-side; the plaintext is shown once at creation, and revoking a key (from chekr.io/keys or DELETE /keys/{id}) stops it immediately. Keys deliberately cannot call the key-management endpoints — a leaked key can never mint or revoke keys.

When both gates are on, a valid per-user key satisfies them on its own. Requests from the configured WEB_ORIGIN are exempt from the service-key gate (that's how the bundled web app connects).

GET /files/{id} (image serving) is intentionally always open so <img> tags and report tooling can load creatives without header plumbing. The public single-use scan (/api/try, below) is also outside both gates by design.

SSE and auth: browser EventSource cannot send an Authorization header. When the server runs with REQUIRE_AUTH, consume the stream with fetch (all four flow examples in this guide already do) or poll GET /scans/{scanId}.

# curl
curl -H "X-API-Key: $CHEKR_API_KEY" http://localhost:8080/api/creatives
# Python (requests)
session = requests.Session()
session.headers["X-API-Key"] = os.environ["CHEKR_API_KEY"]
// JavaScript
const headers = { "X-API-Key": process.env.CHEKR_API_KEY };
// Go
req.Header.Set("X-API-Key", os.Getenv("CHEKR_API_KEY"))

Failed auth returns 401 {"error":"unauthorized"}.

Conventions

Errors. Every non-2xx response is {"error": "<human-readable message>"} with Content-Type: application/json.

Status Meaning
400 Malformed body / invalid parameter (message says which).
401 Missing or invalid credentials.
402 Monthly scan quota reached (QUOTA_MONTHLY, default 100 model-backed actions per user; active subscribers and API-key callers are unmetered).
404 Creative, finding, scan, rule set or file not found.
409 Conflict — currently only "cannot approve a blocked creative".
429 Rate limit exceeded (see below).
502 Upstream vision service failed (similarity lookups).
503 The feature is not configured on this server — see the flag table.

Feature flags → 503 matrix. Several capabilities are optional server-side. A well-behaved client treats 503 on these endpoints as "not enabled here", not as a transient failure:

Endpoint(s) Requires 503 message
POST /creatives/{id}/scan, /rescan GEMINI_API_KEY scanning unavailable: no model configured
POST /…/fix, /fix-all, /edit ENABLE_FIXES=true fixes unavailable
GET /creatives/{id}/matches ENABLE_SIMILARITY=true similarity unavailable
POST /billing/* MOLLIE_API_KEY billing unavailable

Rate limits. JSON endpoints under /api are limited to 120 requests per IP per minute. The SSE stream (/scans/{scanId}/stream) and /files/* are exempt. The client IP is taken from the platform edge header (Fly-Client-IP), never from spoofable X-Forwarded-For / X-Real-IP values.

Request size. Uploads and all other bodies are capped at 32 MiB; the guided-edit compositeImage must decode to ≤ 12 MiB.

IDs and files. Creatives are cr_<32 hex>, scans sc_<32 hex>. Images are served from /files/<imageId> on the server origin (not under /api). Fix and edit outputs get derived ids prefixed with the creative id plus a random suffix (e.g. <creativeId>_fix_<8 hex>) — the last path segment of a newImageUrl is exactly the imageId you pass to rescan or as baseImageId.

CORS. A single allowed browser origin (WEB_ORIGIN). Server-to-server clients are unaffected.

Timestamps are RFC 3339 / ISO-8601 UTC strings. Coordinates (bbox, region) are integer pixels in the source image, origin top-left.

The review flow, end to end

The same eight steps in each language. Full runnable versions (with error handling and CLI arguments) live in examples/.

curl

BASE=http://localhost:8080/api
AUTH=(-H "X-API-Key: $CHEKR_API_KEY")   # omit if the server is open

# 1. Upload (repeat -F files=@… for a batch)
CREATIVE=$(curl -s "${AUTH[@]}" -F "files=@hero-01.png" $BASE/uploads \
  | jq -r '.creatives[0].creativeId')

# 2. Start the scan
SCAN=$(curl -s "${AUTH[@]}" -X POST $BASE/creatives/$CREATIVE/scan \
  | jq -r '.scanId')

# 3. Follow live progress (SSE; blocks until the `done` event closes the stream)
curl -sN "${AUTH[@]}" $BASE/scans/$SCAN/stream

# 4. Fetch the scored creative with pinned findings
curl -s "${AUTH[@]}" $BASE/creatives/$CREATIVE | jq '{score, status, findings: [.findings[] | {id, severity, title, bbox, code}]}'

# 5. Auto-fix everything fixable in one pass
FIX=$(curl -s "${AUTH[@]}" -X POST $BASE/creatives/$CREATIVE/fix-all \
  -H 'Content-Type: application/json' -d '{}')
echo "$FIX" | jq '{rejected, newImageUrl, applied: [.appliedFixes[].title]}'

# 6. Re-check the corrected image (imageId = last segment of newImageUrl)
IMAGE_ID=$(echo "$FIX" | jq -r '.newImageUrl | split("/") | last')
curl -s "${AUTH[@]}" -X POST $BASE/creatives/$CREATIVE/rescan \
  -H 'Content-Type: application/json' -d "{\"imageId\":\"$IMAGE_ID\"}" \
  | jq '{score, status}'

# 7. Check IP / originality (optional, needs ENABLE_SIMILARITY)
curl -s "${AUTH[@]}" $BASE/creatives/$CREATIVE/matches | jq '.riskSummary'

# 8. Approve
curl -s "${AUTH[@]}" -X POST $BASE/creatives/$CREATIVE/approve

Python

No dependencies beyond requests (SSE is handled with a plain streaming GET — see the SSE section for a version using sseclient-py).

import json, os, requests

BASE = "http://localhost:8080/api"
s = requests.Session()
if key := os.environ.get("CHEKR_API_KEY"):
    s.headers["X-API-Key"] = key

# 1. Upload
with open("hero-01.png", "rb") as f:
    up = s.post(f"{BASE}/uploads", files=[("files", ("hero-01.png", f, "image/png"))])
up.raise_for_status()
creative_id = up.json()["creatives"][0]["creativeId"]

# 2. Start the scan
scan_id = s.post(f"{BASE}/creatives/{creative_id}/scan").json()["scanId"]

# 3. Stream progress until done
with s.get(f"{BASE}/scans/{scan_id}/stream", stream=True) as resp:
    for line in resp.iter_lines(decode_unicode=True):
        if not line or not line.startswith("data: "):
            continue                      # skip keep-alive blank lines
        event = json.loads(line[len("data: "):])
        if event["type"] == "progress":
            print(f"  {event.get('pct', 0):>3}%  {event.get('phase', '')}")
        elif event["type"] == "done":
            print(f"scan done: score {event['score']}, status {event['status']}")
            break

# 4. Findings
detail = s.get(f"{BASE}/creatives/{creative_id}").json()
for i, f in enumerate(detail["findings"], 1):
    b = f["bbox"]
    print(f"{i}. [{f['severity']}] {f['title']} @ ({b['x']},{b['y']},{b['w']}x{b['h']})")

# 5. Fix all findings that have an automatic fix
fix = s.post(f"{BASE}/creatives/{creative_id}/fix-all", json={})
if fix.status_code == 503:
    raise SystemExit("fixes are not enabled on this server (ENABLE_FIXES)")
fix = fix.json()
if fix.get("rejected"):
    raise SystemExit(f"fix rejected by quality gate: {fix['verdict']['reason']}")

# 6. Re-check the corrected image
image_id = fix["newImageUrl"].rsplit("/", 1)[-1]
rechecked = s.post(f"{BASE}/creatives/{creative_id}/rescan",
                   json={"imageId": image_id}).json()
print(f"after fix: score {rechecked['score']}, status {rechecked['status']}")

# 7. Approve (blocked creatives return 409)
approve = s.post(f"{BASE}/creatives/{creative_id}/approve")
print(approve.json())

JavaScript (Node ≥ 18, no dependencies)

const BASE = "http://localhost:8080/api";
const headers = process.env.CHEKR_API_KEY
  ? { "X-API-Key": process.env.CHEKR_API_KEY }
  : {};

// 1. Upload
const form = new FormData();
form.append("files", new Blob([await fs.readFile("hero-01.png")]), "hero-01.png");
const up = await (await fetch(`${BASE}/uploads`, { method: "POST", headers, body: form })).json();
const creativeId = up.creatives[0].creativeId;

// 2. Start the scan
const { scanId } = await (await fetch(`${BASE}/creatives/${creativeId}/scan`, {
  method: "POST", headers,
})).json();

// 3. Stream progress until done (data-only SSE frames)
const stream = await fetch(`${BASE}/scans/${scanId}/stream`, { headers });
const reader = stream.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
outer: for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += value;
  let nl;
  while ((nl = buf.indexOf("\n\n")) >= 0) {
    const frame = buf.slice(0, nl); buf = buf.slice(nl + 2);
    if (!frame.startsWith("data: ")) continue;
    const event = JSON.parse(frame.slice(6));
    if (event.type === "progress") console.log(`  ${event.pct ?? 0}%  ${event.phase ?? ""}`);
    if (event.type === "done") { console.log(`done: score ${event.score} (${event.status})`); break outer; }
  }
}

// 4. Findings
const detail = await (await fetch(`${BASE}/creatives/${creativeId}`, { headers })).json();
detail.findings.forEach((f, i) =>
  console.log(`${i + 1}. [${f.severity}] ${f.title} @`, f.bbox));

// 5. Fix all → 6. Re-check
const fix = await (await fetch(`${BASE}/creatives/${creativeId}/fix-all`, {
  method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: "{}",
})).json();
if (!fix.rejected && fix.newImageUrl) {
  const imageId = fix.newImageUrl.split("/").at(-1);
  const rechecked = await (await fetch(`${BASE}/creatives/${creativeId}/rescan`, {
    method: "POST", headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({ imageId }),
  })).json();
  console.log(`after fix: score ${rechecked.score} (${rechecked.status})`);
}

// 7. Approve
console.log(await (await fetch(`${BASE}/creatives/${creativeId}/approve`, {
  method: "POST", headers,
})).json());

Go

package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"strings"
)

const base = "http://localhost:8080/api"

func do(req *http.Request, out any) error {
	if key := os.Getenv("CHEKR_API_KEY"); key != "" {
		req.Header.Set("X-API-Key", key)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		body, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("%s: %s", resp.Status, body)
	}
	return json.NewDecoder(resp.Body).Decode(out)
}

func main() {
	// 1. Upload
	var form bytes.Buffer
	mw := multipart.NewWriter(&form)
	part, _ := mw.CreateFormFile("files", "hero-01.png")
	img, _ := os.Open("hero-01.png")
	io.Copy(part, img)
	img.Close()
	mw.Close()

	req, _ := http.NewRequest("POST", base+"/uploads", &form)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	var up struct {
		Creatives []struct{ CreativeID string `json:"creativeId"` } `json:"creatives"`
	}
	if err := do(req, &up); err != nil {
		panic(err)
	}
	id := up.Creatives[0].CreativeID

	// 2. Start the scan
	req, _ = http.NewRequest("POST", base+"/creatives/"+id+"/scan", nil)
	var scan struct{ ScanID string `json:"scanId"` }
	if err := do(req, &scan); err != nil {
		panic(err)
	}

	// 3. Stream SSE until done
	streamReq, _ := http.NewRequest("GET", base+"/scans/"+scan.ScanID+"/stream", nil)
	if key := os.Getenv("CHEKR_API_KEY"); key != "" {
		streamReq.Header.Set("X-API-Key", key)
	}
	resp, err := http.DefaultClient.Do(streamReq)
	if err != nil {
		panic(err)
	}
	sc := bufio.NewScanner(resp.Body)
	for sc.Scan() {
		line := sc.Text()
		if !strings.HasPrefix(line, "data: ") {
			continue
		}
		var ev struct {
			Type   string `json:"type"`
			Pct    int    `json:"pct"`
			Phase  string `json:"phase"`
			Score  int    `json:"score"`
			Status string `json:"status"`
		}
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev)
		switch ev.Type {
		case "progress":
			fmt.Printf("  %3d%%  %s\n", ev.Pct, ev.Phase)
		case "done":
			fmt.Printf("done: score %d (%s)\n", ev.Score, ev.Status)
		}
	}
	resp.Body.Close()

	// 4. Findings
	req, _ = http.NewRequest("GET", base+"/creatives/"+id, nil)
	var detail struct {
		Score    int    `json:"score"`
		Status   string `json:"status"`
		Findings []struct {
			Severity string `json:"severity"`
			Title    string `json:"title"`
		} `json:"findings"`
	}
	if err := do(req, &detail); err != nil {
		panic(err)
	}
	for i, f := range detail.Findings {
		fmt.Printf("%d. [%s] %s\n", i+1, f.Severity, f.Title)
	}

	// 5–8: fix-all, rescan, approve — see examples/review_flow.go
}

Streaming scan progress (SSE)

GET /api/scans/{scanId}/stream is a standard Server-Sent Events endpoint with data-only frames — there is no event: name; dispatch on the type field of the JSON payload:

data: {"type":"category","category":"text","state":"scanning"}

data: {"type":"progress","pct":3,"phase":"reading the scene"}

data: {"type":"progress","pct":40,"category":"anatomy","state":"scanned"}

data: {"type":"category","category":"text","state":"issue","count":2}

data: {"type":"progress","pct":100}

data: {"type":"done","score":54,"status":"blocked","errored":0}

Semantics worth knowing:

  • Replay: connecting mid-scan replays all prior events first — you never miss the beginning, and reconnecting is safe.
  • Terminal event: the server closes the stream right after done. Treat stream-end-without-done as a network drop and reconnect.
  • error events are per-category and non-terminal — one failed check doesn't abort the scan; the done event's errored count says how many checks failed.
  • Polling alternative: GET /api/scans/{scanId} returns the creative detail at any time; the scan is finished once status is no longer in_review or a new version appears. The SSE endpoint is exempt from rate limits; polling isn't.

Browser EventSource works out of the box (dispatch inside onmessage):

const es = new EventSource(`${BASE}/scans/${scanId}/stream`);
es.onmessage = (m) => {
  const event = JSON.parse(m.data);
  if (event.type === "done") es.close();
};

Python with sseclient-py:

import sseclient  # pip install sseclient-py
resp = s.get(f"{BASE}/scans/{scan_id}/stream", stream=True)
for msg in sseclient.SSEClient(resp).events():
    event = json.loads(msg.data)
    if event["type"] == "done":
        break

For raw-requests Python, fetch JavaScript and Go versions, see the flow walkthroughs above — all three parse frames by splitting on blank lines and stripping the data: prefix.

Endpoint reference

Field-level schemas for every object live in openapi.yaml; this section is the practical cheat-sheet. All paths are relative to /api.

POST /try — public single-use scan (no auth)

The "try it free" endpoint behind chekr.io/try: exactly one image (multipart field file, ≤ 10 MiB), uploaded and scanned in one call. It bypasses both auth gates but is hard-limited to 5 requests per IP per day (429 beyond that). Read results without credentials on the mirrors GET /try/{scanId} and GET /try/{scanId}/stream — scan ids are unguessable capability tokens.

curl -F "file=@hero.png" $BASE/try
# {"creativeId":"cr_…","scanId":"sc_…"}

Fixes, edits, re-scans and the rest of the flow still require authentication.

POST /uploads — upload creatives

Multipart form, field files (repeatable). Accepted (sniffed from bytes): PNG, JPEG, WebP. Max request size 32 MiB. Uploading does not start a scan.

curl -F "files=@a.png" -F "files=@b.jpg" $BASE/uploads
{ "creatives": [ { "creativeId": "cr_9f2c…", "imageUrl": "/files/cr_9f2c…", "name": "a.png" } ] }

POST /imports — import from creative tools, DAMs and pipelines

JSON alternative to /uploads for callers that hold image bytes (plugin sandboxes) or a public URL (DAM/CDN) instead of a form file. Up to 20 items; each item takes exactly one of contentBase64 or url, plus optional metadata:

curl -X POST $BASE/imports -H 'Content-Type: application/json' -d '{
  "items": [
    { "name": "hero-01.png", "contentBase64": "<base64 bytes>",
      "campaign": "Summer Launch", "author": "mara@brand.co", "generator": "figma-export" },
    { "url": "https://cdn.example.com/assets/hero-02.png", "campaign": "Summer Launch" }
  ]
}'

Response is the same shape as /uploads. URL sources must be absolute http(s) URLs on public hosts — redirects are refused and private/loopback/link-local destinations are blocked at connect time (SSRF guard). Same 32 MiB cap and type allowlist as uploads. See integrations.md for ready-made Figma / Photoshop / Canva / Zapier recipes built on this endpoint.

GET /creatives/{id}/export — export the review

  • ?format=json (default): a portable chekr.creative/v1 bundle — the complete creative detail (findings, matches, versions, provenance) plus exportedAt — served as an attachment. Everything another system needs to re-render pins or archive the review.
  • ?format=csv: the findings table for spreadsheets/BI, one row per finding:
creativeId,name,score,status,findingId,category,severity,confidence,code,title,detail,x,y,w,h,fixKind,fixLabel
curl -OJ $BASE/creatives/$CREATIVE/export?format=csv

To export the image itself (original or any fixed version), download its imageUrl / newImageUrl from $FILES — e.g. curl -O http://localhost:8080/files/cr_9f2c…_fixall.

GET /creatives — list + queue stats

Optional query params: status (in_review|flagged|needs_work|blocked|cleared), campaign, q (name search).

{
  "creatives": [ { "id": "cr_9f2c…", "name": "a.png", "status": "blocked", "score": 54,
                   "categories": { "text": { "score": 38, "severity": "critical" } }, "…": "…" } ],
  "stats": { "inReview": 3, "flagged": 1, "blocked": 1, "clearedToday": 4 }
}

GET /creatives/{id} — detail with findings

Everything from the list shape plus findings[] (each with severity, confidence 0–1, pixel bbox, stable code, and its available fix), matches[], versions[], optional provenance (declared C2PA), and — after a scan — advice: the art-direction layer above the defects, with a plain summary, up to five suggestions tagged quick-fix / edit / regenerate, and a direction (named concept, rationale, search-verified trends, and a paste-ready generation brief).

{
  "id": "cr_9f2c…", "score": 54, "status": "blocked",
  "findings": [
    { "id": "fd_1…", "category": "text", "severity": "critical",
      "title": "Garbled headline glyphs", "detail": "…", "confidence": 0.94,
      "bbox": { "x": 100, "y": 110, "w": 600, "h": 70 },
      "code": "TEXT_GIBBERISH", "fix": { "kind": "respell", "label": "Respell text" } }
  ],
  "matches": [], "versions": [ { "label": "Original", "score": 54, "…": "…" } ]
}

DELETE /creatives/{id} — delete a creative

Permanently removes the creative, its findings, scans and version history, and deletes the stored original plus every derived fix/edit image. Irreversible. Returns {"status":"deleted"}, or 404 if the id is unknown.

curl -X DELETE $BASE/creatives/$CREATIVE

POST /creatives/bulk-delete — delete several creatives

Body {"ids": ["cr_1…", "cr_2…"]} (max 200). Ids that don't exist or belong to another owner are skipped silently; the response is {"deleted": n}.

POST /creatives/{id}/scan — start a scan

Optional body {"ruleSetId": "rs_global"}. Returns {"scanId": "sc_…"} immediately; the scan runs in the background. 503 when no model is configured.

GET /scans/{scanId} / GET /scans/{scanId}/stream

Poll the creative detail, or stream progress — see Streaming scan progress.

POST /creatives/{id}/findings/{fid}/fix — auto-fix one finding

Body optional: {"baseImageId": "cr_9f2c…_fix_1a2b3c4d"} applies the fix on top of a previous fix output so corrections stack; omitted, the creative's current image is used. The fix is composited only inside the finding's bounding box, then judged by the quality gate. Response:

{
  "newImageUrl": "/files/cr_9f2c…_fix_fd_1…",
  "appliedFix": { "findingId": "fd_1…", "title": "Garbled headline glyphs", "label": "Respell text" },
  "verdict": { "verdict": "better", "identityPreserved": true,
               "introducedNewIssues": false, "reason": "…" }
}

A rejected fix (verdict worse, identity drift, new issues, or nothing actually fixed) returns "rejected": true with an empty newImageUrl — the original is untouched.

POST /creatives/{id}/findings/{fid}/dismiss — dismiss a finding

No body. Marks the finding as reviewed-and-waived (false positive or accepted risk) and recomputes the creative's score and status from the remaining active findings; the response is the updated creative detail. The finding stays in the payload with fix.params.dismissed: true, and a re-scan resets dismissals.

POST /creatives/{id}/fix-all — fix several findings at once

Body {"findingIds": ["fd_1…", "fd_2…"], "baseImageId": "…"}; both fields optional — empty findingIds fixes everything fixable, and baseImageId stacks the batch on a previous fix output. appliedFixes lists only findings the judge confirmed fixed — compare it against what you requested to detect partial fixes.

POST /creatives/{id}/edit — guided edit

For defects without an automatic fix: draw the target region onto a copy of the image, then send

{
  "compositeImage": "<base64 image, ≤ 12 MiB decoded>",
  "prompt": "remove the extra finger",
  "region": { "x": 250, "y": 600, "w": 150, "h": 170 }
}

region is optional (omit for a whole-image instruction), and so is baseImageId (stack the edit on a previous fix output). Same response shape and quality gate as fixes.

POST /creatives/{id}/reimagine — generate a new version from the direction brief

Generates a full reimagining with the image model: same product and brand, new execution following advice.direction.brief (or a prompt override in the body), with the scan's active defects explicitly excluded. Accepts baseImageId to stack on earlier fixes. Returns {newImageUrl, prompt, adopted: true} — the reimagined image is adopted as the creative's working image: previous findings, advice, scores and categories described the old composition and are cleared (the old state stays in the version history), the creative returns to in_review, and subsequent fixes/edits operate on the new image. Run rescan to score it (the fix-gate judge is intentionally skipped: a reimagining changes composition by design).

When the generation ran on Reve, the response's creative also gains layers — the generated layout (labels, region prompts, pixel bboxes) — which the review page renders as inspectable, editable layers.

POST /creatives/{id}/layers/render, POST /creatives/{id}/layers/extract — layer editing

layers/render (body {"edits":[{"index":0,"prompt":"…"} , {"index":2,"remove":true}]}) re-renders the creative from its edited layers: the structured edits become a guided layout edit (Reve keeps the scene consistent), the render runs, and the result is adopted like a reimagining ("Layer edit" in the version history). Prompt changes are faithful; removals are best-effort model judgment. 409 when the creative has no layers yet.

layers/extract reads layers out of ANY creative's current image (~10 s), so uploaded images get the same editing surface as generated ones. Both count as metered actions; 503 without a layout engine.

Model routing note: regional fixes and edits always run on the image editor model. Full reimagine generations route to Reve (POST /v2/image/create with the original as reference) when the server is configured with REVE_API_KEY; on any Reve failure the request falls back to the editor, so behavior degrades rather than erroring.

POST /creatives/{id}/rescan — re-check a corrected image

Body {"imageId": "cr_9f2c…_fixall_1a2b3c4d"} — the last path segment of any newImageUrl the server gave you for this creative. Swaps the working image, re-runs the full scan synchronously and returns the updated creative detail (new score, findings, and a version snapshot labeled Fix / Fix all / Edit).

POST /creatives/{id}/approve — approve

No body. Returns {"status": "cleared"}. A blocked creative returns 409 {"error":"cannot approve a blocked creative"} — fix and re-check first.

GET /creatives/{id}/matches — IP / originality lookup

Reverse-image web detection (top 10, sorted by similarity) plus an optional plain-language risk summary:

{
  "matches": [ { "source": "Getty Images", "ref": "GT-8842-19", "similarity": 0.82,
                 "bucket": "stock", "risk": "ip", "thumbUrl": "/files/cr_9f2c…_match", "…": "…" } ],
  "ms": 640,
  "buckets": { "stock": 1, "competitor": 0, "trademark": 0 },
  "riskSummary": { "level": "high", "text": "Strong overlap with licensed stock…" }
}

503 when similarity is not enabled on the server.

GET / PUT /rulesets/{id} — brand rules

The default rule set is rs_global. PUT replaces the whole object; validation: thresholds within [0,1], minScore within [0,100], ≤ 20 aspect ratios matching \d+:\d+, ≤ 100 banned terms.

curl -X PUT $BASE/rulesets/rs_global -H 'Content-Type: application/json' -d '{
  "name": "Global",
  "thresholds": { "TEXT_GIBBERISH": { "block": 0.85, "warn": 0.6 } },
  "minScore": 70,
  "allowedAspectRatios": ["1:1", "4:5", "9:16"],
  "bannedTerms": ["guaranteed"]
}'

Pass a ruleSetId to POST /creatives/{id}/scan to enforce a specific set on that scan.

GET / POST /keys, DELETE /keys/{id} — per-user API keys

Manage the machine credentials described under Authentication. POST /keys (body: {label}) returns the plaintext key exactly once; GET /keys lists keys without secrets (label, last4, created, last used, revoked); DELETE /keys/{id} revokes immediately. These endpoints require a web-session JWT — requests authenticated with a key get 403.

GET / PUT / DELETE /brand, POST /brand/extract — brand profile

One brand profile per account: the brand guide in structured form. Once saved, every scan runs an extra audit against it (off-palette designed elements, logo misuse, banned phrases — findings carry code BRAND_GUIDE), cleanup advice stays inside the palette and tone, and reimagine briefs are constrained to the guide. Unlike key management, these endpoints accept API-key callers — the Figma plugin syncs file styles into the profile.

GET /brand returns the profile (404 before one is saved). PUT /brand upserts it; rules must not be empty:

curl -X PUT $BASE/brand -H 'Content-Type: application/json' -H "X-API-Key: $KEY" -d '{
  "name": "Acme",
  "source": "manual",
  "rules": {
    "colors": [{ "name": "Acme Red", "hex": "#E03A2F", "role": "primary" }],
    "typography": [{ "family": "Inter", "usage": "headlines" }],
    "logoRules": ["Clear space of 2x the mark height."],
    "tone": "Confident and plain. No hype.",
    "bannedPhrases": ["world-class"],
    "notes": "Imagery is always daylight."
  }
}'

POST /brand/extract (body: {"files":[{"name","mime","contentBase64"}]}, up to 5 PDF/PNG/JPEG/WebP files, 10 MB each) reads an uploaded brand guide into {name, rules} without saving — extraction is deliberately conservative, and a human reviews the result before PUT /brand persists it. Counts as one metered action; 503 when no model is configured.

Billing (optional)

POST /billing/checkout {"plan":"studio|team","interval":"month|year","email":"…"}{"checkoutUrl":"…"}; GET /billing/status (authenticated) → {"status":"…"}. Both return 503 unless the server is configured with a payment key.

Webhooks

Set NOTIFY_WEBHOOK_URL on the server to receive a POST when a scan completes. NOTIFY_ON=flagged (default) fires only for flagged, needs_work and blocked outcomes; NOTIFY_ON=all fires for every scan.

Generic endpoints receive:

{
  "event": "scan.complete",
  "creativeId": "cr_9f2c…",
  "name": "hero-01.png",
  "status": "blocked",
  "score": 54,
  "findings": 5,
  "ipOverlap": false
}

URLs on hooks.slack.com get a Slack-compatible {"text": "…"} message instead. Delivery is best-effort with a 5-second timeout and no retries — treat webhooks as a nudge and re-fetch GET /creatives/{id} for the source of truth.

Generating typed clients

The OpenAPI spec is designed to round-trip through standard generators:

# TypeScript (fetch)
npx openapi-typescript docs/api/openapi.yaml -o src/chekr-api.d.ts

# Python
pip install openapi-python-client
openapi-python-client generate --path docs/api/openapi.yaml

# Go, Java, C#, PHP, Ruby, …
openapi-generator-cli generate -i docs/api/openapi.yaml -g go -o ./chekr-go

Note the SSE stream is modeled as text/event-stream and most generators will expose it as a raw response — use the hand-rolled readers from the SSE section for that one endpoint.


Connecting Chekr to creative tools

API keys are self-serve: create one at chekr.io/keys (or POST /api/keys from a signed-in session). A key acts as your user for tenancy and quota, is sent as X-API-Key, and can be revoked any time. Shared plugin assets live in integrations/shared/.

Chekr's API is deliberately tool-agnostic: anything that can make an HTTP request can push creatives in, follow the scan, and pull results out. Two endpoints make plugin work easy:

  • POST /api/imports — send image bytes as base64 (plugin sandboxes) or a public URL (DAM/CDN), with campaign / author / generator metadata.
  • GET /api/creatives/{id}/export — pull the review back out as a JSON bundle (format=json) or findings CSV (format=csv); fixed images are plain files under /files/….

A third is worth wiring early: PUT /api/brand stores the account's brand profile (palette, typefaces, logo rules, tone, banned phrases), after which every scan audits creatives against it and advice/reimagine stay on-brand. The bundled Figma plugin syncs the file's local styles into it with one click; any tool can PUT its own design tokens the same way.

The recipes below are minimal but complete: each one imports the user's current artwork, starts a scan, and surfaces the score and findings inside the tool. Replace CHEKR_BASE / CHEKR_API_KEY with your deployment's values. See README.md for auth and the full endpoint reference.

A note on CORS: the server allows one browser origin (WEB_ORIGIN). Plugins that run in a browser-like sandbox with a special origin (Figma, Canva) should either route through your own tiny proxy or set WEB_ORIGIN accordingly for development. Photoshop UXP, scripts, and server-side integrations are unaffected (no CORS in those runtimes).


Figma (plugin)

Ready-made: a development plugin ships in integrations/figma/ — see its README for install (Plugins → Development → Import plugin from manifest). The recipe below is for building your own variant.

Figma plugins can export any node as PNG bytes and hand them to fetch from the plugin's main thread.

// code.ts — export the current selection to Chekr and report the verdict
const BASE = "https://chekr.example.com/api";
const KEY = "…"; // store via figma.clientStorage in a real plugin

async function checkSelection() {
  const node = figma.currentPage.selection[0];
  if (!node) {
    figma.notify("Select a frame to check");
    return;
  }

  // 1. Export the node as PNG bytes (Uint8Array)
  const bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 2 } });

  // 2. Import into Chekr (base64 — no multipart needed in the sandbox)
  const res = await fetch(`${BASE}/imports`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({
      items: [{
        name: `${node.name}.png`,
        contentBase64: figma.base64Encode(bytes),
        campaign: figma.root.name,
        generator: "figma-plugin",
      }],
    }),
  });
  const { creatives } = await res.json();
  const creativeId = creatives[0].creativeId;

  // 3. Scan and wait (poll — EventSource isn't available in the sandbox)
  const { scanId } = await (await fetch(`${BASE}/creatives/${creativeId}/scan`, {
    method: "POST", headers: { "X-API-Key": KEY },
  })).json();

  let detail;
  do {
    await new Promise((r) => setTimeout(r, 2000));
    detail = await (await fetch(`${BASE}/scans/${scanId}`, { headers: { "X-API-Key": KEY } })).json();
  } while (detail.status === "in_review");

  // 4. Surface the verdict in-canvas
  figma.notify(`Chekr: ${detail.score}/100 — ${detail.findings.length} finding(s), ${detail.status}`);
}

To draw Chekr's pins back onto the Figma canvas, create rectangles from each finding's bbox (source pixels — divide by the export scale you used):

for (const f of detail.findings) {
  const pin = figma.createRectangle();
  pin.x = node.x + f.bbox.x / 2;      // ÷2 because we exported at 2x
  pin.y = node.y + f.bbox.y / 2;
  pin.resize(f.bbox.w / 2, f.bbox.h / 2);
  pin.fills = [];
  pin.strokes = [{ type: "SOLID", color: f.severity === "critical" ? { r: 0.88, g: 0.31, b: 0.17 } : { r: 0.71, g: 0.4, b: 0.11 } }];
  pin.name = `⚠ ${f.title}`;
}

Chrome & Safari (browser extension)

Ready-made: a WebExtension ships in integrations/browser/ — see its README. Chrome: load unpacked from chrome://extensions. Safari: convert the same folder with xcrun safari-web-extension-converter.

Right-click any image on any page → "Check image with Chekr", or use the toolbar popup to scan local files / an image URL. The panel is the same actionable review as the Figma and Photoshop plugins (fix, dismiss, reimagine, download, re-check). Under the hood it is nothing but the public API: the background worker fetches the image bytes and POST /api/imports them; auth is the user's ck_… key.

Adobe Photoshop (UXP plugin)

Ready-made: a UXP plugin ships in integrations/photoshop/ — see its README for the UXP Developer Tool load and the direct .ccx install. The recipe below is for building your own variant.

UXP has fetch and filesystem access; no CORS restrictions apply. Export the active document to a temp file, then import it:

const { app, core } = require("photoshop");
const fs = require("uxp").storage.localFileSystem;

async function sendToChekr() {
  const BASE = "https://chekr.example.com/api";

  // 1. Save the active document as PNG into a temp file
  const tmp = await fs.getTemporaryFolder();
  const file = await tmp.createFile("chekr-export.png", { overwrite: true });
  await core.executeAsModal(async () => {
    await app.activeDocument.saveAs.png(file, { compression: 6 }, true);
  }, { commandName: "Export for Chekr" });

  // 2. Import as base64
  const bytes = await file.read({ format: require("uxp").storage.formats.binary });
  const b64 = btoa(String.fromCharCode(...new Uint8Array(bytes)));
  const { creatives } = await (await fetch(`${BASE}/imports`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({ items: [{ name: app.activeDocument.name, contentBase64: b64, generator: "photoshop-uxp" }] }),
  })).json();

  // 3. Scan; stream progress with EventSource or poll GET /scans/{scanId}
  const { scanId } = await (await fetch(`${BASE}/creatives/${creatives[0].creativeId}/scan`, {
    method: "POST", headers: { "X-API-Key": KEY },
  })).json();
  // …poll /scans/{scanId} as in the Figma recipe, then render findings in your panel.
}

Canva (app)

Canva apps get an export URL for the current design from the platform — pass it straight to Chekr's URL import (the URL is on Canva's public CDN, so the SSRF guard allows it):

import { requestExport } from "@canva/design";

const result = await requestExport({ acceptedFileTypes: ["png"] });
if (result.status === "completed") {
  await fetch(`${BASE}/imports`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({
      items: [{ url: result.exportBlobs[0].url, name: result.title ?? "canva-design.png", generator: "canva-app" }],
    }),
  });
}

After Effects / Premiere / anything scriptable

If the tool can run a shell command or script on an exported frame, the example scripts are the integration:

# e.g. an After Effects render hook
./review-flow.sh "$RENDER_OUTPUT/frame-0001.png"

Zapier / Make / n8n (no-code)

Two directions:

  • Inbound (creative → Chekr): a "Webhooks: POST" step to /api/imports with {"items":[{"url":"{{file_url}}","campaign":"{{campaign}}"}]} and header X-API-Key, followed by a POST to /creatives/{{creativeId}}/scan. Any trigger that yields a public file URL works — new Drive/Dropbox file, Airtable attachment, DAM webhook.
  • Outbound (Chekr → your stack): set NOTIFY_WEBHOOK_URL on the server to a catch-hook. Every completed scan posts {"event":"scan.complete","creativeId":…,"status":…,"score":…,"findings":…,"ipOverlap":…} — route it to Slack, create a ticket, or fetch /creatives/{creativeId}/export?format=csv and file it in a sheet. (URLs on hooks.slack.com receive a ready-made Slack message instead.)

CI / asset pipelines

Gate creative merges the way you gate code. Example GitHub Actions step that fails the build when a creative scores below 80 or gets blocked:

- name: Chekr gate
  env:
    CHEKR_BASE: ${{ vars.CHEKR_BASE }}
    CHEKR_API_KEY: ${{ secrets.CHEKR_API_KEY }}
  run: |
    for f in assets/creatives/*.png; do
      id=$(curl -sfS -H "X-API-Key: $CHEKR_API_KEY" -F "files=@$f" \
        "$CHEKR_BASE/uploads" | jq -r '.creatives[0].creativeId')
      scan=$(curl -sfS -H "X-API-Key: $CHEKR_API_KEY" -X POST \
        "$CHEKR_BASE/creatives/$id/scan" | jq -r '.scanId')
      # Wait for the terminal `done` SSE event, then read the verdict
      curl -sN -H "X-API-Key: $CHEKR_API_KEY" "$CHEKR_BASE/scans/$scan/stream" \
        | grep -m1 '"type":"done"' >/dev/null
      detail=$(curl -sfS -H "X-API-Key: $CHEKR_API_KEY" "$CHEKR_BASE/creatives/$id")
      score=$(jq -r '.score' <<<"$detail"); status=$(jq -r '.status' <<<"$detail")
      echo "$f -> $score/100 ($status)"
      if [ "$status" = blocked ] || [ "$score" -lt 80 ]; then
        jq -r '.findings[] | "::error file='"$f"'::[\(.severity)] \(.title)"' <<<"$detail"
        exit 1
      fi
    done

DAMs and archives (import/export round-trip)

  • Import: point POST /api/imports at the asset's public URL; keep the DAM id in name so results are easy to join back.
  • Export: archive GET /creatives/{id}/export (the chekr.creative/v1 JSON bundle) next to the asset — it contains the score, status, every finding with its bounding box, IP matches, version history and C2PA provenance, so the review is reproducible without Chekr. The CSV variant drops straight into reporting warehouses.
  • Images: the original and every accepted fix are plain files — GET /files/<imageId> — so a sync job can mirror them with one GET per version (versions[].imageUrl).