Keepable docs
Sender API

Forms

Publish reusable form templates that recipients fill in from their inbox, then read the responses back. Covers field types, template lifecycle, and collecting responses.

A form is a structured question set a recipient fills in from their inbox: an address change, a survey, a KYC update. Add a document field and it becomes a document request — the recipient returns a file (a bank statement, a certificate), sealed and verifiable. You define a template once, deliver it, and read responses back as they come in. Forms are how you collect structured data from recipients, the inverse of sending content to them.

Templates and responses need forms.write to create and delete, forms.read to read.

Define a template

A template has a title, an optional description, and an ordered list of fields. Each field has a stable field_id (the key answers come back under), a label, a type, and whether it is required:

curl https://api.keepable.co/sender/tenants/ten_01HXP/forms \
  -H "Authorization: Bearer $KEEPABLE_TOKEN" \
  -H "Keepable-Version: 2026-05-24" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Address change",
    "description": "Tell us where to reach you.",
    "fields": [
      { "field_id": "street", "label": "Street", "type": "text", "required": true },
      { "field_id": "state",  "label": "State",  "type": "choice", "required": true,
        "options": ["Lagos", "Abuja", "Rivers", "Kano"] }
    ]
  }'
const res = await fetch(
  "https://api.keepable.co/sender/tenants/ten_01HXP/forms",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Keepable-Version": "2026-05-24",
      "Idempotency-Key": crypto.randomUUID(),
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      title: "Address change",
      description: "Tell us where to reach you.",
      fields: [
        { field_id: "street", label: "Street", type: "text", required: true },
        { field_id: "state", label: "State", type: "choice", required: true,
          options: ["Lagos", "Abuja", "Rivers", "Kano"] },
      ],
    }),
  },
);

const { form_id } = await res.json();
201 Created
{
  "form_id": "frm_01HXP",
  "title": "Address change",
  "fields": [
    { "field_id": "street", "label": "Street", "type": "text", "required": true }
  ]
}

Field types

typeCollectsNotes
textA free-text string
numberA numeric value
dateA calendar date
ratingA rating valuePairs well with survey content.
choiceOne of a fixed setSupply the set in options.
documentA returned documentThe recipient attaches a file. See Requesting documents.

Give field_id a stable, meaningful slug (street, not field_1): it is the key you read answers under, so a good id makes response handling self-documenting.

Deliver the form

Templates are delivered to recipients as content with the form content type. The recipient fills it in from their inbox, and you read the submitted responses back here.

Read responses

List the responses to a template (paginated). Each summary carries the response_id, when it was submitted_at, and which recipient_id submitted it:

curl "https://api.keepable.co/sender/tenants/ten_01HXP/forms/frm_01HXP/responses?limit=50" \
  -H "Authorization: Bearer $KEEPABLE_TOKEN" \
  -H "Keepable-Version: 2026-05-24"
200 OK
{
  "responses": [
    { "response_id": "rsp_01HXP", "submitted_at": "2026-05-24T12:00:00Z", "recipient_id": "rcp_01HXP", "status": "submitted" }
  ],
  "next_token": null
}

Each summary carries a terminal status of submitted or declined — a recipient can refuse a document request, and that refusal is tracked (see Requesting documents).

Fetch a single response to get the answers, keyed by your field_ids:

curl https://api.keepable.co/sender/tenants/ten_01HXP/forms/frm_01HXP/responses/rsp_01HXP \
  -H "Authorization: Bearer $KEEPABLE_TOKEN" \
  -H "Keepable-Version: 2026-05-24"
200 OK
{
  "response_id": "rsp_01HXP",
  "form_id": "frm_01HXP",
  "submitted_at": "2026-05-24T12:00:00Z",
  "recipient_id": "rcp_01HXP",
  "answers": { "street": "12 Marina Rd", "state": "Lagos" },
  "status": "submitted"
}

Requesting documents

A document field asks the recipient to return a file, not just type an answer: a bank statement for a visa application, a CAC certificate, a utility bill. It turns a form into a document request, closing the loop that statement- and identity-only rails in the market leave open.

Add a document field

A document field takes the usual field_id / label / required, plus two optional constraints:

FieldNotes
accepted_mediaNarrows the media types the field accepts, from application/pdf, image/jpeg, image/png, image/gif. Omit to accept any allowed upload type.
guidancePer-field instruction shown to the recipient, e.g. "Statement of account covering the last 6 months".

One document per field: "six months of statements" is one field, not six. A document field can be a conditional target of a choice field's branching rules (for example, ask for a CAC certificate only if the recipient answered "self-employed").

fields
[
  { "field_id": "reason", "label": "Purpose of application", "type": "text", "required": true },
  { "field_id": "statement", "label": "Bank statement", "type": "document", "required": true,
    "accepted_media": ["application/pdf"], "guidance": "Statement covering the last 6 months" }
]

Deliver it as a document request

A template with document fields is delivered as the document_request content type, not form. Because collecting documents is a data collection under the NDPA, the delivery attributes must declare why and for how long alongside the form_id:

attributes
{ "form_id": "frm_01HXP", "purpose": "Verify income for your visa application", "retention_note": "Deleted 90 days after your application is decided." }

The recipient sees these as the disclosure framing before they attach anything. See Sending content for the delivery envelope.

Two ways a document field is satisfied

ProvenanceHowWhat the manifest records
recipient_uploadThe recipient uploads a fresh file (allow-listed media, magic-byte checked, 20 MB cap, sealed on upload).Upload hash and sealed-at. Provenance is the recipient's own attestation.
sender_originatedAttach-from-vault (Phase 2): the recipient forwards an item already in their mailbox. If a verified sender delivered it, that seal chain survives the forward.Origin sender identity and the original receipt code, so a bank-delivered statement stays bank-originated.

Keepable never grades the contents of a returned file. recipient_upload means the recipient provided it, not that Keepable verified it. The manifest and the sealed cover state the provenance tier in plain language on every item.

Read the return

A document-request response adds a manifest and a sealed return bundle to the usual answers:

200 OK
{
  "response_id": "rsp_01HXP",
  "form_id": "frm_01HXP",
  "submitted_at": "2026-07-01T12:00:00Z",
  "recipient_id": "rcp_01HXP",
  "answers": { "reason": "UK study visa" },
  "status": "submitted",
  "return_content_id": "cnt_01HXR",
  "documents": [
    { "field_id": "statement", "name": "statement.pdf", "media_type": "application/pdf",
      "sha256": "9f2b…", "provenance": "recipient_upload" }
  ]
}
  • The sealed return document — a deterministic PDF cover with the answers, the per-item provenance manifest, and a receipt code — downloads from GET …/responses/{response_id}/document. It is verifiable at /public/verify with the file and its receipt code, so a third party (an embassy, a lender) can check it in seconds.
  • Each returned file downloads from GET …/responses/{response_id}/documents/{field_id}.

Decline is first-class

A recipient can refuse a document request. That is a terminal state, not silence: the response comes back with status: "declined" and an optional decline_reason, and no files are disclosed. "Asked and refused" is itself evidence a request was made.

declined response
{
  "response_id": "rsp_01HXQ",
  "form_id": "frm_01HXP",
  "recipient_id": "rcp_01HXS",
  "status": "declined",
  "decline_reason": "I no longer bank with this institution."
}

Chase who still owes you

Two endpoints track and nudge a document request across its recipients:

OperationEndpoint
Response-tracking rosterGET /sender/tenants/{tenant_id}/forms/{form_id}/recipients
Remind pending recipientsPOST /sender/tenants/{tenant_id}/forms/{form_id}/remind

The roster marks each recipient pending, submitted, or declined (pending is computed — delivered, no response yet). remind nudges only the pending ones through their chosen notification channel; it is idempotent per recipient per day, so a repeated press never double-emails.

Manage templates and responses

OperationEndpoint
List templatesGET /sender/tenants/{tenant_id}/forms
Fetch a templateGET /sender/tenants/{tenant_id}/forms/{form_id}
Delete a templateDELETE /sender/tenants/{tenant_id}/forms/{form_id}
List responsesGET /sender/tenants/{tenant_id}/forms/{form_id}/responses
Fetch a responseGET /sender/tenants/{tenant_id}/forms/{form_id}/responses/{response_id}
Delete a responseDELETE /sender/tenants/{tenant_id}/forms/{form_id}/responses/{response_id}
Download the return documentGET /sender/tenants/{tenant_id}/forms/{form_id}/responses/{response_id}/document
Download a returned fileGET /sender/tenants/{tenant_id}/forms/{form_id}/responses/{response_id}/documents/{field_id}
Response-tracking rosterGET /sender/tenants/{tenant_id}/forms/{form_id}/recipients
Remind pending recipientsPOST /sender/tenants/{tenant_id}/forms/{form_id}/remind

Deleting a template does not retract forms already delivered to inboxes, and deleting a response is permanent: there is no trash for form data. Delete a response only when a data-retention or correction obligation requires it.