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, so a 100-message batch costs 100 sends; the batch call saves you round trips, not money. 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. The message archive reports the state as sent straight away rather than as something pending, and the email namespace publishes no cancel route at all, so a scheduled message is neither visible as a future send nor revocable once the API has accepted it — if a user deletes their account four hours after signup, or your support team spots a broken link in the copy, there is no call that stops the message going out tomorrow morning. For anything you might want to withdraw, 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

Per email, $0.000115 — that’s the figure we read on 2026-07-26, and the same unit applies to batch. Templates, suppression reads and event reads are free, rate-limited calls, so the only line item in a campaign-lite sequence is the sends themselves: a three-step onboarding flow for 5,000 signups is 15,000 emails, about $1.73. New accounts start with $2 of credit, roughly 17,391 emails.

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: it’s that the scheduler, the queue and the audit storage behind this sequence are on the same key and the same invoice, which a specialist email vendor can’t offer at any price.

References

Browse more email developer guides