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/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)
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.
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).
attributesnoType-specific structured fields (below).
metadatanoOpaque 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:

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), 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