API Documentation
Add text or image watermarks to images, videos, and PDFs with one HTTP call. Synchronous for image and PDF, async for video. No SDK required.
#Quick start
POST your image and watermark config to /watermark. The response body is the watermarked image binary.
curl -X POST https://api.apiwatermark.com/watermark \
-H "Authorization: Bearer aw_••••••••" \
-F "image=@photo.jpg" \
-F "type=text" \
-F "text=© 2026 MyBrand" \
-F "position=bottom-right" \
-F "opacity=0.5" \
-F "font_size=24" \
-F "output_format=webp" \
-o watermarked.webpconst form = new FormData();
form.append("image", fileInput.files[0]);
form.append("type", "text");
form.append("text", "© 2026 MyBrand");
form.append("position", "bottom-right");
form.append("opacity", "0.5");
form.append("output_format", "webp");
const res = await fetch("https://api.apiwatermark.com/watermark", {
method: "POST",
headers: { Authorization: "Bearer aw_••••••••" },
body: form,
});
const blob = await res.blob();
// Save or display the watermarked imageimport requests
with open("photo.jpg", "rb") as f:
res = requests.post(
"https://api.apiwatermark.com/watermark",
headers={"Authorization": "Bearer aw_••••••••"},
files={"image": f},
data={
"type": "text",
"text": "© 2026 MyBrand",
"position": "bottom-right",
"opacity": "0.5",
"font_size": "24",
"output_format": "webp",
},
)
with open("watermarked.webp", "wb") as f:
f.write(res.content)#Base URL & authentication
All examples use the canonical API subdomain:
https://api.apiwatermark.comThe same routes are also available at https://apiwatermark.com/api/* — both URLs hit the same handlers via a small Next.js rewrite. Pick whichever you prefer.
Authentication
Send your API key as a Bearer token:
Authorization: Bearer aw_••••••••••••••••Image / PDF / batch: API key is optional but recommended for production. Without it, requests fall back to anonymous IP rate limits.
Video: Required — either an API key or an authenticated dashboard session.
Get a key: /app/api-keys (free, instant).
#POST/watermark
Accepts multipart/form-data. Returns the watermarked file as binary with the appropriate Content-Type header. Source can be an image (JPEG, PNG, WebP, AVIF) or a PDF — the engine auto-detects.
https://api.apiwatermark.com/watermarkContent-Type: multipart/form-data · Max body: 4.5 MB direct or 25 MB via R2
#Parameters
All parameters are sent as multipart form fields. File fields are standard file uploads; all other fields are strings (numbers serialized as strings).
Source
| Parameter | Type | Default | Description |
|---|---|---|---|
imagerequired | File | — | Source file — image (JPEG, PNG, WebP, AVIF) or PDF. Required unless r2_key is set. Direct upload is capped at ~4.5 MB on Vercel; use r2_key for larger files (up to 25 MB). |
r2_key | string | — | R2 object key returned by /upload/presign. Use this for files above the direct-upload limit. |
Watermark config
| Parameter | Type | Default | Description |
|---|---|---|---|
type | "text" | "image" | "text" | Watermark kind: text overlay or image overlay. |
text | string | "© Your Brand" | Watermark text (type=text). Supports dynamic variables: {{email}}, {{name}}, {{ip_address}}, {{timestamp}} — resolved server-side per request. |
font_size | number | 24 | Font size in pixels (type=text). |
font_family | string | "sans-serif" | CSS font family (type=text). Server-side fonts are limited. |
color | string | "#ffffff" | Hex color for the text watermark, including the # prefix. |
watermark_image | File | — | Image used as the overlay (PNG with transparency works best). Required when type=image. |
watermark_width | number | 150 | Pixel width to resize the overlay image to. Height scales proportionally. |
Position & layout
| Parameter | Type | Default | Description |
|---|---|---|---|
position | string | "bottom-right" | One of: "top-left", "top-center", "top-right", "center-left", "center", "center-right", "bottom-left", "bottom-center", "bottom-right", "tiled", "custom". |
custom_x | number | 0.5 | X position as a 0-1 ratio (position=custom). |
custom_y | number | 0.5 | Y position as a 0-1 ratio (position=custom). |
opacity | number | 0.5 | 0 = invisible, 1 = fully opaque. |
padding | number | 20 | Distance from the edge in pixels (preset positions). |
rotation | number | 0 | Watermark rotation in degrees, -180 to 180. |
tile_gap | number | 100 | Gap between repeated watermarks in pixels (position=tiled). |
tile_angle | number | -30 | Angle of the tile grid in degrees, -90 to 90 (position=tiled). |
Output
| Parameter | Type | Default | Description |
|---|---|---|---|
output_format | "jpeg" | "png" | "webp" | "avif" | "png" | Output image format. Ignored for PDF sources — PDFs always return application/pdf. |
output_quality | number | 90 | Compression quality 1-100 (JPEG, WebP, AVIF only). |
#Text watermark
Set type=text and provide text, font_size, color, and opacity. The text is rendered as an SVG layer and composited into the source.
curl -X POST https://api.apiwatermark.com/watermark \
-H "Authorization: Bearer aw_••••••••" \
-F "image=@photo.jpg" \
-F "type=text" \
-F "text=© 2026 MyBrand" \
-F "position=bottom-right" \
-F "opacity=0.5" \
-F "font_size=24" \
-F "output_format=webp" \
-o watermarked.webp#Image watermark (logo)
Set type=image and attach a watermark_image file. PNG with transparency works best. Resize via watermark_width.
curl -X POST https://api.apiwatermark.com/watermark \
-H "Authorization: Bearer aw_••••••••" \
-F "image=@photo.jpg" \
-F "type=image" \
-F "watermark_image=@logo.png" \
-F "position=center" \
-F "opacity=0.3" \
-F "watermark_width=200" \
-o branded.jpg#Tiled / repeated watermark
position=tiled repeats the watermark diagonally across the entire canvas — crop-proof for proof protection. Control spacing with tile_gapand angle with tile_angle.
curl -X POST https://api.apiwatermark.com/watermark \
-H "Authorization: Bearer aw_••••••••" \
-F "image=@photo.jpg" \
-F "type=text" \
-F "text=DRAFT" \
-F "position=tiled" \
-F "opacity=0.15" \
-F "rotation=-30" \
-F "tile_gap=120" \
-F "tile_angle=-30" \
-F "font_size=48" \
-F "color=#ff0000" \
-o draft.png#PDF watermark
Send a PDF in the image field (or via r2_key) and the engine auto-detects it. Every page receives the same watermark; original text remains selectable. Output is always application/pdf — output_format is ignored for PDF sources.
# Source PDF is auto-detected — output is always PDF.
# Every page receives the same watermark.
curl -X POST https://api.apiwatermark.com/watermark \
-H "Authorization: Bearer aw_••••••••" \
-F "image=@contract.pdf" \
-F "type=text" \
-F "text=CONFIDENTIAL · {{email}}" \
-F "position=tiled" \
-F "opacity=0.15" \
-o stamped.pdfDynamic variables resolve server-side per request: {{email}}, {{name}}, {{ip_address}}, {{timestamp}}. Perfect for burning the viewer's identity into the document for audit trails.
#Batch processing
POST /watermark/batch processes up to 20 files at once and returns a ZIP archive. Mix images and PDFs freely — each output keeps its source extension. Total upload capped at 100 MB.
images[] for direct uploads or r2_keys[] for pre-uploaded R2 objects. Watermark fields are the same as the single-file endpoint.curl -X POST https://api.apiwatermark.com/watermark/batch \
-H "Authorization: Bearer aw_••••••••" \
-F "images[]=@photo-1.jpg" \
-F "images[]=@photo-2.jpg" \
-F "images[]=@photo-3.jpg" \
-F "type=text" \
-F "text=© 2026 MyBrand" \
-F "position=bottom-right" \
-o watermarked-batch.zip
# Up to 20 files per request · 100 MB total · returns a ZIP.
# Mix images and PDFs freely — outputs keep their source extension.#Video jobs (async)
Video processing is asynchronous and runs on dedicated FFmpeg workers. The flow is: upload source → create job → poll status → download result. Either an API key or an authenticated dashboard session owns the job and can poll it.
Upload
PUT the source video to a presigned R2 URL.
Create
POST /video/watermark with the r2 key and watermark config.
Poll
GET /video/watermark/{jobId} every 2–5s until status is completed or failed.
# 1. Get a presigned R2 upload URL
PRESIGN=$(curl -s -X POST https://api.apiwatermark.com/upload/presign \
-H "Content-Type: application/json" \
-d '{"contentType":"video/mp4","fileName":"clip.mp4","fileSize":125000000}')
R2_KEY=$(echo "$PRESIGN" | jq -r .key)
UPLOAD_URL=$(echo "$PRESIGN" | jq -r .uploadUrl)
# 2. PUT the source video to R2
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: video/mp4" \
--data-binary @clip.mp4
# 3. Create the asynchronous video job
JOB=$(curl -s -X POST https://api.apiwatermark.com/video/watermark \
-H "Authorization: Bearer aw_••••••••" \
-H "Content-Type: application/json" \
-d '{
"sourceR2Key": "'$R2_KEY'",
"sourceFileName": "clip.mp4",
"sourceFileSize": 125000000,
"outputFormat": "mp4",
"watermarkConfig": {
"type": "text",
"text": "© {{email}}",
"fontSize": 32,
"color": "#ffffff",
"position": "bottom-right",
"opacity": 0.5
}
}')
JOB_ID=$(echo "$JOB" | jq -r .jobId)
# 4. Poll status (2-5s intervals are fine; polling is free)
while true; do
STATUS=$(curl -s -H "Authorization: Bearer aw_••••••••" \
https://api.apiwatermark.com/video/watermark/$JOB_ID)
STATE=$(echo "$STATUS" | jq -r .status)
echo "[$STATE] $(echo "$STATUS" | jq -r .progress)%"
case "$STATE" in
completed) echo "$STATUS" | jq -r .downloadUrl; break ;;
failed) echo "$STATUS" | jq -r .error >&2; exit 1 ;;
esac
sleep 3
done
# 5. (Shortcut) Skip steps 4 with ?download=1 — redirects to the asset URL
curl -L -H "Authorization: Bearer aw_••••••••" \
"https://api.apiwatermark.com/video/watermark/$JOB_ID?download=1" -o watermarked.mp4watermarkConfig shape: same field names as the synchronous endpoint, in camelCase JSON instead of snake_case form fields. So font_size becomes fontSize, tile_gap becomes tileGap, etc.
Polling is free: status checks don't count against your monthly quota. Only the initial job creation does.
#Large files (R2 upload)
Direct uploads to /watermark are capped at ~4.5 MB by Vercel. For larger files (up to 25 MB images/PDF, 500 MB video) use the presigned R2 flow.
- 1Get a presigned URL — POST to
/upload/presignwith the file's MIME type and size. - 2PUT directly to R2 — using the returned
uploadUrl. The bytes never touch Vercel. - 3Process — by passing the returned
keyasr2_keyin your watermark request. The source object is auto-deleted after processing.
#POST/upload/presign
| Field | Type | Description |
|---|---|---|
contentType | string | MIME type: image/jpeg, image/png, image/webp, image/avif, video/mp4, video/quicktime, video/webm, application/pdf |
fileName | string | Original filename (used for extension detection). |
fileSize | number | File size in bytes. Validated against the 500 MB cap. |
# 1. Ask for a presigned upload URL
PRESIGN=$(curl -s -X POST https://api.apiwatermark.com/upload/presign \
-H "Content-Type: application/json" \
-d '{"contentType":"image/jpeg","fileName":"large.jpg","fileSize":15000000}')
UPLOAD_URL=$(echo "$PRESIGN" | jq -r .uploadUrl)
R2_KEY=$(echo "$PRESIGN" | jq -r .key)
# 2. PUT the file directly to Cloudflare R2 (bypasses Vercel body limits)
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/jpeg" \
--data-binary @large.jpg
# 3. Process the uploaded R2 object
curl -X POST https://api.apiwatermark.com/watermark \
-H "Authorization: Bearer aw_••••••••" \
-F "r2_key=$R2_KEY" \
-F "type=text" \
-F "text=© MyBrand" \
-F "position=bottom-right" \
-o watermarked.jpgCORS: The PUT in step 2 goes browser → R2 directly. Your R2 bucket must allow PUT from the origin you upload from. The dashboard handles this for our hosted setup; from your own frontend, add a CORS rule on the bucket.
#POST/upload/direct(small files only)
Convenience endpoint that pipes through the app server to R2. Strictly for small files (capped at 4.5 MB on Vercel, 10 MB local). For anything larger use the presigned flow above.
# Alternative for small files (< 4.5 MB on Vercel)
curl -X POST https://api.apiwatermark.com/upload/direct \
-F "file=@photo.jpg"
# Response: { "key": "uploads/uuid.jpg" }#Error codes
Errors are JSON with an error field. Some 500 responses also include a details field with the underlying message.
| Status | Error | Cause |
|---|---|---|
| 400 | Missing required field: image (or r2_key for R2 uploads) | No image or r2_key provided. |
| 400 | Invalid R2 key format | The r2_key contains invalid characters or path traversal. |
| 400 | Missing required field: watermark_image (for image type) | type=image but no watermark_image file was attached. |
| 400 | Invalid content type | Presign endpoint: unsupported MIME type. |
| 401 | Authentication required | Video endpoints require a valid API key or an authenticated session. |
| 404 | Source image not found in storage | R2 key references an expired or missing object. |
| 413 | File too large | Exceeds 25 MB (image/PDF) or 500 MB (video upload). |
| 429 | Too many requests / Monthly API call limit reached | IP rate limit (anonymous) or plan monthly quota (authenticated). |
| 500 | Failed to process watermark | Server error during Sharp / FFmpeg processing. |
| 503 | R2 storage is not configured | Server is missing R2 environment variables. |
{
"error": "Missing required field: image (or r2_key for R2 uploads)"
}
// For 500 errors:
{
"error": "Failed to process watermark",
"details": "Input image exceeds pixel limit"
}#Rate limits
Two layers, applied separately
Anonymous calls are IP-rate-limited per endpoint. Authenticated calls are bounded by your plan's monthly quota (see pricing).
| Endpoint | Anonymous limit | Window |
|---|---|---|
| POST /watermark | 20 requests | 1 minute |
| POST /watermark/batch | 5 requests | 1 minute |
| POST /upload/presign | 10 requests | 1 minute |
| POST /upload/direct | 10 requests | 1 minute |
| POST /video/watermark | Auth required | Monthly quota |
When rate-limited the API returns 429 Too Many Requests with Retry-After and X-RateLimit-Limit / X-RateLimit-Remaining headers.
#OpenAPI specification
A machine-readable OpenAPI 3.1 spec is at /api/openapi.json. Use it to generate client SDKs, import into Postman, or wire up any OpenAPI-compatible tooling.
curl https://apiwatermark.com/api/openapi.json | jq .Ready to watermark?
Try the live API builder or grab an API key in one click.