Campaign-lite onboarding: reusable templates plus a batch send

Run a small onboarding sequence on a transactional email API — stored templates, 100-message batches, suppression preflight — and know where it stops.

“Campaign-lite” usually means three or four onboarding emails, personalised, sent to people who just signed up — no segments, no A/B arms, no unsubscribe centre. A transactional API handles that well: store one template per step, fan out in batches of up to 100 with Infrai’s POST /v1/email/batch/send, and keep the sequencing in your own scheduler. What it won’t handle is the moment marketing asks for a segment.

Knowing exactly where that line sits is the useful part, so this page draws it with real responses rather than adjectives. Infrai’s send endpoint refuses marketing-class mail outright, which turns out to be a clarifying constraint rather than an annoying one.

Where a transactional API stops being enough

NeedTransactional API + your schedulerA campaign tool (Loops, Brevo)
Personalised welcome on signupNatural fitWorks, heavier setup
Day-2 and day-7 follow-upsYour cron plus a stored templateBuilt-in sequence editor
Segments and audience listsYou own the queryBuilt-in
Unsubscribe centre and preference pageYou build itBuilt-in, usually mandatory
Marketing-class sendingNot implemented, 501The whole product
Same key does storage, queues, AIYes, on InfraiNo

Rows one and two are the definition of campaign-lite.

Once rows three and four appear in a ticket, buy the campaign tool. You’d be better off with software that already models consent, preference centres and list hygiene than reimplementing that machinery beside your billing code, and the migration later is far more painful than the licence now — every provider in this market has a graveyard of half-built unsubscribe pages behind it, usually written by a backend engineer who thought a boolean column would be enough and discovered eight weeks later that regional rules disagree.

The refusal is explicit, which is helpful

curl -sS -X POST https://api.infrai.cc/v1/email/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?put your_infrai_api_key here}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "dana@example.net",
    "subject": "New in Ledgerly this month",
    "html": "<p>Product news</p>",
    "message_class": "marketing"
  }'
{
  "ok": false,
  "error": {
    "code": "CAPABILITY_NOT_IMPLEMENTED",
    "http_status": 501,
    "message": "email.send currently supports transactional mail only; message_class='marketing' is not live yet",
    "retryable": false
  }
}

A 501 with the reason in the message beats a silent acceptance followed by a compliance conversation. Treat it as the boundary marker: everything below is transactional onboarding, and product-news mail belongs elsewhere.

Suppression preflight before you fan out

Batch sends amplify mistakes. Bounced and complained addresses land on an account-level suppression list, and while the send path drops them into suppressed_recipients rather than mailing them, you want to know before you assemble a batch of 100.

curl -sS https://api.infrai.cc/v1/email/suppression/list \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?put your_infrai_api_key here}"
{
  "ok": true,
  "data": {
    "items": [
      {"email": "user@example.com", "reason": "manual", "added_at": "2026-07-04T17:02:22Z", "scope": "account", "attempt_count_blocked": 0},
      {"email": "unsub-probe@example.com", "reason": "unsubscribed", "added_at": "2026-06-29T09:54:38Z", "scope": "account", "attempt_count_blocked": 0}
    ],
    "count": 2,
    "next_cursor": null
  }
}

For a single address there’s a cheaper read — GET /v1/email/suppression/check/{email} returns a one-field answer, and it’s free.

curl -sS https://api.infrai.cc/v1/email/suppression/check/dana@example.net \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?put your_infrai_api_key here}"

The batch call, and an honest note about it

The batch route takes a messages array of up to 100 entries; each entry is a normal send request, so template_id and template_vars work per recipient. We deliberately didn’t fire a large batch against a shared live account, so read the body below as the published request shape and confirm the response fields against the API reference before you depend on them.

curl -sS -X POST https://api.infrai.cc/v1/email/batch/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?put your_infrai_api_key here}" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {"to": "dana@example.net", "template_id": "tmpl_0cZZSWki9BwVitO3IpTUGpvx",
       "template_vars": {"first_name": "Dana", "workspace": "Acme HQ", "next_url": "https://app.example.net/connect"}},
      {"to": "ravi@example.net", "template_id": "tmpl_0cZZSWki9BwVitO3IpTUGpvx",
       "template_vars": {"first_name": "Ravi", "workspace": "Northwind", "next_url": "https://app.example.net/connect"}}
    ]
  }'

Three constraints to design around.

The array caps at 100 — go over and you get EMAIL_BATCH_TOO_LARGE, so chunk in your own code. Billing is per email on both routes, so what batching buys you is not a cheaper recipient: it is one round trip, one idempotency_key covering the whole chunk, and a per-message index in the response that tells you exactly which entries failed. Treat it as a throughput and reconciliation tool. And unlike the single-send route, batch currently runs through the western region only.

Chunking it properly in Node

// onboard-fanout.mjs — Node 22 ESM. Chunks a signup cohort into batches of 100.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY missing — set it to your_infrai_api_key");
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };

const WELCOME = "tmpl_0cZZSWki9BwVitO3IpTUGpvx";
const MAX_PER_BATCH = 100;

async function read(path) {
  const res = await fetch(`${API}${path}`, { headers });
  const payload = await res.json();
  if (!res.ok || payload.ok === false) throw new Error(payload?.error?.code ?? `http_${res.status}`);
  return payload.data;
}

function chunk(rows, size) {
  const out = [];
  for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size));
  return out;
}

export async function sendWelcomeCohort(users) {
  const suppressed = new Set((await read("/v1/email/suppression/list")).items.map((x) => x.email));
  const deliverable = users.filter((u) => !suppressed.has(u.email));
  const skipped = users.length - deliverable.length;

  const results = [];
  for (const group of chunk(deliverable, MAX_PER_BATCH)) {
    const res = await fetch(`${API}/v1/email/batch/send`, {
      method: "POST",
      headers,
      body: JSON.stringify({
        messages: group.map((u) => ({
          to: u.email,
          template_id: WELCOME,
          template_vars: { first_name: u.firstName, workspace: u.workspace, next_url: "https://app.example.net/connect" },
        })),
      }),
    });
    const payload = await res.json();
    if (!res.ok || payload.ok === false) throw new Error(`batch failed: ${payload?.error?.code ?? res.status}`);
    results.push(payload.data);
    await new Promise((r) => setTimeout(r, 250));
  }
  return { batches: results.length, skipped };
}

console.log(await sendWelcomeCohort([
  { email: "dana@example.net", firstName: "Dana", workspace: "Acme HQ" },
  { email: "ravi@example.net", firstName: "Ravi", workspace: "Northwind" },
]));

The 250ms pause between batches isn’t superstition — it keeps a cohort import from tripping the account rate limit, and a failed batch is more expensive to reason about than a slightly slower loop.

The day-2 email, and a scheduling trap

scheduled_at on the single-send route accepts an ISO 8601 timestamp and a send 24 hours out is accepted with a 200.

curl -sS -X POST https://api.infrai.cc/v1/email/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?put your_infrai_api_key here}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "dana@example.net",
    "template_id": "tmpl_0cZZSWki9BwVitO3IpTUGpvx",
    "template_vars": {"first_name": "Dana", "workspace": "Acme HQ", "next_url": "https://app.example.net/tour"},
    "scheduled_at": "2026-07-27T09:00:00Z"
  }'

Here’s the caveat.

It’s a real one, and it changes the design. A message that hasn’t been dispatched yet can be pulled back with POST /v1/email/cancel/{id}, so the id in the send response is worth persisting — but that’s a per-message escape hatch, not a queue you can inspect. GET /v1/email/list hands back message_id, state, to, vendor and created_at, with no send-at field to sort future mail by, so “show me everything queued for tomorrow morning” is a question your own database answers rather than this API. For anything you might need to withdraw in bulk — a user deletes their account four hours after signup, or support spots a broken link in the copy — keep the delay in your own scheduler and call send at the moment you actually mean it. Reserve scheduled_at for sends that are safe to be wrong about.

What it costs

One rate to know, and it isn’t the reason to batch. POST /v1/email/send meters at $0.00046 per email, read on 2026-07-27 and published as approximate because the vendor mix behind it can change; both send routes bill per recipient, so a four-step sequence costs four sends however you group them. Templates, suppression reads and event reads are free, rate-limited calls, so the sends are the only line item a campaign-lite sequence has. A new account starts with $2 of free credit; divide by today’s rate rather than by this paragraph’s.

curl -sS https://api.infrai.cc/v1/account/usage \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?put your_infrai_api_key here}"

Rates in this market keep drifting down and promotions run against them, so verify rather than budget from a page. The stable part of the argument isn’t the number. Day 2 needs a scheduler and a fan-out that survives a deploy: POST /v1/cron/create and POST /v1/queue/publish are already on the same key you just sent the batch with, next to POST /v1/errors/capture for the run that died halfway. No second account, no second bill — which is the one thing a specialist email vendor can’t match at any price.

References

Browse more email developer guides