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.
Everything lives under
api.imgmarker.net.
Download links point at the website, because that is where files are served from.
- Base URL
- https://api.imgmarker.net/v1
- Downloads
- https://imgmarker.net/d/…
- 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://api.imgmarker.net/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://api.imgmarker.net/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://api.imgmarker.net/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://api.imgmarker.net/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()));
/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 | Values | Description |
|---|---|---|---|
| image required | file |
Up to 25 MB and 50 megapixels. Sent as multipart/form-data. |
The image to label. Anything else is refused with unsupported_type. |
| type required | string |
|
Decides which DigitalSourceType is written. See "What ends up in the file". |
| corner required | string |
|
Where the badge is drawn. none leaves the picture untouched — see "Metadata without a badge". |
| badge_locale optional | string |
Default:
|
Language of the badge caption. Ignored when corner is none. |
| creator optional | string |
Free text, 1 to 200 characters. |
Written to XMP-dc:Creator and IPTC By-line. |
| description optional | string |
Free text, 1 to 2000 characters. |
Written to XMP-dc:Description and IPTC Caption-Abstract. |
| credit optional | string |
Free text, 1 to 200 characters. |
Written to XMP-photoshop:Credit. |
| c2pa_acknowledged optional | boolean |
Default:
|
Consent that existing Content Credentials are invalidated. Only needed when the image carries them — see "Content Credentials". |
| response optional | string |
Default:
|
What comes back. See "A link instead of the image". |
Response headers
| Header | Type | Values | Description |
|---|---|---|---|
| X-Imgmarker-Credits-Remaining | integer |
Zero or more. |
What is left after this call: the rest of the monthly allowance plus any bought credits. Sent on every label call, which is why /usage is rarely needed. |
| Content-Type | string |
|
Matches the input format. We never convert between formats. |
| Content-Disposition | string |
attachment; filename="yourname-aimarked.jpg" |
Only when the body is the image. Your filename with the suffix before the extension. |
Full example, with metadata
curl -X POST https://api.imgmarker.net/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://api.imgmarker.net/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://api.imgmarker.net/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://api.imgmarker.net/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://api.imgmarker.net/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
}
| Property | Type | Values | Description |
|---|---|---|---|
| filename | string | — | The name we would have served the file under: yours with -aimarked before the extension. |
| url | string |
Absolute URL on imgmarker.net, not on this domain. |
Download link. The link is the entire authorisation — treat it as a secret. |
| expires_at | string |
ISO 8601 with offset, e.g. 2026-08-05T09:14:02+00:00 |
When the file and the link disappear, 60 minutes after the call. |
| type | string |
|
What you asked for, echoed back. |
| source_type | string |
|
The IPTC value written into the file. Note the http, not https — that is the identifier, not a link. |
| width | integer |
Pixels. |
Of the finished image. Unchanged from the original. |
| height | integer |
Pixels. |
Of the finished image. Unchanged from the original. |
| had_content_credentials | boolean |
|
Whether the image arrived carrying C2PA Content Credentials. |
| credits_remaining | integer |
Zero or more. |
Left after this call: the monthly allowance plus any credits bought on top. |
Metadata without a badge
corner=none
writes the declaration and draws nothing. Meant for callers who put their own visible
label on the picture before sending it — a second badge on top of theirs is not a
service. Everything else is unchanged: the same IPTC and XMP fields are written, and
the call costs the same one credit.
One difference worth knowing: without a badge nothing is decoded and re-encoded, so the image comes back byte for byte as it was sent, with its EXIF intact and no second round of JPEG compression. Only the metadata is touched.
It also moves a responsibility. The machine-readable half is still there, but nothing on the picture tells a person — providing that is then yours. Whether a visible label is required depends on the case.
# The declaration goes into the file; nothing is drawn on the picture
curl -X POST https://api.imgmarker.net/v1/label \
-H "Authorization: Bearer YOUR_KEY" \
-F image=@already-labelled.jpg \
-F type=generated \
-F corner=none \
-o out.jpg
$out = label('already-labelled.jpg', 'generated', 'none');
out = label("already-labelled.jpg", "generated", "none")
const out = await label(file, { type: "generated", corner: "none" });
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.
/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.
Request fields
| Field | Type | Values | Description |
|---|---|---|---|
| images[] required | file[] |
Repeat the field once per image. How many fit in one call comes from your plan; each up to 25 MB. |
The images to label. Everything else is exactly as for /label. |
| type required | string |
|
One value for the whole batch. Mixed batches need separate calls. |
| corner required | string |
|
One value for the whole batch. |
| badge_locale, creator, description, credit, c2pa_acknowledged optional | string |
Same values as for /label. |
Apply to every image in the batch. There is no response field here: the answer is always JSON with links. |
curl -X POST https://api.imgmarker.net/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://api.imgmarker.net/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)
- Each image costs one credit, so ten in a batch cost ten. The endpoint saves round trips, not money.
- 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.
/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://api.imgmarker.net/v1/usage \
-H "Authorization: Bearer YOUR_KEY"
{
"plan": "starter",
"quota": 2000,
"used": 143,
"remaining": 2357,
"credits": 500,
"resets_at": "2026-09-04T00:00:00+00:00"
}
| Property | Type | Values | Description |
|---|---|---|---|
| plan | string |
The key of your plan, as shown on the pricing page. |
Which plan the account is on. |
| quota | integer |
Images per month. |
What the plan includes, before anything is used. |
| used | integer |
Zero or more. |
Of the monthly allowance, in this period. Credits bought on top are not counted here. |
| remaining | integer |
Zero or more. |
What the next call is measured against: the rest of the allowance plus any bought credits. |
| credits | integer |
Zero or more. |
Bought on top. Spent only once the monthly allowance is gone, and never cleared by the reset. |
| resets_at | string | null |
ISO 8601 with offset, or null on a plan that counts per day. |
When used goes back to zero — your billing date, not the first of the month. |
/v1/health
Reachability, no key required. Meant for uptime monitoring, so it never touches your allowance.
curl https://api.imgmarker.net/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.
| Header | Type | Values | Description |
|---|---|---|---|
| Idempotency-Key optional | string |
Any value you choose, 1 to 128 characters. A UUID or a hash of the file both work. |
Makes a repeat of the same call free. Scoped to your account and kept for 24 hours. |
| Authorization required | string |
Bearer im_live_… |
Your API key. Required on every endpoint except /health. |
curl -X POST https://api.imgmarker.net/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://api.imgmarker.net/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