Form API
Create and manage end-to-end encrypted forms for confidential intake through a REST API.
The Form API lets you programmatically create and manage end-to-end encrypted forms for confidential intake workflows — whistleblowing tip lines, patient intake, legal contact, HR applications, and other workloads where responses must stay private. Submissions are encrypted in the submitter's browser using hybrid public-key crypto and can only be decrypted in your anon.li vault.
Authentication
The Form API supports two authentication modes depending on the endpoint and use case:
- API Key (recommended for programmatic use) — pass
Authorization: Bearer ak_your_api_key_here. Generate keys in your Dashboard. - Browser session — automatic via logged-in session cookie. 2FA must be verified if enabled on the account.
Public form pages at /f/:id and the submit endpoint require no authentication (anyone with the link can submit). All creator-side endpoints require a session or API key and count against your monthly Form API quota.
Authorization: Bearer ak_your_api_key_here
Base URL
https://anon.li/api/v1
End-to-End Encryption
Per-form keypair: Each form has a dedicated P-256 keypair. The public key is served alongside the form schema; the private key is stored AES-GCM encrypted against your vault and never touches our servers in plaintext. Submissions are encrypted in the submitter's browser with ECDH + HKDF + AES-256-GCM; only a vault-unlocked client can decrypt them.
How It Works
- Create keypair — Generate a P-256 keypair locally. Wrap the private key with your vault key.
- Publish — Send the public key and a vault-wrapped private key when creating the form.
- Submit — Submitters derive a shared secret with the form's public key and encrypt their answers client-side.
- Decrypt — Unlock your vault, unwrap the private key, and decrypt submissions in your dashboard or CLI.
Form metadata (title, description, field schema) is stored in plaintext. An unprotected form serves that schema to anyone with its link; a password-protected form withholds the schema until the visitor supplies a valid password-derived witness. Only submission payloads are end-to-end encrypted.
Form Schema
Forms are defined by a versioned JSON schema. Field IDs must be unique within a form.
{
"version": 1,
"fields": [
{ "id": "name", "type": "short_text", "label": "Name", "required": true },
{ "id": "email", "type": "email", "label": "Email" },
{ "id": "report", "type": "long_text", "label": "What happened?", "required": true, "maxLength": 50000 },
{ "id": "urgency", "type": "single_select", "label": "Urgency", "options": ["Low", "Medium", "High"] }
],
"submitButtonText": "Submit report",
"thankYouMessage": "Thanks. Your report has been encrypted for the intake team."
}
Supported Field Types
| Type | Options |
|---|---|
short_text | maxLength (≤ 500) |
long_text | maxLength (≤ 50000) |
email | — |
number | min, max, step |
phone | — |
single_select | options[] (1–50) |
multi_select | options[] (1–50) |
dropdown | options[] (1–100) |
rating | max (3–10) |
date | min, max (ISO-8601) |
file | maxFiles (1–20), acceptedMimeTypes[] |
Every field accepts label, required, and helpText.
Endpoints
List Forms
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
limit | number | No | Max items (1–100, default 25) |
offset | number | No | Pagination offset |
includeDeleted | boolean | No | Include soft-deleted forms |
Returns an array of forms with submission counts, public flags, and plan caps in meta.plan.
Create Form
Creating a form requires a vault-unlocked caller. Clients using API-key authentication must first call POST /api/v1/vault/unlock to obtain vault material, then wrap the form's private key locally before sending it here.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Human-readable title (1–300 chars) |
description | string | No | Shown above the form (≤ 2000 chars) |
schema | object | Yes | FormSchemaDoc JSON (see above) |
publicKey | string | Yes | Base64url-encoded raw P-256 public key (87 chars) |
wrappedPrivateKey | string | Yes | Vault-wrapped P-256 private key (PKCS#8 inside AES-GCM) |
vaultId | string | Yes | Caller's current vault identity |
vaultGeneration | number | Yes | Caller's current vault generation |
allowFileUploads | boolean | No | Enable file-attachment fields (default false) |
maxFileSizeOverride | number | null | No | Per-upload byte cap override |
maxSubmissions | number | null | No | Stop accepting submissions after this many |
closesAt | ISO-8601 | null | No | Close the form at this date |
hideBranding | boolean | No | Remove anon.li branding (Pro only) |
notifyOnSubmission | boolean | No | Email the account owner when a new encrypted submission arrives (default true) |
customKey | boolean | No | Password-protect the public form (Plus and Pro) |
salt, customKeyData, customKeyIv, customKeyVerifier | string | No | Required when customKey: true; the verifier is a hash of the decrypted password witness |
Example response
{
"data": {
"id": "aB3x0Yz9Qw8t",
"title": "Whistleblower intake",
"public_key": "<base64url>",
"created_at": "2026-04-22T12:00:00.000Z"
},
"meta": { "request_id": "req_..." }
}
Returns 402 PAYMENT_REQUIRED if the caller is over their active-form cap for the current plan.
Get Form (Public)
Returns public metadata needed to render the form and encrypt a submission. No authentication required. A disabled or closed form is returned with active: false; a taken-down form returns 410 Gone.
Response
{
"data": {
"id": "aB3x0Yz9Qw8t",
"title": "Whistleblower intake",
"description": null,
"schema": { "version": 1, "fields": [ /* ... */ ] },
"field_count": 4,
"public_key": "<base64url>",
"allowFileUploads": false,
"hideBranding": false,
"customKey": false
},
"meta": { "request_id": "req_..." }
}
For a password-protected form, schema is null. Decrypt the password witness in the browser, then call POST /form/:id/unlock with { "customKeyProof": "..." }. A successful response returns the schema; an invalid witness returns 403. Never send the raw password.
Update / Toggle Form
Partial update. Pass any subset of the fields accepted by POST /api/v1/form. Toggle the form on/off with { "disabledByUser": true }.
Delete Form
Deletes the form, its submissions, its vault-wrapped private key, and any paired drops.
Submit (Public)
Posts an encrypted submission. No authentication is required by default. Attachment storage is always billed to the form owner and constrained by the owner's Form attachment entitlement.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
ephemeralPubKey | string | Yes | Submitter's ephemeral P-256 public key (base64url) |
iv | string | Yes | 12-byte AES-GCM IV (16 base64url chars) |
encryptedPayload | string | Yes | Ciphertext of the JSON answers object (≤ 5 MB) |
attachedDropId | string | No | Paired Drop when the form has file fields |
attachmentUploadToken | string | No | Required with attachedDropId; consumed when the submission is recorded |
attachmentManifest | array | No | Required with attachedDropId; binds uploaded files to file fields |
turnstileToken | string | No | Required for public submissions when Turnstile is enabled and no attachment upload token was already verified; respondent authentication does not bypass this check |
customKeyProof | string | No | Required when the form is password-protected; this is the decrypted witness, not the password |
Returns 403 if the form is disabled, closed, or taken down, and 402 when the owner is past the calendar-month submission allowance.
List / Read / Delete Submissions
| Path | Verbs |
|---|---|
/api/v1/form/:id/submission | GET (creator list) |
/api/v1/form/submission/:sid | GET, DELETE (creator only) |
Submission payloads are returned as ciphertext. Decryption requires ECDH between the caller's vault-unwrapped form private key and the per-submission ephemeral public key, followed by AES-GCM decryption with the stored IV.
Upload Tokens
When a form allows file uploads, this endpoint validates the planned files and mints a form-scoped Drop upload token. Every caller must pass a valid Turnstile token when Turnstile is enabled. Storage is metered against the form owner using the owner's Form attachment limit, and concurrent staging is capped per form.
Decrypting Submissions
// 1. Fetch the wrapped private key from /api/v1/vault/form-keys (vault session required)
const { wrapped } = await fetch('/api/v1/vault/form-keys?formId=' + formId).then(r => r.json())
// 2. Unwrap the P-256 private key using the vault key (AES-GCM payload unwrap)
const privateKey = await unwrapVaultPayload(wrapped, vaultKey)
// 3. For each submission, derive the shared secret and decrypt
const sharedSecret = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: importedSubmitterPubKey },
privateKey, 256,
)
const aesKey = await hkdfAesGcmKey(sharedSecret)
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivBytes }, aesKey, ciphertext,
)
// plaintext is the JSON answers object keyed by field id
Rate Limits
| Operation | Limit |
|---|---|
| Form creation | 30 requests/hour |
| Form list | 60 requests/minute |
| Form operations | 100 requests/hour |
| Public submission or attachment preparation | 20 requests/hour/IP (strict, including authenticated respondents) |
| Authenticated public route ceiling | 200 requests/hour/account, in addition to the IP limit |
| Attachment staging for one Form | 60 preparations/hour/Form and at most 10 live staging Drops |
Monthly Form API quotas for API-key requests are 500 on Free, 25,000 on Plus, and 100,000 on Pro.
Plan Limits
| Feature | Free | Plus | Pro |
|---|---|---|---|
| Active forms | 3 | 10 | 30 |
| Submissions / month | 50 | 1,000 | 10,000 |
| Retention | 30 days | 90 days | 365 days |
| Password protection | — | Yes | Yes |
| Remove branding | — | — | Yes |
| File attachments cap | 100 MB | 5 GB | 50 GB |
Error Responses
| Status | Description |
|---|---|
400 | Validation error |
401 | Unauthorized — missing or invalid API key / session |
402 | Upgrade required — caller is over their plan cap |
403 | Form is disabled or submitter is not permitted |
404 | Form not found |
410 | Public form metadata was taken down |
429 | Hourly abuse rate limit reached |
Security Notes
- Per-form P-256 keypair — rotating a key rotates the form and invalidates cached ciphertext.
- Zero server plaintext — submission payloads are never available to the server in readable form.
- Vault-wrapped private keys — the form's decryption material is stored AES-GCM-encrypted against your vault.
- Metadata is plaintext at rest — titles and descriptions are public to link holders. Field schemas are public for ordinary forms and access-gated for password-protected forms. Don't put server-side secrets in form metadata.