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
- Machine-readable spec:
openapi.yaml(OpenAPI 3.1) — import it into Postman/Insomnia, or generate clients withopenapi-generator. - Runnable end-to-end examples:
examples/in shell/curl, Python, JavaScript (Node ≥ 18) and Go. - Creative-tool integrations:
integrations.md— Figma, Photoshop (UXP), Canva, Zapier/Make, CI pipelines, plus import/export recipes.
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
- Authentication
- Conventions — errors, rate limits, ids, feature flags
- The review flow, end to end — curl, Python, JavaScript, Go
- Streaming scan progress (SSE) — in all four languages
- Endpoint reference
- Webhooks
- 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
EventSourcecannot send anAuthorizationheader. When the server runs withREQUIRE_AUTH, consume the stream withfetch(all four flow examples in this guide already do) or pollGET /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). |
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 |
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 '.reverseSearch'
# 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-doneas a network drop and reconnect. errorevents are per-category and non-terminal — one failed check doesn't abort the scan; thedoneevent'serroredcount says how many checks failed.- Polling alternative:
GET /api/scans/{scanId}returns the creative detail at any time; the scan is finished oncestatusis no longerin_reviewor 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 portablechekr.creative/v1bundle — the complete creative detail (findings, matches, versions, provenance) plusexportedAt— 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/createwith the original as reference) when the server is configured withREVE_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 — saved reverse-image evidence
Reads the latest scan's evidence without calling Cloud Vision or Gemini again.
Run scan or rescan to refresh it. The creative detail also includes
reverseSearch, so inline clients do not need a separate request.
{
"reverseSearch": {
"status": "complete",
"provider": "google_cloud_vision",
"checkedAt": "2026-09-27T12:00:00Z",
"imageUrl": "/files/cr_example",
"matches": [{
"id": "mt_example",
"kind": "partial",
"imageUrl": "https://example.com/original.jpg",
"sourceUrl": "https://example.com/article",
"thumbUrl": "/files/cr_example_match_mt_example",
"assessment": {
"status": "complete",
"verdict": "derived_image",
"explanation": "The same distinctive details appear within a tighter crop."
}
}],
"assessment": {"status": "complete"}
}
}
The example is illustrative. Search states are pending, complete,
unavailable, and disabled. null means no saved report exists; it does not
mean no matches were found. A completed empty search found no matches in that
lookup. Provider kinds are full, partial, and visually_similar; they are
not similarity percentages. Up to ten matches are retained and up to three
available candidate images are compared with the original. Comparison can be
not_assessed, complete, or unavailable independently of search success.
Visual comparison distinguishes the same image, an edited derivative, a similar subject, unrelated content, or uncertainty. Neither retrieval nor comparison establishes copyright ownership, licensing, or permission. Forensic provenance is informational; it and reverse search do not lower the visual quality score. Adopting a changed image invalidates its old report.
This replaces the former matches/ms/buckets/riskSummary response. Historical
closestMatch values must not be presented as calibrated probabilities.
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.