Skip to content
← BlogDeveloper

Automating cold-email ops checks with the Norbelys CLI in CI

A working GitHub Actions example: install the norbelys CLI, authenticate with $NORBELYS_API_KEY, and run a scripted campaign-config check on every push or schedule.

By Gabriel Lara, Developer Relations, Norbelys

Founder-reviewed ·How we research and correct articles

Some checks against your Norbelys workspace are worth running the same way every time, on a schedule, without a person remembering to do it. “Does every active sender still have valid credentials?” “Is there a program configured with no sender attached to it?” Those are exactly the kind of question a CI job answers better than a human does — a human forgets to check on a Friday; a scheduled job doesn’t.

This post walks through wiring the norbelys CLI into a GitHub Actions pipeline, authenticated with $NORBELYS_API_KEY, running a real scripted check. For the broader question of when a script beats clicking through the dashboard at all, that’s a separate post — this one assumes you’ve already decided the check is worth automating and just want it working.

Why $NORBELYS_API_KEY specifically

The CLI resolves its bearer credential from an environment variable before it looks at anything saved on disk — that’s covered in more depth in the auth deep-dive, but the practical consequence for CI is simple: you never run norbelys login inside a pipeline. You set one secret, export it as NORBELYS_API_KEY, and every command in that job picks it up automatically. There’s no config file to persist between runs, no interactive step, nothing that depends on a browser existing on the runner.

Setting up the secret

Generate an org-scoped API key from the dashboard the same way you would for any script, and store it as a repository (or environment) secret in GitHub — Settings → Secrets and variables → Actions. Name it NORBELYS_API_KEY so it lines up with what the CLI already looks for.

Treat it like any other production credential: scope it to the org it belongs to, don’t echo it in logs, and rotate it if a runner’s secret store is ever exposed. None of that is CLI-specific advice — it’s the same hygiene you’d apply to a database URL or a payment-provider key — but it’s worth restating because a CI secret is easy to set once and then forget exists.

What makes a good check to automate

Not every question is worth a pipeline. A good candidate for this pattern has three properties: it has a clear pass/fail answer (not “does this look right,” which needs a human), it’s worth catching before something depends on it being true (a broken sender before a send window opens beats finding out after), and it would otherwise rely on someone remembering to check manually. “Every sender is healthy,” “every active program has a sender attached,” and “a domain’s verification hasn’t silently lapsed” all fit that shape. “Does this campaign’s copy sound right” doesn’t — that’s a dashboard task, not a CI one, precisely because it needs a person reading it.

A realistic workflow

Here’s a complete GitHub Actions workflow that runs a scripted check on every push to main and again on a nightly schedule, so a broken configuration gets caught either at merge time or overnight, whichever happens first:

name: norbelys-ops-check

on:
  push:
    branches: [main]
  schedule:
    - cron: "0 6 * * *" # 06:00 UTC daily

jobs:
  verify-senders:
    runs-on: ubuntu-latest
    env:
      NORBELYS_API_KEY: ${{ secrets.NORBELYS_API_KEY }}
    steps:
      - name: Install the norbelys CLI
        run: curl -fsSL https://cli.norbelys.com/install | sh

      - name: List senders as JSON
        run: |
          ~/.local/bin/norbelys senders list --json > senders.json

      - name: Fail if any active sender is unhealthy
        run: |
          set -e
          UNHEALTHY=$(jq '[.[] | select(.status != "healthy")] | length' senders.json)
          if [ "$UNHEALTHY" -gt 0 ]; then
            echo "::error::$UNHEALTHY sender(s) not healthy"
            jq '[.[] | select(.status != "healthy")]' senders.json
            exit 1
          fi
          echo "All senders healthy."

The mechanics generalize past sender health. The same three steps — install, run a command with --json, assert on the output with jq — work for any generated command: verifying every active program has at least one sender attached before a send window opens, checking that a suppression list import landed the row count you expected, or confirming a domain’s verification status before a campaign that depends on it goes live.

Try it locally before you trust it in CI

Debugging a failing GitHub Actions run through log output alone is slow. Run the same commands locally with the same environment variable set, so you’re iterating on the jq filter directly instead of pushing commits to see what breaks:

Terminal
$ export NORBELYS_API_KEY=ak_...$ norbelys senders list --json | jq '.'[{"id":"snd_4k2...","email":"outreach@acme.com","status":"healthy"}, {"id":"snd_9p1...","email":"sdr@acme.com","status":"needs_reauth"}]$ norbelys senders list --json | jq '[.[] | select(.status != "healthy")] | length'1

Once the filter produces the exit code you expect against real data, paste the same command into the workflow file. The CI run should now be reproducing a check you’ve already watched work on your own machine, not one you’re debugging for the first time in a runner’s logs.

Scheduling frequency and rate limits

Resist the urge to run an ops check every five minutes “just to be safe.” A daily or hourly cadence catches configuration drift long before it costs you anything real, and it keeps your automated traffic well clear of anything that could look like abuse to the API’s own rate limiting. If you’re running the same check across several client orgs — an agency pattern — stagger the schedule or explicitly target one org’s key per job rather than looping through all of them inside a single tight script.

Beyond a single check: gating a deploy or a launch

The pattern in this post is a standalone scheduled job, but the same three steps drop cleanly into a pre-deploy or pre-launch gate too — a workflow step that runs before a campaign activation job, exits non-zero on a problem, and blocks the rest of the pipeline from proceeding. That’s the real payoff of scripting an ops check instead of a person eyeballing a dashboard before hitting “launch”: the check runs identically every time, and it runs even on the Friday afternoon nobody wants to be doing it manually.

It’s not GitHub-Actions-specific

Nothing above depends on GitHub Actions particularly — the CLI is a small bundle that runs anywhere Bun or Node runs, so the same three steps (install, authenticate via env var, assert on --json output) work unchanged in GitLab CI, CircleCI, Buildkite, or a plain cron entry on a box you manage yourself. The only thing that changes between platforms is the syntax for declaring a secret and a schedule; the actual norbelys ... invocation in the run step is identical everywhere, which is a reasonable sanity check that you haven’t accidentally hard-coded a GitHub-specific assumption into the check itself.

Next steps