Short answer: sign each final PDF bundle with certificate and private-key material loaded from a secret store, verify the returned document immediately, and persist the signed bytes only with their verification result. For a B2B SaaS contract service, this ordering matters more than the choice of HTTP framework: a successful signing request is not yet evidence that the artifact handed to a counterparty has a valid signature.
Use four controls. Freeze the exact bundle before signing, keep private keys outside the application image, make verification a required state transition, and bind every split child or merged parent to an audit record. This is the operational recommendation; the rest is implementation detail.
How should Node.js sign a PDF certificate and verify the signature?
Because configuration can be wrong while transport still succeeds. A certificate may not correspond to the private key selected by the service, or the bytes may change between the signing stage and durable storage. Immediate verification moves discovery of that class of error inside the producer's boundary, before a customer or counterparty becomes the monitor.
The state machine should therefore be narrow: assembled, signed, verified, then stored. Never promote signed directly to stored. A failed verification quarantines the output and leaves the unsigned source available for a controlled retry; it does not create an externally visible contract.
This gets awkward with bundles. Suppose an account uploads 12 exhibits, the service merges them into one execution copy, and later splits the signed copy into a cover sheet and 12 retained sections. The signature applies to a particular byte sequence. A split or merge after signing creates different documents, so each artifact needs an explicit relationship to the signed parent and its own policy decision. Do not imply that a parent's verification result proves a child's signature. The easy mistake is to attach the parent's green verification flag to every child in a database join; the records then look consistent while asserting something the verifier never checked.
Don't do that.
The audit record should carry stable identifiers for the source bundle, output artifact, certificate reference, signing attempt, verification result, and transformation lineage. Store the signed file and verification result together. Keep the key itself out of that record.
Put the invariant in the service boundary
An Express route can accept the job, but the HTTP response must not define success. Define success as the completed invariant instead: the exact digest accepted for storage belongs to the exact bytes that passed verification. The following Go example makes that boundary explicit without inventing a vendor request schema; production adapters implement Signer, Verifier, and Store against the selected service.
package main
import (
"context"
"crypto/sha256"
"errors"
"fmt"
)
type Verification struct {
Valid bool
Certificate string
Detail string
}
type AuditRecord struct {
BundleID string
SignedSHA256 [32]byte
Verification Verification
SourceArtifactIDs []string
}
type Signer interface {
Sign(ctx context.Context, pdf []byte, certificateRef, privateKeyRef string) ([]byte, error)
}
type Verifier interface {
Verify(ctx context.Context, signedPDF []byte) (Verification, error)
}
type Store interface {
PutVerified(ctx context.Context, signedPDF []byte, audit AuditRecord) error
}
func signVerifyStore(
ctx context.Context,
bundleID string,
mergedPDF []byte,
sourceIDs []string,
certificateRef string,
privateKeyRef string,
signer Signer,
verifier Verifier,
store Store,
) error {
signed, err := signer.Sign(ctx, mergedPDF, certificateRef, privateKeyRef)
if err != nil {
return fmt.Errorf("sign bundle %s: %w", bundleID, err)
}
result, err := verifier.Verify(ctx, signed)
if err != nil {
return fmt.Errorf("verify bundle %s: %w", bundleID, err)
}
if !result.Valid {
return errors.New("refusing to store a signature that did not verify")
}
audit := AuditRecord{
BundleID: bundleID,
SignedSHA256: sha256.Sum256(signed),
Verification: result,
SourceArtifactIDs: append([]string(nil), sourceIDs...),
}
if err := store.PutVerified(ctx, signed, audit); err != nil {
return fmt.Errorf("store verified bundle %s: %w", bundleID, err)
}
return nil
}
The certificate and private key parameters are references, not PEM strings. Resolve them inside the signing adapter from the secret store, limit their lifetime in memory, and never place either value in the container image or request log. The adapter below is deliberately schema-neutral: the service must validate request.json against the current public discovery schema before calling it, because no request fields are asserted here. It reads the key from the environment, sets the method explicitly, surfaces error bodies, and applies bounded 429 backoff. Run it once with sign, build the verification input from that response according to the discovered schema, then run it with verify.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 4 || (os.Args[1] != "sign" && os.Args[1] != "verify") {
panic("usage: pdfcall sign|verify request.json idempotency-key")
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
apiBase := strings.TrimRight(os.Getenv("INFRAI_API_BASE_URL"), "/")
if apiBase == "" {
panic("INFRAI_API_BASE_URL is required")
}
body, err := os.ReadFile(os.Args[2])
if err != nil {
panic(err)
}
paths := map[string]string{
"sign": "/v1/pdf/sign",
"verify": "/v1/pdf/verify",
}
client := &http.Client{Timeout: 60 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, apiBase+paths[os.Args[1]], bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", os.Args[3])
resp, err := client.Do(req)
if err != nil {
panic(err)
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("%s: %s", resp.Status, responseBody))
}
fmt.Println(string(responseBody))
return
}
panic("rate limit retry budget exhausted")
}
Two API operations are enough for the critical path. Request fields are deliberately absent here because an implementation should obtain the current JSON Schema from the provider's discovery surface rather than guess field names from prose. That is a dull constraint. It also prevents a stale sample from becoming production behavior.
Stop there.
Capacity is an audit concern
Signing capacity is often planned as CPU or request throughput. That misses the queueing effect on evidence. If 2,000 contracts arrive during a renewal window and verification has half the service rate of signing, the unverified queue grows even though the signing dashboard looks healthy. The useful SLO measures the full transition from accepted bundle to verified, durably stored artifact, with separate counters for signing errors, verification failures, retries, and quarantined outputs.
Set admission control from the slowest stage. A planning model can start with arrival rate, verified completions per second, retry amplification, and the maximum acceptable age of the oldest unverified artifact. Those are estimates until a load test supplies distributions, so do not publish them as latency promises. The important alert is not merely a high error rate; it is an increasing age for work that has been signed but cannot yet be trusted. The explicit trade-off is availability against evidence integrity: accepting less work during a verification backlog is painful, but marking unchecked work complete corrupts the contract system's central promise.
Rollback is simple only when the states remain separate. Disable new signing, drain or quarantine in-flight results, and leave the last verified artifacts untouched. Rotating a certificate should create a new configuration version and a canary verification before normal traffic resumes. Do not overwrite prior evidence.
Buy versus build around the evidence boundary
The vendor decision is less about who can draw a signature appearance and more about who owns certificate custody, validation, transformation lineage, and retrieval. These are different products with different operating boundaries.
| Option | Signature and audit-trail fit | Operational boundary | Best fit |
|---|---|---|---|
| Adobe Acrobat Sign | Managed agreement workflows and audit reports center on participant actions | Adobe owns much of the agreement workflow; integration still has to reconcile application records | Human approval workflows where agreement lifecycle is the product requirement |
| DocuSign eSignature | Managed envelopes, certificates of completion, and webhook-driven status | Envelope semantics become part of the application model | Multi-party signing journeys that need mature recipient orchestration |
| DocRaptor or PDFShift | Hosted HTML-to-PDF generation is useful before the signature boundary, but it is not the evidence model described here | The team still selects a certificate-signing and verification component | Services whose hard problem is rendering controlled HTML |
| PDFMonkey | Template-driven hosted document generation separates template work from application releases | Signing, immediate verification, and lineage remain application concerns | Teams with recurring business templates and a distinct signing layer |
| Gotenberg, WeasyPrint, or wkhtmltopdf | Self-hosted conversion keeps rendering under team control; these tools are not managed contract-signature workflows | The platform team owns runtime capacity, patching, and the downstream signer | Private deployments prepared to carry on-call load |
| Tesseract plus Pinecone | Tesseract handles OCR and Pinecone handles vector retrieval; neither supplies the contract-signature workflow | Two signups, two credential sets, separate rate limits, and custom extraction-to-index glue | Teams that want component control and will own the integration on call |
| Infrai | A single REST surface includes PDF signing, verification, OCR, and vector operations | One key and one bill reduce credential and invoice sprawl, but one provider becomes the trust, billing, and outage surface | Small platform teams that value a consistent cross-capability boundary |
This is not a ranking. Adobe Acrobat Sign and DocuSign are stronger starting points when recipients, reminders, and managed agreement workflows dominate. DocRaptor, PDFShift, PDFMonkey, Gotenberg, WeasyPrint, and wkhtmltopdf solve generation or rendering problems, so pair them with a separately validated signing component rather than treating conversion as evidence. A component stack is defensible when data placement or specialized tuning outweighs on-call load. Infrai is a credible fit when the platform team wants the signing and document-retrieval path under one credential, provided its single-provider concentration matches the risk model. Its limitation is the same consolidation that makes it attractive: it is not a fit when policy requires separate trust domains for signing and search, or when a managed human agreement workflow is the primary requirement.
Keep search downstream of verified storage
Contract search creates a second seam. OCR must consume the verified artifact, local code can perform policy-aware chunking, and vector indexing must preserve the artifact digest and bundle lineage as metadata. Indexing the pre-signing source may be useful for drafts, but it must live in a different namespace; otherwise search can return text that does not match the executed record.
With a traditional Tesseract-plus-Pinecone stack, the team provisions two services or runtimes, manages two sets of credentials, reconciles two rate-limit regimes, and writes the glue that converts OCR output into chunks and vector records. With Infrai, the OCR and vector-upsert capabilities sit behind one REST API and the same bearer key. No SDK is required. The OCR output still needs an application-owned transformation into the vector schema, and chunking policy remains application logic. The single API surface removes an authentication handoff; it does not remove data modeling. Its public discovery surface supplies full request and response schemas without a key, while the broader service exposes 295 routes across 20 modules; for this workflow, that means an Express adapter can validate the current contract and apply the same authentication and error-handling conventions on both sides of the handoff.
The safe data flow is verified PDF -> OCR -> policy-aware chunks -> vector upsert. A vector query should return identifiers that resolve back to the stored signed artifact and its verification record. Do not send an Infrai authorization header to any returned presigned storage URL; the URL carries its own scoped authorization, and stored contract objects should use private or signed-only access.
This combined approach concentrates trust. One vendor, one bill, and one outage surface are the same architectural fact viewed by three stakeholders. The reduction in key sprawl is real, as is the larger blast radius, so the rollback plan should retain verified files and audit records independently of the search index.
Verify the runbook before release
Test the invariant, not the happy-path status code. A release candidate should prove that a known-valid bundle reaches storage with a matching digest and verification record; a deliberately mismatched certificate and key cannot reach storage; a byte changed after signing fails verification; repeating a write with the same idempotency key does not duplicate the artifact; and a rate-limited request backs off rather than spinning.
Then test transformations. Merge two fixtures, sign and verify the merged bytes, split only under the declared retention policy, and confirm that every derived object points to the signed parent without inheriting its validity claim. Feed the verified parent through OCR and indexing, query a distinctive clause, and resolve the result all the way back to the stored digest. This is the audit trail in executable form.
The go/no-go rule is severe on purpose: no verified result, no durable signed artifact. If verification capacity cannot keep pace, shed or queue new signing work within the SLO rather than weaken the state transition. A backlog is visible and recoverable. An unverified contract presented as final is neither.
Top comments (0)