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/coreNAPI 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
/setupreplaces 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/settingsexposes relay configuration in the panel, so common changes no longer require editing.envand restarting. - In-browser admin CLI.
/admin/cliis a web terminal for admin debugging: inspect relay state without opening an SSH session. - Crypto-mode negotiation (ADR R006, accepted).
/v2/capabilitiesadvertises 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_FRONTENDstatic 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).
Install the SDK (Python 3.10+ or Node 18+; both ship the same wire-format-v1 encoder)
# Python pip install paramant-sdk # JavaScript / Node npm install paramant-sdk
Send a file. It is encrypted client-side, before it leaves your device.
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:
# 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.
| Key | Prefix | Who | Purpose |
|---|---|---|---|
| pgp_ | pgp_ | End users | Send/receive files via the API |
| plk_ | plk_ | Relay operators | Licence 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.
Install
# Python (3.10+) pip install paramant-sdk # JavaScript (Node 18+) npm install paramant-sdk
Python SDK (class-based)
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.
# 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.
# 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.
# 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.
# 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
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /health | Node status, version, uptime, edition | n/a |
| POST | /v2/inbound | Upload encrypted blob from an authenticated client (RAM-only) | Key |
| POST | /v2/anon-inbound | Deprecated, 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/sends | Turns 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/:token | The 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/:token | Same 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/:hash | Download and burn: uses one read of the link | Key |
| GET | /v2/status/:hash | Check blob availability without consuming | Key |
| POST | /v2/ack | Confirm delivery; recorded in your key’s private audit chain, not in the CT log | Key |
| GET | /v2/monitor | Live stats: blobs in flight, ACK rate | Key |
| POST | /v2/ws-ticket | Get a one-time WebSocket ticket, valid for 30s | Key |
| GET | /v2/stream | WebSocket push: blob_ready events | Ticket / Key |
| POST | /v2/webhook | Register a delivery webhook URL | Key |
| POST | /v2/pubkey | Register an ML-KEM + ECDH keypair | Key |
| GET | /v2/check-key | Verify key validity and plan | n/a |
| POST | /v2/did/register | Register a W3C decentralized identity (DID) | Key |
| GET | /v2/did/:did | Resolve a DID document | n/a |
| GET | /v2/ct/log | Certificate Transparency log (public) | n/a |
| GET | /v2/relays | Registered relay nodes and status | n/a |
| POST | /v2/verify | Check an old .psign (v1/v2) against this relay's notary key | n/a |
| GET | /v2/audit | Merkle audit chain, exportable as JSON or CSV | Key |
| GET | /v2/team/devices | List team devices | Key |
| POST | /v2/team/add-device | Add 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.
// 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.
| Field | Value |
|---|---|
| Auth | X-Api-Key: pgp_... |
| Tier | Firm 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.
| Field | Value |
|---|---|
| Auth | X-Api-Key: pgp_... |
| Tier | Business 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.
| Field | Value |
|---|---|
| Auth | X-Api-Key: pgp_... (a valid key). No key → 401. |
| product | firm, or parasign for Business |
| plan | Firm: product firm, plan firm, which grants the ParaSign and ParaSend paid limits together. ParaSign: business. |
| interval | monthly 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: Ubuntu 22.04+ / Debian 12+ · Docker 24+ · 1 GB RAM minimum · swap disabled · a domain with wildcard DNS
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.
# 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
| Container | Host port | Sector | Recommended domain |
|---|---|---|---|
| relay-main | 127.0.0.1:3000 | relay | relay.your-domain.com |
| relay-health | 127.0.0.1:3001 | health | health.your-domain.com |
| relay-finance | 127.0.0.1:3002 | finance | finance.your-domain.com |
| relay-legal | 127.0.0.1:3003 | legal | legal.your-domain.com |
| relay-iot | 127.0.0.1:3004 | iot | iot.your-domain.com |
| admin | 127.0.0.1:4200 | n/a | your-domain.com/admin/ |
Key .env variables
| Variable | Required | Description |
|---|---|---|
| ADMIN_TOKEN | Yes | Admin panel password (min 32 chars, hex) |
| REDIS_PASSWORD | Yes | Password for the redis container; empty, redis refuses to start |
| RELAY_REDIS_URL | Yes | redis://:<REDIS_PASSWORD>@redis:6379 |
| PARAMANT_TOTP_MASTER_KEY | Yes | Encrypts users' TOTP secrets at rest (32 bytes, base64); never change it on a running install |
| INTERNAL_AUTH_TOKEN | Yes | Second gate on the relays' internal routes (admin plane to relay) |
| PARASIGN_PUBLIC_ORIGIN | No | Your own public site; checkout returns here instead of paramant.app |
| RELAY_SELF_URL_<SECTOR> | No | Per relay (MAIN, HEALTH, FINANCE, LEGAL, IOT) its own public URL; without it signed heads and receipts name a paramant.app host |
| TOTP_SECRET | Recommended | Base32 TOTP secret: enables MFA on admin login |
| PLK_KEY | No | Licence key: allows more than 5 users (Community is free, max 5) |
| RESEND_API_KEY | No | Email provider key (for key delivery notifications) |
| LOG_LEVEL | No | info (default) · debug · warn · error |
| RAM_LIMIT_MB | No | Blob 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.
# 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:
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
# 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.
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.
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.
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
# 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
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.
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:
| Script | Description |
|---|---|
| scripts/paramant-sender.py | Encrypt and upload a file, stdin or text; with --watch DIR watch a directory |
| scripts/paramant-receiver.py | Fetch and decrypt by hash; with --listen keep listening |
| scripts/paramant-verify-sth | Check a relay's signed tree head (STH) |
| scripts/paramant-verify-peers | Check the mirrored tree heads of other relays |
| scripts/paramant-scan.sh | Find relay nodes through the registry |
| deploy/paramant-admin.py | Manage 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.
| Algorithm | Role | Standard |
|---|---|---|
| ML-KEM-768 | Post-quantum key encapsulation | NIST FIPS 203 |
| ECDH P-256 | Classical key exchange (hybrid, defence-in-depth) | NIST SP 800-56A |
| AES-256-GCM | Symmetric authenticated encryption | NIST FIPS 197 |
| HKDF-SHA256 | Key derivation from hybrid KEM output | RFC 5869 |
| SHA3-256 | CT log Merkle hashing · DID hashes | NIST FIPS 202 |
| ML-DSA-65 | Relay identity signatures (relay-to-relay auth) | NIST FIPS 204 |
| Argon2id | Password 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
| Plan | Default TTL | Maximum TTL | Max file size |
|---|---|---|---|
| Community (pgp_) | 1 hour | 1 hour | 500 MB |
| Firm | 1 hour | 24 hours | 500 MB |
| Enterprise | 1 hour | 7 days | 500 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.
| Sector | URL | Primary use | Compliance |
|---|---|---|---|
| health | health.paramant.app | DICOM, patient records, vitals | NEN 7510, GDPR |
| legal | legal.paramant.app | Contracts, notary, evidence | GDPR, eIDAS |
| finance | finance.paramant.app | ISO 20022, compliance data | NIS2, DORA |
| iot | iot.paramant.app | SCADA, PLCs, sensor streams | IEC 62443 |
Security: threat model
The relay is untrusted by design. Security does not depend on trusting the operator.
| What a compromised relay can do | What it cannot do |
|---|---|
| Deny or delay service | Read file contents (no decryption key) |
| Learn transfer timing and frequency | Substitute 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 hash | Decrypt 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: 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
| Date | Auditor | Findings | Status |
|---|---|---|---|
| Apr 2026 | R. Zwarts (verification) | 14 total (1H · 8M · 5L) | All resolved: e6f216d |
| Apr 2026 | R. Zwarts (independent) | 6 total (3H · 3M) | All resolved: 0db3ef0 |
| Apr 2026 | Ryan Williams · Smart Cyber Solutions | 4C · 5H · 6M · 5L | Fixed 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.psignenvelope (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. Thesigncommand 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).
# 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
PLK_KEY to .env to allow unlimited users. The source is available under BUSL-1.1.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.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.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.