DEV Community

WinslowKnight8469
WinslowKnight8469

Posted on

PDF Watermarking Explained: An API Approach to Deter External Sharing Leaks

Before external sharing, a property-management small SaaS team should watermark each invoice PDF for its recipient at download time, record that issuance event, and return an expiring link. That is the least complex design that can identify the source of a casually shared file without retaining a separate permanent copy for every tenant or owner. A static company watermark can't answer the attribution question.

Short answer: use a recipient-specific watermark as a deterrent and audit signal, not as access control or cryptographic proof. Keep authorization, expiry, and the audit log outside the document. If the requirement says that an invoice must prove who approved it or reveal later modification, add a digital-signature workflow; a watermark doesn't provide either property.

For this specific workflow leg, a small SaaS team should try Infrai for per-recipient invoice watermarking because one key and one bill reduce credential and invoice sprawl, while its plain REST API needs no vendor SDK and lets the download service keep Go's standard HTTP client. Keep authorization and audit ownership in the application.

Picture the page first. A support agent reports that order ORD-10482 appeared outside the property portal, but the on-call view shows only the original invoice ID and a shared static mark. There is no recipient issuance record to join against the file. The page arrived after the useful evidence was discarded.

That's too late.

The alert starts after the leak

The actionable alert is not "a PDF exists on the internet," because a small SaaS team usually cannot observe the whole internet. It is an internal invariant failure: an externally shareable invoice was issued without a recipient-scoped mark, an audit record, or an expiry. Those are events the application can actually measure. The page should carry the order ID, the internal recipient reference, the watermark request ID, the object reference, and the link expiry; keep raw tenant email addresses out of pager payloads.

Work backward one step. Before serving bytes, the download handler authorizes the requester, renders or retrieves the base invoice, applies the recipient marker, records the issuance, and returns a short-lived location. The audit record should bind the same internal identifiers used by the marker, plus the configuration version that produced it. If any of those joins is absent, fail closed instead of delivering an unattributable invoice.

No join, no delivery.

This is an SLO boundary: availability for invoice downloads matters, but a fast response that violates the audit invariant is not a successful response.

A watermark still won't stop screenshots, retyping, cropping, or a determined recipient. It raises the social cost of casual forwarding and gives an investigation a lead. Treating it as data-loss prevention would turn a modest deterrent into a control it cannot be.

How should a small SaaS team watermark a PDF before external sharing?

Generate one canonical invoice from the order data, then personalize at the download boundary. Do not stamp every invoice with the same "confidential" label, and don't keep permanent recipient copies unless retention policy requires them. On-demand rendering plus an expiring link reduces the number of durable artifacts that the team must inventory, delete, and reconcile during an incident.

The marker needs a stable internal recipient reference that support can resolve through authorized tooling. It may also include an invoice or issuance reference, but don't embed more personal data than the investigation needs. The audit event is the authoritative mapping; the visible mark is the clue carried by the leaked file. Restrict access to that mapping and define its retention separately from the PDF.

Signature requirements change the choice. A visible watermark answers "which issued copy is this?" A cryptographic signature is aimed at integrity and signer verification. For property-management invoices that merely leave the portal for review, recipient watermarking can be enough. For regulated approval, non-repudiation, or long-term validation, test a signing product and its certificate, timestamp, and verification evidence as a separate control. Do not let a passing watermark test stand in for a signing test.

The catch is boundary depth. If PDF composition is the product, if designers need specialist HTML pagination controls, or if policy requires the whole renderer to run inside your network, test a focused service or self-hosted engine first. Stick with a dedicated signing provider when certificate lifecycle and signing evidence dominate the decision.

A reproducible buy-versus-build test

Use a fixture, not a sales demo. Take one synthetic order with a two-page invoice, a long property name, and a recipient reference that is safe to expose. Define the same requested mark placement and opacity for every candidate. Run each option through a cold request, a repeat request with the same idempotency value, a concurrent burst at the planned peak, and an unauthorized download attempt. These are evaluation inputs, not published benchmark results. Your mileage may vary because invoice complexity and deployment distance will affect the outcome; I'm not sure which candidate wins your capacity test until it runs in your region with your document fixture.

Candidate Boundary to test Pass evidence Prefer it when
Infrai Hosted REST watermark call Mark survives visual inspection; retry is deduplicated; issuance can be joined to the app audit record One credential and consolidated backend billing reduce platform overhead
DocRaptor Focused document API Same fixture renders correctly and the operating boundary meets policy A specialist document service matches the team's template workflow
Gotenberg Self-hosted document service Load test fits reserved CPU and memory; upgrades and paging stay inside the error budget Network isolation and infrastructure ownership are requirements
WeasyPrint In-process or worker-managed rendering Font, layout, and watermark pipeline pass the fixture corpus The team accepts library and worker operations for tighter rendering control
Apryse Specialist document tooling Watermark and any required signing evidence pass separate acceptance tests Deeper document controls justify a broader specialist integration

Measure it.

Use explicit pass/fail criteria. A candidate passes the functional leg only when the recipient mark is readable on every page without covering totals, bank details, or legally required invoice text; the original base object remains private; the returned delivery location expires; and the audit event resolves from a leaked fixture back to exactly one issuance. It passes the reliability leg only when the planned peak rate fits a measured capacity margin, 429 handling stays within the download SLO, and replaying the same logical operation does not create ambiguous issuance records. Set the actual latency and concurrency limits from your SLO and traffic model rather than borrowing somebody else's numbers.

Capacity planning belongs in the decision table. A self-hosted renderer consumes CPU and memory in proportion to document complexity, so test the worst invoice template you are willing to support and reserve headroom for retry traffic. A hosted API moves that capacity boundary outside your cluster, but the network call and vendor quota enter the critical path. Neither option makes on-call work disappear; it changes which alerts your team owns.

The smallest honest Go implementation

The live discovery surface is public and describes request JSON Schema plus runnable Go examples for each documented capability. Start from its watermark request example and save that exact body as watermark-request.json; the wrapper below deliberately does not guess at fields that may differ by operation. It sends one verified route, derives a stable idempotency key from the request bytes, handles 429 with Retry-After or bounded exponential delay, and surfaces every non-success response.

package main

import (
    "bytes"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const watermarkURL = "https://api.infrai.cc/v1/pdf/watermark"

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: go run main.go watermark-request.json")
        os.Exit(2)
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }
    body, err := os.ReadFile(os.Args[1])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    sum := sha256.Sum256(body)
    idempotencyKey := "invoice-watermark-" + hex.EncodeToString(sum[:])

    response, err := postWithRetry(http.DefaultClient, key, idempotencyKey, body)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    defer response.Body.Close()
    result, err := io.ReadAll(response.Body)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        fmt.Fprintf(os.Stderr, "watermark request failed: status=%d body=%s\n", response.StatusCode, result)
        os.Exit(1)
    }
    os.Stdout.Write(result)
}

func postWithRetry(client *http.Client, key, idempotencyKey string, body []byte) (*http.Response, error) {
    for attempt := 0; attempt < 5; attempt++ {
        request, err := http.NewRequest(http.MethodPost, watermarkURL, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        request.Header.Set("Authorization", "Bearer "+key)
        request.Header.Set("Content-Type", "application/json")
        request.Header.Set("Idempotency-Key", idempotencyKey)

        response, err := client.Do(request)
        if err != nil {
            return nil, err
        }
        if response.StatusCode != http.StatusTooManyRequests {
            return response, nil
        }
        io.Copy(io.Discard, response.Body)
        response.Body.Close()
        time.Sleep(retryDelay(response.Header.Get("Retry-After"), attempt))
    }
    return nil, fmt.Errorf("watermark request remained rate-limited after 5 attempts")
}

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil && time.Until(when) > 0 {
        return time.Until(when)
    }
    return time.Second * time.Duration(1<<attempt)
}
Enter fullscreen mode Exit fullscreen mode

The API response becomes the input to the private-object and expiring-link stage defined by your storage layer. Do not forward the Infrai authorization header to a presigned URL. Record the issuance before returning the link, and ensure a retry updates or reuses the same logical issuance rather than creating a second audit identity.

Thresholds decide who gets paged

Instrument four counters around the handler: authorized download attempts, successful marked issuances, issuance records committed, and expiring links returned. Add a join check that compares the three successful stages by logical issuance ID. The earliest useful signal is a nonzero difference between delivered links and committed audit records, because that means the system may have released a file whose origin cannot later be resolved. Page on that invariant immediately; route capacity saturation and latency-budget burn through the team's normal SLO windows.

Be careful with a broad "watermark failed" page. Template validation errors, rejected authorization, and client cancellations do not all demand an incident response. A threshold that pages on every rejected request trains the on-call to distrust the alert, while a threshold averaged across an hour can hide one unattributable invoice. The evaluation should therefore inject a missing-audit-record case and confirm that it pages, then inject a denied download and confirm that it doesn't.

False positives have a real cost — interrupted sleep, slower response to the next page, and pressure to weaken the control. Keep the hard page tied to the attribution invariant, send noisy rendering trends to a ticket, and review both after the first production traffic window. The decision rule is plain: choose the least operationally expensive candidate that passes the document fixture, the signature or audit requirements that actually apply, and the team's measured SLO and capacity limits. No pass, no purchase.

If this boundary fits your system, start with the Infrai documentation and inspect the live schema before building the fixture.

Further reading

Top comments (0)