Skip to content

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:

  1. API Key (recommended for programmatic use) — pass Authorization: Bearer ak_your_api_key_here. Generate keys in your Dashboard.
  2. 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.

Code
Authorization: Bearer ak_your_api_key_here

Base URL

Code
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

  1. Create keypair — Generate a P-256 keypair locally. Wrap the private key with your vault key.
  2. Publish — Send the public key and a vault-wrapped private key when creating the form.
  3. Submit — Submitters derive a shared secret with the form's public key and encrypt their answers client-side.
  4. 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 so submitters can render the form without a decryption key. Only submission payloads are encrypted.

Form Schema

Forms are defined by a versioned JSON schema. Field IDs must be unique within a form.

Code
{
  "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

TypeOptions
short_textmaxLength (≤ 500)
long_textmaxLength (≤ 50000)
email
numbermin, max, step
phone
single_selectoptions[] (1–50)
multi_selectoptions[] (1–50)
dropdownoptions[] (1–100)
ratingmax (3–10)
datemin, max (ISO-8601)
filemaxFiles (1–20), acceptedMimeTypes[]

Every field accepts label, required, and helpText.


Endpoints

List Forms

Query parameters

FieldTypeRequiredDescription
limitnumberNoMax items (1–100, default 25)
offsetnumberNoPagination offset
includeDeletedbooleanNoInclude 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

FieldTypeRequiredDescription
titlestringYesHuman-readable title (1–300 chars)
descriptionstringNoShown above the form (≤ 2000 chars)
schemaobjectYesFormSchemaDoc JSON (see above)
publicKeystringYesBase64url-encoded raw P-256 public key (87 chars)
wrappedPrivateKeystringYesVault-wrapped P-256 private key (PKCS#8 inside AES-GCM)
vaultIdstringYesCaller's current vault identity
vaultGenerationnumberYesCaller's current vault generation
allowFileUploadsbooleanNoEnable file-attachment fields (default false)
maxFileSizeOverridenumber | nullNoPer-upload byte cap override
maxSubmissionsnumber | nullNoStop accepting submissions after this many
closesAtISO-8601 | nullNoClose the form at this date
hideBrandingbooleanNoRemove anon.li branding (Pro only)
notifyOnSubmissionbooleanNoEmail the account owner when a new encrypted submission arrives (default true)
customKeybooleanNoPassword-protect the public form (Plus and Pro)
salt, customKeyData, customKeyIv, customKeyVerifierstringNoRequired when customKey: true; the verifier is a hash of the decrypted password witness

Example response

Code
{
  "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. Returns 410 Gone if the form has been taken down, disabled, or closed.

Response

Code
{
  "data": {
    "id": "aB3x0Yz9Qw8t",
    "title": "Whistleblower intake",
    "description": null,
    "schema": { "version": 1, "fields": [ /* ... */ ] },
    "public_key": "<base64url>",
    "allowFileUploads": false,
    "hideBranding": false,
    "customKey": false
  },
  "meta": { "request_id": "req_..." }
}

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

FieldTypeRequiredDescription
ephemeralPubKeystringYesSubmitter's ephemeral P-256 public key (base64url)
ivstringYes12-byte AES-GCM IV (16 base64url chars)
encryptedPayloadstringYesCiphertext of the JSON answers object (≤ 5 MB)
attachedDropIdstringNoPaired Drop when the form has file fields
attachmentUploadTokenstringNoRequired with attachedDropId; consumed when the submission is recorded
attachmentManifestarrayNoRequired with attachedDropId; binds uploaded files to file fields
turnstileTokenstringNoRequired for anonymous submissions when Turnstile is enabled and no attachment upload token was already verified
customKeyProofstringNoRequired when the form is password-protected; this is the decrypted witness, not the password

Returns 403 if the form is disabled, 410 Gone if it has been closed or taken down, and 429 when the form is past its per-month submission cap.


List / Read / Delete Submissions

PathVerbs
/api/v1/form/:id/submissionGET (creator list)
/api/v1/form/submission/:sidGET, 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. Anonymous callers must pass a valid Turnstile token when Turnstile is enabled. Storage is metered against the form owner using the owner's Form attachment limit.

Decrypting Submissions

Code
// 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

OperationLimit
Form creation30 requests/hour
Form list60 requests/minute
Form operations100 requests/hour
Public submission20 requests/hour/IP (strict)

Monthly Form API quotas for API-key requests are 500 on Free, 25,000 on Plus, and 100,000 on Pro.

Plan Limits

FeatureFreePlusPro
Active forms31030
Submissions / month501,00010,000
Retention30 days90 days365 days
Password protectionYesYes
Remove brandingYes
File attachments cap100 MB5 GB50 GB

Error Responses

StatusDescription
400Validation error
401Unauthorized — missing or invalid API key / session
402Upgrade required — caller is over their plan cap
403Form is disabled or submitter is not permitted
404Form not found
410Form closed, deleted, or taken down
429Rate-limited or per-month submission cap 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 public — titles, descriptions, and the field schema are served as plaintext so submitters can render the form. Don't include secrets in those fields.