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. 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.

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. A disabled or closed form is returned with active: false; a taken-down form returns 410 Gone.

Response

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

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 public submissions when Turnstile is enabled and no attachment upload token was already verified; respondent authentication does not bypass this check
customKeyProofstringNoRequired 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

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. 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

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 submission or attachment preparation20 requests/hour/IP (strict, including authenticated respondents)
Authenticated public route ceiling200 requests/hour/account, in addition to the IP limit
Attachment staging for one Form60 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

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
410Public form metadata was taken down
429Hourly 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.