PDFOven is live on DevHunt this week — check it out →
← All guides

How to watermark a PDF in code or with an API (2026)

A watermark is just text or an image drawn on top of each page with some transparency. The drawing part is easy. The parts that take time are centering rotated text, pages that are themselves rotated, and characters the standard PDF fonts can't draw. This guide builds a text and an image watermark with pdf-lib, covers those gotchas, then shows the same results with one PDFOven request.

Text watermarks with pdf-lib

npm install pdf-lib

This stamps a diagonal "CONFIDENTIAL" across the center of every page:

import { readFile, writeFile } from "node:fs/promises";
import { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib";

const pdf = await PDFDocument.load(await readFile("contract.pdf"));
const font = await pdf.embedFont(StandardFonts.HelveticaBold);
const text = "CONFIDENTIAL";

for (const page of pdf.getPages()) {
  const { width, height } = page.getSize();
  const size = Math.min(width, height) / 8;
  const textWidth = font.widthOfTextAtSize(text, size);
  const textHeight = font.heightAtSize(size, { descender: false });
  const angle = Math.atan2(height, width);

  page.drawText(text, {
    x: width / 2 - (textWidth / 2) * Math.cos(angle) + (textHeight / 2) * Math.sin(angle),
    y: height / 2 - (textWidth / 2) * Math.sin(angle) - (textHeight / 2) * Math.cos(angle),
    size,
    font,
    color: rgb(0.42, 0.45, 0.5),
    opacity: 0.2,
    rotate: degrees((angle * 180) / Math.PI),
  });
}

await writeFile("watermarked.pdf", await pdf.save());

drawText rotates around its starting point, the left end of the baseline, not around the middle of the text. That's why x and y are offset by half the text's width and height along the rotated axes: without it, the watermark starts at the center and runs off toward the top-right corner. Math.atan2(height, width) makes the text follow the page's diagonal on any paper size.

Image watermarks

For a logo, embed the image once and draw it on each page:

const logo = await pdf.embedPng(await readFile("logo.png"));
const { width: logoWidth, height: logoHeight } = logo.scale(0.5);

for (const page of pdf.getPages()) {
  const { width } = page.getSize();
  page.drawImage(logo, {
    x: width - logoWidth - 36,
    y: 36,
    width: logoWidth,
    height: logoHeight,
    opacity: 0.3,
  });
}

Use embedJpg for JPEGs. A PNG with a transparent background looks much better as a watermark than a JPEG, which always has a solid rectangle behind it.

Gotcha 1: rotated pages

Scanned documents and landscape pages often have a /Rotate entry: the page content is stored in portrait, and the viewer turns it 90 degrees when displaying it. page.getSize() returns the unrotated size, and drawText draws in the unrotated coordinate system, so on those pages the code above puts the watermark sideways or in the wrong corner.

Work in the coordinates the reader sees, then convert:

function viewTransform(page) {
  const { width: w, height: h } = page.getSize();
  const rotation = ((page.getRotation().angle % 360) + 360) % 360;
  const swap = rotation === 90 || rotation === 270;
  const toPage = (vx, vy) => {
    if (rotation === 90) return { x: w - vy, y: vx };
    if (rotation === 180) return { x: w - vx, y: h - vy };
    if (rotation === 270) return { x: vy, y: h - vx };
    return { x: vx, y: vy };
  };
  return { width: swap ? h : w, height: swap ? w : h, rotation, toPage };
}

Do the layout with the visible width and height, convert the starting point with toPage(x, y), and add rotation to the text angle: rotate: degrees(textAngle + rotation). Pages with a crop box smaller than the media box need one more adjustment, since getSize() reports the media box.

Gotcha 2: non-Latin text

The 14 standard PDF fonts, including the Helvetica used above, only cover Latin characters (the WinAnsi character set). drawText("機密") throws an error like WinAnsi cannot encode. To watermark in other scripts, embed a font file that contains those glyphs:

npm install @pdf-lib/fontkit
import fontkit from "@pdf-lib/fontkit";

pdf.registerFontkit(fontkit);
const font = await pdf.embedFont(await readFile("NotoSansJP-Bold.ttf"), { subset: true });

subset: true embeds only the glyphs you use, which matters for CJK fonts that are many megabytes.

Gotcha 3: watermarks are removable

A watermark drawn this way is ordinary page content with transparency. Anyone with a PDF editor can delete it. It deters casual sharing and labels drafts clearly; it's not access control. If you need the text to be hard to remove, render the pages to images, which costs you selectable text and file size.

The PDFOven way

POST /v1/pdf/watermark takes one PDF and either text (up to 5 lines) or an image (PNG or JPEG). Rotated pages are handled, so the watermark always reads upright.

Options:

Option What it does
position center (default, diagonal), tile (repeated across the page), or a corner or edge: top-left, top-center, top-right, bottom-left, bottom-center, bottom-right
opacity 0.01 to 1. Default 0.2 for text, 0.3 for images
color Hex text color. Default #6b7280
rotation Degrees. Center and tile follow the page diagonal unless set
font_size / scale Text size in points, or a size multiplier (0.1 to 3) when the size is automatic
margin Distance from the edge for corner and edge positions, in points. Default 36
pages Only these pages, e.g. "1" or "2-last"

Diagonal text across the center

curl https://pdfoven.com/v1/pdf/watermark \
  -H "Authorization: Bearer $PDFOVEN_KEY" \
  -F file=@contract.pdf \
  -F text=DRAFT \
  --output draft.pdf

Tiled, for documents that get screenshotted

curl https://pdfoven.com/v1/pdf/watermark \
  -H "Authorization: Bearer $PDFOVEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file": { "url": "https://example.com/contract.pdf" },
    "text": "CONFIDENTIAL\nPrepared for Acme Corp",
    "position": "tile",
    "opacity": 0.12
  }' \
  --output confidential.pdf
curl https://pdfoven.com/v1/pdf/watermark \
  -H "Authorization: Bearer $PDFOVEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file": { "url": "https://example.com/report.pdf" },
    "text": "Internal use only",
    "position": "bottom-right",
    "font_size": 9,
    "opacity": 0.6,
    "color": "#c8155f",
    "margin": 24
  }' \
  --output stamped.pdf

A logo on the first page only, from Python

import os
import requests

with open("proposal.pdf", "rb") as pdf, open("logo.png", "rb") as logo:
    resp = requests.post(
        "https://pdfoven.com/v1/pdf/watermark",
        headers={"Authorization": f"Bearer {os.environ['PDFOVEN_KEY']}"},
        files={
            "file": ("proposal.pdf", pdf, "application/pdf"),
            "image": ("logo.png", logo, "image/png"),
        },
        data={"position": "top-right", "scale": "0.5", "pages": "1", "opacity": "1"},
        timeout=120,
    )
resp.raise_for_status()
with open("proposal-branded.pdf", "wb") as out:
    out.write(resp.content)

In JSON, the image is { "url": "..." } or { "base64": "..." }, like the PDF.

Text watermarks use a standard font, so they're Latin-only here too: characters outside that set return unsupported_text. For other scripts, draw the text into a transparent PNG and send it as image.

Each watermark counts as one render; files can be up to 50 MB and 5,000 pages, and password-protected PDFs take password. Agents can use the watermark_pdf MCP tool, and the free watermark PDF tool works without an account.

When to use which

  • pdf-lib when you need full control over the design, non-Latin text through your own fonts, or watermarking in the browser.
  • PDFOven when you want centered, tiled and corner stamps that handle rotated pages without writing the geometry yourself. 100 renders a month are free; paid plans are $9, $39 and $99 for 2,000, 15,000 and 60,000.

Skip the browser plumbing

PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.

Get a free API key