REFERENCE

Documentation

Evaluating Paramant? Your IT can check everything here. Plans and prices are on Pricing.

API reference, SDK guide, self-hosting, and compliance docs for PARAMANT v3.1.3. The relay is untrusted by design: it never holds a decryption key. The ParaSign API is available from Firm upwards; see pricing.

What's new in 3.0.0

Version 3.0.0 keeps the wire format, API surface and crypto guarantees of 2.5.x, so every 2.5.x client stays compatible. It focuses on the operator and self-host experience. The headline change is structural: the post-quantum primitives now live in their own audited library.

  • paramant-core crypto split (M5b). Server-side ML-DSA-65 signing is now provided by paramant-core: a standalone, audit-ready Rust library (BUSL-1.1) the relay consumes through its @paramant/core NAPI binding. Client-side ML-KEM-768 still runs in the browser/SDK; the relay never holds a key. See Crypto stack.
  • Document workspace. The user dashboard starts document flows, shows account-owned signing requests and exposes owner actions without adding a new trust surface.
  • Refreshed interactive UI. Dark mode (top-right toggle), real-time status updates and a mobile-friendly layout across the dashboard.
  • First-run setup wizard. A web wizard at /setup replaces hand-run admin scripts for first-time operators: set the admin token, enrol TOTP and create the first key from the browser (ADR R005).
  • Visual admin config. /admin/settings exposes relay configuration in the panel, so common changes no longer require editing .env and restarting.
  • In-browser admin CLI. /admin/cli is a web terminal for admin debugging: inspect relay state without opening an SSH session.
  • Crypto-mode negotiation (ADR R006, accepted). /v2/capabilities advertises a compact core mode (2 algorithms) by default; extended algorithm sets are opt-in. See Crypto stack.
  • Add-on architecture (ADR R007, draft). A specification for container-isolated integrations (storage mirrors, SIEM exporters, SSO) that work on ciphertext and metadata only, never plaintext or keys.
  • Static front-end serving (ADR R011). An opt-in SERVE_FRONTEND static handler lets a single relay serve its own UI for plug-and-play self-hosting. See Self-hosting.
  • Documented design decisions. Architecture Decision Records R001 to R011 capture the choices behind 3.0.0, from the hotfix flow to the crypto split. They live in the repository under docs/adrs/.

Quick start

Send your first encrypted file in under 5 minutes. Create a free account (protected with TOTP; your key is shown in the dashboard).

1

Install the SDK (Python 3.10+ or Node 18+; both ship the same wire-format-v1 encoder)

bash
# Python
pip install paramant-sdk

# JavaScript / Node
npm install paramant-sdk
2

Send a file. It is encrypted client-side, before it leaves your device.

python
from paramant_sdk import GhostPipe

gp = GhostPipe(api_key="pgp_your_key", device="laptop-01")
gp.receive_setup()  # register this device's keys (once)

# Send: returns (blob hash, inclusion proof). Share the hash with the receiver.
hash_, proof = gp.send(open("document.pdf", "rb").read(), ttl=3600)

# Receive: burn-on-read. One download on Community, more on a paid plan.
data, proof = gp.receive(hash_)

Or call the HTTP API directly, without the SDK:

curl
# Upload encrypted blob
curl -X POST https://health.paramant.app/v2/inbound \
  -H "X-Api-Key: pgp_your_key" \
  -H "Content-Type: application/json" \
  -d '{"hash":"sha256_of_payload","payload":"base64_5mb_blob","ttl_ms":3600000}'

# Retrieve (Community: one-time, the blob is burned after this)
curl https://health.paramant.app/v2/outbound/abc123... \
  -H "X-Api-Key: pgp_your_key" -o received.bin

Authentication

There are two key types, each with its own role. You can never use one in place of the other.

KeyPrefixWhoPurpose
pgp_pgp_End usersSend/receive files via the API
plk_plk_Relay operatorsLicence key: allows more than 5 users on a self-hosted relay

Pass the pgp_ key in the X-Api-Key header. A key in the ?k= query parameter is refused with 400, so it never lands in an access log:

X-Api-Key: pgp_your_key_here
# for example:
GET /v2/check-key
X-Api-Key: pgp_your_key_here

SDK

Two official SDKs ship the same wire-format-v1 encoder, ML-KEM-768 + ML-DSA-65 by default, and capability negotiation against /v2/capabilities. Pick the one that matches your runtime; they are byte-compatible on the wire.

PyPI · paramant-sdk npm · paramant-sdk GitHub · sdk-py source GitHub · sdk-js source

Install

# Python (3.10+)
pip install paramant-sdk

# JavaScript (Node 18+)
npm install paramant-sdk

Python SDK (class-based)

python
from paramant_sdk import GhostPipe

gp = GhostPipe(
    api_key = "pgp_xxx",
    device  = "device-001",
    sector  = "health",     # routes to health.paramant.app
)
gp.receive_setup()          # register this device's keys first (once per device)

# Send: returns (blob hash, inclusion proof)
hash_, proof = gp.send(open("scan.dcm", "rb").read(), ttl=3600)

# Receive: burn-on-read, returns (plaintext bytes, receipt or None)
data, receipt = gp.receive(hash_)

This is paramant-sdk 3.0.0 from PyPI. send and receive each return a pair. A device that receives registers its keys first with receive_setup(); without it send refuses with "No pubkeys for this device". The receipt is usually None in 3.0.0: the SDK only reads it from the old X-Paramant-Receipt header, which the relay no longer sends by default since September 2026. Fetch the receipt with GET /v2/transfers/:receipt_id/receipt (see docs/api.md).

Use cases

PARAMANT has five sector relays, each tuned to its compliance domain. Pick the sector that matches your use case.

healthHealthcare: NEN 7510, DICOM, HL7 FHIR

Send MRI scans, referral letters, and lab results between care providers. health.paramant.app is designed for NEN 7510 compliance: encrypted in transit, RAM-only, EU jurisdiction. Every upload becomes an entry in the public CT log; downloads and acknowledgements are recorded in your key’s private audit chain (/v2/audit), not in the CT log.

python
# Send DICOM scan to specialist
from paramant_sdk import GhostPipe
gp = GhostPipe(api_key="pgp_xxx", device="mri-001", sector="health")
hash_, proof = gp.send(open("scan.dcm","rb").read(), ttl=3600)

legalLegal & Notary: eIDAS, KNB

Send signed deeds and court documents that are cryptographically gone after receipt. The CT log records, tamper-evidently, that the document was handed to the relay, without storing content. The pickup and the burn are recorded in your key’s private audit chain.

python
# Signed deed: burn-on-read, CT log entry as proof of hand-over to the relay
from paramant_sdk import GhostPipe
gp = GhostPipe(api_key="pgp_xxx", device="notary-01", sector="legal")
hash_, receipt = gp.send(open("deed.pdf","rb").read())
gp.verify_receipt(receipt)  # ML-DSA-65 signed

iotIndustrial IoT: IEC 62443

Relay PLC and sensor data without a VPN and without exposing OT systems directly to the internet. The relay acts as a data diode (post-quantum encrypted with the Python SDK): the OT side only pushes outbound, the IT side receives. The SDK pads every block to a fixed 5 MB, so payload size is hidden inside a block; the block count of a multi-block transfer is not.

python
# PLC telemetry: outbound only, no VPN, no inbound port
from paramant_sdk import GhostPipe
gp = GhostPipe(api_key="pgp_xxx", device="plc-factory-01", sector="iot")
while True:
    gp.send(read_plc_state(), ttl=60)
    time.sleep(15)

financeFinance: NIS2, DORA, ISO 20022

Relay ISO 20022 payment files with a Merkle proof per file sent (every upload becomes an entry in the relay’s CT log) for DORA audit trails. finance.paramant.app: EU jurisdiction, no US Cloud Act, no metadata retention.

python
# Send ISO 20022 payment file with CT proof for DORA audit
from paramant_sdk import GhostPipe
gp = GhostPipe(api_key="pgp_xxx", device="bank-nl-01", sector="finance")
hash_, receipt = gp.send(open("pacs008.xml","rb").read())
# receipt → tree_size, sha3_root, ML-DSA-65 signature

All endpoints

MethodEndpointDescriptionAuth
GET/healthNode status, version, uptime, editionn/a
POST/v2/inboundUpload encrypted blob from an authenticated client (RAM-only)Key
POST/v2/anon-inboundDeprecated, sunset 31 December 2026 (the date the relay sends in the Sunset response header). Anonymous upload retained for sdk-js 3.x backward compatibility. New integrations must use /v2/inbound.n/a
POST/v2/sendsTurns blocks you have already uploaded into one send to named recipients. Each recipient gets a personal link that works once. One recipient per send on Community, up to 30 on Firm and Enterprise; more is refused with 403 over_limit. Body carries the block hashes, the addresses, and one wrapped file key per person.Key
POST/v2/pickup/:tokenThe receiving end. It needs no credential: the token from the invitation is the capability. {"action":"code"} mails a short code to the same mailbox; {"code":"123456"} returns the bytes, once; {"action":"confirm"} confirms the file opened, which makes the collection final.n/a
GET/v2/pickup/:tokenSame route, kept for links created before the switch to POST. Link scanners open every GET link, so a GET that sends mail would fire for each scan; new clients use POST.n/a
GET/v2/outbound/:hashDownload and burn: uses one read of the linkKey
GET/v2/status/:hashCheck blob availability without consumingKey
POST/v2/ackConfirm delivery; recorded in your key’s private audit chain, not in the CT logKey
GET/v2/monitorLive stats: blobs in flight, ACK rateKey
POST/v2/ws-ticketGet a one-time WebSocket ticket, valid for 30sKey
GET/v2/streamWebSocket push: blob_ready eventsTicket / Key
POST/v2/webhookRegister a delivery webhook URLKey
POST/v2/pubkeyRegister an ML-KEM + ECDH keypairKey
GET/v2/check-keyVerify key validity and plann/a
POST/v2/did/registerRegister a W3C decentralized identity (DID)Key
GET/v2/did/:didResolve a DID documentn/a
GET/v2/ct/logCertificate Transparency log (public)n/a
GET/v2/relaysRegistered relay nodes and statusn/a
POST/v2/verifyCheck an old .psign (v1/v2) against this relay's notary keyn/a
GET/v2/auditMerkle audit chain, exportable as JSON or CSVKey
GET/v2/team/devicesList team devicesKey
POST/v2/team/add-deviceAdd a device to a team (Firm or higher)Key

POST /v2/inbound

Upload an encrypted, padded blob. It is stored in RAM only and never written to disk. Returns the hash you use to retrieve it.

Request body (JSON):
{
  "hash":    "sha256_of_padded_payload",
  "payload": "base64_encoded_5mb_blob",
  "ttl_ms":  3600000,
  "max_views": 1
}

200: { "ok": true, "hash": "...", "ttl_ms": 3600000 }
409: hash already exists
503: relay at capacity (memory limit)

TTL and max_views are both clamped to the plan maximum: Community gets 1 hour and 1 read, Firm 24 hours and up to 10 reads, Enterprise 7 days and 100 reads. Leave max_views out and the link burns on the first read on every plan, which is what the ParaSend web app does. The hash field is the SHA-256 of the padded payload (not the original file). The SDK computes this automatically.

GET /v2/outbound/:hash

Uses one read of the link. A Community link has one read, so this call is the only retrieval; a paid link can have more, up to 10 reads on Firm. A read only counts once the whole blob was delivered, meaning the relay has sent the last byte: if your connection breaks off before that, it is still there and you fetch it again; the relay serves it at most five times in all. Sent means handed to the connection, not arrived at your end. The operating system buffers a few MB per connection, so a blob up to the 5 MB default is as a rule sent in one go, and a connection that breaks off after the first kilobytes has then already used the read. If a broken download must cost nothing, use the share link /v2/dl with ?claim=: there the read only counts after your own confirmation. After the last delivered read the blob buffer is overwritten with random bytes and removed from RAM. There is no recovery then.

GET /v2/outbound/abc123...
X-Api-Key: pgp_xxx

200: binary blob, exactly the bytes that were uploaded (the relay does not pad; the SDK and the live hand-over send 5 MB blocks)
404: burned, expired, or never existed
403: API key mismatch

GET /v2/stream

WebSocket endpoint for real-time blob_ready push notifications. Use a one-time ticket to avoid exposing the key in the URL.

javascript
// Step 1: get a one-time WebSocket ticket (30s TTL)
const { ticket } = await fetch('https://health.paramant.app/v2/ws-ticket', {
  method: 'POST', headers: { 'X-Api-Key': 'pgp_xxx' }
}).then(r => r.json());

// Step 2: connect with ticket (API key never appears in URL)
const ws = new WebSocket(`wss://health.paramant.app/v2/stream?ticket=${ticket}`);
ws.onmessage = e => {
  const msg = JSON.parse(e.data);
  // { type: "blob_ready", hash: "...", size: 5242880, ts: "..." }
};

GET /v2/monitor

{
  "ok": true, "plan": "pro", "blobs_in_flight": 3,
  "stats": { "inbound": 42, "burned": 38, "webhooks_sent": 12 },
  "delivery": { "total": 42, "acked": 38, "success_rate": 0.905 }
}

POST /v2/webhook

{ "device_id": "scanner-01", "url": "https://you.com/hook", "secret": "your-own-secret" }

Response (secret only when you sent none; shown once):
{ "ok": true, "events": ["blob_ready", "blob_downloaded"], "secret": "whsec_..." }

Relay POSTs to your url when a blob for that device arrives (POST /v2/inbound):
{ "event": "blob_ready", "device_id": "scanner-01", "ts": "2026-04-14T...",
  "hash": "...", "size": 5242880, "ttl_ms": 3600000, "sig_valid": true }

And when it is downloaded (via the link or GET /v2/outbound):
{ "event": "blob_downloaded", "device_id": "scanner-01", "ts": "...",
  "hash": "...", "size": 5242880, "via": "link" }   // or "api"

Pro plan and up. Every webhook is signed. If you send a secret, the relay signs with it. If you leave it out, the relay makes one (whsec_…) and returns it once in the response; store it right away, it is not shown again. Each call carries the headers X-Paramant-Event (the event name) and X-Paramant-Sig: the hex HMAC-SHA256 of the raw body, keyed with the secret. Each call also carries X-Paramant-Timestamp and X-Paramant-Signature: t=<unix seconds>,v1=<hex>, the HMAC-SHA256 over <t>.<raw body>: the time is inside the signature, so reject a call older than five minutes and a captured call cannot be replayed later. Recompute the signature over the unparsed body, compare in constant time and reject callbacks whose signature does not match. The registration is stored in redis (memory is only a cache), so it survives a restart and holds on every sector; the last 5 registrations per device are kept, an account has at most 20 devices with a webhook, and a registration expires after 90 days unless you register it again. The relay tries once (5-second timeout) and does not retry.

GET /health

{
  "ok": true,
  "version": "3.0.0",
  "sector": "health",
  "edition": "licensed",
  "uptime_s": 3600,
  "license_expires": "2027-01-01"
}

ParaSign API (/v1)

Run hosted signing ceremonies from your own stack: create an envelope from a PDF plus a signer list, route each signer to a hosted signing page, and pull the completed .psign proof. Authenticate with Authorization: Bearer psk_live_... (create keys in the developer dashboard (in Dutch); API access is included in Firm).

Endpoints:
POST   /v1/envelopes                Create an envelope; returns id + one sign_url per signer
GET    /v1/envelopes/:id            Envelope status and per-signer progress
GET    /v1/envelopes/:id/receipt    The full .psign proof, once complete
GET    /v1/envelopes/:id/document   The signed PDF, once complete
POST   /v1/envelopes/:id/void       Void an open envelope (owner only)
# Create an envelope. The document travels base64; signers get hosted pages.
curl -X POST https://paramant.app/v1/envelopes \
  -H "Authorization: Bearer psk_live_..." \
  -H "Idempotency-Key: quote-8842-v1" \
  -H "Content-Type: application/json" \
  -d '{
        "document": { "content_base64": "JVBERi0xLjc..." },
        "original_filename": "quote-8842.pdf",
        "signers": [ { "name": "A. Jansen", "email": "a@example.org" } ]
      }'

# On completion, pull the offline-verifiable proof (id from the 201 above).
curl https://paramant.app/v1/envelopes/<id>/receipt \
  -H "Authorization: Bearer psk_live_..." --output quote-8842.psign
201 (shortened):
{
  "id": "Us4rFoLj35sU_4cOlPJcs3eMZlw4xjMp",
  "status": "sent",
  "signers": [
    { "index": 0, "name": "A. Jansen", "status": "pending",
      "sign_url": "https://paramant.app/co-sign?env=...&p=0&t=..." }
  ]
}

The id is a random string of 20 to 64 characters with no prefix. Each signer gets their own sign_url to the hosted page /co-sign. A signer is pending until they have signed and signed after, the same word in the create response and in GET /v1/envelopes/:id.

Quota: 50 envelope creations per key in any sixty minutes (a sliding window, not the clock hour), so never more than 50 within an hour, also around the full hour. An envelope is kept for 30 days by default (set ttl_days, up to 365). While it exists the relay also keeps the PDF itself, encrypted in redis, because the signers have to open it and the stamped PDF is made from it. When it expires it is gone. Webhooks: set webhook_url and envelope.sent, signer.completed, envelope.completed and envelope.voided arrive signed with HMAC-SHA256 in the X-Paramant-Sig header. A delivery that fails (no connection, or a 5xx or 429 from your side) is tried again after 2 and after 10 seconds, three attempts in all, with the same body and the same X-Paramant-Delivery, so you can recognise a repeat by that id; X-Paramant-Attempt says which attempt it is. The events of one envelope go out in order, one at a time, and every body carries a rising seq (envelope.sent 1, signer.completed 1 plus the number of signatures, envelope.completed and envelope.voided the number of signers plus 2). After three failed attempts the relay gives up, so also check the status yourself with GET /v1/envelopes/:id. Receipts verify offline against the public CT log; see /verify.

Safe retries: send an Idempotency-Key header with POST /v1/envelopes (8 to 128 characters of A-Z a-z 0-9 _ . : -). When the same key comes again from the same API key within 24 hours, you get the first 201 response back with Idempotent-Replay: true, with no second envelope and no quota spent. Only a successful 201 is stored. The relay checks the body before the hourly quota, so 400 ambiguous_document (both content_base64 and url), 400 invalid_binding_mode (not email or open), 400 invalid_signer_email (with email binding every signer needs a valid address; signer_index names the first bad one) 400 invalid_metadata (metadata is not an object) and 400 invalid_idempotency_key cost no creation. Further: 400 missing_signers, 400 missing_document (neither content_base64 nor url), 400 empty_document (an empty content_base64), 400 too_many_signers (more names than your plan allows on one document; max_signers says how many), 413 document_too_large, 422 not_a_pdf and 422 document_unfetchable. A 429 rate_limited carries Retry-After with the seconds until the oldest creation of the past hour leaves the window. When your plan's monthly signature quota is used up the relay answers 402 monthly_sign_quota_reached with plan, limit, used, reset_date and Retry-After: 86400, and nothing is created. An error on /v1 always has the shape {"error": "<code>", "message": "<sentence>"}.

GET /v2/user/history

Your account's own send and envelope history, as a read-only view of the per-key Merkle audit chain. It returns identifiers, status and timing only, never a payload, download token or key. Authenticate with your pgp_ key in the X-Api-Key header.

FieldValue
AuthX-Api-Key: pgp_...
TierFirm or higher on ParaSend or ParaSign. Community → 403.
Query?limit= (default 100, max 1000)
GET /v2/user/history?limit=100
X-Api-Key: pgp_xxx

# 200 OK
{
  "ok": true,
  "count": 2,
  "entries": [
    { "id": "9f2c1a...", "status": "downloaded_burned", "time": "2026-07-19T14:22:03.114Z", "recipient_hash": "dev-88a1", "bytes": 5242880 },
    { "id": "b7e0d4...", "status": "sent",               "time": "2026-07-19T14:05:41.882Z", "recipient_hash": "dev-2f10", "bytes": 5242880 }
  ]
}

Statuses map from the audit chain: sent (inbound), aborted (inbound aborted), downloaded (outbound view), downloaded_burned (outbound burn). Entries are sorted newest first and capped at limit.

# 401 no / invalid key
{ "error": "API key required" }

# 403 plan too low
{ "error": "tier_upgrade_required", "feature": "history",
  "message": "Send history and link management require a Firm plan or higher." }

GET /v2/parasign/audit-export

The compliance part of the paid ParaSign plans. It gives an auditor the account's tamper-evident signing trail, the Certificate Transparency signed tree head (STH) that anchors it, and the full per-envelope .psign proofs for every completed envelope, as CSV or JSON. No document bytes leave the relay; the export carries hashes and signatures only.

FieldValue
AuthX-Api-Key: pgp_...
TierBusiness or higher (the audit_export entitlement). Firm and below → 403.
Query?format=csv for CSV (default JSON); ?limit= (default 1000, max 10000)
# JSON (default)
curl https://paramant.app/v2/parasign/audit-export \
  -H "X-Api-Key: pgp_xxx"

# CSV, saved to a file
curl "https://paramant.app/v2/parasign/audit-export?format=csv" \
  -H "X-Api-Key: pgp_xxx" --output parasign_audit.csv
# 200 OK (JSON)
{
  "ok": true,
  "type": "parasign-audit-export",
  "generated_at": "2026-07-19T14:30:00.000Z",
  "chain_valid": true,
  "ct_head": { "tree_size": 10432, "tree_hash": "sha3-256:...", "ts": "2026-07-19T14:29:00Z" },
  "count": 2,
  "entries": [
    { "time": "2026-07-19T14:22:03Z", "event": "sign", "doc_hash": "b3f1...", "bytes": 82113, "device": "notary-01", "chain_hash": "a19c..." }
  ],
  "envelope_count": 1,
  "envelopes": [
    { "envelope_id": "Us4rFoLj35sU_4cOlPJcs3eMZlw4xjMp", "status": "completed", "psign": { "...": "full multi-signer .psign proof" } }
  ]
}

The CSV export has one header row and one line per audit entry: time,event,doc_hash,bytes,device,chain_hash (the .psign envelope proofs are in the JSON export only). chain_valid: false signals possible tampering: the audit chain failed its Merkle re-check. Envelope statuses are completed (full .psign), in_progress, sent, void or expired_or_gone (no proof).

# 403 plan too low
{ "error": "tier_upgrade_required", "feature": "audit_export",
  "message": "The ParaSign audit export requires a Business plan or higher." }

Billing API: POST /v2/billing/checkout

Start a Mollie payment for a Paramant plan. The price is never supplied by the caller: it is looked up server-side from the billing catalog by (product, plan, interval), and the webhook re-verifies the amount actually paid before granting anything. Authenticate with your pgp_ key.

FieldValue
AuthX-Api-Key: pgp_... (a valid key). No key → 401.
productfirm, or parasign for Business
planFirm: product firm, plan firm, which grants the ParaSign and ParaSend paid limits together. ParaSign: business.
intervalmonthly or yearly

The separate parasign/pro and parasend/pro plans are no longer sold. They stay resolvable in the catalog so an outstanding renewal and an existing Mollie subscription keep working.

curl -X POST https://paramant.app/v2/billing/checkout \
  -H "X-Api-Key: pgp_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "product": "parasign", "plan": "business", "interval": "monthly" }'

# 200 OK: redirect the user to checkout_url to pay
{ "ok": true, "payment_id": "tr_XXXX", "checkout_url": "https://www.mollie.com/checkout/...", "mode": "live" }

On a successful payment Mollie sends the user back to /dashboard?billing=return and calls the webhook below; the account's entitlement is granted from the webhook, never from the redirect.

# 400 unknown product / plan / interval, or bad JSON
{ "error": "unknown_plan" }        # also: unknown_product, unknown_interval, bad_json
# 400 a plan the site does not sell (the separate Pro plans)
{ "error": "not_on_sale" }
# 409 another plan is running on that product; changing plans goes by mail
{ "error": "other_plan_running", "running": "business", "paid_until": "...", "message": "..." }
# 401 no / invalid key
{ "error": "unauthorized" }
# 502 the payment provider call failed
{ "error": "checkout_failed" }

POST /v2/billing/webhook

Called by Mollie, not by your integration. Mollie POSTs a form-encoded id=tr_...; the relay re-fetches that payment from Mollie (trusting only the provider, never the request body), checks the amount against the catalog and idempotently grants the entitlement. It always answers 200 once an event is handled so Mollie stops retrying; a transient fetch failure returns 503 so Mollie retries. No API key: authenticity comes from re-fetching the payment by id.

# Mollie -> relay (application/x-www-form-urlencoded)
POST /v2/billing/webhook
id=tr_XXXX

# 200 handled          { "ok": true }
# 400 malformed payment id  { "error": "bad_payment_id" }
# 503 provider fetch failed (Mollie will retry)  { "error": "fetch_failed" }

Self-hosting: Docker Compose

Community Edition is free forever for up to 5 users. One docker compose up starts 6 containers: five sector relays and an admin panel.

Requirements

Requirements: Ubuntu 22.04+ / Debian 12+ · Docker 24+ · 1 GB RAM minimum · swap disabled · a domain with wildcard DNS

Where to start

These links are no longer in the site navigation; this is the only place they appear. One-line install: install.sh for a server, install-pi.sh for a Raspberry Pi. Images: Docker Hub. Source and tags: GitHub, Releases. Client libraries: SDK on PyPI, SDK on npm.

bash
# 1. Clone
git clone https://github.com/Apolloccrypt/paramant-relay
cd paramant-relay

# 2. Configure
cp .env.example .env
nano .env   # fill in the required secrets:
#   ADMIN_TOKEN, REDIS_PASSWORD, INTERNAL_AUTH_TOKEN: openssl rand -hex 32
#   RELAY_REDIS_URL=redis://:<REDIS_PASSWORD>@redis:6379
#   PARAMANT_TOTP_MASTER_KEY: openssl rand -base64 32
#   PARASIGN_PUBLIC_ORIGIN, RELAY_SELF_URL_<SECTOR>: your own public URL
#   (install.sh writes all of these for you)

# 3. Launch
docker compose up -d

# 4. Verify
curl http://localhost:3000/health
# → {"ok":true,"version":"3.1.3","sector":"relay"}

Container port map

ContainerHost portSectorRecommended domain
relay-main127.0.0.1:3000relayrelay.your-domain.com
relay-health127.0.0.1:3001healthhealth.your-domain.com
relay-finance127.0.0.1:3002financefinance.your-domain.com
relay-legal127.0.0.1:3003legallegal.your-domain.com
relay-iot127.0.0.1:3004iotiot.your-domain.com
admin127.0.0.1:4200n/ayour-domain.com/admin/

Key .env variables

VariableRequiredDescription
ADMIN_TOKENYesAdmin panel password (min 32 chars, hex)
REDIS_PASSWORDYesPassword for the redis container; empty, redis refuses to start
RELAY_REDIS_URLYesredis://:<REDIS_PASSWORD>@redis:6379
PARAMANT_TOTP_MASTER_KEYYesEncrypts users' TOTP secrets at rest (32 bytes, base64); never change it on a running install
INTERNAL_AUTH_TOKENYesSecond gate on the relays' internal routes (admin plane to relay)
PARASIGN_PUBLIC_ORIGINNoYour own public site; checkout returns here instead of paramant.app
RELAY_SELF_URL_<SECTOR>NoPer relay (MAIN, HEALTH, FINANCE, LEGAL, IOT) its own public URL; without it signed heads and receipts name a paramant.app host
TOTP_SECRETRecommendedBase32 TOTP secret: enables MFA on admin login
PLK_KEYNoLicence key: allows more than 5 users (Community is free, max 5)
RESEND_API_KEYNoEmail provider key (for key delivery notifications)
LOG_LEVELNoinfo (default) · debug · warn · error
RAM_LIMIT_MBNoBlob storage memory cap per container (default 1024)

nginx & TLS

Use system nginx as a TLS terminator in front of the Docker containers. Certbot handles certificate renewal automatically.

bash
# Install nginx + certbot
apt install -y nginx python3-certbot-nginx

# Obtain wildcard TLS certificates
certbot certonly --nginx -d your-domain.com \
  -d relay.your-domain.com \
  -d health.your-domain.com \
  -d finance.your-domain.com \
  -d legal.your-domain.com \
  -d iot.your-domain.com

Add an nginx server block for each subdomain that proxies to the matching container port:

nginx
server {
    listen 443 ssl;
    server_name health.your-domain.com;
    ssl_certificate     /etc/letsencrypt/live/your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;

    location / {
        proxy_pass         http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection 'upgrade';
        proxy_set_header   Host $host;
        proxy_read_timeout 3600s;
    }
}
# Repeat for relay (:3000), finance (:3002), legal (:3003), iot (:3004)

First user

bash
# Create your first API key
python3 deploy/paramant-admin.py add \
  --label "alice" \
  --plan  pro \
  --email alice@example.com

# Push to all relay containers
python3 deploy/paramant-admin.py sync

# Admin panel (requires ADMIN_TOKEN + TOTP if configured)
open https://your-domain.com/admin/

Community Edition enforces a hard cap of 5 users: the 6th add returns HTTP 402. Add a PLK_KEY to .env to allow unlimited users.

New in 3.0.0

Guided setup: a first-run web wizard at /setup replaces hand-run admin scripts for first-time operators: generating the admin token, enrolling TOTP and minting the first key from the browser. The wizard is gated to first boot (no keys yet) or an explicit SETUP_MODE flag, and runs deeper /v2/health/deep and DNS checks before it reports that everything is ready (design: ADR R005). Once the relay is running, /admin/settings shows the relay configuration in the panel, so you no longer edit .env by hand, and /admin/cli gives admins a browser terminal for debugging without SSH.

Setup code

Finishing setup is not anonymous: POST /v2/setup/apply needs the one-time setup token in the X-Setup-Token header (or your ADMIN_TOKEN in X-Admin-Token), otherwise it answers 401 setup_token_required. On start the relay writes a fresh token (pst_...) to the file setup-token next to users.json (mode 600); the log only names the file, never the token. Paste it into the Setup code field of the wizard. After setup it is deleted and stops working. Find it with docker compose exec relay-main cat /data/setup-token.

New in 3.0.0

Plug-and-play serving: set SERVE_FRONTEND=true to have a single relay serve its own UI from a read-only static handler, so a fresh self-host needs no separate web server (opt-in, default off; ADR R011). Set CRYPTO_MODE to choose the algorithm set the relay advertises on /v2/capabilities: core (the default: ML-KEM-768 + ML-DSA-65) or extended for the full opt-in set (ADR R006).

Upgrade

bash
# Installed with install.sh: fetches the newest v* release tag
# (or $PARAMANT_VERSION), rebuilds and restarts
paramant upgrade

# By hand: the clone is pinned to a tag, so git pull does not move it
cd /opt/paramant
git fetch --depth 1 origin tag v3.1.3
git checkout v3.1.3
docker compose up -d --build
# Named volumes (users.json, CT log, relay identity) are preserved
Important

Note: User data and the CT log persist in Docker named volumes across upgrades. The relay identity keypair (ML-DSA-65) is generated once on first boot and stored at /data/relay-identity.json: do not delete this volume or the relay will lose its registered identity. Since v3.0.0 the ML-DSA-65 signing that backs this identity comes from paramant-core, the relay's audit-ready Rust crypto dependency (consumed via the @paramant/core NAPI binding). It ships inside the Docker image, so no extra install step is needed.

Admin scripts

Admin scripts ship with the relay repository. After cloning paramant-relay, the server-side admin tool lives under deploy/: run python3 deploy/paramant-admin.py [command] directly. Operator helpers (key add/revoke, first-boot setup) live under scripts/. There is currently no system-wide install package; the SDK packages on PyPI (paramant-sdk) and npm (paramant-sdk) are the official client integrations.

Coming soon

Add-ons: an add-on architecture is specified for integrations that sit just outside the relay core, such as storage mirrors, notification bridges, SIEM exporters and OIDC/SAML SSO. Each add-on runs in its own container and declares its capabilities in a manifest (HomeAssistant-style). Add-ons work on ciphertext and metadata only, never plaintext or keys. Specification: ADR R007, draft.

CLI reference

There is no installable CLI: pip install paramant-sdk and npm install paramant-sdk ship the library only, no commands. What does exist are scripts in the relay repository, which you run from a clone:

ScriptDescription
scripts/paramant-sender.pyEncrypt and upload a file, stdin or text; with --watch DIR watch a directory
scripts/paramant-receiver.pyFetch and decrypt by hash; with --listen keep listening
scripts/paramant-verify-sthCheck a relay's signed tree head (STH)
scripts/paramant-verify-peersCheck the mirrored tree heads of other relays
scripts/paramant-scan.shFind relay nodes through the registry
deploy/paramant-admin.pyManage users and keys on your own relay

The two Python scripts take a hosted sector with --relay (health, legal, finance, iot) or the https address of your own relay, for example --relay https://relay.example.com. They encrypt with a transfer secret of their own (--secret or PARAMANT_TRANSFER_SECRET), never with the API key.

# Example: send a file (the transfer secret is printed once; give it to the receiver)
python3 scripts/paramant-sender.py --key pgp_xxx --relay health --file scan.dcm

# Example: receive by hash with that secret
python3 scripts/paramant-receiver.py --key pgp_xxx --relay health --hash <hash> --secret <secret>

Crypto stack

Encryption happens client-side on every route on this page except one. The relay never sees plaintext and never holds a private key, apart from the hosted signing ceremony on the /v1 API. There, the curl example above uploads a PDF as content_base64, so the relay reads that document because the request asks it to.

AlgorithmRoleStandard
ML-KEM-768Post-quantum key encapsulationNIST FIPS 203
ECDH P-256Classical key exchange (hybrid, defence-in-depth)NIST SP 800-56A
AES-256-GCMSymmetric authenticated encryptionNIST FIPS 197
HKDF-SHA256Key derivation from hybrid KEM outputRFC 5869
SHA3-256CT log Merkle hashing · DID hashesNIST FIPS 202
ML-DSA-65Relay identity signatures (relay-to-relay auth)NIST FIPS 204
Argon2idPassword gate on a transfer (hash, optional module)RFC 9106

Browser-side encryption runs as Rust/WASM compiled from crypto-wasm/. The WASM binary is SHA-256 verified at runtime before first use. The relay JS code has no access to decryption keys at any point.

The two halves of the stack run different builds of the same Rust crypto. ML-KEM-768 key encapsulation stays client-side (browser WASM / SDK): the relay never performs a key exchange and never holds a private key. ML-DSA-65 runs server-side for the relay identity keypair, signed delivery receipts and signed tree heads (STH), and for verifying device and receipt signatures. Since the M5b migration (2026-05-27) that server-side ML-DSA-65 is provided by paramant-core: a standalone, audit-ready Rust crypto library (BUSL-1.1) consumed by the relay through its @paramant/core NAPI binding.

Burn-on-read

Once GET /v2/outbound/:hash has delivered the last byte, the relay overwrites the blob buffer with cryptographically random bytes and removes the entry from memory. If the receiver breaks the download off before the relay handed the last byte to the connection, the blob stays and the next attempt delivers it again, in full. For a blob of up to a few MB the operating system buffers usually take that last byte at once. Encrypted payload data is never written to disk at any point during the transfer.

The CT log persists cryptographic hashes (Merkle leaf hashes, tree hashes, device hashes) to disk, never payload content. Even a complete disk image of the relay server reveals no file contents.

Two-step burn: first the blob is zeroed in-place, then the Map entry is deleted. This prevents a brief window where a partial read could occur.

5 MB fixed padding (SDK and live hand-over)

Padding happens at the sender, not on the relay: the relay stores and returns exactly the bytes it receives. The SDKs (by default) and the live hand-over in the ParaSend web app pad every block to exactly 5,242,880 bytes (5 MiB) with cryptographically random bytes before upload. A one-time link from the web app and a send to named recipients are not padded; those blobs keep their real encrypted size. For a padded block, the block size tells an observer nothing about the file, but a transfer larger than one block is sent as several blocks and its total_chunks travels in the clear, so the number of blocks is visible and the file size can be placed within an order of magnitude. That is finding 5 of the April 2026 audit, an accepted trade-off.

Certificate Transparency log

Every transfer hash and device registration is appended to a public Merkle tree. The root hash changes with every write and is publicly verifiable. Anyone can prove a transfer occurred without learning what was transferred.

GET https://health.paramant.app/v2/ct/log?limit=100&from=0

{
  "root": "cf7436a9efa4ee9a...",
  "entries": [
    { "index": 56, "type": "transfer", "leaf_hash": "ecb779...", "tree_hash": "cf7436...",
      "ts": "2026-04-14T19:00:00.000Z" }
  ]
}

Live CT log: paramant.app/ct-log →

W3C Decentralized Identity

Devices can register a W3C DID backed by ML-DSA-65 and ECDH keys. Once registered, the device signs its requests instead of sending an API key in the header.

POST /v2/did/register
{ "device_id": "mri-001", "ecdh_pub": "base64...", "dsa_pub": "base64..." }

{ "did": "did:paramant:abc123...", "ct_index": 42 }

# Authenticate with DID:
X-DID: did:paramant:abc123...
X-DID-Signature: base64_request_signed_with_dsa_key

Team management

# Create an API key (admin script)
python3 deploy/paramant-admin.py add \
  --label "scanner-room-3" --plan pro --email admin@hospital.nl

# Sync to all relay containers
python3 deploy/paramant-admin.py sync

# Revoke a key immediately
python3 deploy/paramant-admin.py revoke --key pgp_xxxxx
python3 deploy/paramant-admin.py sync

Key changes are zero-downtime: the relay hot-reloads users.json on sync without restart. Revoked keys receive WebSocket close code 4401 within seconds.

Retention policy

PlanDefault TTLMaximum TTLMax file size
Community (pgp_)1 hour1 hour500 MB
Firm1 hour24 hours500 MB
Enterprise1 hour7 days500 MB, or the limit set for your own relay

The plans do not differ on file size, so the ceiling is the same on all of them. Two other numbers are often mistaken for it. A blob from the SDK on POST /v2/inbound is a fixed 5 MB block, which is the padding size on the wire, so a file larger than one block travels as several blocks. A one-time link from the ParaSend web app is one block under one token, so that way of sending stops at 5 MB per file. The live hand-over in the same web app splits the file into chunks and reaches the 500 MB in this table.

Relay sectors

All sector nodes run identical Ghost Pipe relay software. Sectors are routing domains: they provide traffic isolation and sector-specific compliance documentation. The crypto stack and API surface are identical.

SectorURLPrimary useCompliance
healthhealth.paramant.appDICOM, patient records, vitalsNEN 7510, GDPR
legallegal.paramant.appContracts, notary, evidenceGDPR, eIDAS
financefinance.paramant.appISO 20022, compliance dataNIS2, DORA
iotiot.paramant.appSCADA, PLCs, sensor streamsIEC 62443

Security: threat model

The relay is untrusted by design. Security does not depend on trusting the operator.

What a compromised relay can doWhat it cannot do
Deny or delay serviceRead file contents (no decryption key)
Learn transfer timing and frequencySubstitute a registered ML-KEM public key (CT log prevents rollback)
Observe that a blob arrived (content opaque; a fixed 5 MB from the SDK and the live hand-over, the real encrypted size for a one-time link or a send to named recipients)Forge relay signatures (ML-DSA-65 key is per-relay, signed)
Block a specific device hashDecrypt any stored ciphertext

The threat model assumes network-level attackers, compromised relay operators, and post-quantum adversaries. For the live hand-over in the web app, encryption uses a hybrid scheme (ML-KEM-768 + ECDH P-256) so that breaking either primitive is not enough; both must be broken at the same time. The Python SDK uses ML-KEM-768. A one-time link and a send to named recipients use AES-256-GCM without ML-KEM.

Key insight

Key insight: For the live hand-over, the SDK and a one-time link, the relay stores encrypted blobs it cannot decrypt, and Merkle hashes it cannot forge. Two exceptions. For a send to named recipients the relay receives, in the same request, the wrapped key and the token that unwraps it, and that token is emailed to the recipient, so that route is not zero-knowledge. And the ParaSign /v1 API keeps the PDF itself, encrypted under a relay key, for as long as the envelope exists.

Jurisdiction

Managed relay infrastructure runs only at Hetzner in Nuremberg, Germany. EU/GDPR jurisdiction applies. No US Cloud Act jurisdiction: Hetzner is a German company with no US parent. Files and documents do not leave the EU. The exception is email: the email address and the link go through Resend in the United States. For a send to named recipients that link opens the file, so the key to the file travels through Resend. See every party we use and its status.

Security audits

DateAuditorFindingsStatus
Apr 2026R. Zwarts (verification)14 total (1H · 8M · 5L)All resolved: e6f216d
Apr 2026R. Zwarts (independent)6 total (3H · 3M)All resolved: 0db3ef0
Apr 2026Ryan Williams · Smart Cyber Solutions4C · 5H · 6M · 5LFixed in 0db3ef0, except three: #4 (critical, filename readable in relay RAM), #6 (high, key zeroization in the Python scripts only works on CPython and does not warn yet) and #14 (CT tree not RFC 6962) are still open, see SECURITY.md

All findings are publicly documented in SECURITY.md. To report a vulnerability, email privacy@paramant.app.

Compliance: NIS2 / DORA

NIS2 (EU 2022/2555) applies to operators of essential services in the EU. PARAMANT supports NIS2 compliance through:

  • Post-quantum encryption for the live hand-over and the SDK (protection that also holds against future quantum computers)
  • Merkle CT log: tamper-evident audit trail for every transfer
  • EU-only infrastructure (Hetzner DE): files stay in EU jurisdiction; email goes through Resend in the US, and for a send to named recipients that email carries the link with the token that opens the file
  • Burn-on-read: minimises data retention exposure
  • Key revocation without downtime, so you can respond to an incident immediately

DORA (EU 2022/2554) applies to financial entities. The finance.paramant.app sector is designed with DORA in mind: a Merkle proof per file sent, no US Cloud Act jurisdiction, ISO 20022 compatible.

NEN 7510 (Healthcare)

NEN 7510 is the Dutch standard for information security in healthcare. The health.paramant.app sector is designed for NEN 7510 compliance:

  • Encrypted in transit and at rest (in practice: held in RAM only, never written to disk)
  • Uploads in the public CT log; every download and acknowledgement in your key’s private audit chain (/v2/audit)
  • Device identity registration (DID): traceable per-device access
  • Key-based access control: per-user API keys with revocation
  • Hetzner DE: EU GDPR jurisdiction, suitable for patient data routing

IEC 62443 (Industrial / OT)

IEC 62443 is the international standard for operational technology security. The iot.paramant.app sector supports IEC 62443 deployments:

  • Acts as a quantum-safe data diode: PLCs/sensors push data without opening inbound ports
  • No VPN, no certificates on the OT side: works with Raspberry Pi and any Linux device
  • Fixed 5 MB blocks (the SDK pads): a single-block heartbeat and a single-block payload look identical on the wire; a multi-block transfer still shows its block count
  • Network segmentation: OT side connects outbound only; IT side receives inbound only
  • ML-DSA-65 device signatures: cryptographic device authentication

Operator scripts

Self-host operators get a set of command-line helpers under scripts/ in the paramant-relay repo. SSH into your relay host and run them from the repo root. For most of these there is an in-browser equivalent at /admin/cli (admin login required).

  • Keys. scripts/paramant-key-add.sh: provision an API key.
  • Signing. scripts/paramant-sign: checks a .psign envelope (verify --psign ... --document ...). It checks the document and the signature locally and asks the relay for the notary signature (POST /v2/verify), so that part needs a connection. The sign command no longer works: the relay retired /v2/sign (410). Signing happens in the browser at /en/sign.
  • Deploy safety. scripts/post-deploy-verify.sh: post-deploy smoke test (exit 2 on critical failure); scripts/rollback-3.0.0.sh: roll back to the previously built relay image.
  • Backups. scripts/backup-users-json.sh: age-encrypted backup of relay key metadata (already wired into cron, 03:15 daily; decrypt only in tmpfs).
bash
# on the relay host, from the repo root
./scripts/paramant-key-add.sh "customer-acme" pro ops@acme.example
./scripts/post-deploy-verify.sh https://paramant.app

Frequently asked questions

Can the relay operator read my files?
No. Encryption happens in the browser or SDK before the file leaves your device. The relay receives ciphertext it cannot decrypt. Even a full root compromise of the relay server yields only encrypted blobs and Merkle hashes.
What happens after the file is downloaded?
The blob buffer is immediately overwritten with random bytes and removed from memory. There is no recovery, no backup, and no second download. The CT log holds only the hash of the upload; the pickup and the burn are in your key’s private audit chain. Neither holds the file content.
Is Community Edition really free forever?
Yes. Up to 5 users, no licence key required, no time limit. Adding a 6th user returns HTTP 402; add a PLK_KEY to .env to allow unlimited users. The source is available under BUSL-1.1.
What is the difference between pgp_ and plk_ keys?
pgp_ keys are for end users: they authenticate API calls to send and receive files. plk_ keys are relay licence keys for operators: they go in .env, not in API calls, and allow an unlimited number of users. They are never the same key and cannot be substituted for each other.
How does 5 MB padding protect me?
The SDK and the live hand-over pad every block to the same 5 MiB (a one-time link and a send to named recipients do not), so a passive observer cannot tell a 1 KB heartbeat from a 4.9 MB DICOM scan: both are one block. The block size tells an observer nothing about the file, but a transfer larger than one block is sent as several blocks and its total_chunks travels in the clear, so the number of blocks is visible and the file size can be placed within an order of magnitude. That is finding 5 of the April 2026 audit, an accepted trade-off.
Can I run this on a Raspberry Pi?
Yes. The relay runs on arm64: Raspberry Pi 3B+, 4, and 5 are supported. The curl -fsSL https://paramant.app/install-pi.sh | bash script detects your Pi model, installs Docker, disables swap, and starts the relay. Minimum 512 MB RAM for a single-sector relay.
Is the source code auditable?
Yes. The full relay source is at github.com/Apolloccrypt/paramant-relay under BUSL-1.1. Three independent security audits were conducted in April 2026; all findings are publicly documented in SECURITY.md. The relay binary checksum is verified on startup and logged to the CT log.
What is Ghost Pipe?
Ghost Pipe is the relay protocol: a WebSocket-based encrypted transfer protocol (the SDK encrypts post-quantum, ML-KEM-768) where data is stored in RAM only and destroyed after the last read the link allows (one on Community, up to 10 reads on Firm). The SDK pads every block to a fixed size. It is designed for adversarial environments where the relay itself may be compromised.
How do I get a pgp_ API key?
Create a free account to get your API key, protected with TOTP. Community plan: 50 transfers a month, 500 MB per file sent as 5 MB blocks, 1-hour TTL, burn-on-first-read. A one-time link from the web app is a single block, so that way stops at 5 MB a file. See pricing for plan details.