Keepable docs
Sender API

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.

Mode A: shared content
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 fieldRequiredNotes
subjectyesDefault subject; a manifest subject column overrides it per row.
content_typenoThe type applied to every document.
generated_atnoWhen you produced the documents. Defaults to now.
retention_daysno30 or 390. Hold window for recipients not yet registered. Omit for none.
metadatanoJSON object of opaque string values applied to every document (e.g. a campaign_tag).
manifestyesCSV with a header row (see below).
bundleyesZIP 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:

202 Accepted
{ "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.

A completed batch
{ "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.

OperationEndpoint
Save a presetPOST /tenants/{tenant_id}/content_templates
List presetsGET /tenants/{tenant_id}/content_templates
Delete a presetDELETE /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.