HTTP API reference
Read this if you call
laidline servefrom your own code, a gateway or a load balancer. You will find every route with its method, authentication, request, response and error codes, and the shape of a detect result. Skip if you only use the library or the command line (Command line reference), or you point an OpenAI client at the proxy (OpenAI-compatible proxy). Time: 5 minutes to find a route. Needs:pip install "laidline[server]"and a runninglaidline serve.
In short
laidline servelistens on127.0.0.1:8080unless you pass--host. Every example below uses that address.GET /healthz,/readyz,/metrics,POST /detectandGET /v1/assets/{name}never need a token. The mark and proxy routes need a bearer token once you setserver.mark_token_env;/adminhas its own bearer token.- Request bodies over
server.max_upload_bytes(64 MiB) get413, video uploads excepted (they havejobs.max_upload_bytes). A missing or wrong token gets401. POST /v1/mark/imageand/v1/mark/audioanswer with the marked file; the ids come back inX-Laidline-*headers. Video is asynchronous:202, then pollGET /v1/jobs/{id}.POST /detectis free, returns alaidline.detect/1result and is rate limited per address.
Before you call
Start the sidecar from a directory that holds your laidline.yaml (see Quickstart):
laidline serve
2026-10-26 08:55:52,623 INFO laidline.cli listening on 127.0.0.1:8080 (loopback only) ... INFO: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit)
Check that it is ready:
curl -i localhost:8080/readyz
HTTP/1.1 200 OK
content-type: application/json
{"ready":true,"error":null}
The sidecar also serves the interactive pages FastAPI generates, /docs and /openapi.json. They list the routes
below except /admin. With server.mark_token_env set they need the mark token.
Authentication
| Routes | Authentication |
|---|---|
GET /healthz, GET /readyz, GET /metrics |
None |
POST /detect, POST /v1/detect |
None by default. In keyed mode the optional header X-Laidline-Detect-Token selects the response tier (Detect tokens) |
GET /v1/assets/{name} |
None. An asset name is an unguessable address that expires after assets.ttl_hours |
POST /v1/mark/image, /v1/mark/audio, /v1/mark/video, GET /v1/jobs/{id} |
None until you set server.mark_token_env; then Authorization: Bearer <token> |
The OpenAI-compatible proxy, the generic adapter routes, /docs, /openapi.json |
As the mark routes |
POST /admin/reload, GET /admin/evidence/status, GET /admin/evidence/export |
Authorization: Bearer <admin token>, the value of the variable named by admin.token_env |
The mark token. Set server.mark_token_env to the name of an environment variable (not the token itself). While
that variable holds at least 32 characters, every route in the table that needs it answers 401 without a matching
Authorization: Bearer <token>. The server reads the variable on every request, so you can set or rotate the token
without a restart. If the variable is unset or shorter than 32 characters, the routes stay open and the start-up log
says so. A request that passes has its Authorization header removed, so the token never reaches an upstream. An
upstream.auth: passthrough upstream then gets no key: use upstream.auth: env there
(OpenAI-compatible proxy).
The admin token. /admin answers 404 as if the path did not exist unless the variable named by admin.token_env
(default LAIDLINE_ADMIN_TOKEN) holds at least 32 characters. With it set, a missing or wrong token gets 401 with
WWW-Authenticate: Bearer. The server reads the variable on every request.
Both tokens are compared in constant time and never appear in logs, errors or metrics.
Errors
Native routes return one of three bodies. A proxy route returns an OpenAI-shaped body.
| Where | Body | Example |
|---|---|---|
/v1/mark/*, /v1/jobs/*, token 401 on those routes |
{"detail": {"reason": "<code>", "detail": "<text>"}} |
{"detail":{"reason":"upload_too_large","detail":"upload exceeds server.max_upload_bytes (67108864 bytes)"}} |
/detect: input and size errors |
{"detail": "<text>"} |
{"detail":"upload exceeds server.max_upload_bytes (67108864 bytes)"} |
/detect: auth, rate and capacity errors |
{"error": "<code>", ...} |
{"error":"rate_limited"} |
/admin/* errors |
{"reason": "<code>", "detail": "<text>"}, except 401, 404 and 413, which are {"detail": "<text>"} |
{"reason":"invalid_range","detail":"..."} |
The proxy and the generic adapter, including their token 401 |
{"error": {"message": "...", "type": "...", "code": "<code>"}} |
{"error":{"message":"a valid Authorization: Bearer token is required (server.mark_token_env)","type":"laidline_error","code":"unauthorized"}} |
Every 401 for the mark token carries WWW-Authenticate: Bearer.
Health and metrics
GET /healthz
Answers 200 with {"status": "ok"} as long as the process is up. It never touches the signer, so use it for liveness.
GET /readyz
Answers 200 with {"ready": true, "error": null} once the start-up self-test passed. The self-test marks a small
image with the real signer, timestamp server and evidence sink, and is never counted against your credits. It answers
503 with {"ready": false, "error": "<category>"} while the self-test fails. /readyz re-runs the self-test 30
seconds after a failure, then backs off to 15 minutes, and goes back to 30 seconds once it passes.
error is a short category, never a stack trace: startup_failed (the signer did not start), <reason>: MarkError
(for example tsa_unavailable: MarkError or signer_unavailable: MarkError) or self_test_failed: <ExceptionName>.
The full message goes to the server log.
GET /metrics
Prometheus text on the same port. It is open without a token, so keep the port off the public network or scrape it through your reverse proxy. Laidline's series:
| Series | Labels |
|---|---|
laidline_marks_total |
modality, status (ok, unmarked, error) |
laidline_detects_total |
verdict |
laidline_attestations_total |
outcome (signed, unsigned, skipped) |
laidline_mark_seconds |
modality (a histogram) |
laidline_jobs_queued |
none (a gauge) |
laidline_jobs_total |
status (done, failed) |
Mark routes
Request headers on mark routes
Optional headers on POST /v1/mark/image, /v1/mark/audio and /v1/mark/video label the evidence entry:
| Header | Meaning |
|---|---|
X-Laidline-Request-Id |
Your id for the request, recorded in the evidence entry |
X-Laidline-Tenant |
The tenant the mark belongs to; per-tenant statements and exports filter on it |
X-Laidline-Route |
A label for the route, recorded in the entry (default http:/v1/mark/image, http:/v1/mark/audio) |
X-Laidline-Upstream |
The upstream that produced the media, recorded in the entry |
A X-Laidline-Route or X-Laidline-Upstream value over 128 characters is ignored, not rejected.
Response headers on a marked file
| Header | Meaning |
|---|---|
X-Laidline-Asset-Id |
The asset id, for example ast_01M5W41HT36EQ09SZQMERHD503 |
X-Laidline-Manifest-Id |
The Content Credentials manifest id (urn:c2pa:...); empty when the container carries none |
X-Laidline-Entry-Hash |
The hash of the evidence entry for this mark |
X-Laidline-Warnings |
Comma-separated warnings; empty when there are none |
With on_mark_failure: fail_closed (the default) a mark that fails returns an error, never the unmarked file. With
fail_open it returns 200 with the unmarked input and says so in X-Laidline-Warnings.
POST /v1/mark/image
Marks one image and returns it.
| Part | Meaning |
|---|---|
file |
Multipart file field (required): the image |
fmt |
Multipart form field: the output format, for example png or jpeg. The default follows the upload's content type, else png |
curl -s -D - -o marked.png -F file=@photo.png localhost:8080/v1/mark/image
HTTP/1.1 200 OK x-laidline-asset-id: ast_01M5W41HT36EQ09SZQMERHD503 x-laidline-manifest-id: urn:c2pa:7749ba64-312a-430c-9ff8-e8dfff329b8d x-laidline-entry-hash: ff2e4240049c2dbbd95e5207ce2517bdd5c65d4af4590bc5e5e825dec090548f x-laidline-warnings: content-length: 454191 content-type: image/png
What just happened: Laidline embedded the watermark, signed the Content Credentials, logged an evidence entry and
returned the marked PNG in marked.png.
Errors:
| Status | reason |
Cause |
|---|---|---|
| 400 | image_too_large |
More pixels than media.image.max_pixels (8192 x 8192) |
| 401 | unauthorized |
server.mark_token_env is set and the bearer token is missing or wrong |
| 413 | upload_too_large |
The upload is over server.max_upload_bytes |
| 422 | undecodable_image, unsupported_format |
The bytes are not an image Laidline reads, or fmt is not supported |
| 503 | signer_unavailable, tsa_unavailable, evidence_unavailable, ... |
The signer, the timestamp server, the evidence sink or a model is not available; also a missing extra |
POST /v1/mark/audio
Marks one audio file and returns it. Any container PyAV reads is accepted.
| Part | Meaning |
|---|---|
file |
Multipart file field (required): the audio |
fmt |
Multipart form field: the output format. The default is the upload's own container, else wav |
The response and headers are those of /v1/mark/image. Errors are the image errors with these differences:
| Status | reason |
Cause |
|---|---|---|
| 413 | audio_too_long |
The clip is longer than watermark.audio.max_sync_duration_s (120 seconds). Mark shorter clips (Audio) |
| 422 | undecodable_audio, unsupported_format |
The bytes are not audio Laidline reads, or fmt is not supported |
| 503 | audio_unavailable, ... |
The audio extra is not installed, or the usual signer, timestamp and sink causes |
POST /v1/mark/video
Queues one video for marking and answers at once. Video marking is off by default: set watermark.video.enabled: true
and install the video extra.
| Part | Meaning |
|---|---|
file |
Multipart file field (required): the video |
webhook_url |
Multipart form field: an https URL that receives the finished job as a JSON POST |
curl -s -X POST -F file=@clip.mp4 localhost:8080/v1/mark/video
{"id":"job_01M5W4...","status":"queued","status_url":"/v1/jobs/job_01M5W4..."}
The status is 202. Errors:
| Status | reason |
Cause |
|---|---|---|
| 413 | upload_too_large |
The upload is over jobs.max_upload_bytes (2 GiB). Video uses its own cap, not server.max_upload_bytes |
| 422 | webhook_url_invalid |
The URL is not https, names an internal IP address, or is not in jobs.webhook_hosts when you set that list |
| 429 | jobs_queue_full |
jobs.max_queued (16) jobs are already waiting |
| 503 | video_disabled |
watermark.video.enabled is false |
GET /v1/jobs/{id}
Returns the state of a video job. Jobs live in this process and expire after jobs.ttl_hours (24).
{ "id": "job_01M5W4...", "kind": "video", "status": "done", "created_at": "...", "updated_at": "...", "expires_at": "...", "webhook_url": null, "result": { "asset_id": "ast_...", "manifest_id": "urn:c2pa:...", "entry_hash": "...", "url": "/v1/assets/...", "bytes": 1048576, "warnings": [], "watermark": {"scheme": "videoseal", "device": "cpu"} }, "error": null, "status_url": "/v1/jobs/job_01M5W4..." }
status is queued, running, done or failed. A failed job has result: null and
error: {"reason": "...", "detail": "..."}. Download the marked video from result.url (see
GET /v1/assets/{name}). When assets.public_base_url is set the URL is absolute. The webhook
receives this same JSON. An unknown or expired job, or a server without video marking, answers 404 with
job_not_found.
GET /v1/assets/{name}
Returns a stored marked asset: the output of a video job, or an image the proxy returned as a URL. It answers 200
with the file and Cache-Control: private, max-age=<assets.ttl_hours in seconds>, or 404
{"detail": "asset not found or expired"}.
Detect
POST /detect
POST /v1/detect is an alias. Detection is free and never reads your licence. The request is one of:
| Request | Fields |
|---|---|
| Multipart | Content-Type: multipart/form-data with a file field |
| JSON, a file | {"b64": "<base64 file>", "mime": "image/png"} (mime is optional) |
| JSON, text | {"text": "<text>", "model": "<tokenizer id>"}. The model must be listed in watermark.text.detect_models and is at most 256 characters. When a body has both text and b64, text wins |
curl -s -F file=@marked.png localhost:8080/detect
{"schema":"laidline.detect/1","asset":{"sha256":"5f01f8ef...","mime":"image/png","bytes":454191},"verdict":"marked_by_this_system","grade":"A","signals":[{"type":"c2pa","present":true,"status":"valid_trusted","grade":"A",...}],...}
What just happened: Laidline read the Content Credentials and the watermark and returned the graded result
described under The laidline.detect/1 result. Nothing was written to your evidence log.
Errors:
| Status | Body | Cause |
|---|---|---|
| 401 | {"error": "unauthorized", "detail": "a valid X-Laidline-Detect-Token is required"} |
Keyed mode: the token does not match a detect.credentials entry, or you set detect.unauthenticated: refuse and sent none |
| 413 | {"detail": "upload exceeds server.max_upload_bytes (67108864 bytes)"} |
A file or base64 body over server.max_upload_bytes |
| 413 | {"detail": "text exceeds watermark.text.max_detect_chars (100000)"} |
Text over 100,000 characters |
| 415 | {"error": "media_not_served", "media": "<family>"} |
You set detect.media and this family is not listed |
| 422 | {"detail": "<text>"} |
No file field, a JSON body without b64 or text, or text and model that are not strings |
| 429 | {"error": "rate_limited"} with Retry-After: 60 |
More than detect.rate_limit requests from this address in a minute |
| 429 | {"error": "daily_limit"} with Retry-After: <seconds to 00:00 UTC> |
You set detect.daily_limit and it is used up |
| 503 | {"error": "detect_busy"} with Retry-After: 5 |
You set detect.max_concurrent and detect.max_queued callers already wait |
| 503 | {"error": "video_detect_busy"} with Retry-After: 5 |
Another video is being checked; a process checks one video at a time |
A file Laidline cannot process is not an HTTP error: the answer is 200 with verdict: "error" and a caveat that names
the reason.
Detect tokens
In keyed mode (watermark.payload_keys with mode: keyed; laidline init writes it) the response tier depends on the
caller. Send the token in the header X-Laidline-Detect-Token. Laidline reads no other header for this, never
Authorization, so a gateway token there cannot collide.
| Caller | Tier |
|---|---|
A token that matches a detect.credentials entry (the SHA-256 of the token) |
The full result, until that credential passes its tag-failure budget, then the public tier |
| A token that matches none | 401 |
| No token | detect.unauthenticated decides: public_tier (the default), full or refuse (401) |
The public tier shows grade B or D only, without fingerprints, distances, scores, unconfirmed ids or tag fields. A keyed deployment whose payload keyring did not load serves the public tier to every caller. In unkeyed mode every caller gets the same result. Keys has the full list of what each tier hides.
Rate limits
POST /detect is limited per client address: detect.rate_limit (default 60/min/ip) in a sliding 60-second window.
IPv6 clients share a bucket per /64 network. The limit is per server process, not shared across replicas, and a
distributed client ignores it, so a public checker also needs a limit at its reverse proxy.
Behind a reverse proxy, set the environment variable FORWARDED_ALLOW_IPS to the proxy's address before you start
laidline serve. The server runs on uvicorn, which reads the client address from X-Forwarded-For only for senders in
that list (default 127.0.0.1,::1). Otherwise every caller shares the proxy's bucket, or a forged X-Forwarded-For
picks one. Optional settings cap a public checker further: detect.media, detect.max_concurrent with
detect.max_queued, and detect.daily_limit (Configuration). All are off by
default.
CORS
CORS applies to POST /detect and POST /v1/detect only. List the origins that may call it from a browser in
detect.cors_origins. Laidline answers the preflight, allows POST, allows no credentials and exposes Retry-After.
No other route sends CORS headers, so a page on a listed origin can read detect replies and nothing else. With no
origins listed, the server sends no CORS headers.
Signed results
With detect.attest.enabled, a result that names the content's SHA-256 carries an attestation block: the result, signed
with your active signing key. A signing failure serves the result unsigned with the caveat result not signed: <reason>. Grading describes the block;
laidline verify-result checks it offline.
The laidline.detect/1 result
Every 200 from /detect is a JSON object with these top-level fields:
| Field | Meaning |
|---|---|
schema |
Always "laidline.detect/1" |
asset |
sha256, mime and bytes of what you sent; a result for input Laidline could not read can carry less |
verdict |
marked_by_this_system, marked_by_known_system, signals_present_unverified, no_signal_found or error |
grade |
A, B, C, D or none: the best grade of any signal |
signals |
One object per check: c2pa, watermark (one per scheme) or text_watermark |
evidence_log |
{stream, seq, entry_hash, status} when the asset resolves to an entry in your log, else null |
caveats |
Strings the reader must see. Always includes Absence of a signal is not evidence that content is human-made. |
validators_disagree |
true when the secondary validator disputes the Content Credentials |
attestation |
Only with detect.attest.enabled: the signed block |
Every signal has type, present and grade, and most have status. The other fields depend on the type:
type |
Notable fields |
|---|---|
c2pa |
status (valid_trusted, valid_untrusted, invalid, absent), validator, manifest_id, claim_generator, signed_at, cert (common_name, issuer, serial, trusted, trusted_by, revocation), digital_source_type, soft_binding, failure_codes, issuer |
watermark |
scheme (pixelseal, trustmark, audioseal, videoseal, ...), status, key_family (shared for a mark under the shared pool key), payload (issuer_id, asset_seq, hex, ...), registry_match, asset_ids, fingerprint, decode_path, false_accept_bound, caveats, issuer |
text_watermark |
scheme (kgw or synthid), model, tokens, scored_tokens, z_score, p_value, threshold_p, decode_path |
The issuer block on a signal says who a mark or credential points to, in the words laidline detect prints
(Issuer lines). The public tier removes fields as described above, so code against the
fields you need and ignore the rest. A result never says "human-made", "authentic" or "not AI". How each signal earns
its grade, and what every field means, is in Grading.
OpenAI-compatible proxy
Point an OpenAI client at http://127.0.0.1:8080/v1. Laidline marks what comes back and forwards everything else
unchanged. Details, formats and errors are in OpenAI-compatible proxy.
| Route | What Laidline does |
|---|---|
POST /v1/images/generations, /v1/images/edits, /v1/images/variations |
Marks every image in the answer and returns it in the client's format. url answers are served from /v1/assets/ |
POST /v1/audio/speech |
Marks the speech. Header x-laidline-speech-mode: buffered or streaming picks the mode; a streaming answer adds x-laidline-asset-id, x-laidline-mode and x-laidline-signed-twin-url. Needs the audio extra, else 501 audio_unavailable |
POST /v1/chat/completions, POST /v1/completions |
Adds vllm_xargs.laidline for the vLLM plugin and records one text evidence entry. The plugin acknowledges with x-laidline-watermarked: 1 and x-laidline-scheme |
Any other /v1/* |
Forwarded byte for byte. Paths under Laidline's own /v1/assets, /v1/mark, /v1/detect and /v1/jobs answer 404 locally instead |
adapter.rules[].path |
The generic adapter: upstream JSON with the images it names marked; header x-laidline-rule |
The client's Authorization header goes upstream (upstream.auth: passthrough, the default) or is replaced with
Bearer $OPENAI_API_KEY (upstream.auth: env). Proxy errors use the OpenAI body shape. The upstream's own errors pass
through unchanged. Laidline's: 400 invalid_request_error, 500 upstream_auth, 502 upstream_unreachable,
upstream_bad_response, upstream_read or adapter_no_match, 504 upstream_timeout, and 503 when marking fails.
Admin routes
Admin routes are for operators, not for your application. They are off until you set the admin token
(Authentication) and are not listed in /docs. Use them on a private network or over TLS.
POST /admin/reload
Reloads the signing keys, as SIGHUP does. Answers 200:
{"reloaded": true, "active": "default", "previous": "default", "changed": false, "keys": [{"id": "default", "status": "active"}]}
422 reload_failed when the key file or a chain is invalid; the running keys stay in force. laidline keys rotate --admin-url calls this route.
curl -s -X POST -H "Authorization: Bearer $LAIDLINE_ADMIN_TOKEN" localhost:8080/admin/reload
GET /admin/evidence/status
Returns the evidence writer's health: stream, seq, head, sink_failures (per sink), replica_queue_depth,
last_checkpoint (stream, range, ts, status, tsa_gen_time) and signing_key. In keyed mode it adds a
detect_state block of counters (tag failures, credential budget use, queue drops). Numbers and ids only.
GET /admin/evidence/export
Streams an evidence bundle (a zip). One export runs at a time, bounded in range and size.
| Query | Meaning |
|---|---|
from, to |
Required: YYYY-MM-DD or ISO 8601 with Z or an offset |
sink |
The index into evidence.sinks (default 0) |
tenant |
Count only this tenant's entries in the summary |
curl -s -H "Authorization: Bearer $LAIDLINE_ADMIN_TOKEN" -o bundle.zip \ "localhost:8080/admin/evidence/export?from=2026-10-01&to=2026-10-31"
The answer is 200 with Content-Type: application/zip and the header x-laidline-bundle-sha256. Errors:
| Status | reason |
Cause |
|---|---|---|
| 400 | invalid_range |
from or to is missing, unparseable, or from is after to |
| 400 | range_too_large |
The range is longer than admin.export_max_days (31) |
| 402 | licence_required |
tenant without the tenant_reports licence feature |
| 409 | export_in_progress |
Another export is running; retry when it finishes |
| 413 | bundle_too_large |
The bundle is over admin.export_max_bytes (2 GiB); export a shorter range |
| 500 | export_failed |
The export could not be written |
Limits
| Limit | Value | Setting |
|---|---|---|
| Any request body but a video upload (mark, detect, proxy, adapter) | 64 MiB (67,108,864 bytes) | server.max_upload_bytes |
| Synchronous audio length | 120 seconds | watermark.audio.max_sync_duration_s |
| Video upload | 2 GiB | jobs.max_upload_bytes |
| Queued video jobs | 16 | jobs.max_queued |
| Detect text | 100,000 characters | watermark.text.max_detect_chars |
| Detect requests per address | 60 a minute | detect.rate_limit |
| Evidence export range and size | 31 days, 2 GiB | admin.export_max_days, admin.export_max_bytes |
The server refuses a mark or detect request whose Content-Length is already over the cap before it reads the body.
A body sent without a length is checked while it is read, so a public deployment also caps request bodies at its
reverse proxy.
Troubleshooting
| Message | Cause | Fix |
|---|---|---|
curl: (7) Failed to connect from another host |
laidline serve binds 127.0.0.1 by default |
Start it with --host 0.0.0.0 and set server.mark_token_env (Command line reference) |
{"detail":{"reason":"unauthorized",...}} on a mark route |
server.mark_token_env is set and the bearer token is missing or wrong |
Send Authorization: Bearer <token> |
{"error": "unauthorized"} on /detect |
Keyed mode and the X-Laidline-Detect-Token matches no credential, or detect.unauthenticated is refuse |
Send a valid token, or change detect.unauthenticated |
{"detail":{"reason":"upload_too_large",...}} |
The upload is over server.max_upload_bytes |
Send a smaller file, or raise the setting |
{"ready":false,"error":"tsa_unavailable: MarkError"} on /readyz |
The timestamp server did not answer | Open egress to signing.tsa.url, or set signing.tsa.required: false and accept grade B at best |
404 Not Found on /admin/... |
The admin token variable is unset or shorter than 32 characters | Set the variable named by admin.token_env to 32 or more characters |
{"error": "rate_limited"} |
More than detect.rate_limit requests a minute from this address |
Wait for Retry-After, or raise the limit |
Reference
- Command line reference:
laidline serveand every other command. - Configuration: the settings named above.
- Grading: what the grades, verdicts and signals in a detect result mean.
- Keys: the response tiers and detect tokens.
- OpenAI-compatible proxy: the proxy routes in full.
- Evidence: what an exported bundle holds.