Mark a picture, song or clip
POSThttps://api.kennmarq.com/v1/mark
Send a picture or a song; get it back with an invisible watermark and a signed, time-stamped C2PA manifest. Send a clip; get a job, and the marked clip from GET /v1/marks/{id} when it's done. A picture counts one mark, a song five per started minute, a clip five per started second. Needs a key with the mark scope.
Request
multipart/form-data, with these fields. Text fields are trimmed; flags take true/false (or 1/0, on/off, yes/no).
| Field | Type | Description |
|---|---|---|
file | file, required (or upload) | The file: a PNG, JPEG or WebP picture; an MP3, WAV or M4A song; or an MP4 clip. Its type is read from its bytes, not its name. The whole request is at most 4 MB; a larger file goes through an upload, named in upload: up to 25 MB, and a clip up to 1 GB. |
upload | text, instead of file | An upload's ID (up_…) from POST /v1/uploads, for a file over 4 MB. It works once, within the hour, with the key that made it. |
source | text, default created | How the picture was made, recorded as its IPTC digital source type: created (made by AI), composite (AI combined with other material) or edited (an existing picture changed with AI). You declare it; we sign only AI source types. |
provider | text, optional | Your legal name, as the provider of the AI system, e.g. Acme Inc. With app_name and app_version it completes California's latent disclosure. At most 200 characters. |
model | text, optional | The model's name, e.g. FLUX.1 schnell. At most 200 characters. |
model_version | text, optional | The model's version. At most 200 characters. |
app_name | text, optional | Your AI system's name, recorded as the software agent of the created action. Without it, the manifest names no software agent. At most 200 characters. |
app_version | text, optional | Your AI system's version. Needs app_name. At most 200 characters. |
markets | text, optional | The markets the mark is made for, separated by commas: eu, us-ca, us-wa, us-ut, or us for all three states. A call missing what those laws need is refused (us-ca and us-wa need provider, app_name and app_version), and the mark gets a signed receipt. China, India, South Korea and Vietnam need visible labels we don't add yet: they're refused with option_not_available. |
title | text, optional | A title for the file. At most 200 characters. |
watermark | flag, default true | Add the invisible watermark. Turn it off only if the file already carries ours, e.g. watermarked locally. |
iptc | flag, default true | Pictures: write the IPTC DigitalSourceType into the file's XMP metadata. Songs and clips state it in their manifest. |
output_format | text, default keep | A picture: keep, png, jpeg or webp. A song: keep, mp3, wav or m4a. A clip: keep or mp4 (always H.264 in MP4). |
keep_input_credentials | flag, default true | Keep Content Credentials the file already carries (from an upstream model's API, say) as an ingredient, as the Code of Practice's Measure 1.2 asks. |
input | file, optional | For edit tools: the user's own input picture (or, for a song or clip, an input song). If it carries Content Credentials, they're kept as an ingredient; nothing else of it is recorded. An input picture meets our floor, and with check on, your policy. |
input_upload | text, instead of input | The input's upload ID, for an input over 4 MB (or a file and input over 4 MB together). |
check | text, default off | The safety check before marking: ai-act, safe-only or off. On a block nothing is marked and you get 422 content_blocked with the reason. Each picture checked counts two marks; a clip is checked on 12 frames, two marks each. A song has no pictures: leave it off. |
Options of later releases (metadata, webhook, ai_disclosure, categorize, describe, marking by url) are refused with 400 option_not_available rather than ignored, so you never believe a file got something it didn't.
Example
curl https://api.kennmarq.com/v1/mark \
-H "Authorization: Bearer $KENNMARQ_API_KEY" \
-F file=@render.jpg \
-F source=created \
-F provider="Acme Inc." \
-F model="FLUX.1 schnell" \
-F model_version="1.0" \
-F app_name="Acme Studio" \
-F app_version="2.1" \
-F output_format=webp \
-D headers.txt \
-o render.marked.webpimport { readFile, writeFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("render.png")], { type: "image/png" }), "render.png");
form.append("source", "created");
form.append("model", "FLUX.1 schnell");
const res = await fetch("https://api.kennmarq.com/v1/mark", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.KENNMARQ_API_KEY}` },
body: form,
});
if (!res.ok) throw new Error((await res.json()).error.message);
await writeFile("render.marked.png", Buffer.from(await res.arrayBuffer()));
console.log(res.headers.get("x-mark-id"), res.headers.get("x-marks-used"));import os, requests
with open("render.png", "rb") as f:
res = requests.post(
"https://api.kennmarq.com/v1/mark",
headers={"Authorization": f"Bearer {os.environ['KENNMARQ_API_KEY']}"},
files={"file": ("render.png", f, "image/png")},
data={"source": "created", "model": "FLUX.1 schnell"},
timeout=60,
)
res.raise_for_status()
open("render.marked.png", "wb").write(res.content)
print(res.headers["X-Mark-Id"], res.headers["X-Marks-Used"])Response
200 with the marked file as the body, and these headers:
| Header | Value |
|---|---|
Content-Type | The marked file's type: image/png, image/jpeg, image/webp, audio/mpeg, audio/wav or audio/mp4. |
Content-Disposition | attachment, with the file's name plus .marked and the right extension (image.marked.png for an uploaded picture). |
X-Mark-Id | This mark's ID (mk_…). Quote it to support. |
X-Watermark-Id | The ID carried by the watermark (hex). Absent when watermark=false. |
X-Log-Index | Where this file's signature sits in our public transparency log: GET /v1/log/proof/{index} proves it's there. |
X-Marks-Used | Marks used this month in this key's environment, this one included. |
X-Marks-Limit | The month's allowance for this key's environment. |
X-Marks-Counted | Songs: the marks this one counted, five per started minute. |
X-Audio-Watermark | Songs: claimed (the mark reads back from the file, and its manifest says so), unclaimed (too quiet to read back: the file is still signed) or off. |
X-Safety-Verdict, X-Safety-Reason | With check on: allow or flag, and a flag's reason (sexual_realistic_face: get the person's consent). |
X-Request-Id | This request's ID (req_…), also in every error body. |
X-RateLimit-Limit, -Remaining, -Reset | See rate limits; Reset is a Unix time in seconds. |
Songs
MP3, WAV or M4A, up to 15 minutes. The song comes back in the same call, in its own format (or the one output_format names), with its sample rate, channels and text tags; cover art isn't kept. Its sound carries Meta's AudioSeal mark, shaped under the music so no one hears it, and its manifest is signed and time-stamped like a picture's. AudioSeal's 16 bits say the file was marked by us, not which file: a song's credential travels in its manifest, and a song has no X-Watermark-Id.
The mark is read back from the encoded file: when it reads back, the manifest says the file carries it (X-Audio-Watermark: claimed). In a very quiet or silent recording there's nothing to hide it under; the file is then signed without that claim (unclaimed). A song longer than the marks you have left this month is refused before any work, with 402 quota_exceeded.
Clips
MP4 (or QuickTime), up to 4,500 frames (2.5 minutes at 30 frames a second, 75 seconds at 60, 5 minutes at 15), 1920x1080 either way round, 60 frames a second, 1 GB through an upload: what we can mark within a job's time. Your plan marks a few clips at a time (clips at a time). A clip is marked as a job: the call answers 202 at once with the mark's ID, and GET /v1/marks/{id} says when it's done, with a link to download it. Endpoints you add in the dashboard get mark.completed or mark.failed (webhooks).
{ "object": "mark", "id": "mk_…", "status": "running", "kind": "clip",
"created_at": "2026-11-12T10:00:00.000Z", "url": "https://api.kennmarq.com/v1/marks/mk_…" }Every frame carries Meta's Video Seal watermark, with a random ID like a picture's (watermark_id): it leads to the clip's durable credential when its metadata is stripped. Its sound carries AudioSeal, as a song does. The clip comes back as H.264 in MP4, upright (a phone's rotation is applied to the pixels), at the same size, frame times and sound, signed and time-stamped. Before it goes out, the watermark's ID is read back from 64 of its frames; a clip it doesn't read back from fails, as a picture does. Our safety floor reads 12 frames of every clip, and with check your policy too (two marks a frame); a blocked frame fails the job with the frame's answer in safety.
What we check before answering
We fail closed: a file goes back only if it was marked as asked. Before answering, the marker reads the signed file back and validates its manifest and time-stamp; then we check that the answer is for this mark, in this environment, in the format asked for, with the exact bytes the marker described. If anything is off you get 502 mark_failed and no file, and the mark isn't counted.
The safety check
Every picture we mark goes through our own floor, whatever you send: explicit content with someone who appears to be a child is never marked, and the call answers 422 content_blocked. With check set, the file and the input are checked under your policy as well. A clip is read on 12 frames. A block answers with the check in safety, and picture says which one it was:
{
"error": { "code": "content_blocked", "message": "The safety check blocked this picture, so nothing was marked. See \"safety\" for why.", "request_id": "req_…" },
"safety": { "object": "safety_check", "policy": "ai-act", "verdict": "block", "reason": "sexual_apparent_minor",
"nudity": { "label": "explicit", "score": 0.98 }, "apparent_minor": true, "realistic_face": true,
"picture": "file", … }
}A blocked call counts the checks it ran, not the mark. A block by our floor alone, with check off, counts nothing.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | No file, a field sent twice, or an option out of range. |
| 400 | unknown_field | A field this endpoint doesn't know: a typo never passes silently. |
| 400 | option_not_available | An option of a later release, such as metadata or webhook. |
| 401 | missing_api_key, invalid_api_key, api_key_revoked | See authentication. |
| 402 | quota_exceeded | The month's marks or data allowance is used up, or a song needs more marks than are left (nothing is marked). |
| 403 | insufficient_scope, ip_not_allowed | The key lacks the mark scope, or the address isn't allowlisted. |
| 404 | upload_not_found | The upload named isn't one this key can use: unknown, used, expired, or another key's. |
| 413 | payload_too_large | The request is over 4 MB (send larger files through an upload), or the file is over our limits: songs 15 minutes, clips 4,500 frames at up to 1920x1080. |
| 415 | unsupported_media_type | Not a picture, song or clip we take, or not sent as multipart/form-data. |
| 422 | content_blocked | The safety check blocked the file or the input. The body's safety says why; nothing was marked. |
| 429 | rate_limited | Too many calls this minute (your organisation's keys together), too many calls failed today after we worked on them, or (a clip) your plan's clips at a time are running, or too many clips failed: retry after Retry-After seconds. |
| 502 | mark_failed | The file couldn't be marked and verified; no file is returned. Nothing is charged. |
| 503 | marker_unavailable, storage_unavailable | The marking service or uploads are down for a moment. Nothing is charged; retry. |
Error bodies follow the common format.
Privacy
We record each mark's ID, watermark ID, SHA-256 and sizes, never the file, its name or its contents. A clip's marked file waits for you for a day, then is deleted. Marks say how a file was made, never who made it or the prompt. A safety check's answer is never written into the file's marks, and nothing of a blocked picture is kept. An upload is deleted as soon as the call has used it.