Watermark API
Agents

AI agents

View as Markdown

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:

Shell
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.

Shell
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.

Shell
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.

Shell
# 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 into source or image_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) and PUT the bytes there. Do not ask the user to put the file online first.
  • Send wait. "wait": 20 (at most 25 seconds) returns 200 with the finished request when it finishes in time, which images, reads, snapshots and short videos usually do. Otherwise you get 202 and poll GET /v1/requests/{id}?wait=25 (get_request) until the status is succeeded, failed or aborted. 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 a destination has no link: the file was written to the user's storage, and result.files[0].destination says where.
  • Reuse a watermark. Run a logo or a text stamp through one image request, then pass its file_… id as the watermark operation's image on 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 failed request carries error.code and error.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_file gives a file's dimensions, duration or page count; list_fonts gives the names a text operation accepts. A wrong parameter is a 422 with error.errors naming 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.

JSON
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):

JSON
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:

Shell
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

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.

JSON
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
Shell
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).

JSON
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.

Shell
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