Rox
Register · Developers
PUBLIC DOCS
v1 · api.roxregister.com
Sign in to Platform
OverviewQuickstartsGuidesSDKsAPI referenceErrorsWebhooksJWS vectors
Guides / Seal and stamp a PDF
Seal and stamp a PDF
A plain seal records a digest you computed; a stamped seal also puts the proof ON the document. You upload the PDF, Rox reserves the record identity, draws a compact seal stamp with a scannable QR onto the first page, hashes the stamped output, and seals that digest exactly like any other record. Anyone holding a printout can scan the stamp and land on the public verification page. Same uniform sealing contract as everywhere else: validated and either rejected immediately or accepted with 202, then exactly one terminal outcome, anchored or failed.
1 · Know what the server does with your file
  • PDF only, 50MB cap. The upload must start with the %PDF- magic bytes; anything else, including encrypted PDFs, is rejected with 415. Image formats arrive in a later milestone.
  • The digest covers the STAMPED bytes, not your upload. The server stamps first, then computes SHA-256 over the stamped output and seals that. The stamped file is therefore the document of record; your original upload will not verify against this record.
  • The original is never persisted. It lives in request memory only. The stamped file is stored just long enough for you to download it (next step).
2 · Build and sign the multipart request
POST /v1/seal/stamp is multipart/form-data: text fields record_type (required), external_ref, metadata and field_hashes (both JSON-encoded text fields, both optional), plus the PDF under the field name file. Two things trip integrators up:
  • The JWS signs the raw multipart bytes. The canonical input is RRv1\nPOST\n/v1/seal/stamp\n{sha256_hex(raw multipart body)}\n{ts}\n{nonce}, where the hashed bytes are the encoded multipart payload, boundaries and all, exactly as sent on the wire. Build the body yourself with a boundary you chose, hash it, sign it, send those exact bytes.
  • X-RR-Timestamp is unix milliseconds. Sending seconds puts the timestamp far outside the 300 second window and every request fails with RR-1003; this exact mistake has burned a live integration. In Node it is Date.now() as is.
The Node SDK (@rox-register/sdk 0.1.1+) does all of this for you: sealStamp builds the multipart body itself as one buffer, hashes those exact bytes for the JWS, and sends them verbatim.
node sdk
import { readFileSync, writeFileSync } from "node:fs";
import { RoxRegister } from "@rox-register/sdk"; // 0.1.1+

const rox = new RoxRegister({
  apiKey: process.env.RR_API_KEY,
  requestSigningKey: {
    kid: "ish-prod-2026-01",
    privateJwk: JSON.parse(readFileSync("request-key.jwk", "utf8")),
  },
});

const stamped = await rox.sealStamp({
  file: readFileSync("policy.pdf"),           // raw PDF bytes
  filename: "policy.pdf",
  recordType: "policy",
  externalRef: "POL-2026-88123",
  idempotencyKey: "stamp-POL-2026-88123",     // one key per document
});
// stamped.salt_hex           -> STORE IT, never shown again
// stamped.content_digest_hex -> sha256 of the STAMPED bytes, not your upload
// stamped.stamped_file_url   -> presigned download, valid for 24 hours

// Download the stamped file NOW; the stored object expires after 1 day.
const file = Buffer.from(
  await (await fetch(stamped.stamped_file_url)).arrayBuffer(),
);
writeFileSync("policy.stamped.pdf", file);
Integrating without the SDK? The raw request looks like this; the API reference has the equivalent curl.
node, raw
import { createHash, createPrivateKey, randomUUID, sign } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";

const API = "https://api.roxregister.com";
const API_KEY = process.env.RR_API_KEY;   // rr_live_ak_… (vault key, scope: seal)
const KID = "ish-prod-2026-01";           // your registered request-signing key
const privateKey = createPrivateKey({
  key: JSON.parse(readFileSync("request-key.jwk", "utf8")),
  format: "jwk",                          // Ed25519 (default)
});

// 1 · Build the multipart body YOURSELF, byte for byte. The JWS must cover
//     the exact bytes on the wire, so do not use FormData: the runtime would
//     pick its own boundary and serialize lazily, and you cannot hash bytes
//     you never held.
const pdf = readFileSync("policy.pdf");
const boundary = `rr-${randomUUID().replaceAll("-", "")}`;
const field = (name, value) =>
  `--${boundary}\r\ncontent-disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
const body = Buffer.concat([
  Buffer.from(field("record_type", "policy"), "utf8"),
  Buffer.from(field("external_ref", "POL-2026-88123"), "utf8"),
  // Optional JSON text fields go the same way:
  // Buffer.from(field("metadata", JSON.stringify({ amount: 120 })), "utf8"),
  Buffer.from(
    `--${boundary}\r\ncontent-disposition: form-data; name="file"; filename="policy.pdf"\r\n` +
      `content-type: application/pdf\r\n\r\n`,
    "utf8",
  ),
  pdf,
  Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"),
]);

// 2 · Sign the RAW multipart bytes, boundaries and all (spec 7.3):
//     RRv1\n{METHOD}\n{path}\n{sha256_hex(raw body)}\n{ts}\n{nonce}
const ts = Date.now();      // unix MILLISECONDS. Seconds put the timestamp
                            // decades in the past and fail with RR-1003.
const nonce = randomUUID(); // single-use (10 min replay window)
const bodyHash = createHash("sha256").update(body).digest("hex");
const input = `RRv1\nPOST\n/v1/seal/stamp\n${bodyHash}\n${ts}\n${nonce}`;
const signature = sign(null, Buffer.from(input, "utf8"), privateKey)
  .toString("base64url");

// 3 · Stamp and seal.
const res = await fetch(`${API}/v1/seal/stamp`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${API_KEY}`,
    "content-type": `multipart/form-data; boundary=${boundary}`,
    "idempotency-key": "stamp-POL-2026-88123",  // one key per document
    "x-rr-key-id": KID,
    "x-rr-timestamp": String(ts),
    "x-rr-nonce": nonce,
    "x-rr-signature": signature,
  },
  body,                                     // the EXACT bytes the hash covered
});
const record = await res.json();            // 202 Accepted
// record.salt_hex           → STORE IT, never shown again
// record.content_digest_hex → sha256 of the STAMPED bytes, not your upload
// record.stamped_file_url   → presigned download, valid for 24 hours

// 4 · Download the stamped file NOW and store it in your own systems; the
//     stored object expires from Rox storage after 1 day.
const stamped = Buffer.from(
  await (await fetch(record.stamped_file_url)).arrayBuffer(),
);
writeFileSync("policy.stamped.pdf", stamped);

// Sanity check: the stamped bytes hash to the sealed digest.
const check = createHash("sha256").update(stamped).digest("hex");
if (check !== record.content_digest_hex) throw new Error("digest mismatch");
Either way, send an Idempotency-Key: a retry with the same key returns the original record with Idempotent-Replay: true and a freshly presigned URL to the original stamped bytes. Because stamping is not byte-deterministic, replays are never digest-compared, so the key alone decides: use one key per document, never reuse a key for a different file.
3 · Store the 202, and download the stamped file NOW
The acceptance carries the usual seal fields plus two stamp-specific ones:
  • record_id, reference, status (pending), and salt_hex: returned once, never again. Store the salt with the document; verification by digest needs it.
  • content_digest_hex: the SHA-256 of the stamped bytes, computed server-side. This is the digest that gets anchored.
  • stamped_file_url: a presigned download URL valid for 24 hours. The stored stamped file itself expires from Rox storage after 1 day, so download it promptly and keep it in your own systems; it is the file that verifies and the file you print. The RECORD and its proof are permanent regardless, only the file copy expires. If you miss the window, the record still anchors and verifies, but the stamped bytes are gone; stamp a fresh copy under a new record if you need the file again.
4 · What is on the page: the scan contract
The stamp is a compact seal card on the first page of the PDF: a QR code, the full public reference, the sealing method line and the scan domain. The QR encodes https://scan.roxregister.com/r/{reference}#s={salt_hex}. Scanning it lands on the public verification page for the record, verdict first, no account needed. The salt rides in the URL fragment: browsers never transmit fragments to servers, so the salt cannot appear in server or proxy logs; the scan page reads it client-side and uses it for the byte-exact file check. Placement is configured per record type (qr_position in the record type's verify fields, set in your Platform): bottom-right (default), bottom-left, top-right or top-left; it is not a request field.
5 · Wait for the terminal outcome
The 202 means validated and accepted, not anchored. The record anchors asynchronously and reaches exactly one terminal outcome: anchored (with anchor coordinates and proof) or failed. Consume it push-style via the record.anchored / record.failed webhooks (see the webhooks guide), or poll the record. Build on the outcome, not on any expectation of when it lands.
curl
curl -sS https://api.roxregister.com/v1/records/rr_ref_<ULID> \
  -H "Authorization: Bearer $RR_API_KEY"
# status: "pending" or "batched"  -> pre-anchor, treat identically, keep waiting
# status: "anchored"              -> terminal: anchor tx, slot and block time set
# status: "failed"                -> terminal: permanent failure after retries
6 · Verify the stamped file, like a stranger would
Verification is public: no key, no login. Remember which bytes were sealed: the stamped output. Hash your stored stamped copy and check it with the salt, or just scan the stamp on a printout and drop the file on the verification page.
curl
# Verify the STAMPED file (the sealed digest covers the stamped bytes,
# so this is the file that verifies, not the original upload):
DIGEST=$(shasum -a 256 policy.stamped.pdf | cut -d' ' -f1)
curl -sS -X POST https://api.roxregister.com/verify/hash \
  -H "Content-Type: application/json" \
  -d '{"content_digest_hex":"'"$DIGEST"'",
       "salt_hex":"'"$RR_SALT_HEX"'",
       "reference":"rr_ref_<ULID>"}'
# → {"result":"genuine","match":true,…} once the record is anchored
New to the sealing model? Start with your first seal for keys and the JWS basics. Then subscribe to webhooks so outcomes push to you, or commit per-field hashes with selective disclosure; the field_hashes text field works on stamping too. Need the document SIGNED before it is sealed and stamped? Run the whole ceremony with send a document for signature.