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.
When you need to deliver to more than a handful of recipients, do not loop over
POST /contents client-side. Keepable has a
first-class batch job: you submit once, Keepable fans the work out
asynchronously, and you poll for results. There are two modes, plus reusable
templates for the compose step.
Batches use the same scopes as single sends: content.write to submit and
retry, content.read to read status. Every item is delivered through the exact
same path as a single send, so retention, addressing, and webhooks all behave
identically.
Mode A: shared content to a list
When every recipient gets the same document, send one delivery template plus a
recipient list as JSON to POST /tenants/{tenant_id}/content_batches. Per-item
attributes let you vary the structured fields (an amount, a due date) per
recipient while the shared parts stay constant.
curl https://api.keepable.co/tenants/ten_01HXP/content_batches \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"subject": "{{recipient_name}}: your 2026 fee invoice",
"content_type": "invoice",
"generated_at": "2026-05-24T09:00:00Z",
"retention_days": 390,
"parts": [
{ "name": "fee-invoice.pdf", "media_type": "application/pdf", "data": "JVBERi0xLjcK..." }
],
"recipients": [
{ "recipient": { "identifier_type": "nin", "identifier": "12345678901" },
"attributes": { "amount": "45000.00", "currency": "NGN", "due_date": "2026-06-30" } },
{ "recipient": { "identifier_type": "nin", "identifier": "10987654321" },
"attributes": { "amount": "52000.00", "currency": "NGN", "due_date": "2026-06-30" } }
]
}'The 32 MiB body cap applies to the shared parts; keep the recipient list
within the documented maxItems and split larger lists client-side.
Mode B: a distinct document per recipient
When each recipient gets a different file, use the ingest job:
POST /tenants/{tenant_id}/content_batches/ingest, as multipart/form-data.
You upload a manifest CSV that maps each recipient to a filename, and a ZIP
bundle of those documents.
| Form field | Required | Notes |
|---|---|---|
subject | yes | Default subject; a manifest subject column overrides it per row. |
content_type | no | The type applied to every document. |
generated_at | no | When you produced the documents. Defaults to now. |
retention_days | no | 30 or 390. Hold window for recipients not yet registered. Omit for none. |
metadata | no | JSON object of opaque string values applied to every document (e.g. a campaign_tag). |
manifest | yes | CSV with a header row (see below). |
bundle | yes | ZIP archive of the per-recipient PDFs. |
The manifest header row carries an identifier column
(recipient / nin / email / tin), an optional type, a file naming the
recipient's document in the bundle, an optional subject, and any other column
as a per-recipient merge value. Rows with no matching document, or a non-PDF
file, are returned in rejected rather than delivered.
The upload is capped at 64 MiB; split larger runs client-side.
Polling results
Both modes return 202 Accepted with a batch id and a running tally:
{ "batch_id": "btc_01HXP", "status": "pending",
"totals": { "accepted": 2, "pending": 2, "delivered": 0, "retained": 0, "failed": 0 } }Delivery is asynchronous. Poll GET /tenants/{tenant_id}/content_batches/{batch_id}
for the rolled-up totals, and .../items to page the per-recipient outcomes
(filter ?status=failed to find what to retry). When it finishes, delivered
and retained carry the same meaning as a single send: retained items are
held for recipients who have not signed up yet.
{ "batch_id": "btc_01HXP", "status": "completed",
"totals": { "accepted": 2, "pending": 0, "delivered": 1, "retained": 1, "failed": 0 },
"created_at": "2026-05-24T09:00:01Z", "completed_at": "2026-05-24T09:00:09Z" }Retrying is safe by construction. Each item's idempotency key is derived from
the batch id and the recipient, so re-submitting a batch (or calling
POST .../content_batches/{batch_id}/retry, which re-enqueues only the
failed items) never double-sends an already-delivered item.
Variables and privacy
Subjects and templated text can carry {{variables}}. There are two kinds, and
the distinction is a privacy boundary:
- Built-in
{{recipient_name}}is resolved at render time from the viewing recipient's own identity. You address recipients by NIN, email, or TIN. You never send Keepable a name, and you never receive one back. The recipient sees their own name; you never learn it. - Sender variables (your own merge fields) must already be substituted by the caller before you submit. Keepable does not resolve sender-supplied data.
Templates
A content template saves the compose preset (subject, content type, and the shape you reuse) so a recurring batch is one short call instead of a rebuilt envelope each time.
| Operation | Endpoint |
|---|---|
| Save a preset | POST /tenants/{tenant_id}/content_templates |
| List presets | GET /tenants/{tenant_id}/content_templates |
| Delete a preset | DELETE /tenants/{tenant_id}/content_templates/{template_id} |
Templates require content.write to create or delete and content.read to
list. They are a convenience over the compose step; they do not change how
delivery, retention, or idempotency work.
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.
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.