AI agents
Call the API from Claude, ChatGPT, Claude Code or your own agent: the MCP server, the CLI, plain HTTP, and the rules that keep an agent out of trouble.
The Watermark API is built to be called by AI agents as much as by people. This page says when an agent should reach for it, the three ways to connect (an MCP server, the command line, plain HTTP), the rules that keep an agent out of trouble, and three worked examples. If you are a model reading this: the rules and examples below are meant for you.
When to call Watermark
A code sandbox can stamp one JPEG with a library. Call the API instead when the job is any of these:
- A batch. Tens or hundreds of files, each with the same operations. One request per file, run in parallel, with results you can download or have written to the user's own bucket.
- Video. A logo on every frame, resized and re-encoded, without a sandbox trying to run ffmpeg for minutes.
- PDF and Word. Every page watermarked, and the file stays a PDF or a DOCX.
- Camera files. HEIC, AVIF, Canon CR3, Nikon NEF, Sony ARW and other RAW formats, PSD, wide-gamut photos: formats a sandbox's image library usually cannot open.
- Fonts and text. Text in a built-in font (
GET /v1/fonts) or the user's own font file, made into a transparent stamp once and used on every file after. - Delivery. Results written straight to the user's S3 bucket or to a presigned URL, so no file travels through the conversation.
For one small image that the sandbox can already open, local code is fine.
Three ways in
1. The MCP server
https://watermark.ws/mcp is a Model Context Protocol server over Streamable HTTP. It signs in with a watermark.ws account (OAuth, the connector's "Sign in" option) or with an API key sent as the bearer token: Authorization: Bearer wm_live_….
Its tools, each the thin shape of one endpoint:
| Tool | What it does |
|---|---|
list_operations |
Every operation and its parameters, per media type (GET /v1/operations). |
request_files |
Shows the user a drop zone in the chat (Claude and ChatGPT) for files on their computer; they are uploaded from the user's browser and the user's next message carries the upl_… ids. |
create_upload |
An upload for a file that is not on the web (POST /v1/uploads): the agent PUTs the bytes itself, for a file in its own workspace. |
process_image, process_video, process_document |
Run operations on a file (POST /v1/{images,videos,documents}/process). |
create_image |
Draw on a blank canvas: a text stamp, a logo (POST /v1/images/create). |
read_file |
A file's dimensions, format, duration or page count (POST /v1/{images,videos,documents}/read). |
video_screenshot, document_snapshot |
One frame of a video, or one page of a document, as an image. |
get_request, list_requests, abort_request |
Follow, list and stop requests. |
get_file |
A fresh download link for a file a request made. |
list_fonts |
The built-in fonts a text operation can name. |
get_account, get_usage |
Balance, limits and spend. |
Arguments are the endpoint's JSON body: source, operations, output, wait and so on, as the API reference lists them.
Claude.ai: Customize → Connectors → Add custom connector. Name it Watermark, give it the URL https://watermark.ws/mcp, and sign in when asked, or set the Authorization header to Bearer wm_live_… under advanced settings. Team and Enterprise admins add it once for everyone under Organization settings → Connectors.
ChatGPT: Settings → Apps & Connectors → turn on developer mode → add an MCP server with the same URL.
Claude Code:
claude mcp add --transport http watermark https://watermark.ws/mcp --header "Authorization: Bearer wm_test_…"
Cursor, VS Code and other MCP clients: add an HTTP (Streamable HTTP) MCP server with the URL and an Authorization: Bearer wm_live_… header.
Your own agent on the Claude API: attach the server to a Messages request with the MCP connector. Both halves are needed: the server, and a toolset that names it.
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: mcp-client-2025-11-20" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5-5",
"max_tokens": 16000,
"mcp_servers": [{"type": "url", "url": "https://watermark.ws/mcp", "name": "watermark", "authorization_token": "wm_live_…"}],
"tools": [{"type": "mcp_toolset", "mcp_server_name": "watermark"}],
"messages": [{"role": "user", "content": "Put https://example.com/logo.png in the bottom-right corner of https://example.com/photo.jpg and give me the link."}]
}'
2. The command line
For an agent with a shell (Claude Code, Codex, Cursor, a CI job), the watermark command is the shortest path: a local file given as the source is uploaded for you, the command waits for the result, and --out downloads it.
npm install -g @watermarkws/cli # or: brew install watermarkws/tap/watermark, or run once with npx @watermarkws/cli
export WATERMARK_API_KEY=wm_test_… # from https://watermark.ws/developers
watermark image process ./IMG_4471.heic \
--op resize:width=1600,height=1600 \
--op watermark:image_url=https://example.com/logo.png,x=40,y=40 \
--format webp --out IMG_4471.webp --json
--json prints the finished request as the API returns it and nothing else on stdout; the exit code is 0 when the request succeeded, 1 when the API refused the call or the request failed, 2 when the command was used wrongly. A ready-made skill file for the CLI is at github.com/watermarkws/watermark-cli/SKILL.md: save it as .claude/skills/watermark/SKILL.md in a project for Claude Code, or paste it into any agent's instructions.
3. Plain HTTP
Any agent that can make HTTP requests can use the API as the Quickstart shows. A file that is not on the web takes two calls more: ask for an upload, then PUT the bytes to the URL it gives you.
# 1. Ask for an upload
curl https://api.watermark.ws/v1/uploads \
-H "Authorization: Bearer $WATERMARK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename": "photo.jpg", "size": 2481193, "content_type": "image/jpeg"}'
# → {"id": "upl_9f3Tq1LmA2bC4dE6fG8hJ0", "upload": {"url": "https://….s3.amazonaws.com/…?X-Amz-Signature=…", "headers": {"Content-Type": "image/jpeg"}}, …}
# 2. PUT the file to upload.url (UPLOAD_URL here) with upload.headers, and no API key
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @photo.jpg
# 3. Process it, waiting up to 20 seconds for the result
curl https://api.watermark.ws/v1/images/process \
-H "Authorization: Bearer $WATERMARK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"source": "upl_9f3Tq1LmA2bC4dE6fG8hJ0", "operations": [{"type": "watermark", "image_url": "https://example.com/logo.png", "x": 40, "y": 40}], "wait": 20}'
The machine-readable docs are llms.txt, llms-full.txt and the OpenAPI spec; any docs page is Markdown with .md on its URL.
Rules for agents
- Never put a file's bytes in arguments. Tool arguments and JSON bodies carry references, not files: a public URL, an upload id (
upl_…), the id of a file an earlier request made (file_…), or{"storage": "stg_…", "key": "…"}for an object in the user's connected bucket. Do not base64 a file intosourceorimage_url; it is refused. - A local file is uploaded, not hosted. A file in your workspace, attached to the chat or on the user's computer is not on the web. Create an upload (
create_upload,POST /v1/uploads, or just give the CLI the path) andPUTthe bytes there. Do not ask the user to put the file online first. - Send
wait."wait": 20(at most 25 seconds) returns200with the finished request when it finishes in time, which images, reads, snapshots and short videos usually do. Otherwise you get202and pollGET /v1/requests/{id}?wait=25(get_request) until the status issucceeded,failedoraborted. Do not poll in a tight loop. - The result is
result.files[0].url. Give the user that link, or download from it. It works for 7 days (url_expires_at);GET /v1/files/{id}(get_file) gives a fresh link while the file is kept. An output with adestinationhas no link: the file was written to the user's storage, andresult.files[0].destinationsays where. - Reuse a watermark. Run a logo or a text stamp through one image request, then pass its
file_…id as thewatermarkoperation'simageon every file after. One upload, not one per file. - Ask before spending. Credit is prepaid and every finished request is charged: $0.002 an image, $0.004 a document page, $0.02 a started minute of video. Before processing more than about 20 files, a video longer than about 10 minutes, or a document with hundreds of pages, tell the user what it will cost and ask. Work in parallel once they agree, within the account's "running at once" limit.
- Failed and aborted requests are free. A
failedrequest carrieserror.codeanderror.message; fix the cause (usually the source, a parameter or the format) and send it again. Do not retry an identical request blindly. - Test keys return real results.
wm_test_…keys are free and process files for real, within small limits: 10 requests a minute, 100 a day, 2 running at once, sources up to 10 MB. Video needs a live key on an account that has bought credit. Build and test with a test key, then switch the key, not the code. - Look before guessing.
list_operations(GET /v1/operations) is the catalog, with every parameter and its range;read_filegives a file's dimensions, duration or page count;list_fontsgives the names atextoperation accepts. A wrong parameter is a422witherror.errorsnaming the field, and nothing is charged.
Worked examples
Each example shows the MCP tool calls and the same job on the command line. A tool's arguments are the endpoint's JSON body, except that the tools take format for output.format and size_bytes for an upload's size. The images used as watermarks are placed at x and y pixels from the top left, at their own size; resize a logo first if it is too big.
A folder of photos: logo, 1600 px, WebP
Prepare the logo once, so every photo refers to the same file_… id rather than uploading the logo again. With a logo that is already online, skip the first two calls and send image_url instead of image.
create_upload {"filename": "logo.png", "size_bytes": 48213, "content_type": "image/png"}
→ PUT the file to upload.url with upload.headers
process_image {"source": "upl_logoUpload…", "operations": [{"type": "orient"}], "format": "png", "wait": 20}
→ result.files[0].id = "file_2kqV7PzM1aT4hQ0bR9sX3c"
Then one request per photo, several at a time (ask first if there are more than about 20):
create_upload {"filename": "IMG_4471.heic", "size_bytes": 3812044, "content_type": "image/heic"}
→ PUT the file
process_image {
"source": "upl_photoUpload…",
"operations": [
{"type": "resize", "width": 1600, "height": 1600},
{"type": "watermark", "image": "file_2kqV7PzM1aT4hQ0bR9sX3c", "x": 40, "y": 40}
],
"format": "webp",
"wait": 20
}
→ result.files[0].url, valid 7 days
resize keeps the proportions by default (preserve_aspect_ratio), so a landscape photo comes out 1600 px wide and a portrait one 1600 px tall. The same with the CLI, which uploads each local file itself:
logo=$(watermark image process ./logo.png --op orient --format png --json | jq -r '.result.files[0].id')
mkdir -p out
for f in photos/*; do
name=$(basename "${f%.*}")
watermark image process "$f" \
--op resize:width=1600,height=1600 \
--op "watermark:image=$logo,x=40,y=40" \
--format webp --out "out/$name.webp"
done
A 30-second preview of a video, with a logo
max_duration keeps only the first so many seconds, so a long video becomes a short preview, priced by the minutes of the source. Video needs a live key on an account that has bought credit, and a long video rarely finishes within wait: expect 202, then follow the request.
process_video {
"source": "https://example.com/talk.mp4",
"operations": [
{"type": "resize", "width": 1280, "height": 720},
{"type": "watermark", "image_url": "https://example.com/logo.png", "x": 24, "y": 24}
],
"max_duration": 30,
"wait": 20
}
→ 202, status "queued"
get_request {"id": 1240, "wait": 25}
→ status "processing", progress 45 … then "succeeded", result.files[0].url
watermark video process https://example.com/talk.mp4 \
--op resize:width=1280,height=720 \
--op watermark:image_url=https://example.com/logo.png,x=24,y=24 \
--max-duration 30 --out preview.mp4
CONFIDENTIAL across every page of a PDF, kept as a PDF
A document's watermark operation places an image on every page, so make the stamp first: a text operation on a transparent canvas, turned 30 degrees and trimmed to the text, saved as PNG. Then watermark the PDF with the stamp's file_… id. The output stays a PDF (and a DOCX stays a DOCX).
create_image {
"width": 800, "height": 800,
"operations": [
{"type": "text", "text": "CONFIDENTIAL", "font": "inter-bold", "size": 80, "color": "#c0392b", "opacity": 35, "position": "center", "rotation": -30},
{"type": "trim"}
],
"format": "png",
"wait": 20
}
→ result.files[0].id = "file_stampId…", about 560 × 340 px
create_upload {"filename": "contract.pdf", "size_bytes": 1204911, "content_type": "application/pdf"}
→ PUT the file
process_document {
"source": "upl_contractUpload…",
"operations": [{"type": "watermark", "image": "file_stampId…", "x": 20, "y": 230}],
"wait": 20
}
→ result.files[0].url: the PDF, one stamp on each page
At the default density of 72, one pixel of the stamp is one point on the page, so a 560 × 340 px stamp at x 20, y 230 sits across the middle of a Letter or A4 page. Pass max_pages to stamp only the first pages.
stamp=$(watermark image create --width 800 --height 800 \
--op 'text:text=CONFIDENTIAL,font=inter-bold,size=80,color=#c0392b,opacity=35,position=center,rotation=-30' \
--op trim --format png --json | jq -r '.result.files[0].id')
watermark document process ./contract.pdf \
--op "watermark:image=$stamp,x=20,y=230" \
--out contract-confidential.pdf
Read on
- Files in and out: every way to give the API a file and every place it can put a result.
- Requests, results and retries: statuses,
wait, idempotency andresult.files. - Operations: every operation, its parameters and a picture of what it does.
- Pricing and limits: prices per endpoint and the limits of each kind of key.