HTTP API reference

Read this if you call laidline serve from 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 running laidline serve.

In short

  • laidline serve listens on 127.0.0.1:8080 unless you pass --host. Every example below uses that address.
  • GET /healthz, /readyz, /metrics, POST /detect and GET /v1/assets/{name} never need a token. The mark and proxy routes need a bearer token once you set server.mark_token_env; /admin has its own bearer token.
  • Request bodies over server.max_upload_bytes (64 MiB) get 413, video uploads excepted (they have jobs.max_upload_bytes). A missing or wrong token gets 401.
  • POST /v1/mark/image and /v1/mark/audio answer with the marked file; the ids come back in X-Laidline-* headers. Video is asynchronous: 202, then poll GET /v1/jobs/{id}.
  • POST /detect is free, returns a laidline.detect/1 result 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