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.
A correspondence's body_html lets you brand a mailpiece: your own colours, a
hero card, a call-to-action button. To keep every recipient safe, Keepable
sanitises the HTML on write: it stores only markup on the allowlist below,
and strips the rest. Nothing you send can run a script, load a remote asset, track an open, or
break out of its frame.
This page is the reference for exactly what survives. Send anything; only the allowlisted parts are kept. There are no surprises at delivery if you stay within it.
Prefer not to hand-write HTML? The portal composer builds all of this for you (including the branded blocks below) and shows a live preview of what the recipient will see. This page is for senders authoring HTML directly via the API or an upload.
Two ways to brand
There are two levels, and you can mix them:
- The
kp-*blocks (recommended): a small vocabulary of ready-made classes for panels, buttons, and text styles. They carry a light and dark variant, so they stay legible whichever theme the recipient uses. This is the safest way to get the branded "envelope" look. - Your own inline styles: for full control, set
style="…"on any element, from an allowlisted set of CSS properties. A colour you set is fixed, so it will not adapt to dark mode (see Dark mode).
Allowed elements
| Group | Tags |
|---|---|
| Text | p, br, span, strong/b, em/i, u, s/strike/del, ins, sub, sup, mark, small, code |
| Headings | h1-h6 |
| Lists | ul, ol, li |
| Structure | div, section, header, footer, figure, figcaption, blockquote, hr |
| Tables | table, thead, tbody, tfoot, tr, th, td, caption, colgroup, col |
| Links & images | a, img |
Any tag not listed is unwrapped (its text is kept, the tag dropped). class is
allowed on any element; your own class names are kept, though they only take
effect for the kp-* vocabulary; Keepable does not
accept sender <style> blocks, so a class of your own has no stylesheet to bind
to.
Inline styles
Set style="…" on any element. Each declaration is validated against the
property list below, and its value is checked (a colour must parse as a colour, a
length as a length). A declaration that names a property outside this list, or
carries an unsupported value, is dropped; the rest of the style is kept.
| Category | Properties |
|---|---|
| Colour | color, background-color, opacity |
| Spacing | padding (+ -top/-right/-bottom/-left), margin (+ sides) |
| Border | border (+ sides), border-color, border-width, border-style, border-radius |
| Size | width, max-width, min-width, height, box-sizing |
| Text | text-align, text-decoration, text-transform, vertical-align, font-weight, font-size, font-style, font-family, line-height, letter-spacing |
| Layout | display, flex, flex-direction, flex-wrap, justify-content, align-items, align-self, float, list-style-type |
There is no url() property here on purpose: not background-image, not
list-style-image, not cursor. That is what makes styles safe: a
declaration can never point at a URL, so it can never load a remote asset or
become a tracking pixel. position is also excluded, so content cannot escape
the reading flow.
The kp-* branded blocks
These classes are pre-styled by Keepable and adapt to light and dark mode. Use them for the branded look without setting a single colour yourself.
| Class | On | What it does |
|---|---|---|
kp-panel | section | A soft card: a bordered, padded block (a footer, an aside). |
kp-panel kp-panel--brand | section | The branded hero: a full-bleed, edge-to-edge coloured card, the "envelope" at the top of the letter. |
kp-lede | p | The larger intro line under a heading. |
kp-button | a | Styles a link as a call-to-action button. Still a link: it degrades to underlined text anywhere the styling is absent. |
kp-muted | p | Small print (contact details, fine print). |
kp-contact | ul | A plain, un-bulleted list for contact rows. |
ka-align-center / ka-align-right / ka-align-justify | any block | Text alignment. |
<section class="kp-panel kp-panel--brand">
<h1>Your April statement is ready</h1>
<p class="kp-lede">Hi Ada, your account summary for April is ready to view.</p>
<p>Everything you were sent is sealed and yours to keep.</p>
<a class="kp-button" href="https://app.example.com/statement">View your statement</a>
</section>Links and images
Links may use http, https, mailto, or tel. Every link is given
rel="nofollow"; javascript:, vbscript: and every other scheme is removed.
Images do not load. The recipient reads your body inside a sandboxed frame
on an opaque origin, under default-src 'none' with img-src data:. A remote
<img src="https://…"> survives the sanitiser and then fetches nothing, which
is the tracking-pixel vector this design closes: a sender cannot learn when a
letter was opened, and that is a promise the reader is given rather than a
setting.
There is no way to carry your own image bytes alongside a body on this API.
Send the picture-heavy version as a PDF document,
which seals, and keep body_html for the words around it. See
What your body is for how to say which of the two the
recipient should read first.
What your body is
A body and a document on the same file are one of two things, and the recipient
lays them out differently. body_role is how you say which:
cover(the default, and what you get by saying nothing): your body is a note that introduces the document. The recipient reads it above the document, and the document is drawn in full below, because your note is not a substitute for it.document: your body is the document, rendered as HTML. This is the case when you send a prepared mailpiece beside the sealed PDF of the same letter. The recipient leads with your markup at full height and reduces the PDF to a preview they can open, because the pages underneath would otherwise be the same letter a second time.
POST https://api.keepable.co/sender/v2/correspondence
Authorization: Bearer {{KEEPABLE_TOKEN}}
Content-Type: application/json
{
"kind": "letter",
"subject": "Your January statement",
"body_html": "<section>…the statement, as HTML…</section>",
"body_role": "document",
"document_ids": ["doc_01J8ZQ4T"],
"recipients": [
{ "addressing": { "type": "nin", "nin": "12345678901" } }
]
}document is a claim about both halves: send it only when a body and a
document are both present and are the same content. On a file with no
document it changes nothing. On a document your body does not actually
render, it puts pages the reader needs behind a preview.
Reach for document when you already produce a designed letter in HTML and
seal a PDF of it for the record. Reach for cover — or say nothing — for a
covering message, which is most mail.
Dark mode
Recipients can read in light or dark mode. The kp-* blocks flip automatically.
A colour you set with an inline style does not: a dark teal you choose for
text stays dark teal, which may be unreadable on a dark background.
If your brand look must hold in both themes, build it from the kp-* blocks.
If you set your own colours, set the background and the text together on
the same element (as in a fully-coloured panel) so you control the contrast;
don't rely on the recipient's theme behind your text.
Always removed
Regardless of the above, these are stripped every time; they are the reason the sanitiser exists:
<script>, event handlers (onclick,onerror, …), and any executable content<style>blocks and external stylesheets (<link rel="stylesheet">)url()in CSS, so no declaration can name an asset at all<iframe>,<object>,<embed>, andjavascript:/vbscript:/data:URLspositionin any form, so content cannot escape the reading flow
And in the frame the recipient reads it in, nothing external is fetched: no image, font, or stylesheet from a host you control, which is the same guarantee one layer down.
A full example
A complete branded letter (a hero, body, a CTA, and a contact footer) using only the allowlist:
<section class="kp-panel kp-panel--brand">
<h1>Your orange envelope 2026</h1>
<p class="kp-lede">Sign in to open your annual pension summary.</p>
<ul>
<li>Your pension forecast</li>
<li>What you earned toward your pension this year</li>
<li>Tips that affect your future pension</li>
</ul>
<a class="kp-button" href="https://app.example.com/open">Open your envelope</a>
</section>
<p>You can read your statement any time from your Keepable inbox.</p>
<section class="kp-panel">
<h3>Questions?</h3>
<p class="kp-muted">We are glad to help, any time.</p>
<ul class="kp-contact">
<li><a href="tel:+2340000000000">+234 000 000 0000</a></li>
<li><a href="mailto:support@example.com">support@example.com</a></li>
</ul>
</section>Send it as the body_html of a
correspondence; the recipient sees the branded
card in both light and dark mode.
body_html is write-only. It is what the recipient reads, and it is not
echoed back on any read endpoint, so keep your own copy of what you sent if
you need to show it in your own system. {{key}} placeholders in it are
filled per recipient from merge, HTML-escaped on the way in.
Claim codes
Address somebody you have a name for and nothing else. Keepable deposits the file and mints a single-use code; you hand it over on your own channel, and they redeem it in the app after verifying their NIN.
Plans
A reusable definition of a piece of work. Write the route, the questions, the conditions, and the retention once, then run it as often as you like.