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).
| 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
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. |
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-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, 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 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[], 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.