Scrub a recipient list before send

Before a large blast, filter addresses against your suppression list client-side. Avoids round-trips, saves quota, gives you the "dropped" count upfront.

Via the API

const API = "https://www.sendoka.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.SENDOKA_API_KEY}` };

// `stream` is what you are about to send: "broadcast" for list mail (blocked by
// every row), "transactional" for receipts and the like (blocked only by
// stream "all" rows, never by a one-click unsubscribe).
async function scrub(
  addresses: string[],
  stream: "broadcast" | "transactional" = "broadcast"
): Promise<{ keep: string[]; drop: string[] }> {
  // Paginate the full suppression list (email channel only)
  const suppressed = new Set<string>();
  let cursor: string | null = null;
  do {
    const url = new URL(`${API}/suppressions`);
    url.searchParams.set("channel", "email");
    url.searchParams.set("limit", "200");
    if (cursor) url.searchParams.set("cursor", cursor);
    const res = await fetch(url, { headers });
    const page = await res.json();
    for (const row of page.data) {
      if (stream === "transactional" && row.stream !== "all") continue;
      suppressed.add(row.value.toLowerCase());
    }
    cursor = page.has_more ? page.next_cursor : null;
  } while (cursor);

  const keep: string[] = [];
  const drop: string[] = [];
  for (const a of addresses) {
    (suppressed.has(a.toLowerCase()) ? drop : keep).push(a);
  }
  return { keep, drop };
}

Or just let the audience endpoint do it

POST /api/v1/audiences/:id/send already drops suppressed addresses before insert. It reads both streams, since a blast is broadcast mail, and it also skips members who unsubscribed from that audience. The response gives you the counts:

{ "job_id": "job_...", "total": 9917, "scheduled": 9805, "suppressed": 112 }

total counts the members still subscribed to the audience. suppressed is total - scheduled: the members your suppression list removed, plus any with no address on the blast's channel. See the bulk audience recipe.

Use client-side scrub only when you need the list of dropped addresses (to remove them from your own DB) or when you're sending via /emails/batch instead of audiences.

Gotchas

  • Case-insensitive — suppressions are stored lowercase; match accordingly.
  • Plus-addressinga+foo@example.com and a@example.com are different suppressions. Treat as-written.
  • Race condition — between scrub and send, a new bounce could auto-add an address. Sendoka still blocks it at send time, so the worst case is a per-item SUPPRESSED error in your batch response while the rest of the batch sends.
  • Tenants — with X-Sendoka-Tenant-Ref, GET /v1/suppressions returns that tenant's rows plus the platform-wide ones, and both block the tenant's sends. Without the header you see only the platform-wide rows. Scrub a tenant's list with the tenant's header.
  • Don't cache the suppression list long. It changes every time a user unsubscribes. Re-scrub for each blast.