Developer Experience
Adaptable

Bulk invite tool for Facebook ad leads

A throttled, idempotent CLI that converts a CSV of ad-campaign leads into signup magic-link invites by driving the existing signup endpoint — and stops rather than stranding half-provisioned accounts when the email provider rate-limits.

Shipped May 2026OnlineMihna
TypeScriptNode.jsSupabaseNext.js

The Problem

A paid Facebook ad brought in interest from a large number of people who wanted to join the platform. Each lead arrived as just a name and an email. To onboard them, a teammate was opening the signup form and typing in one lead at a time to send each person a signup magic link.

That approach has three problems:

  • It doesn't scale. Hand-entering a large batch is slow and tedious.
  • It's error-prone. Manual copy-paste drops and mistypes addresses.
  • It's blind to the rate limit. The email provider behind Supabase Auth caps how many emails go out per hour. A human clicking fast has no idea when they've crossed it — and crossing it silently breaks signups.

The team needed to invite the whole list in one shot, safely, without anyone babysitting a form.

The Approach

The script is deliberately thin. It holds no Supabase service-role key and talks to no database directly. It does exactly one thing: fetch() against the deployed /api/auth/signup/jobseeker endpoint — the same route the real signup modal calls.

That's the central design decision. The endpoint already owns account creation, duplicate detection, jobseeker-profile creation, and the magic-link send. Reimplementing any of that inside a one-off script would duplicate production logic and invite drift the moment the route changes. So the script stays a dumb, safe caller and inherits every guarantee the route already provides.

Around that single call sit four safety layers:

  1. Dry-run by default. With no APPLY=true, the script parses, dedupes, diffs against the sent-log, prints exactly who would be contacted, writes a plan file, and makes zero HTTP calls. Nobody emails real people by accident.
  2. An on-disk sent-log for idempotency. Every successful (or already-existing) address is appended to backfill-output/invite-fb-leads-sent.json immediately, so a crash, a Ctrl-C, or a deliberate stop mid-batch resumes cleanly — already-invited people are never double-emailed.
  3. A configurable throttle (DELAY_MS) between sends, so the run can be tuned to stay under the Auth email rate limit.
  4. A masked-failure guard — the part that took the most thought.

The signup route creates the auth user first, then calls signInWithOtp to send the link. When the email provider rate-limits that send, the route doesn't return a 429. It catches the error and returns a generic 400 "Failed to send magic link". From the script's side, that looks like an ordinary validation failure — but the account already exists. A loop that simply logged the 400 and moved on would manufacture a large number of accounts that exist with no usable login link, and a re-run would treat each one as "already exists" and skip it permanently. So the guard pattern-matches that masked failure (plus the half-create 500 "Account created but..."), halts the entire batch, records those addresses in a separate created_link_not_sent list, and — critically — does not add them to the sent-log, so they remain re-invitable after the rate limit is raised.

  1. 1

    Parse & dedupe CSV

    BOM strip, quoted-field aware, header auto-detect; lowercase + dedupe emails within the file

  2. 2

    Diff against sent-log

    Load prior runs from disk; drop anyone already invited — this is what makes re-runs safe

  3. 3

    Dry-run gate

    Default mode: print the recipient count + first few, write a plan file, make zero HTTP calls

  4. 4

    Throttled POST per lead

    APPLY=true: POST {name, email, locale} to /api/auth/signup/jobseeker, sleep DELAY_MS between sends

  5. 5

    Classify & guard

    ok / already-exists / masked-send-failure (STOP) / network-or-4xx-fail — each handled distinctly

  6. 6

    Persist sent-log + summary

    Append each success to disk immediately; write a final summary with sent / skipped / failed / created-no-link counts

The Code

Three pieces carry the design: the masked-failure guard, the per-success idempotency write, and the dry-run gate. All three are verbatim from the shipped script.

The masked-failure guard — stop, don't strand

The signup route creates the auth user before sending the magic link and masks the email provider's 429 as a 400 "Failed to send magic link". This branch catches that masked shape (and the half-create 500), records the address in a separate list, and breaks the loop — those addresses are intentionally never written to the sent-log, so they stay re-invitable.

} else if (
  status === 429 ||
  (status >= 400 &&
    (bodyText.includes("Failed to send magic link") ||
      bodyText.includes("Account created but") ||
      bodyText.toLowerCase().includes("rate limit")))
) {
  // Route creates the auth user BEFORE sending the link and masks GoTrue 429s as
  // 400 "Failed to send magic link". A rate-limited/failed send leaves an account
  // that EXISTS with no usable link. Do NOT add to sent-log (a re-run would only
  // mark it "exists" and skip — the link never gets sent). Stop: budget is spent;
  // continuing only creates more half-provisioned accounts.
  createdNoLink.push({
    name: lead.name,
    email: lead.email,
    status,
    body: bodyText.slice(0, 500),
  });
  rateLimited = true;
  console.log(`${label}  STOP: account created, magic link NOT sent`);
  console.error(
    "\nMagic-link send failed (likely Auth email rate limit). Stopping.\n" +
      "Accounts in created_link_not_sent[] EXIST but got no link — raise Auth → Rate\n" +
      "Limits / increase DELAY_MS, then resend a link to JUST those addresses\n" +
      "(they are NOT retried automatically — the sent-log excludes them).",
  );
  break;
}

Idempotency — persist after each success

Both a successful send and an "already exists" response write the email to the sent-log immediately, not at the end. Any interruption resumes from exactly where it stopped, and nobody is double-emailed.

if (status === 200) {
  sent.add(lead.email);
  writeSentLog(sent);
  okCount++;
  console.log(`${label}  ok`);
} else if (status === 400 && bodyText.includes("already exists")) {
  sent.add(lead.email);
  writeSentLog(sent);
  existsCount++;
  console.log(`${label}  exists`);
}

Dry-run gate — zero HTTP calls by default

Without APPLY=true, the script computes the exact recipient set (CSV minus the sent-log), previews it, writes a plan file, and returns before any network call. Sending real email to real people is strictly opt-in.

const sent = loadSentLog();
const remaining = unique.filter((l) => !sent.has(l.email));
const alreadyDone = unique.length - remaining.length;

if (!APPLY) {
  const preview = remaining.slice(0, 5).map((l) => l.email);
  if (preview.length > 0) {
    console.log(`\nFirst ${preview.length}: ${preview.join(", ")}`);
  }
  writeFileSync(PLAN_PATH, JSON.stringify({ /* plan */ }, null, 2));
  console.log(`\nDry-run — 0 HTTP calls. Plan written to ${PLAN_PATH}.`);
  return;
}

Trade-offs

Most of these are about what the script deliberately does not do — the restraint is the point.

Chose

Call the live endpoint, not a service-role client

The script holds no Supabase admin key and writes no rows directly. The signup route already owns account creation, dedupe, profile creation, and the magic-link send. A script that reimplemented that would duplicate production logic and drift the moment the route changed. Staying a thin fetch() caller means the tool inherits every guarantee the route provides — including future fixes — for free.

Stop on the first masked send-failure

Because the route creates the user before sending the link, a failed send leaves an account that exists but can't log in. Continuing the loop would multiply that damage across a large list. Halting on the first occurrence caps the blast radius at one, and the sent-log exclusion makes the eventual resume clean.

An on-disk JSON sent-log over a database table

This is a one-off ops tool. A gitignored JSON file under backfill-output/ gives full idempotency and resumability with zero schema footprint on the production database — no migration, no table to clean up afterward.

Dry-run as the default, sending as the opt-in

The destructive action here is emailing real people, which can't be undone. Making APPLY=true an explicit gate — with a zero-HTTP preview of the exact recipient set first — means a mistaken invocation prints a plan instead of blasting a campaign.

Skipped

Automatic in-loop retry of failed sends

A masked 429 means the hourly email budget is spent — retrying immediately just burns more of a budget that's already gone and creates more half-provisioned accounts. The correct recovery is human: raise the Auth rate limit (or increase the throttle), then resend to exactly the created_link_not_sent list. So retry was deliberately left out.

Outcomes

Qualitative, because the lead count is private — but the operational wins are concrete.

Eliminated — whole batch in a couple of minutes

Manual form entry

Idempotent — already-invited skipped

Re-runs

Zero — stop-on-masked-failure guard

Stranded accounts

None — thin endpoint caller, no DB writes

Production schema touched

None — CSV path gitignored, no data in code

Lead PII in the repo