Skip to content

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 alias image/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 with 415 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, or expired. 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_refundedtrue once status is failed or expired: the charge above has already been reversed automatically, and this field is how you reconcile that against your own records without a separate lookup.
  • verdictnull until the scan completes, then one of exactly three values: real, ai, or manipulated.
  • confidencenull until completion, then a 0–1 score.
  • confidence_tier LOW, MEDIUM, or HIGH, or null until completion.
  • error_code null unless status is failed, in which case this names why.
  • created_at / completed_at — ISO 8601 timestamps. completed_at is null until 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:

CodeHTTP statusWhat to do
unauthorized401Missing, malformed, or unknown/revoked key. Check the header and the key itself.
forbidden403The key is valid but the account's email is unverified. Verify the account.
invalid_request400Bad query parameter, malformed multipart body, wrong form fields, or an empty file. Fix the request; do not retry unmodified.
not_found404No such scan, for that key. Double-check the id; do not retry.
insufficient_credits402Not enough balance to run the scan; nothing was charged. Buy credits at /app/credits.
payload_too_large413Image exceeds 4 MB. Resize or compress before uploading.
unsupported_media_type415The part's declared Content-Type is positively wrong (not JPEG/PNG/WebP and not undeclared). See the content-type rules above.
rate_limited429Too many requests. The response carries a Retry-After header, in seconds — wait at least that long before retrying.
internal_error500An 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_allowed405Wrong 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} and GET /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.