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.
Every content delivery names a content_type. It
does two things: it tells the recipient app how to frame the item (icon,
grouping, the by-type filter), and it selects which attributes schema applies.
The envelope is identical for every type. content_type is not a
discriminator that changes the required envelope fields; it selects the
optional attributes shape. Adding a new content type is an additive change;
it never disturbs the envelope or existing types.
Stable vs preview
The vocabulary is split by release status:
| Status | Types |
|---|---|
| Stable | letter, payslip, invoice, statement |
| Preview | credit_note, booking, receipt, form, survey_invitation, registered_letter, processing_notice, document_request, document_return |
- Stable types are production-ready; their semantics and
attributesare covered by the deprecation policy. - Preview types are visible and usable, but their semantics and
attributesshape may still change. Don't depend on them in production yet.
The status is also machine-readable in the spec as x-keepable-status, so
tooling can surface it.
An unknown content_type is rejected with
422. The recipient's
by-type filter relies on the value, so the set is closed and validated.
campaign is not a content type. Promotional banners are a separate
overlay entity. See Campaigns. A delivery opts into
a campaign by carrying a campaign_tag in its metadata, never by setting
content_type: campaign.
Why these are the stable four
The stable set is the highest-frequency, identity-anchored mail in the Nigerian market:
payslip: issuing payslips is a statutory payroll record (the Labour Act, the Personal Income Tax Act, the Pension Reform Act), so every formal employer produces them monthly.invoice: FIRS National E-Invoicing is mandatory for large taxpayers from August 2025 and extends to smaller VAT-registered businesses through 2026. A cleared e-invoice can travel to the inbox carrying its clearance reference (irn).statement: monthly bank statements are a customer right, and a signed, identity-anchored channel is a direct answer to the "fake credit alert" fraud the CBN repeatedly warns about. Pension (RSA) statements fit here too.letter: the generic baseline for anything without a more specific type.
Attributes by type
attributes carries the structured, machine-readable fields the recipient app
renders into a rich view. Five types have a typed schema today; the rest accept a
mostly free-form attributes object, except that a few preview types still
require specific fields (for example form requires a form_id, and
document_request requires form_id plus purpose and retention_note — each
rejected 422 without them).
Put anything the recipient must see or act on (an amount, a due date, a
location) in attributes or a part,
never in metadata, which Keepable never reads or renders.
payslip (stable)
| Field | Required | Notes |
|---|---|---|
pay_period | yes | The period covered, e.g. "2026-03" (YYYY-MM). |
net_pay | yes | Take-home pay as a decimal string. Never a float. |
gross_pay | no | Gross pay as a decimal string. |
currency | yes | ISO 4217 code, e.g. NGN. |
{ "pay_period": "2026-03", "gross_pay": "320000.00", "net_pay": "250000.00", "currency": "NGN" }invoice (stable)
| Field | Required | Notes |
|---|---|---|
amount | yes | Total due as a decimal string. |
currency | yes | ISO 4217 code. |
due_date | yes | ISO 8601 date (YYYY-MM-DD). |
invoice_number | no | Your invoice reference. |
irn | no | FIRS Invoice Reference Number from e-invoicing clearance, where applicable. |
{ "amount": "125000.00", "currency": "NGN", "due_date": "2026-06-30", "invoice_number": "INV-88213", "irn": "IRN-7F3A9C20-2026" }statement (stable)
| Field | Required | Notes |
|---|---|---|
period_start | yes | ISO 8601 date. |
period_end | yes | ISO 8601 date. |
currency | yes | ISO 4217 code. |
opening_balance | no | Decimal string. |
closing_balance | no | Decimal string. |
account_reference | no | The account or RSA reference the statement covers. |
{ "period_start": "2026-03-01", "period_end": "2026-03-31", "currency": "NGN", "opening_balance": "10000.00", "closing_balance": "42500.50" }booking (preview)
A calendar-ready appointment: a clinic visit, an enrollment slot, a virtual
hearing or interview. The booking's human title is the delivery subject,
not an attribute.
| Field | Required | Notes |
|---|---|---|
starts_at | yes | RFC 3339 date-time. Not required to be in the future — validation is clock-free so idempotent retries never flip to 422. |
ends_at | no | RFC 3339 date-time. Must be strictly after starts_at. |
location | no | Physical venue or address, when there is one. |
meeting_url | no | The virtual join link. https only, no userinfo. |
meeting_code | no | Platform-native meeting id/code (Zoom meeting ID, Meet code, Teams meeting ID) the recipient can key in without a URL. |
meeting_passcode | no | Meeting passcode, stored and rendered verbatim (case matters). Never sufficient on its own. |
meeting_dial_in | no | Telephone join path, optionally with a one-tap dial tail, e.g. "+234 1 227 8900,,832#". |
reference | no | Your booking reference, which the recipient may need to present. |
A booking must carry at least one reachable coordinate — location,
meeting_url, meeting_code, or meeting_dial_in — so the recipient can always
reach the room, physical or virtual. Any combination (a hybrid hearing with both
a venue and a link) is accepted. A meeting_passcode alone is rejected 422.
{ "starts_at": "2026-04-02T10:30:00Z", "ends_at": "2026-04-02T11:00:00Z", "location": "Lagos Island branch", "reference": "BK-4471" }{ "starts_at": "2026-04-02T10:30:00Z", "meeting_url": "https://zoom.us/j/9876543210", "meeting_code": "987 654 3210", "meeting_passcode": "aB3xZ9", "meeting_dial_in": "+234 1 227 8900,,987#" }Recipient apps render the tap-to-join affordance from the typed meeting_*
fields only — links in the message body are never promoted to a Join
button. This is deliberate anti-phishing: put the join link in meeting_url,
not the body, so recipients learn to join from the sealed mailpiece.
Every booking is also a presentable artifact. When your delivery includes no PDF
part, Keepable renders a
deterministic sealed booking slip from these fields (identity plate, date,
location and/or join coordinates, reference), file-hash-verifiable through
/public/verify. Attach your own PDF to keep your
attestation. Recipients also get an add-to-calendar action (a server-side .ics
projection or native calendar insert) and a single reminder the day before.
processing_notice (preview)
The compliance fields a controller must disclose when it processes personal data under a non-consent lawful basis (NDPC GAID 2025 Art. 18(2), ADR 0068). These project into the notice's ISO 27560 receipt. For consent, use a consent request instead, not this type.
| Field | Required | Notes |
|---|---|---|
lawful_basis | yes | One of contract, legal_obligation, legitimate_interest, vital_interest, public_interest. consent is rejected 422. |
purpose | yes | Plain-language purpose, shown to the data subject and recorded on the receipt. |
data_categories | yes | Non-empty array of the data categories processed, e.g. ["full name", "bvn"]. |
retention_note | yes | Plain-language retention disclosure. |
retention_floor_years | no | Sector retention floor in years; drives the receipt's retain_until. |
authority_name | no | The authority the processing answers to, e.g. "NDPC", "CBN". |
authority_reference | no | A reference for that authority. |
{
"lawful_basis": "legal_obligation",
"purpose": "Anti-money-laundering monitoring of your account",
"data_categories": ["full name", "bvn", "transaction history"],
"retention_note": "Retained 5 years after the relationship ends (MLPPA 2022 s.8).",
"retention_floor_years": 5,
"authority_name": "CBN"
}document_request (preview)
Asks the recipient to return documents (and answers), rather than just read
something. It is a form whose template carries document
fields. Because collecting documents is a data collection under the NDPA, the
disclosure framing travels with the ask:
| Field | Required | Notes |
|---|---|---|
form_id | yes | The template being requested, as form uses it. |
purpose | yes | Plain-language reason you are collecting the documents. |
retention_note | yes | Plain-language retention disclosure for the copies you receive. |
Full field types, the two ways a recipient satisfies a document field, the sealed
return bundle, and response tracking are covered in Forms.
document_return is the sealed bundle a submission produces; it is
Keepable-authored and delivered to the recipient's vault, never sent by you.
Money, dates, currencies
Across every typed schema: money is a decimal string ("125000.00", never a
float), dates are ISO 8601 (YYYY-MM-DD), date-times are RFC 3339, and
currencies are ISO 4217 codes (NGN). Violations are rejected 422.
Adding a content type
New types graduate from preview to stable as their semantics settle, and new
preview types appear as the market calls for them (receipt and
registered_letter are reserved this way as forward-looking preview types).
Because a type is just a vocabulary entry plus an optional attributes schema,
adding one never changes the envelope or any existing type, so your integration
keeps working untouched.
When you need a signature
If the recipient must sign rather than just read, that is not a content type at all: use an agreement, which returns a signed, tamper-evident covenant.
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.
Batch send & templates
Deliver to many recipients in one job, either shared content to a list (Mode A) or a distinct document per recipient (Mode B), and save reusable compose presets, all without a client-side loop.