---
title: "Issue a per-user leaf certificate, sign the PDF ByteRange digest server-side, embed in a CMS PKCS#7 (PAdES-B-B, or PAdES-B-T when TSA_URL is configured)"
method: POST
path: "/api/envelopes/{envelope_id}/seal"
---

# Issue a per-user leaf certificate, sign the PDF ByteRange digest server-side, embed in a CMS PKCS#7 (PAdES-B-B, or PAdES-B-T when TSA_URL is configured)

`POST /api/envelopes/{envelope_id}/seal`

## Request

- Base URL: `https://free-sign.com`
- URL: `https://free-sign.com/api/envelopes/{envelope_id}/seal`
- Auth: none declared. Required headers: `x-fsig-session-signature`, `x-fsig-session-nonce`, `x-fsig-session-timestamp`, `x-fsig-session-action`

After /sign and after the browser has APPENDED an incremental update to the locally stamped PDF (new /Sig + Widget + re-emitted Page/Catalog + new xref pointing at /Prev, with placeholder /ByteRange and /Contents <hex>), the browser POSTs the SHA-256 of the PDF's ByteRange plus the verified signer email. The Worker verifies the email matches the envelope-scoped HMAC stored at /sign time, generates an ephemeral ECDSA P-256 keypair, issues a 10-year leaf certificate under the FreeSign CA (Google Cloud HSM in production; the long validity is required because Adobe Reader re-validates the signer cert against the current wall clock when the chain is not AATL-trusted, the ephemeral signing key itself is still destroyed inside the same request) with Subject CN = the human signer's typed name and subjectAltName.rfc822Name = the verified email, signs SignedAttributes with the ephemeral key, and assembles a CMS SignedData with both the leaf and CA certs in the certs set. When env.TSA_URL is configured, the Worker additionally fetches an RFC 3161 TimeStampToken over the signature value and embeds it as the SignerInfo signatureTimeStampToken unsigned attribute (PAdES-B-T). The ephemeral private key never leaves the Worker. The browser embeds the resulting CMS bytes into the /Contents placeholder. PDF bytes are never accepted on this endpoint. The base PDF stays intact as revision 1, the signature is revision 2 — multi-signer documents stack additional revisions cleanly, each with its own per-user leaf cert. Idempotent: same byterange_sha256 on retry returns the cached CMS (and the previously-issued leaf cert); a different byterange_sha256 returns 409 already_sealed_with_different_byterange. Requires the four session-binding headers (see x-fsig-session-* component parameters). FreeSign also submits the byterange hash to OpenTimestamps for independent timestamp proof; after calendar upgrade this proof resolves to public block headers.

## Path parameters

- `envelope_id` string, required

## Headers

- `x-fsig-session-signature` string, required
- `x-fsig-session-nonce` string, required
- `x-fsig-session-timestamp` string, date-time, required
- `x-fsig-session-action` 'otp.request' | 'otp.verify' | 'sign' | 'seal' | 'platform-seal' | 'finalize' | 'webauthn.register' | 'webauthn.authenticate', required

## Request body

- object
  - `signer_id` string, required
  - `signer_email` string, email, required: The signer's email — must match the envelope-scoped HMAC stored at /sign time. Used as the leaf cert's subjectAltName.rfc822Name.
  - `byterange_sha256` string, required: SHA-256 of (preparedPdf[0..ByteRange[1]) ++ preparedPdf[ByteRange[2]..ByteRange[2]+ByteRange[3])). I.e., everything except the hex chars inside /Contents <...>.
  - `evidence_json` object: Optional. The signer's pre-seal evidence record (signer identity, OTP, consent, the signed canonical_payload + its signature + the browser public_key_jwk). The Worker embeds it verbatim into this signer's CMS as a signedAttribute (OID 1.3.6.1.4.1.65834.1.2), so editing the record in the PDF invalidates the CMS signature, so every signer of a multi-signer PDF carries their own. Dropped silently if it is not a plain object, claims a different envelope_id, or stringifies past ~12 KB; the seal still succeeds.

## Response `200`

Per-user CMS produced (or returned from cache if idempotent). Response includes the base64 CMS, the leaf cert + serial + validity, and audit hashes.

- object
  - `envelope_id` string, required
  - `cms_base64` string, required: Base64-encoded CMS DER. Caller writes hex(cms_base64-decoded) into the /Contents <...> placeholder, padding the rest with '0'.
  - `seal_cms_sha256` string, required
  - `seal_cert_sha256` string, required: SHA-256 of the issued LEAF cert (i.e. the per-user cert, not the CA).
  - `seal_signed_attrs_sha256` string
  - `seal_byterange_sha256` string
  - `seal_signed_at` string, date-time, required
  - `signer_cert_base64` string, nullable: Base64-encoded DER of the issued leaf cert. Carries Subject CN = signer's name and subjectAltName.rfc822Name = signer's email.
  - `signer_cert_serial_hex` string, nullable: 16-byte serial of the issued leaf cert (hex).
  - `signer_cert_not_after` string, date-time, nullable: ISO timestamp at which the leaf cert expires (default 10 years after issuance; the ephemeral signing key is destroyed earlier, the cert outlives it so Adobe's wall-clock revalidation stays green).
  - `seal_ca_mode` 'local-pem' | 'gcp-kms': Which Signing-CA implementation produced this signature.
  - `seal_tst_sha256` string, nullable: SHA-256 of the embedded RFC 3161 TimeStampToken bytes. Null when no TSA was configured.
  - `seal_tst_base64` string, nullable: Base64 of the raw TimeStampToken (ContentInfo DER) for PDF-free re-verification.
  - `seal_tst_signed_at` string, date-time, nullable: ISO timestamp at which the Worker accepted the TSA reply. Distinct from the TST's own genTime, which is signed by the TSA.
  - `seal_profile` 'PAdES-B-B' | 'PAdES-B-T', required: CMS-level profile. The browser upgrades the final PDF to PAdES-B-LT when `dss` is non-null by appending a /DSS revision.
  - `dss` object, nullable: PAdES-B-LT material the browser embeds into the PDF /DSS as a separate incremental update (cert chain + CRL). Null unless the deployment configured SIGNING_CA_CRL_DER_BASE64. The leaf cert is already in signer_cert_base64.
    - `ca_cert_base64` string: Base64 DER of the FreeSign CA cert.
    - `crl_base64` string: Base64 DER of the (always-empty) FreeSign CA CRL.
  - `pq_signature` object, nullable: ML-DSA (FIPS 204) post-quantum co-signature embedded in this CMS. Null unless the deployment set PQ_SIGNATURE to a variant. It is an addition, not a substitution: the SignerInfo signature stays ECDSA P-256, so Adobe, pyHanko and openssl validate the file unchanged. The signed attributes carry a commitment SEQUENCE { algorithm OID, SHA-256(public key) } at OID 1.3.6.1.4.1.65834.1.3; the signature itself rides as an unsigned attribute SEQUENCE { algorithm OID, public key, signature } at OID 1.3.6.1.4.1.65834.1.4, computed over the same DER SignedAttributes the classical signature covers. The public key and signature bytes are in cms_base64 — these are their digests.
    - `variant` 'ml-dsa-44' | 'ml-dsa-65' | 'ml-dsa-87'
    - `pubkey_sha256` string: SHA-256 of the raw ML-DSA public key — the value committed to inside the signed attributes.
    - `signature_sha256` string: SHA-256 of the raw ML-DSA signature bytes.
  - `idempotent` boolean: True when the response was served from the cached CMS (same byterange digest replayed).
  - `ots_anchor` object, nullable: The DOCUMENT OpenTimestamps anchor produced inline as part of this /seal (kind=byterange). Null on the idempotent cache-hit path when no anchor row exists for this seal. Shape unchanged since before the second anchor existed. The .ots proof is always exposed through proof_download. By default (OTS_EMBED_IN_CMS=true) embedded_in_cms=true also places the .ots proof inside the PDF as CMS unsignedAttribute OID 1.3.6.1.4.1.65834.1.1; Adobe Acrobat Reader, pyHanko, and openssl all validate that attribute identically to a plain PAdES-B-T seal. Set OTS_EMBED_IN_CMS=false to keep it out of the PDF.
    - `id` string
    - `kind` 'byterange' | 'signed_attrs': byterange: anchors the signed document (= seal_byterange_sha256). signed_attrs: anchors SHA-256(CMS SignedAttributes) (= seal_signed_attrs_sha256) — the hash-based, quantum-resistant proof that the post-quantum key commitment existed at that time.
    - `anchored_hash` string: The hash committed through OpenTimestamps: seal_byterange_sha256 for kind=byterange, seal_signed_attrs_sha256 for kind=signed_attrs.
    - `status` 'pending' | 'deferred': pending: calendar attestation in hand. deferred: inline submission exhausted its 6-second budget; a 30-min cron job retries (rows older than 30 days are abandoned). The resulting .ots is retrievable via proof_download. `confirmed` only ever appears on the /api/receipts response after the BTC-upgrade cron stage runs (typically ~1-2 h after signing) — never inline from /seal.
    - `embedded_in_cms` boolean
    - `calendar_urls` string[]
    - `pending_submitted_at` string, date-time, nullable
    - `proof_download` string: Path to GET the .ots file. Available regardless of status (404 until the proof actually lands).
  - `ots_anchor_signed_attrs` object, nullable: Null for every seal made before this anchor shipped (the idempotent cache-hit path returns what was persisted). The SIGNED-ATTRIBUTES OpenTimestamps anchor (kind=signed_attrs), embedded as CMS unsignedAttribute OID 1.3.6.1.4.1.65834.1.5 with the same OCTET STRING { .ots } payload shape. It commits to SHA-256(SignedAttributes), which cover the messageDigest, the signingCertificateV2 binding to the signer's certificate and the post-quantum ML-DSA key commitment — the document anchor alone would not detect a wholly re-minted CMS over the same document. Submitted concurrently with the document anchor and equally non-blocking: a calendar outage still returns 200 with status=deferred.
    - `id` string
    - `kind` 'byterange' | 'signed_attrs': byterange: anchors the signed document (= seal_byterange_sha256). signed_attrs: anchors SHA-256(CMS SignedAttributes) (= seal_signed_attrs_sha256) — the hash-based, quantum-resistant proof that the post-quantum key commitment existed at that time.
    - `anchored_hash` string: The hash committed through OpenTimestamps: seal_byterange_sha256 for kind=byterange, seal_signed_attrs_sha256 for kind=signed_attrs.
    - `status` 'pending' | 'deferred': pending: calendar attestation in hand. deferred: inline submission exhausted its 6-second budget; a 30-min cron job retries (rows older than 30 days are abandoned). The resulting .ots is retrievable via proof_download. `confirmed` only ever appears on the /api/receipts response after the BTC-upgrade cron stage runs (typically ~1-2 h after signing) — never inline from /seal.
    - `embedded_in_cms` boolean
    - `calendar_urls` string[]
    - `pending_submitted_at` string, date-time, nullable
    - `proof_download` string: Path to GET the .ots file. Available regardless of status (404 until the proof actually lands).
  - `ots_anchors` object[]: Both anchors above, in one array (kind=byterange first). Prefer this when iterating; ots_anchor / ots_anchor_signed_attrs are the stable named handles.

## Other responses

- `400`: signer_email mismatch or invalid byterange_sha256.
- `409`: Envelope already sealed (with a different byterange digest) or envelope/signer not in 'signed' state.
- `502`: TSA_URL was configured but the TSA round-trip failed (network error or non-granted PKIStatus). The seal is not committed; the client may retry.
- `503`: Signing CA is not configured (no SIGNING_CA_CERT_DER_BASE64 or no CA signer wired up).

---

[API](https://skmtc.dev/free-sign/apis/freesign-free-e-signature-api.md) · [All operations](https://skmtc.dev/free-sign/apis/freesign-free-e-signature-api/llms.txt) · [OpenAPI document](https://skmtc.dev/free-sign/apis/freesign-free-e-signature-api/revisions/5d8e9854ab85?raw)
