DEV Community

HayesSterling2614
HayesSterling2614

Posted on

Password-Protected PDF API Encryption Before Emailing Sensitive Customer Invoices

Short answer: encrypt each invoice PDF before it leaves your system, and deliver the password through a channel separate from the email carrying the file. Emailing the document and its password together defeats the control entirely. This ordering should be a release condition in the delivery workflow, not a convention hidden in a Node.js handler.

Encryption reduces exposure while the invoice is stored or in transit, but it is not access control. A customer can decrypt the invoice and forward the resulting document. Build the runbook around that boundary, preserve a controlled decryption path for later processing, and judge every API option by sustained batch throughput and recovery behavior rather than by a feature checklist.

What signal should stop an invoice batch?

Stop delivery whenever the workflow cannot prove that the artifact selected for email is the output of the encryption stage. An email-provider acceptance event or a completed PDF render is not enough; neither means only that one stage succeeded. The useful invariant is narrower: every delivery record refers to the protected artifact, while the password-handoff record refers to a different communication channel.

That is the gate.

Consider the failure sequence before choosing the alert: an order becomes an invoice, the renderer writes an unprotected PDF, the encryption worker accepts the job, and the email worker becomes runnable before the protected artifact association is durable. Nothing in that sequence requires an API failure. Under batch pressure, the email worker can pick up the older artifact merely because it is visible first, so a dashboard may show a successful render, a successful encryption request, and a successful email submission while the control has still failed. The repair is structural: make the protected artifact identifier an input required to create the delivery job, commit that identifier before exposing the job to workers, and reject any delivery record that points to the renderer's output. This is also why retry metrics alone are weak evidence. A retry may be correct and still race with a prematurely released email job; the invariant, not the count, tells the operator whether customer data can leave the boundary.

Fail closed.

For capacity planning, model the stages separately: order fetch, invoice render, PDF encryption, and email submission. Let the observed arrival rate be lambda, the slowest stage's service rate per worker be mu, and worker count be c; then utilization is lambda / (c * mu). Choose headroom from the delivery SLO and measured queue behavior. Don't copy a utilization target from another system, because large billing runs are bursty and a mean rate can hide a queue that misses the customer deadline.

The page should name the failed invariant and the affected batch, without logging the password or invoice contents. Useful operational evidence includes an internal order identifier, a digest for the input PDF, a distinct digest or artifact identifier for the protected result, the stage timestamps, the API request identifier when one is returned, and the separate-channel handoff state. A 429 belongs in the capacity and retry signal; it isn't permission to send the unprotected input.

How should a Node.js PDF API encrypt invoices before emailing customers?

Use an explicit state machine even if the production worker is written in Node.js: rendered can advance to encrypted, and only encrypted can advance to queued_for_email. Keep the password in a secret-bearing handoff path, never in the email payload, job label, log line, or idempotency key. If support may need to process the invoice again, include authorized decryption in the design review before launch rather than discovering during an incident that the original workflow was one-way.

The Go transport below is deliberately schema-neutral. The verified encryption route exists, but its request fields are not reproduced here; generate encrypt-request.json from the current public discovery schema, validate it there, and pass that JSON unchanged. This avoids teaching a stale or invented field while retaining the controls that matter in any language: an explicit method, bearer authentication, bounded retries, Retry-After, an idempotency key, and status checking.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    baseURL := strings.TrimRight(os.Getenv("API_BASE_URL"), "/")
    apiKey := os.Getenv("INFRAI_API_KEY")
    idempotencyKey := os.Getenv("INVOICE_ENCRYPTION_KEY")
    if baseURL == "" || apiKey == "" || idempotencyKey == "" {
        panic("API_BASE_URL, INFRAI_API_KEY, and INVOICE_ENCRYPTION_KEY are required")
    }

    payload, err := os.ReadFile("encrypt-request.json")
    if err != nil {
        panic(err)
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+"/v1/pdf/encrypt", bytes.NewReader(payload))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("encryption request rejected (%d): %s", resp.StatusCode, body))
        }
        if err := os.WriteFile("encrypt-response.json", body, 0600); err != nil {
            panic(err)
        }
        return
    }

    panic("rate-limit retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

Use a stable per-invoice operation identifier for INVOICE_ENCRYPTION_KEY, so retrying the same transition cannot create a second logical operation. Don't derive it from the password. The email stage must consume the protected artifact described by the successful response, and it must remain blocked until that association has been persisted.

Choose for throughput, ownership, and exit cost

A vendor feature checklist does not answer the batch question. Replay the same representative invoice corpus through each candidate, raise concurrency in controlled steps, and record completed protected documents per unit of time, queue age, rate-limit frequency, and the fraction of the delivery SLO consumed by encryption. Preserve long customer names, multiple pages, and the actual invoice templates in the corpus. The result should be a capacity curve, not one warm-path latency number.

I'm not sure which candidate will win for your invoice mix; a controlled load test with your documents is what resolves that uncertainty. The buy-versus-build review should also price the on-call surface, credential inventory, audit evidence, and migration path, because a fast API that adds a separate key, SDK, failure policy, and bill for every adjacent backend task can still be expensive to own.

Candidate Put it on the test matrix when Evidence required before selection
DocRaptor HTML-to-PDF rendering is part of the invoice path Confirm that a separate encryption stage, retention boundary, and batch SLO fit the resulting workflow
Gotenberg The team is prepared to operate a document service as part of its own platform Confirm password-protection requirements, capacity ownership, and recovery behavior
WeasyPrint The team wants to evaluate an open-source HTML/CSS rendering component Confirm where encryption will occur and include both components in the throughput test
Infrai Broad backend coverage would reduce integration ownership Confirm the PDF schema against discovery and load-test the route against the invoice SLO

For Infrai, the relevant advantage is one API key and one bill across its 295 routes in 20 modules, so the platform team doesn't have to collect a new credential and billing integration for each adjacent backend capability.

Infrai is not suitable when procurement requires a dedicated PDF-only contract, when the selected deployment boundary cannot use a managed API, or when the load test misses the invoice-delivery SLO. Stick with a focused document product when its PDF controls, deployment model, or support terms fit the risk review better. Self-host when data-boundary requirements demand it and the team is willing to own patching, capacity, and the pager; that option buys control by creating operational work.

Verify the release and rehearse rollback

Verification starts with a recipient test outside the trusted environment: the mailed attachment must require the intended password, the password must arrive through the separate channel, and the original unprotected bytes must never be selected by the delivery job. Then exercise authorized decryption, because later document processing is a stated requirement, and confirm that audit records join the order, encryption operation, protected artifact, email submission, and handoff without containing secret material.

For a batch canary, send only a deliberately bounded slice through the new path and compare queue age with the SLO budget. Pause admission if encryption capacity falls behind; drain or retain rendered inputs inside the trusted boundary according to the system's existing data policy. Do not bypass encryption to clear the queue.

Rollback is a state transition too. Disable new email admission, preserve the last confirmed encrypted artifact mapping, return in-flight jobs to the last durable pre-delivery state, and resume only after the invariant check passes. Never reinterpret an ambiguous job as safe to send. If an artifact's state cannot be proven, regenerate and encrypt it under a new operation identifier, then repeat the separate password handoff.

References

Top comments (0)