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/sender/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/sender/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/sender/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, 1-200 characters. |
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). |
assets | no | Images an HTML body embeds via cid: (below). |
attributes | no | Type-specific structured fields (below). |
metadata | no | Opaque sender-defined key/values. |
sender_signature | no | Sender attestation over the content (below). |
sender_key_id | no | The attestation key that produced sender_signature. |
The request body is capped at 32 MiB (about 24 MiB once base64 is decoded);
an over-sized body is rejected with 413 before it is
buffered. Keep each source document well under 10 MB.
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.
Retention holds only for NIN- or TIN-addressed sends. An unregistered
email recipient is always refused with 403, even with retention_days
set: email is a matching key, not a hold key. To reach someone by email, wait
until they register (then match), or address them by NIN/TIN.
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. 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. The part itself always stays the
canonical, sealed rendering; an alternative is a layer the recipient app shows
over it.
"parts": [
{
"name": "statement.pdf",
"media_type": "application/pdf",
"data": "JVBERi0xLjcK...",
"alternatives": [
{ "media_type": "text/html", "role": "document", "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.
How the alternative is shown: the role field
An HTML alternative can play one of two parts, and the optional role field on
the alternative decides which:
role | How the recipient sees it |
|---|---|
| (omitted) | A cover note: a short display message shown above the sealed document, which stays the thing the reader leads with. Use it for a one-line summary or greeting over a PDF that is still the real content. |
document | A full rendering: the HTML is the document. The reader leads with it at full height and minimises the sealed file to a preview card they can still open and verify. Use it when your HTML is the complete, designed rendering (a branded statement, say) and the PDF is the archival copy behind it. |
document is the only recognised value; an unrecognised or absent role is
treated as a cover note. The field is presentation metadata only: it never
changes the sealed bytes, the content hash, or the
seal, so switching a rendering between cover note and
document does not alter what was sealed.
role only applies to an alternative sitting under a document part (the
PDF is canonical, the HTML rides alongside). If you send HTML as the part
itself, with no PDF, that HTML is already the canonical rendering and there is
no role to set. The portal composer sets role: document for you when
you upload designed HTML; you only set it by hand when authoring the request
directly.
An HTML body can be branded: your own colours, a hero card, a
call-to-action button. Keepable sanitises it on write; see
HTML and CSS for the exact tags, inline-style
properties, and ready-made kp-* blocks that survive.
Embedded images in an HTML body
When an HTML part references images (a letterhead, a logo), send them as
top-level assets and reference each by a content-scoped cid:<id> URL.
The recipient app serves them same-origin, so your images never load from a
sender-controlled host. Each asset is a raster image (image/png,
image/jpeg, image/gif, or image/webp):
"parts": [
{ "name": "letter.html", "media_type": "text/html", "data": "PGltZyBzcmM9ImNpZDpsb2dvIj4=" }
],
"assets": [
{ "id": "logo", "media_type": "image/png", "data": "iVBORw0KGgo..." }
]Sender attestation (optional)
By default the document seal proves Keepable received the bytes
intact. To also prove the document is exactly what you sent, attach a
sender_signature: a base64 ECDSA P-256 signature over the canonical content
hash, made with a key you register via POST /sender/tenants/{id}/sender-keys,
plus the sender_key_id that produced it. Keepable verifies it and chains the
seal over it. See Sender attestation keys for the
full flow. It is only accepted for non-transformed content (a sanitised HTML
body is rejected).
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), its
arrival fires content.arrived like any
delivery. There is no separate "recipient registered" event; subscribe to
content.arrived and 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.
HTML and CSS
What a text/html mailpiece body may contain. The allowed tags, the inline-CSS property list, the kp-* branded blocks, and everything the sanitiser strips, so your branded mail renders the way you intend.