All posts

Verify an Email List Every Night With a GitHub Actions Schedule

··7 min read

To verify a list every night with GitHub Actions, add a workflow with a schedule trigger, store your API key as a repository secret, and run a small Node script that posts addresses to the verification API in batches of 100 and writes the answers to files. The job needs no server, costs nothing to idle, and turns red when something is wrong, such as a rejected key or an exhausted plan.

This guide builds that workflow with the SimpleVerifier lists endpoint. It suits steady, modest volumes: a nightly export of new signups, a scraper's output, a shared leads file. For a one-off list of thousands, a CSV upload is quicker, and the bulk verify with API guide covers the options.

What you need

  • A SimpleVerifier plan with API access: Pro, Business or Agency. Pro is $39 a month and includes 10,000 API calls a month, then $1.50 per 1,000 on monthly billing. Pro email verification also covers the first 100,000 checks a month at full speed, then slower, never cut off. See pricing.
  • An API key from the API keys page. It starts with sv_live_ and is shown once.
  • A GitHub repository, preferably private, with a file of addresses to check.

How the lists endpoint works

POST https://simpleverifier.com/api/v1/lists takes a JSON body with up to 100 addresses in emails and an optional name. It runs as a job on your account, like a CSV upload. Duplicates (ignoring case) are checked once.

  • With wait_seconds between 0 and 45, the request waits for the results. If the job finishes in time the reply is 200 with status: "done" and every row in results. If not, it is 202 with a results_url.
  • GET /api/v1/lists/{id} returns progress and, when done, the rows. Page with offset and limit (up to 500) and follow next_offset until it is null.
  • A job can be running, waiting, done, canceled or failed. waiting means some addresses are held until the shared daily mailbox-check capacity resets at 00:00 UTC.

The details are in the API docs. Each row carries status, reason, action and send_advice, which is everything the script needs.

Step 1: Add the secret

On GitHub, open the repository's Settings, then Secrets and variables, then Actions. On the Secrets tab choose New repository secret. Name it SIMPLEVERIFIER_KEY and paste the key. GitHub's docs warn to take care that secrets are not printed in logs, so the script below never logs the key. Secrets are not passed to workflows triggered from forks, which is what you want.

Step 2: The script

Create scripts/verify-list.mjs. It needs Node 18 or later for the built-in fetch.

import { readFileSync, writeFileSync, mkdirSync } from "node:fs";

const KEY = process.env.SIMPLEVERIFIER_KEY;
const BASE = "https://simpleverifier.com/api/v1/lists";
const INPUT = process.argv[2] ?? "data/new-leads.txt";
const BATCH = 100;

if (!KEY) {
  console.error("SIMPLEVERIFIER_KEY is not set");
  process.exit(1);
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

class FatalError extends Error {}

async function call(url, options = {}, attempt = 0) {
  const response = await fetch(url, { ...options, headers, signal: AbortSignal.timeout(60_000) });
  if (response.status === 402) throw new FatalError("402: plan has no API access, or the yearly quota is used");
  if (response.status === 401) throw new FatalError("401: key missing, wrong or revoked");
  if (response.status === 400) throw new FatalError(`400: ${await response.text()}`);
  if (response.status === 429 && attempt < 3) {
    const wait = Number(response.headers.get("retry-after")) || 30;
    await sleep(wait * 1000);
    return call(url, options, attempt + 1);
  }
  if (response.status >= 500 && attempt < 3) {
    await sleep(2 ** attempt * 2000);
    return call(url, options, attempt + 1);
  }
  if (!response.ok && response.status !== 202) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

async function readAllRows(id) {
  const rows = [];
  let offset = 0;
  while (offset !== null) {
    const page = await call(`${BASE}/${id}?filter=all&limit=500&offset=${offset}`);
    rows.push(...page.rows);
    offset = page.next_offset;
  }
  return rows;
}

async function verifyBatch(emails, name) {
  const job = await call(BASE, {
    method: "POST",
    body: JSON.stringify({ emails, name, wait_seconds: 45 }),
  });
  if (job.status === "done" && job.results) return job.results;

  const id = job.id;
  for (let i = 0; i < 60; i++) {
    await sleep(5000);
    const state = await call(`${BASE}/${id}?limit=1`);
    if (state.status === "done") return readAllRows(id);
    if (state.status === "failed" || state.status === "canceled") break;
  }
  const partial = await call(`${BASE}/${id}?filter=all&limit=500&partial=true`);
  return partial.rows.filter((row) => row.status !== "not_checked_yet");
}

const addresses = [...new Set(
  readFileSync(INPUT, "utf8")
    .split(/\r?\n/)
    .map((line) => line.trim().toLowerCase())
    .filter((line) => line.includes("@")),
)];

const results = [];
let fatal = null;
try {
  for (let i = 0; i < addresses.length; i += BATCH) {
    const batch = addresses.slice(i, i + BATCH);
    results.push(...(await verifyBatch(batch, `nightly ${new Date().toISOString().slice(0, 10)} #${i / BATCH + 1}`)));
    console.log(`Checked ${Math.min(i + BATCH, addresses.length)} of ${addresses.length}`);
  }
} catch (error) {
  if (!(error instanceof FatalError)) throw error;
  fatal = error;
}

const line = (row) => [row.email, row.status, row.reason, row.action].map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",");
mkdirSync("out", { recursive: true });
for (const status of ["valid", "risky", "invalid"]) {
  const subset = results.filter((row) => row.status === status);
  writeFileSync(`out/${status}.csv`, ["email,status,reason,action", ...subset.map(line)].join("\n"));
}
const checked = new Set(results.map((row) => row.email.toLowerCase()));
writeFileSync("out/unchecked.txt", addresses.filter((email) => !checked.has(email)).join("\n"));
console.log(`valid ${results.filter((r) => r.status === "valid").length}, risky ${results.filter((r) => r.status === "risky").length}, invalid ${results.filter((r) => r.status === "invalid").length}`);

if (fatal) {
  console.error(`Stopped: ${fatal.message}`);
  process.exit(1);
}

What it does on the awkward paths:

  • 402, 401, 400: it stops, writes the files it has, and exits with code 1 so the workflow fails. Retrying cannot fix a plan, a key or a bad request.
  • 429: it waits the Retry-After seconds (the same number as retry_after_seconds in the JSON body) and tries again, up to three times.
  • 5xx: it retries with growing pauses.
  • A job that is still running or waiting: it polls every five seconds for up to five minutes, then takes whatever rows are checked. Addresses with no answer land in out/unchecked.txt, so you can feed them to tomorrow's run. Nothing unchecked is ever written as valid.
  • Not a verdict: a risky row whose action is retry_later means the server did not answer our check. Treat it as retry, not as bad.

Step 3: The workflow

Create .github/workflows/verify-leads.yml.

name: Verify new leads

on:
  schedule:
    - cron: "17 2 * * *"   # 02:17 UTC every day
  workflow_dispatch:

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4

      - name: Verify addresses
        env:
          SIMPLEVERIFIER_KEY: ${{ secrets.SIMPLEVERIFIER_KEY }}
        run: node scripts/verify-list.mjs data/new-leads.txt

      - name: Keep results for a week
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: verified-leads
          path: out/
          retention-days: 7

Check each action's README for its current major version before you copy the @v4 tags. The key arrives as an environment variable, the way GitHub's guide recommends, rather than on the command line.

Scheduling facts from GitHub's documentation

From GitHub's workflow events reference:

  • Cron is POSIX syntax, in UTC by default. The shortest interval is five minutes.
  • Scheduled workflows only run on the default branch, so merge the file there.
  • Runs can be delayed at busy times, especially at the start of the hour, and may be dropped under heavy load. That is why the example uses minute 17, not minute 0.
  • In public repositories, scheduled workflows are disabled after 60 days without repository activity. Re-enable them in the Actions tab. The workflow_dispatch trigger above also lets you run the job by hand.

Where the addresses come from

The example reads a text file with one address per line. A few options for keeping that file fresh without committing personal data to Git:

  • Run a step before the script that downloads the day's new signups from your own storage or database export, and delete the file after.
  • Use a private repository and a small pull request from your app.
  • Keep the repository public only for code, and fetch the data at run time.

Whichever you pick, treat the out/ artifacts as personal data too. A seven day retention keeps them short-lived.

What to do with the output

File Meaning Next step
valid.csv The mail server confirmed the mailbox Load into your CRM or sequencer
risky.csv Delivery not confirmed: catch-all, role inbox, provider hides status, or no answer Review or send in small batches. See what to do with catch-alls
invalid.csv Will bounce or is disposable Suppress
unchecked.txt The run stopped before these were answered Retried by the next run

Costs and limits

Your key can make 120 calls a minute, shared with everything else using it. The script makes one request per batch of up to 100 addresses plus a few polls, so it sits far below that. Pro includes 10,000 API calls a month, then $1.50 per 1,000 on monthly billing; on yearly billing the included calls are a monthly quota and more return 402 until the 1st (UTC). If you are unsure how list jobs count against it, see the lists section of the API docs and the errors page.

The short version

Store the key as SIMPLEVERIFIER_KEY, schedule the workflow at an off-peak minute, send batches of 100 with wait_seconds, and fail the job loudly on 402. Wait out 429s, keep unanswered addresses for the next run, and treat the results as personal data. The quickstart has a one-address curl test if you want to try the key first.

Common questions

Can GitHub Actions run on a schedule?

Yes. The schedule event uses POSIX cron syntax, runs in UTC by default, and the shortest interval GitHub supports is every 5 minutes. The workflow file must be on the default branch, and runs can be delayed when GitHub is busy.

Why did my scheduled GitHub Actions workflow stop running?

GitHub's documentation says scheduled workflows are automatically disabled when no repository activity has occurred in 60 days, in public repositories. You can re-enable them in the Actions tab. Add a workflow_dispatch trigger so you can also run it by hand.

How many addresses can one list request hold?

Up to 100 addresses per POST to the lists endpoint. For more, send several requests, or upload a CSV in the dashboard, which runs the same checks in bulk.

Should I commit the list of emails to the repository?

Only to a private repository, and think about whether you should at all. Email addresses are personal data. A safer pattern is to have the workflow download the new addresses from your own storage at run time and keep only the results as short-lived artifacts.

What happens if my plan or quota is exhausted mid-run?

The API answers 402. The script in this guide stops, saves what it has and exits with an error so the workflow shows red and GitHub notifies you. It does not retry, because only a plan change or the monthly reset fixes it.

Check your list before you send

Real mailbox checks over SMTP, and nothing is sent to the address. Sign in with Google and your first 100 email checks are free, no card. Plans for bigger lists are on the pricing page.