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).

Gate Enabled by How to authenticate
API key API_KEY=<secret> env var X-API-Key: <secret> header, or Authorization: Bearer <secret>
User JWT (Supabase) REQUIRE_AUTH=true (+ SUPABASE_URL) Authorization: Bearer <supabase access token>

When both gates are on, send the JWT in Authorization and the key in X-API-Key — each middleware checks its own header. Requests from the configured WEB_ORIGIN are exempt from the API-key gate (that's how the bundled web app connects), so machine clients are the intended audience for X-API-Key.

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.
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. X-Forwarded-For / X-Real-IP are honored behind a proxy.

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 — <creativeId>_fix_<findingId>, <creativeId>_fixall, <creativeId>_edit_<8 hex> — and the last path segment of a newImageUrl is exactly the imageId you pass to rescan.

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, GIF, PDF. 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[], and optional provenance (declared C2PA).

{
  "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, "…": "…" } ]
}

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

No body. 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}/fix-all — fix several findings at once

Body {"findingIds": ["fd_1…", "fd_2…"]}; empty or omitted fixes everything fixable. 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). Same response shape and quality gate as fixes.

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

Body {"imageId": "cr_9f2c…_fixall"} — 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.

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

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/….

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)

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}`;
}

Adobe Photoshop (UXP plugin)

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).