Keepable
Sender API

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_value and omits value, 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}/reveal

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

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}/receipt

An 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}/close

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

On this page