Send content
Deliver digital mail to a recipient, from letters and payslips to invoices and statements. Covers the envelope, structured attributes, multi-part bodies, retention, and the delivered-vs-retained outcome.
Delivering content is the core of the Sender API: one POST puts an item in a
recipient's inbox. The request is a stable envelope (the same for every
content type) plus an optional attributes object whose shape depends on the
content_type. This guide covers all of it.
Everything here needs the content.write scope.
The call
curl https://api.keepable.co/tenants/ten_01HXP/contents \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"recipient": { "identifier_type": "nin", "identifier": "12345678901" },
"subject": "Your March payslip",
"generated_at": "2026-03-28T09:00:00Z",
"content_type": "payslip",
"retention_days": 390,
"parts": [
{ "name": "payslip.pdf", "media_type": "application/pdf", "data": "JVBERi0xLjcK..." }
],
"attributes": { "pay_period": "2026-03", "net_pay": "250000.00", "currency": "NGN" }
}'const res = await fetch(
"https://api.keepable.co/tenants/ten_01HXP/contents",
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Keepable-Version": "2026-05-24",
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
recipient: { identifier_type: "nin", identifier: "12345678901" },
subject: "Your March payslip",
generated_at: new Date().toISOString(),
content_type: "payslip",
retention_days: 390,
parts: [
{ name: "payslip.pdf", media_type: "application/pdf", data: pdfBase64 },
],
attributes: { pay_period: "2026-03", net_pay: "250000.00", currency: "NGN" },
}),
},
);
const { content_id, status } = await res.json();body, _ := json.Marshal(map[string]any{
"recipient": map[string]string{"identifier_type": "nin", "identifier": "12345678901"},
"subject": "Your March payslip",
"generated_at": time.Now().UTC().Format(time.RFC3339),
"content_type": "payslip",
"retention_days": 390,
"parts": []map[string]string{{
"name": "payslip.pdf",
"media_type": "application/pdf",
"data": base64.StdEncoding.EncodeToString(pdfBytes),
}},
"attributes": map[string]string{"pay_period": "2026-03", "net_pay": "250000.00", "currency": "NGN"},
})
req, _ := http.NewRequest("POST",
"https://api.keepable.co/tenants/ten_01HXP/contents",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Keepable-Version", "2026-05-24")
req.Header.Set("Idempotency-Key", uuid.NewString())
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req){ "content_id": "cnt_01HXP", "status": "delivered" }The content_id is also echoed in a keepable-content-id response header.
The envelope
These fields are identical for every content type:
| Field | Required | Notes |
|---|---|---|
recipient | yes | A single explicit identifier (below). |
subject | yes | The inbox subject line. |
generated_at | yes | RFC 3339 timestamp of when you produced the content, not when you sent it. So a long-retained item still shows a meaningful date. |
content_type | yes | One of the content types. Validated: an unknown value is rejected 422. |
parts | yes | The renderable documents (at least one). |
retention_days | no | Hold window for an unregistered recipient (below). |
attributes | no | Type-specific structured fields (below). |
metadata | no | Opaque sender-defined key/values. |
Addressing the recipient
recipient names a single identifier explicitly, by kind:
{ "recipient": { "identifier_type": "nin", "identifier": "12345678901" } }
{ "recipient": { "identifier_type": "email", "identifier": "ada@example.ng" } }
{ "recipient": { "identifier_type": "tin", "identifier": "12345678-0001" } }identifier_type is nin, email, or tin. There is no implicit priority and
no multi-identifier object: you say exactly who you mean.
If the recipient is not registered and you did not set retention_days, the
call returns 403 Forbidden
(recipient is not reachable on Keepable) and nothing is stored. Either
match first, or set retention_days to hold
the item until they sign up.
Parts: the body
parts is a non-empty array, like an email with attachments. Each part has a
name, a MIME media_type, and base64-encoded data:
"parts": [
{ "name": "assessment.pdf", "media_type": "application/pdf", "data": "JVBERi0xLjcK..." },
{ "name": "cover.txt", "media_type": "text/plain", "data": "RGVhciBBZGEs..." }
]data is base64, not raw bytes. Encode before sending, decode on the way
out. media_type is the MIME type of the decoded bytes. It is not the same
as the message-level content_type.
Alternative renderings
A part may carry alternatives: other renderings of the same document that the
inbox can pick from by device. The classic case is a text/html version of a
PDF, so the item reflows on a small screen instead of forcing pinch-to-zoom:
"parts": [
{
"name": "statement.pdf",
"media_type": "application/pdf",
"data": "JVBERi0xLjcK...",
"alternatives": [
{ "media_type": "text/html", "data": "PGgxPlN0YXRlbWVudDwvaDE+" }
]
}
]This matters in Nigeria specifically: many recipients are on small screens and metered data, where a reflowable HTML view reads far better than a zoomed PDF. Provide an alternative when you can.
Attributes: structured per-type data
attributes carries the machine-readable fields the recipient app can render
into a rich view: an invoice's amount and due date, a payslip's net pay, a
statement's balances. Its shape is selected by content_type. See
Content types in depth for each schema.
"content_type": "invoice",
"attributes": {
"amount": "125000.00",
"currency": "NGN",
"due_date": "2026-06-30",
"invoice_number": "INV-88213",
"irn": "IRN-7F3A9C20-2026"
}attributes is for data the recipient should see or act on. metadata
(below) is for your bookkeeping. Anything in attributes is validated
against the type's schema; anything in metadata is opaque.
Metadata
metadata is a string-valued map for your own correlation ids and routing
hints. Keepable stores it but never interprets or renders it:
"metadata": { "campaign_id": "spring-2026", "ledger_ref": "INV-88213" }Retention: delivered vs retained
The response status is the one field you must branch on:
status | Meaning |
|---|---|
delivered | The recipient is registered. The item is in their inbox now. |
retained | The recipient is not registered yet. Keepable holds the item and delivers it on their first login, up to retention_days days. |
retention_days is an optional integer with two allowed values:
retention_days | Behaviour |
|---|---|
| (omitted) | Deliver only if the recipient is already reachable; otherwise 403. |
30 | Hold for an unregistered recipient for 30 days. |
390 | Hold for 390 days (about 13 months). |
{ "retention_days": 390 }When a retained item is finally delivered (because the recipient registered),
you receive a recipient.registered webhook,
and the item's arrival fires content.arrived
like any delivery. The full lifecycle of a retained send is observable without
polling.
Idempotency
Like every mutation, content delivery requires an
Idempotency-Key: replay with the same
key and the same body and the item is delivered exactly once. Persist the key
alongside the content you are sending, so a retry after a crash reuses it.
Next
Recipient matching
Check which of your recipients are reachable on Keepable before you spend work composing mail. Match in batches by NIN, email, or TIN.
Content types in depth
The content types the send endpoint accepts, their stable vs preview status, and the structured attributes each carries. Grounded in the Nigerian market.