Keepable
Sender API

Correspondence

One endpoint sends a letter, runs a signature ceremony, routes a file for approval, asks for a consent decision, or publishes a form. The kind decides which fields matter.

POST /correspondence is the endpoint you will call most. It creates one piece of correspondence, and the kind you pass decides what the rest of the body means.

POST https://api.keepable.co/sender/v2/correspondence
Authorization: Bearer {{KEEPABLE_TOKEN}}
Idempotency-Key: 5f3c1e02-9b7a-4a11-8c2e-6d0f4b8a1c93
Content-Type: application/json

{
  "kind": "letter",
  "subject": "Your March payslip",
  "content_type": "payslip",
  "document_ids": ["doc_01J8ZQ4T"],
  "recipients": [
    { "addressing": { "type": "nin", "nin": "12345678901" },
      "display_name": "Musa Bello",
      "ref": "EMP-4471" }
  ]
}

Only kind and subject are required. Everything else is the shape that kind needs. One call carries up to 10,000 recipients.

The send does not finish inside the call

You get a 202, not a 201. It means the file exists, every recipient has been resolved, and the run is queued; a worker seals and delivers the mailpieces. Three consequences worth writing your code around:

  • participants[].state is pending for everyone addressed as a person. The first page of participants comes back in submission order, so you can pair them against your own input; read participants_next_cursor if there are more.
  • rejected carries what this call decided, and only that: a malformed entry, or an address that resolved to nobody (an email matching no verified account is refused here). Each entry names the index and ref it came from. A rejection does not fail the batch.
  • Anything discovered while the run drains lands on the participant as undeliverable, carrying a problem. Watch rollup.counts, or subscribe to correspondence.send_completed.

Addressing a recipient

Every recipient carries an addressing object, and its type is the whole of how Keepable finds the person.

typeFieldBehaviour when nobody matches
ninninRetained. Held sealed, delivered when they verify.
caccac_numberRetained, for a company: RC1234567 and its four sibling registers. See Recipients.
emailemailBlocked. Never delivers. Check first.
membermember_idSomebody on your own team.
groupgroup_idA routing group (a committee).
claimdeclared_nameA claim code you hand over yourself.

A NIN is writeOnly. It is matched, encrypted, and never echoed back to you, in any response, ever. The same is true of the identifier in every other addressing type: what comes back is a participant_id and whatever display_name you supplied.

Supply display_name. It is what you call this person, for your own reading of the file. Omit it for somebody with no verified name yet and the participant reads as a masked identifier, which is never the raw one but never useful either.

Supply ref. It is your own reference for them, echoed back untouched on the participant and led with in every export. Keepable stores no contact details for the people you address, so this is the only column that rejoins a row to a customer you hold.

The five kinds

letter

Digital mail: payslips, invoices, statements, notices. Carries body_html (a sanitised HTML layer, see HTML and CSS), any document_ids you uploaded, and a content_type that tells the recipient's app how to frame the item.

Sending both a body and a document? body_role says how they relate: a covering note above a document drawn in full, or a rendering of that document, which the recipient reads instead of the pages below. See What your body is.

agreement

A signature ceremony. Each recipient takes a role:

  • signer must sign for the file to complete
  • witness signs in the signers' presence
  • delegate may act on a signer's behalf but never signs
  • viewer receives it read-only

A signer or witness also takes a mark: how their mark renders on the page, as a drawn signature, their initials, or a typed printed name. It defaults to signature, and it changes nothing about the ceremony underneath, which is the same NIN and passkey either way. So what seals is not "somebody clicked accept" but a named, verified person.

approval

A file routed desk to desk. Pass an ordered route; the first step is the first desk.

{
  "kind": "approval",
  "subject": "Facility FAC-2026-0118, ₦45m",
  "document_ids": ["doc_01J8ZQ4T"],
  "route": [
    { "ref": "credit-review", "assignee": { "type": "member", "member_id": "mem_01J8Z" }, "act": "approve" },
    { "ref": "committee",     "assignee": { "type": "group",  "group_id": "grp_01J8Z" }, "act": "approve",
      "quorum": { "of": 5, "need": 3 } },
    { "ref": "md",            "assignee": { "type": "member", "member_id": "mem_01J9A" }, "act": "sign" }
  ]
}

A step's act is what that desk does: approve, sign, acknowledge, or contribute (add documents without deciding). A group desk can carry a quorum, so three of five committee members is a first-class thing rather than something you count yourself.

The ref on each step is its stable handle: conditions reference it, and so do the minutes it writes. See Routing.

A decision on the record, with a verifiable receipt on both sides. Carries a consent object naming the purpose, the lawful_basis, and the scope of what you are asking to do.

A processing notice with no decision to make is not this. It is a letter with content_type: processing_notice, because nothing comes back.

form

Questions answered from the recipient's own inbox. Carries a form array of questions. See Collecting responses, which is also where sensitive answers and attested attributes are covered.

Many recipients

There is no batch endpoint, because there is no batch concept. One correspondence carries as many recipients as you need, and each becomes a participant with its own state.

{
  "kind": "letter",
  "subject": "Your February statement",
  "recipients": [
    { "addressing": { "type": "nin", "nin": "..." }, "ref": "CUST-100401", "merge": { "balance": "₦412,900" } },
    { "addressing": { "type": "nin", "nin": "..." }, "ref": "CUST-100402", "merge": { "balance": "₦88,150" } }
  ]
}

merge fills placeholders in the shared body, and document_id on a recipient gives that one person their own document instead of the shared one. Which covers both halves of what used to be two separate batch modes.

Reading it back

GET  /correspondence?lens=sent&kind=form&cursor=...
GET  /correspondence/{id}
GET  /correspondence/{id}/participants
GET  /correspondence/{id}/minutes
GET  /correspondence/{id}/covenant

Do not poll these on a timer. Every state change you care about arrives as a webhook, and participant.acted alone covers delivery, opening, approval, signature, consent decisions, and submissions.

Changing your mind

CallWhat it does
POST /correspondence/{id}/closeStop collecting early. What arrived stays.
POST /correspondence/{id}/withdrawPull a live file back before it completes.
POST /correspondence/{id}/documents/{document_id}/withdrawRemove one document from a live file.
POST /correspondence/{id}/participants/{participant_id}/remindNudge one pending participant.

Nothing removes a minute. The ledger records that you withdrew, which is the point of having one.

Who can read it

visibility is org or restricted. A restricted file stays visible to your whole organisation as a row (that it exists, who sent it, how it is going) while its contents go only to its participants and the readers you name.

Forms and consent drives default to restricted, because something comes back and lands in your organisation rather than in the recipient's inbox. Letters, agreements, and approvals default to org. Set it explicitly either way.

Billing

Every send debits your prepaid wallet at send time, whatever the kind. Sandbox sends are free by construction. See Billing.

On this page