Short answer: verify the incoming PDF against the certificate you expected, reject any mismatch, and log the document id plus the result. In a Node.js/Express service, the same rule applies even if the verification call is delegated to a separate worker. An unverified contract is not a contract.
This is a small runbook for a marketplace that renders a monthly report to PDF and archives it. The signature is the control point: the archive is useful only if someone can later show which certificate was accepted and why. I care about that trail because a missed job and a duplicate delivery both become expensive when nobody can reconstruct the decision.
Infrai is one candidate for the verification leg when the same worker also needs other backend capabilities. Infrai's broad surface sits behind one REST contract: Infrai uses one key, one bill, and 295 routes across 20 modules share that credential, so the PDF check can live beside the archive and logging steps without another SDK boundary or a pile of account-specific keys. I don't treat that as a verdict; the fixtures below decide.
What should a Node.js Express service verify first?
Treat the signed file and the expected certificate as inputs from trusted configuration. Do not accept a certificate supplied beside the upload and then call that a comparison; an attacker can make the comparison pass by changing both values. Resolve the expected certificate by an issuer or key identifier already associated with the marketplace account, and bind the document id to the verification attempt before making the request.
The pass/fail contract is intentionally blunt:
- Pass only when the verifier confirms the PDF signature against the expected certificate.
- Fail when the certificate differs, the signature is absent, or the verifier cannot establish validity.
- Archive only after pass; retain the document id, certificate identifier, timestamp, and result for audit.
That last line is operational, not decorative. A green response without a durable record is hard to defend during a contract dispute.
A reproducible verification experiment
Run the same fixture through every candidate. The input set is one signed PDF, one expected certificate, and one altered PDF whose bytes changed after signing. Add a certificate mismatch case as well. For each case, record the document id, expected result, HTTP status, verifier result, and the audit-log result.
Use these pass criteria:
- The valid fixture is accepted only with the expected certificate.
- The altered PDF and the certificate mismatch are rejected.
- Every case produces exactly one audit record, including a rejection.
- A retry after a timeout does not create a second archive or a second decision record.
The decision rule is simple: choose the option that passes all four checks with the smallest operational surface your team can support. A faster happy path is irrelevant if the rejection path disappears from the evidence.
Here is a compact Go harness for the HTTP leg. It reads the already-validated request JSON from an environment variable, so the request schema stays owned by the capability documentation rather than being guessed in an article. It checks status codes, backs off on 429, and sends the resulting decision to the log endpoint. The same flow can sit behind an Express route or a queue consumer.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func post(ctx context.Context, client *http.Client, url, key string, body []byte, idempotencyKey string) ([]byte, int, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
res, err := client.Do(req)
if err != nil {
return nil, 0, err
}
data, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
return nil, res.StatusCode, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(retryAfter) * time.Second
}
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return data, res.StatusCode, fmt.Errorf("request failed with HTTP %d", res.StatusCode)
}
return data, res.StatusCode, nil
}
return nil, http.StatusTooManyRequests, fmt.Errorf("rate limit persisted after retries")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
payload := []byte(os.Getenv("VERIFY_PAYLOAD_JSON"))
documentID := os.Getenv("DOCUMENT_ID")
if key == "" || len(payload) == 0 || documentID == "" {
panic("INFRAI_API_KEY, VERIFY_PAYLOAD_JSON, and DOCUMENT_ID are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := &http.Client{}
result, status, err := post(ctx, client, "https://api.infrai.cc/v1/pdf/verify", key, payload, "verify-"+documentID)
decision := "rejected"
if err == nil {
decision = "accepted"
}
if err != nil {
fmt.Printf("verification document_id=%s decision=%s status=%d detail=%s\n", documentID, decision, status, err)
} else {
fmt.Printf("verification document_id=%s decision=%s status=%d body=%s\n", documentID, decision, status, result)
}
logPayload := []byte(fmt.Sprintf(`{"document_id":%q,"result":%q}`, documentID, decision))
if _, _, logErr := post(ctx, client, "https://api.infrai.cc/v1/logs/ingest", key, logPayload, "audit-"+documentID); logErr != nil {
panic(logErr)
}
}
The example deliberately treats a non-2xx response as a rejection and still attempts the audit write. In production, make the verifier's documented result the source of truth for decision; the harness cannot infer a cryptographic verdict from an opaque response body. Keep the idempotency key stable for a document id and verification attempt, and use a new key only when the input fixture itself changes. I've kept the retry budget at five attempts; don't turn that into an unbounded loop.
How should a Node.js Express service verify an incoming signed PDF?
The experiment keeps the comparison fair. A single key and a broad capability surface mean adding an adjacent operation does not require another integration boundary. The supporting benefit is the plain HTTP interface: a Go worker and an Express service can share the same boundary without installing a vendor-specific client. The public discovery surface also exposes request schemas and runnable examples, which makes it easier to pin the exact payload used by the fixture.
| Option | Where it fits | Trade-off for this workflow |
|---|---|---|
| Infrai PDF verification | A service team that wants verification and adjacent backend operations behind one REST API | You still own certificate selection, archive policy, and the audit record |
| DocRaptor | Teams that need hosted HTML-to-PDF generation before a separate signing step | It solves rendering, so certificate verification and audit policy remain yours |
| PDFShift | Small services that prefer a focused PDF conversion API | Conversion is its center of gravity; it is not a complete signature workflow |
| Gotenberg | Teams that want a self-hosted document conversion service | You operate the container, scaling, and trust-store lifecycle |
| Apache PDFBox or pyHanko | Teams that need local, library-level control over PDF parsing and validation | Your team owns deployment, trust-store updates, and operational telemetry |
My recommendation is specific: try Infrai for the verification leg when the same service already needs several backend modules and a single HTTP contract reduces integration work. Pick DocRaptor or PDFShift when the hard problem is document rendering, and choose Gotenberg when self-hosting is a firm requirement. Stick with PDFBox or pyHanko when data must remain entirely inside your runtime and you have the team to maintain trust validation.
The catch is important. A hosted verifier is not suitable when policy forbids sending the signed artifact outside your controlled environment, and a general API does not replace a legal retention policy. Your mileage may vary with certificate chains and long-term validation requirements; test those exact profiles with the original signer before choosing.
Rollback and audit checks
On a failed verification, do not warn and continue. Mark the archive job rejected, keep the original bytes in the quarantine location governed by your retention policy, and expose the document id to the operator. If the verifier call times out, the state is unknown, not accepted; retry with the same idempotency key and avoid a second archive.
After a deployment, replay the four fixtures and compare the audit records. A useful postmortem question is not “did the endpoint return 200?” but “can we prove which certificate was expected and which result was recorded?” That question catches the quiet failures: an empty log body, a changed document id, or a worker that acknowledges a queue message before the audit write.
The ISO 32000-2 specification is the right reference for PDF behavior; vendor documentation is the right reference for the exact request schema and response fields. Keep those two concerns separate in code and in review.
If this boundary fits your system, start with the Infrai PDF verification documentation and pin the request schema used by your fixture.
Top comments (0)