API reference
Label AI images from your own software. One call in, one finished image out, with the badge drawn in and the metadata written.
This reference is available in English only.
Introduction
The API does what the website does, through the same code. An image goes in, the badge is drawn into one of the four corners, and the IPTC digital source type is written to XMP and IPTC. Calls are synchronous: labelling takes milliseconds, so there is no job id and nothing to poll.
- Base URL
- https://imgmarker.net/api/v1
- Also reachable at
- https://api.imgmarker.net/v1
- Request
- multipart/form-data
- Response
- the image itself, errors as JSON
- Max file size
- 25 MB
- Max pixels
- 50 megapixels
- Formats
- JPEG, PNG, WebP
- Authentication
- Bearer token
Authentication
Create a key in your account and send it as a bearer token. We keep only a hash of it, the same way we keep passwords, so a key is shown once at creation and can never be looked up again. Lose it and you make a new one.
Authorization: Bearer 7|kJ3fQ2m…
Accept: application/json
- Keep keys on your server. A key in browser JavaScript is a key you have given away.
- One key per integration, so revoking one does not stop the others.
- Revoking takes effect on the next call, with no delay.
Quickstart
Label one image and write the result next to the original.
curl -X POST https://imgmarker.net/api/v1/label \
-H "Authorization: Bearer YOUR_KEY" \
-F image=@photo.jpg \
-F type=generated \
-F corner=br \
-o photo-aimarked.jpg
<?php
$ch = curl_init('https://imgmarker.net/api/v1/label');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer '.getenv('IMGMARKER_KEY')],
CURLOPT_POSTFIELDS => [
'image' => new CURLFile('photo.jpg', 'image/jpeg', 'photo.jpg'),
'type' => 'generated',
'corner' => 'br',
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// Errors always arrive as JSON, a success never does
if ($status !== 200) {
throw new RuntimeException(json_decode($body, true)['error'] ?? 'unknown');
}
file_put_contents('photo-aimarked.jpg', $body);
import os
import requests
with open("photo.jpg", "rb") as handle:
response = requests.post(
"https://imgmarker.net/api/v1/label",
headers={"Authorization": f"Bearer {os.environ['IMGMARKER_KEY']}"},
files={"image": ("photo.jpg", handle, "image/jpeg")},
data={"type": "generated", "corner": "br"},
timeout=60,
)
response.raise_for_status()
with open("photo-aimarked.jpg", "wb") as out:
out.write(response.content)
print("credits left:", response.headers["X-Imgmarker-Credits-Remaining"])
import { readFile, writeFile } from "node:fs/promises";
const form = new FormData();
form.set("image", new Blob([await readFile("photo.jpg")]), "photo.jpg");
form.set("type", "generated");
form.set("corner", "br");
const response = await fetch("https://imgmarker.net/api/v1/label", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.IMGMARKER_KEY}` },
body: form,
});
if (!response.ok) {
throw new Error((await response.json()).error);
}
await writeFile("photo-aimarked.jpg", Buffer.from(await response.arrayBuffer()));
/api/v1/label
Labels one image and returns it. A successful response carries the image file, not JSON.
The filename keeps its stem and gains
-aimarked
before the extension.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| image | file | yes | JPEG, PNG or WebP. Up to 25 MB and 50 megapixels. |
| type | string | yes | generated for fully AI generated images, modified for AI edited ones. Decides which source type is written. |
| corner | string | yes | tl, tr, bl or br. Where the badge goes. |
| badge_locale | string | no | en, de, es, it or fr. Language of the badge caption. Defaults to en. |
| creator | string | no | Written to XMP-dc:Creator and IPTC By-line. Up to 200 characters. |
| description | string | no | Written to XMP-dc:Description and IPTC Caption-Abstract. Up to 2000 characters. |
| credit | string | no | Written to XMP-photoshop:Credit. Up to 200 characters. |
| c2pa_acknowledged | boolean | no | Needed only when the image carries Content Credentials. See below. |
| response | string | no | file returns the image itself, the default. url stores the result and returns a link. |
Response headers
| X-Imgmarker-Credits-Remaining | Images left in the current period, after this call. |
| Content-Type | image/jpeg, image/png or image/webp, matching the input. |
| Content-Disposition | attachment, with the new filename. |
Full example, with metadata
curl -X POST https://imgmarker.net/api/v1/label \
-H "Authorization: Bearer YOUR_KEY" \
-H "Idempotency-Key: article-88-hero" \
-F image=@hero.png \
-F type=modified \
-F corner=tl \
-F badge_locale=de \
-F "creator=Redaktion Beispielblatt" \
-F "description=Retuschiert mit generativer KI" \
-F "credit=Beispielblatt / imgmarker" \
-D headers.txt \
-o hero-aimarked.png
<?php
function label(string $path, string $type, string $corner, array $meta = []): string
{
$ch = curl_init('https://imgmarker.net/api/v1/label');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.getenv('IMGMARKER_KEY'),
// The same key on a retry means the same call, charged once
'Idempotency-Key: '.hash_file('sha256', $path),
],
CURLOPT_POSTFIELDS => [
'image' => new CURLFile($path),
'type' => $type,
'corner' => $corner,
] + $meta,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status === 200) {
return $body;
}
$error = json_decode($body, true)['error'] ?? 'unknown';
throw new RuntimeException("imgmarker: $error ($status)");
}
file_put_contents('hero-aimarked.png', label('hero.png', 'modified', 'tl', [
'badge_locale' => 'de',
'creator' => 'Redaktion Beispielblatt',
'credit' => 'Beispielblatt / imgmarker',
]));
import hashlib
import os
import requests
KEY = os.environ["IMGMARKER_KEY"]
def label(path, type_, corner, **meta):
with open(path, "rb") as handle:
digest = hashlib.sha256(handle.read()).hexdigest()
handle.seek(0)
response = requests.post(
"https://imgmarker.net/api/v1/label",
headers={
"Authorization": f"Bearer {KEY}",
# Retrying with the same key never charges twice
"Idempotency-Key": digest,
},
files={"image": (os.path.basename(path), handle)},
data={"type": type_, "corner": corner, **meta},
timeout=60,
)
if response.status_code != 200:
raise RuntimeError(response.json()["error"])
return response.content
with open("hero-aimarked.png", "wb") as out:
out.write(label("hero.png", "modified", "tl", badge_locale="de"))
// Server side only. A key shipped to the browser is a key given away.
async function label(file, { type, corner, ...meta }) {
const form = new FormData();
form.set("image", file);
form.set("type", type);
form.set("corner", corner);
Object.entries(meta).forEach(([key, value]) => form.set(key, value));
const response = await fetch("https://imgmarker.net/api/v1/label", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IMGMARKER_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
},
body: form,
});
if (!response.ok) {
const { error, message } = await response.json();
throw new Error(error + ": " + message);
}
return {
image: await response.blob(),
creditsLeft: Number(response.headers.get("X-Imgmarker-Credits-Remaining")),
};
}
A link instead of the image
Send response=url
and you get JSON with a download link instead of the file. Useful when the URL is passed
on to something else, or when you want the metadata without reading headers.
curl -X POST https://imgmarker.net/api/v1/label \
-H "Authorization: Bearer YOUR_KEY" \
-F image=@photo.jpg \
-F type=generated \
-F corner=br \
-F response=url
HTTP/1.1 201 Created
{
"filename": "photo-aimarked.jpg",
"url": "https://imgmarker.net/d/9f3c…/1841",
"expires_at": "2026-08-05T09:14:02+00:00",
"type": "generated",
"source_type": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
"width": 1920,
"height": 1080,
"had_content_credentials": false,
"credits_remaining": 1857
}
The trade is storage. With the default the image passes through and nothing is kept; with a
link it sits on our disk until
expires_at,
60 minutes after the call. The link is the entire
authorisation, so treat it as a secret. Download once, store the file yourself, and use the
default when you have nowhere to pass a URL.
/api/v1/label/batch
Several images in one call, answered with links. Included in the plans that list the batch endpoint; the others get 403 batch_not_available.
Parallel single calls are usually faster
Twenty concurrent calls to /label finish sooner than one batch worked through in sequence, and a failure costs you one image rather than the wait for the whole set. Use the batch endpoint when fanning out is awkward on your side.
Fields are the same as for a single call, with
images[]
instead of image.
One type and one
corner apply to the whole batch. How many images fit
in one call comes from your plan.
curl -X POST https://imgmarker.net/api/v1/label/batch \
-H "Authorization: Bearer YOUR_KEY" \
-F "images[]=@one.jpg" \
-F "images[]=@two.png" \
-F "images[]=@three.webp" \
-F type=generated \
-F corner=br
HTTP/1.1 201 Created
{
"status": "partial",
"expires_at": "2026-08-05T09:14:02+00:00",
"labelled": 2,
"failed": 1,
"zip_url": "https://imgmarker.net/d/9f3c…/zip",
"credits_remaining": 1855,
"source_type": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
"results": [
{
"name": "one.jpg",
"status": "done",
"filename": "one-aimarked.jpg",
"url": "https://imgmarker.net/d/9f3c…/1841",
"width": 1920,
"height": 1080,
"had_content_credentials": false
},
{
"name": "two.png",
"status": "done",
"filename": "two-aimarked.png",
"url": "https://imgmarker.net/d/9f3c…/1842",
"width": 800,
"height": 600,
"had_content_credentials": false
},
{
"name": "three.webp",
"status": "failed",
"error": "too_many_pixels",
"message": "The image has too many pixels."
}
]
}
import os
import requests
paths = ["one.jpg", "two.png", "three.webp"]
handles = [open(path, "rb") for path in paths]
try:
response = requests.post(
"https://imgmarker.net/api/v1/label/batch",
headers={"Authorization": f"Bearer {os.environ['IMGMARKER_KEY']}"},
files=[("images[]", (os.path.basename(p), h)) for p, h in zip(paths, handles)],
data={"type": "generated", "corner": "br"},
timeout=300,
)
finally:
for handle in handles:
handle.close()
payload = response.json()
for result in payload["results"]:
if result["status"] != "done":
print("skipped", result["name"], result["error"])
continue
# The links expire, so download before doing anything else
image = requests.get(result["url"], timeout=60)
open(result["filename"], "wb").write(image.content)
- Only images that came out the other end are charged. A rejected one costs nothing.
- If your remaining allowance is smaller than the batch, nothing is processed and the call answers 402. Half a batch you cannot account for is worse than none.
- zip_url hands you everything that worked as one archive.
- Every link expires with the batch, 60 minutes after the call.
/api/v1/usage
What is left, and when it refills. Cheap to call before a large run, though the credits header on every label call usually makes it unnecessary.
curl https://imgmarker.net/api/v1/usage \
-H "Authorization: Bearer YOUR_KEY"
{
"plan": "starter",
"quota": 2000,
"used": 143,
"remaining": 1857,
"resets_at": "2026-09-04T00:00:00+00:00"
}
/api/v1/health
Reachability, no key required. Meant for uptime monitoring, so it never touches your allowance.
curl https://imgmarker.net/api/v1/health
{
"status": "ok",
"version": "v1"
}
Idempotency
When a connection drops mid-call you cannot tell whether the image was labelled and
charged. Send an
Idempotency-Key
header with a value you choose, up to 128 characters, and a repeat of that call is not
charged again.
- A repeat with a used key answers 409 already_processed. The image is not sent a second time, because we no longer hold it.
- Failed calls are not recorded, so the key stays usable after an error.
- Keys belong to your account. Two customers may use the same value without colliding.
- A file hash makes a good key: same input, same key, no accidental double charge.
curl -X POST https://imgmarker.net/api/v1/label \
-H "Authorization: Bearer YOUR_KEY" \
-H "Idempotency-Key: order-4711-image-3" \
-F image=@photo.jpg -F type=generated -F corner=br
HTTP/1.1 409 Conflict
{
"error": "already_processed",
"message": "A request with this Idempotency-Key was already processed.",
"original_status": 200,
"processed_at": "2026-08-04T21:14:02+00:00"
}
Quota and rate limits
Two separate limits, answering with different codes so you can tell them apart without guessing.
402 quota_exceeded
The monthly allowance is used up. Waiting will not help before the reset date, a larger plan will. We do not bill for overage, so nothing runs up an invoice you did not expect.
429 Too Many Requests
Too many calls per minute for your plan. Read Retry-After and back off. The allowance itself is untouched.
The rate limit counts per key rather than per account, so one runaway script does not throttle your other integrations. The monthly allowance resets on your billing date, not on the first of the month, so a subscription taken out on the 28th still gets a full period.
A rejected image costs nothing. Only calls that return an image are counted.
Content Credentials
Labelling re-encodes the image, which breaks the pixel hash inside an existing C2PA manifest. A broken signature is worse than none, because a verifier reports it as tampered. So when we find credentials, the call is refused rather than quietly destroying them.
HTTP/1.1 409 Conflict
{
"error": "c2pa_consent_required",
"message": "This image carries Content Credentials. Labelling invalidates them. Repeat the request with c2pa_acknowledged=true to proceed."
}
curl -X POST https://imgmarker.net/api/v1/label \
-H "Authorization: Bearer YOUR_KEY" \
-F image=@signed.jpg \
-F type=generated \
-F corner=br \
-F c2pa_acknowledged=true \
-o signed-aimarked.jpg
Once acknowledged, the invalidated manifest is removed instead of being left in place broken. The same rule applies on the website, so the API cannot be used to sidestep a consent the interface insists on.
What ends up in the file
Two things: the badge drawn into the pixels, and the machine readable declaration. The declaration is the IPTC digital source type, written to XMP and to legacy IPTC so old and new readers agree.
| type | DigitalSourceType |
|---|---|
| generated | http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia |
| modified | http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia |
Note the http: the IPTC vocabulary uses that form, and a reader matching the exact string would miss anything else.
Checking the result
exiftool -G1 -s \
-XMP-iptcExt:DigitalSourceType \
-IPTC:Caption-Abstract \
-XMP-dc:Creator \
photo-aimarked.jpg
[XMP-iptcExt] DigitalSourceType : http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia
[IPTC] Caption-Abstract : AI generated image
[XMP-dc] Creator : Redaktion Beispielblatt
Error reference
Every error is JSON with a stable
error
field. Branch on that, not on the human readable message, which may be reworded.
{
"error": "too_many_pixels",
"message": "The image has too many pixels."
}
| Status | error | What to do |
|---|---|---|
| 401 | — | Missing, malformed or revoked key. Create a new one in your account. |
| 402 | quota_exceeded | Wait for the reset date, or move to a larger plan. |
| 403 | api_not_available | Your plan does not include API access. |
| 403 | batch_not_available | Your plan does not include the batch endpoint. |
| 409 | c2pa_consent_required | Repeat with c2pa_acknowledged=true if you accept losing the credentials. |
| 409 | already_processed | This Idempotency-Key was used before. Use a new one to label again. |
| 400 | invalid_idempotency_key | Shorten the key to 128 characters or fewer. |
| 422 | too_large | The file exceeds 25 MB. |
| 422 | too_many_pixels | More than 50 megapixels. Downscale first. |
| 422 | unsupported_type | Only JPEG, PNG and WebP are accepted. |
| 422 | corrupt | The file is not a readable image. |
| 422 | — | Validation failed. The response lists the fields under "errors". |
| 429 | — | Rate limit for your plan. Read Retry-After and back off. |
| 500 | processing_failed | Something broke on our side. Safe to retry with a new Idempotency-Key. |
Retrying sensibly
Retry on 429 and 500, with a growing delay. Never retry 402 or 422: the answer will not change until you change something.
import time
RETRYABLE = {429, 500, 502, 503}
def with_retries(call, attempts=4):
for attempt in range(attempts):
response = call()
if response.status_code not in RETRYABLE:
return response
# Honour Retry-After when the server sends one
wait = float(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
return response
Retention
Nothing is kept. The image exists on our server for the duration of the call and is deleted once the response has gone out. We record the call itself for billing and your usage statistics: timestamp, label type, file format, status. No filename, no image data.
Responsibility for labelling correctly stays with you. We write what you ask us to write. Deciding whether an image needs a label, and which one, is yours.
Versioning
The version sits in the path. Within
v1 we
add fields and error codes but never remove or rename them, so parsing by field name stays
safe. Anything that would break your integration goes into a new version, and v1 keeps
running.
Questions about the API: info@fezznrw.de