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();{
"form_id": "frm_01HXP",
"title": "Address change",
"fields": [
{ "field_id": "street", "label": "Street", "type": "text", "required": true }
]
}Field types
type | Collects | Notes |
|---|---|---|
text | A free-text string | |
number | A numeric value | |
date | A calendar date | |
rating | A rating value | Pairs well with survey content. |
choice | One of a fixed set | Supply the set in options. |
document | A returned document | The 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"{
"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"{
"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:
| Field | Notes |
|---|---|
accepted_media | Narrows the media types the field accepts, from application/pdf, image/jpeg, image/png, image/gif. Omit to accept any allowed upload type. |
guidance | Per-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").
[
{ "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:
{ "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
| Provenance | How | What the manifest records |
|---|---|---|
recipient_upload | The 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_originated | Attach-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:
{
"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/verifywith 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.
{
"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:
| Operation | Endpoint |
|---|---|
| Response-tracking roster | GET /sender/tenants/{tenant_id}/forms/{form_id}/recipients |
| Remind pending recipients | POST /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
| Operation | Endpoint |
|---|---|
| List templates | GET /sender/tenants/{tenant_id}/forms |
| Fetch a template | GET /sender/tenants/{tenant_id}/forms/{form_id} |
| Delete a template | DELETE /sender/tenants/{tenant_id}/forms/{form_id} |
| List responses | GET /sender/tenants/{tenant_id}/forms/{form_id}/responses |
| Fetch a response | GET /sender/tenants/{tenant_id}/forms/{form_id}/responses/{response_id} |
| Delete a response | DELETE /sender/tenants/{tenant_id}/forms/{form_id}/responses/{response_id} |
| Download the return document | GET /sender/tenants/{tenant_id}/forms/{form_id}/responses/{response_id}/document |
| Download a returned file | GET /sender/tenants/{tenant_id}/forms/{form_id}/responses/{response_id}/documents/{field_id} |
| Response-tracking roster | GET /sender/tenants/{tenant_id}/forms/{form_id}/recipients |
| Remind pending recipients | POST /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.
Consent requests
Ask an identity-addressed recipient to grant or decline something specific, get the decision back over a webhook, and keep a verifiable receipt on both sides.
Campaigns
Promotional banners and info-boxes that ride alongside delivered mail, targeted by tag, scheduled, reviewed before they go live, and measurable, without becoming a content type.