Keepable docs
Sender API

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)
201 Created
{ "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:

FieldRequiredNotes
recipientyesA single explicit identifier (below).
subjectyesThe inbox subject line, 1-200 characters.
generated_atyesRFC 3339 timestamp of when you produced the content, not when you sent it. So a long-retained item still shows a meaningful date.
content_typeyesOne of the content types. Validated: an unknown value is rejected 422.
partsyesThe renderable documents (at least one).
retention_daysnoHold window for an unregistered recipient (below).
assetsnoImages an HTML body embeds via cid: (below).
attributesnoType-specific structured fields (below).
metadatanoOpaque sender-defined key/values.
sender_signaturenoSender attestation over the content (below).
sender_key_idnoThe 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:

roleHow 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.
documentA 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:

statusMeaning
deliveredThe recipient is registered. The item is in their inbox now.
retainedThe 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_daysBehaviour
(omitted)Deliver only if the recipient is already reachable; otherwise 403.
30Hold for an unregistered recipient for 30 days.
390Hold 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