truuimage · API v1
API reference
A key carries your account's full scanning ability and spends its credits. Create one at /app/api-keys.
Authentication
Every request carries your key as a bearer token:
Authorization: Bearer ti_live_...Get a key from /app/api-keys. The key is shown once, at creation time — store it somewhere safe, since it cannot be retrieved again afterward. A key authenticates as the account that created it: it has that account's full scanning ability and spends that account's credits, with no lesser-privileged scope available. An account may have at most 10 active keys at a time.
The Bearer scheme name is matched case-insensitively (bearer works too), but the token after it is compared byte-for-byte and is case-sensitive. A key that passes through infrastructure that lowercases headers (some corporate proxies and config layers do this) will stop authenticating with no other symptom — if a previously-working key starts failing, check for exactly this.
/v1 routes never accept a session cookie, only this header. There is no way to call them from a logged-in browser tab's ambient session.
Credits
One scan costs 1 credit, charged when POST /v1/detect accepts the upload — before the verdict is known. If the scan later fails (an undecodable file, a detector error) or expires without completing, the credit is refunded automatically; nothing needs to be requested. The scan's own record reports this: see credits_refunded in the response shape below.
Check your balance with GET /v1/credits, which also reports the current cost per scan so an integration can compute its own remaining-scans figure rather than hard-coding the price.
If a scan is attempted with an insufficient balance, no credit is charged and nothing is retained — see insufficient_credits in the error table below. (Internally, the upload is briefly written to storage and then removed as part of the same rejection; nothing is ever kept and nothing is ever billed.)
POST /v1/detect
Submits an image for analysis. Multipart form body with exactly one field, file.
Use this exact path, with no trailing slash. POST /v1/detect/ (trailing slash) does not hit this handler directly — the framework's router answers with a 308 Permanent Redirect to /v1/detect before this endpoint's own checks ever run, and that redirect response is plain text with no JSON error envelope. Most HTTP clients (a browser's fetch, curl with -L, Python's requests by default) follow a 308 automatically and resend the POST body, so the request still completes — just through one extra round trip you don't need. A client configured not to follow redirects on POST, or one that logs any 3xx as a failure, will see that bare redirect and nothing else. Send the path without a trailing slash and skip the hop entirely.
Query parameter: mode
?mode=sync (the default) waits for the scan to finish and returns the completed result directly. If analysis is still running after 90 seconds, the request does not fail — it returns 202 with the scan in its current, non-terminal state, and the work keeps running in the background exactly as it would in async mode. Your HTTP client's own timeout for a sync request should therefore be set to at least 90 seconds, or you will see a client-side timeout on requests the server itself considers to be still in progress; if you can only set a shorter client timeout, use mode=async instead.
?mode=async returns 202 immediately with the scan in queued status. Either way, poll GET /v1/scans/{id} until status reaches a terminal value (completed, failed, or expired).
Content type
There are three cases, not two. Most HTTP libraries — Python's requests, Go's multipart.Writer.CreateFormFile, a plain JS Blob — never set a per-part Content-Type on the file field at all, so this endpoint is built around that reality rather than fighting it:
- Undeclared (no per-part Content-Type, or one of the placeholder values a multipart encoder emits for "none set") is accepted. The bytes are validated instead, once the scan runs.
- Declared and recognized —
image/jpeg,image/png,image/webp, or the common aliasimage/jpg— is accepted. Parameters (; charset=binary) and case are normalized before the comparison. - Declared and positively wrong —
application/pdf,image/gif,image/svg+xml, anything outside the set above — is rejected with415 unsupported_media_type, before any credit is charged.
A declared-and-recognized type is only a claim; it is not verified against the actual bytes at this stage. An upload that declares image/jpeg but is not a decodable image still passes this check, is still charged, and is then failed and refunded once the real decode step runs — the same outcome an undeclared garbage upload gets.
Limits and formats
Images must be at most 4 MB (4,194,304 bytes). This is a server-side limit specific to the API upload path — the account console at /app accepts larger files (up to 15 MB) because the browser uploads those directly to storage and this server never receives the bytes; an API caller posts the file straight to this endpoint, which is bound by the function's own request-body ceiling. Supported formats: JPEG, PNG, and WebP.
X-Scan-Id
Once a scan row exists — i.e. from the moment a credit has been charged — every response carries X-Scan-Id, including an unexpected 500. If your client loses or fails to parse the response body (a timeout, a proxy that truncates the body, …), read this header to recover the scan id and poll GET /v1/scans/{id} instead of re-uploading and paying for the same image a second time. There is no idempotency-key mechanism today, so a blind retry with no header check is the one thing that can double- charge you.
Example request
POST /v1/detect?mode=sync HTTP/1.1
Host: your-domain.example
Authorization: Bearer ti_live_...
Content-Type: multipart/form-data; boundary=...
--...
Content-Disposition: form-data; name="file"; filename="photo.jpg"
<binary image bytes>
--...--GET /v1/scans/{id} and GET /v1/credits
GET /v1/scans/{id}
Returns the current state of a scan you own. This endpoint always returns 200 for a scan that exists and belongs to you, whether it has finished or not — it never returns 202. Read the status field in the body to find out whether the scan is done: poll this endpoint until status is completed, failed, or expired. A scan id that doesn't exist, isn't a valid id, or belongs to a different account all produce the identical 404 not_found — the API does not distinguish these, so a scan id cannot be used to probe which ids exist.
GET /v1/credits
Returns your current balance and the current per-scan cost:
{
"balance": 42,
"scan_cost": 1
}Both routes accept no query parameters at all — sending any is a 400.
Response shape
POST /v1/detect and GET /v1/scans/{id} return the identical shape, so the same parsing code handles either response:
{
"scan_id": "b3f1c9a0-1234-4a5b-8e9c-0f1a2b3c4d5e",
"status": "completed",
"filename": "photo.jpg",
"bytes": 482913,
"image_sha256": "ce5947b19ad7fb42f596479a2a2a19e647db457260bbef20824387bf5b7c9051",
"credits_charged": 1,
"credits_refunded": false,
"verdict": "ai",
"confidence": 0.94,
"confidence_tier": "HIGH",
"error_code": null,
"created_at": "2026-07-27T18:04:12.000Z",
"completed_at": "2026-07-27T18:04:14.000Z"
}scan_id— the scan's id. Save this if you need to poll.status—queued,processing,completed,failed, orexpired. Only the last three are terminal.filename— the filename you sent, or"upload"if none was given or it was too long.bytes— size of the uploaded image in bytes.image_sha256— SHA-256 of the uploaded bytes, useful for de-duplication on your side.credits_charged— credits debited for this scan (1 today, but read this field rather than assuming).credits_refunded—trueoncestatusisfailedorexpired: the charge above has already been reversed automatically, and this field is how you reconcile that against your own records without a separate lookup.verdict—nulluntil the scan completes, then one of exactly three values:real,ai, ormanipulated.confidence—nulluntil completion, then a 0–1 score.confidence_tier—LOW,MEDIUM, orHIGH, ornulluntil completion.error_code—nullunlessstatusisfailed, in which case this names why.created_at/completed_at— ISO 8601 timestamps.completed_atisnulluntil the scan reaches a terminal status.
This is the complete key set — nothing else is present. No internal storage keys and no raw detector payload are ever included.
Errors
Every non-2xx response from any /v1 route has exactly this shape:
{
"error": {
"code": "...",
"message": "..."
}
}code is one of a fixed set:
| Code | HTTP status | What to do |
|---|---|---|
| unauthorized | 401 | Missing, malformed, or unknown/revoked key. Check the header and the key itself. |
| forbidden | 403 | The key is valid but the account's email is unverified. Verify the account. |
| invalid_request | 400 | Bad query parameter, malformed multipart body, wrong form fields, or an empty file. Fix the request; do not retry unmodified. |
| not_found | 404 | No such scan, for that key. Double-check the id; do not retry. |
| insufficient_credits | 402 | Not enough balance to run the scan; nothing was charged. Buy credits at /app/credits. |
| payload_too_large | 413 | Image exceeds 4 MB. Resize or compress before uploading. |
| unsupported_media_type | 415 | The part's declared Content-Type is positively wrong (not JPEG/PNG/WebP and not undeclared). See the content-type rules above. |
| rate_limited | 429 | Too many requests. The response carries a Retry-After header, in seconds — wait at least that long before retrying. |
| internal_error | 500 | An unexpected server-side failure — including a database problem in the rate-limit check itself, which fails closed rather than silently letting the request through. This is retryable: back off and try again. If it named a scan in X-Scan-Id, poll that scan instead of resubmitting the image. |
| method_not_allowed | 405 | Wrong HTTP method for this path. Check the Allow header. |
Rate limits
Two limits apply once a key is authenticated, each keyed per API key:
POST /v1/detect— 60 requests per 1 hour per key.GET /v1/scans/{id}andGET /v1/credits— 600 requests per 1 hour per key, combined.
There is also a per-IP bound applied before your key is even checked, so it affects requests with an invalid or missing key too, and — if you share an outbound IP with other traffic (a NAT, a corporate proxy, a cloud egress pool) — potentially requests from other tenants behind the same address: 800 bearer-shaped requests per 1 hour per IP. This ceiling is wide enough that normal use of both endpoints above, from one key, will not reach it on its own.
A 429 always carries Retry-After in seconds.
Examples
cURL — sync
curl https://your-domain.example/v1/detect \
-H "Authorization: Bearer ti_live_..." \
-F "file=@photo.jpg"JavaScript (fetch + FormData) — sync
const form = new FormData();
form.append('file', fileBlob);
const res = await fetch('https://your-domain.example/v1/detect', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
});
const scan = await res.json();
console.log(scan.status, scan.verdict, scan.confidence);Python (requests) — sync
import requests
with open('photo.jpg', 'rb') as f:
res = requests.post(
'https://your-domain.example/v1/detect',
headers={'Authorization': f'Bearer {api_key}'},
files={'file': f},
)
scan = res.json()
print(scan['status'], scan['verdict'], scan['confidence'])Async: submit, then poll
Submit with ?mode=async (or handle the 202 a sync call can itself return once its 90-second budget runs out) and poll GET /v1/scans/{id} until status is terminal:
import time
import requests
def poll(scan_id, api_key, timeout_s=300, interval_s=2):
headers = {'Authorization': f'Bearer {api_key}'}
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
res = requests.get(
f'https://your-domain.example/v1/scans/{scan_id}',
headers=headers,
)
scan = res.json()
if scan['status'] in ('completed', 'failed', 'expired'):
return scan
time.sleep(interval_s)
raise TimeoutError('scan did not finish in time')Privacy
In the ordinary case, the original uploaded image is deleted within seconds of the scan finishing — completed, failed, or expired. Two honest edge cases: a scan that gets stuck can take longer to reach that point (up to roughly ten minutes, while an automated job expires it); and if that automated job's credit-refund step keeps failing, the scan never reaches a final state at all, and its original is retained until that failure is fixed. A small, downscaled thumbnail — with all embedded metadata stripped — is retained afterward so the scan can be reviewed on the report page in the account console; the thumbnail is not the original file. See /legal/retention for the exact retention window and mechanism behind every category this API touches, including both edge cases above.