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[].stateispendingfor everyone addressed as a person. The first page of participants comes back in submission order, so you can pair them against your own input; readparticipants_next_cursorif there are more.rejectedcarries 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 andrefit came from. A rejection does not fail the batch.- Anything discovered while the run drains lands on the participant as
undeliverable, carrying aproblem. Watchrollup.counts, or subscribe tocorrespondence.send_completed.
Addressing a recipient
Every recipient carries an addressing object, and its type is the whole of
how Keepable finds the person.
type | Field | Behaviour when nobody matches |
|---|---|---|
nin | nin | Retained. Held sealed, delivered when they verify. |
cac | cac_number | Retained, for a company: RC1234567 and its four sibling registers. See Recipients. |
email | email | Blocked. Never delivers. Check first. |
member | member_id | Somebody on your own team. |
group | group_id | A routing group (a committee). |
claim | declared_name | A 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:
signermust sign for the file to completewitnesssigns in the signers' presencedelegatemay act on a signer's behalf but never signsviewerreceives 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.
consent
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}/covenantDo 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
| Call | What it does |
|---|---|
POST /correspondence/{id}/close | Stop collecting early. What arrived stays. |
POST /correspondence/{id}/withdraw | Pull a live file back before it completes. |
POST /correspondence/{id}/documents/{document_id}/withdraw | Remove one document from a live file. |
POST /correspondence/{id}/participants/{participant_id}/remind | Nudge 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.