Collecting responses
Publish questions, read the answers back, and handle the two things a Nigerian intake form gets wrong: holding data you should not have, and letting everyone in the organisation read it.
A form correspondence carries questions. A consent correspondence carries a
decision. Both come back to your organisation, which makes them different
from everything else in this API and is why they behave differently by default.
{
"kind": "form",
"subject": "Customer KYC refresh, 2026",
"form": [
{ "ref": "full_name", "label": "Full name", "type": "short_text", "required": true },
{ "ref": "bvn", "label": "BVN", "type": "number", "format": "bvn" },
{ "ref": "id_doc", "label": "Government-issued ID", "type": "upload",
"accepted_media": ["application/pdf", "image/jpeg"] }
],
"recipients": [ /* ... */ ]
}A question is short_text, long_text, choice, multi_choice, number,
date, or upload. ref is your own handle for it: it comes back on the
answer, leads the export column, and is what a branching rule names.
Reading them back
GET /correspondence/{id}/responses
GET /correspondence/{id}/responses/{participant_id}{
"participant_id": "prt_01J8ZQ4T",
"display_name": "Chioma Okeke",
"ref": "CUST-100400",
"verification": "nin",
"state": "submitted",
"fields": [
{ "ref": "full_name", "label": "Full name", "value": "Chioma Okeke" },
{ "ref": "bvn", "label": "BVN", "sensitive": true, "masked_value": "••• •••• 0000" },
{ "ref": "id_doc", "label": "Government-issued ID",
"files": [{ "document_id": "doc_01J9ABCD", "name": "nin-slip.pdf", "size_bytes": 61440 }] }
]
}Every record leads with the person and your reference for them, the same two columns in the same order as every export. A reader who pulls two of these for the same audience can join them to each other, and to their own records.
verification says what Keepable established about the respondent. It is a
property of the record rather than a question you had to ask: a form is only
ever answered from the person's own Keepable, which they reached by verifying
their NIN with a mandatory liveness check.
Sensitive answers
Mark a question sensitive, or give it a format (nin, bvn,
account_number, phone, date_of_birth, address), which implies it. A
sensitive answer is:
- stored envelope-encrypted, and never written into the search projection, so no free-text query can match on a BVN
- masked on the wire by default: the field carries
masked_valueand omitsvalue, so every SDK consumer and every screen gets the safe rendering without opting into it - revealed only on request, one record at a time, and the reveal is written to the file's minute ledger before the value comes back
POST /correspondence/{id}/responses/{participant_id}/revealRevealing needs responses.sensitive, which is deliberately not implied by
responses.read. A key minted to pull form answers into a CRM should not
thereby be able to read every applicant's BVN. "Who read this applicant's BVN,
and when" is a question with an answer.
Documents that came back are held to the same gate. Retrieving one through
GET /documents/{document_id} needs responses.sensitive too, because an
uploaded ID scan is not a weaker artifact than the BVN typed on the line above
it.
Better than encrypting it: do not ask for it
Encryption is the floor. Before you add a field, check whether the answer is already implied by how the form was answered: a form is only ever completed from the respondent's own Keepable, which they reached by verifying their NIN with a mandatory liveness check.
So verification on the record is a stronger statement about who they are than
a name field they typed, and a question like "are you the account holder?"
mostly collects a checkbox. The cheapest sensitive value to defend is one you
never held.
There is no attested question type. An earlier draft of this API let you
ask Keepable rather than the person (type: "attested", attribute: "age_over_18"), and it was withdrawn before launch: a verdict is Keepable's
finding about somebody, and a wire field the recipient's own client fills in
could only ever be a forgery. There is now nothing to forge. verdict
survives on consent decisions, where withheld is a fact you cannot obtain
any other way, and withheld is deliberately not a not_confirmed: someone
declining to disclose and Keepable finding against them are different facts.
Consent, and what comes back from it
A consent correspondence carries a consent object: the purpose, the
lawful_basis, the scope you are asking for, and optionally a valid_until
and a use_mode (one_time or recurring).
The decision arrives on the same rail as any other answer, and two endpoints hang off it.
The receipt is the ISO 27560 record for one person: what they were asked,
what they decided, what backs it, and every acknowledgement since. It is
addressed by participant, because the standard has one data subject per receipt,
while what the file asks is on the file. It is a pure projection over the
sealed minutes, so it cannot drift from them, and ?format=pdf returns the same
printable bytes the data subject holds.
GET /correspondence/{id}/responses/{participant_id}/receiptAn acknowledgement records what became of a permission. A grant gets superseded, lapses, or stops being relied on, and under the NDPA the person is entitled to hear which from you:
POST https://api.keepable.co/sender/v2/correspondence/cor_01J8ZQ4T/acknowledge
Authorization: Bearer {{KEEPABLE_TOKEN}}
Content-Type: application/json
{ "kind": "withdrawal", "note": "We have stopped processing on this basis." }kind is replacement (which must name the replacement_correspondence_id
that supersedes this ask), expiry, or withdrawal, which is terminal and
seals the file. It lands as a minute on the file the person already holds, not
as a second mailpiece, and it is not the same thing as
POST /correspondence/{id}/withdraw, which takes back mail nobody has received
yet.
Erasing an answer
DELETE /correspondence/{id}/responses/{participant_id}For an erasure request you have to honour. The answers, the sealed return
document, and any uploaded files go; the participant stays on the file, still
submitted, and the minute ledger keeps every entry it held. Erasing twice
answers 204 either way.
It needs its own scope, responses.erase, which no other operation grants:
deleting somebody's answers is not a stronger form of reading them, and a key
that pulls responses into a CRM has no business doing it.
Not on a consent, which answers 409 (code: consent_not_erasable).
Erasing a decision destroys your own demonstration that permission was ever
given, and leaves a receipt claiming more than was agreed to. Acknowledge a
withdrawal instead: that stops the reliance and keeps the record.
Visibility
Forms and consent drives default to visibility: "restricted". The row stays
visible to your whole organisation, the contents go to its participants and the
readers you name.
The default is doing the real work here. A sender who never thinks about this
gets the right answer on the files that matter, which is the only kind of
privacy control that survives contact with a busy operations team. Set it to
org explicitly if a staff pulse survey really should be open.
Closing
POST /correspondence/{id}/closeStop collecting early. What arrived stays and is still readable; nothing further
is accepted. form.response_received fires per submission if you would rather
react than poll.