Your Audience tab says 340 contacts. Loops says 280. The 60-person gap is everyone who arrived since the CSV you exported three weeks ago: form submissions, chat conversations that ended with an email address, and waitlist signups from a Reddit thread you had stopped watching.

Re-exporting and re-importing closes that gap once. It does not stay closed, and by the fourth time you do it by hand you have quietly stopped doing it. What you want is a job that runs while you sleep and pushes only the people who are new.

OperatorStack has no ESP integration and no outbound webhook, so a sync is a pull, not a push. Log in with POST /v1/auth/login, read GET /v1/projects/{project_id}/signups, and stop paging the moment you hit someone you saw last run. The list is ordered newest first, which is what makes that watermark work without any date filter. Contacts you delete never appear in the feed, so deletions do not propagate.

There Is No Integration Tab, and That Decides the Design

Three things are worth checking before you go looking for a setting that does this for you.

There is no outbound webhook field. The dashboard has nowhere to paste a Zapier or Make URL, and the /v1/webhooks/ routes in the API point the wrong way: they receive Svix-signed delivery events from Resend and billing events from Stripe. Nothing there sends your data outward.

There is a contact_created event, and it is not the one you want. It fires inside upsert_contact() the first time a given email is seen, and it is the universal conversion signal behind the analytics charts. It writes a row to your own event table. It does not call anything.

So the shape of the job is fixed: something you own has to ask for the contacts. The rest of this post is the asking.

Two Shapes of Sync, and When the Lazy One Is Fine

Re-upload the whole CSV. Hit GET /v1/projects/{project_id}/signups/export, get six columns (email, name, source, referral_code, referrals_count, created_at), and import the file into your ESP. Every email tool worth using dedupes on the email address, so re-uploading 340 rows when 280 already exist adds 60 and updates the rest. Nothing breaks.

This is the right answer under about 500 contacts and one send a month. It costs you four minutes and no code.

The export streams at most 10,000 rows, and it is recorded in your project's audit log as an EXPORT action against the contact entity. Both exports (Waitlist tab and Audience tab) share that ceiling.

Pull on a schedule. Once you are sending weekly, or once "did I sync?" is a question you have to stop and answer, the CSV round trip stops being cheap. A nightly job that pushes only new people is about 40 lines and the rest of this post.

Step 1: Log In From a Script

1

Get a cookie, not a key. There are no API keys for the dashboard API. The 12-character project key in your script tag authenticates public endpoints only, things like form posts and waitlist signups. Everything under /v1/projects/{project_id}/ wants a JWT in an httpOnly cookie.

curl -X POST https://api.operatorstack.dev/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"your-password"}' \
  -c cookies.txt

curl https://api.operatorstack.dev/v1/projects -b cookies.txt

The login response sets two cookies. access_token is good for 7 days and refresh_token for 30. That is long enough that a nightly job never needs POST /v1/auth/refresh: logging in on every run is simpler and stays inside both windows even if the job is down for a week.

This puts your actual account password in whatever runs the cron job. Keep it in an environment variable or your platform's secret store, never in the script file, and never in a repo. Anyone holding it has full dashboard access, not scoped read access, because scoped credentials do not exist here.

Step 2: Find the Project ID

2

Use the internal UUID, not the project key. GET /v1/projects returns each project with both an id (a UUID) and a project_key (12 characters). The contact endpoints take the id. Passing the project key instead fails at FastAPI's path validation with a 422, not a 401 or a 404, so if you see an unprocessable-entity error on a request you are sure is authenticated, you have the wrong identifier.

A valid UUID that belongs to somebody else returns 404 rather than 403, which is deliberate: the API does not confirm that another user's project exists.

Step 3: The Watermark Loop

The endpoint is GET /v1/projects/{project_id}/signups. It takes page, per_page, search, and source, and returns the standard envelope:

{
  "items": [
    {
      "id": "3f1c...",
      "email": "jane@example.com",
      "name": "Jane",
      "source": "waitlist",
      "source_detail": null,
      "referral_code": "xk9p2m",
      "referrals_count": 12,
      "invited_at": null,
      "created_at": "2026-09-20T14:05:32.481293",
      "last_activity_at": "2026-09-22T08:11:04.009821"
    }
  ],
  "total": 340,
  "page": 1,
  "per_page": 100
}

There is no since parameter and no date filter. That sounds like a problem and is not, because the query orders by created_at descending. Newest first means the people you need are always at the front, so you page from the top and stop at the first contact you have already seen. On a quiet night that is one request that reads a single page and exits.

// sync-contacts.js  -- Node 18+, no dependencies
import { readFile, writeFile } from "node:fs/promises";

const API = "https://api.operatorstack.dev/v1";
const { OS_EMAIL, OS_PASSWORD, OS_PROJECT_ID } = process.env;
const STATE_FILE = "./last-sync.json";

// created_at is naive UTC with no offset. Append Z or the runtime guesses local.
const asEpoch = (ts) => Date.parse(ts.endsWith("Z") ? ts : ts + "Z");

async function login() {
  const res = await fetch(`${API}/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: OS_EMAIL, password: OS_PASSWORD }),
  });
  if (!res.ok) throw new Error(`login failed: ${res.status}`);
  return res.headers
    .getSetCookie()
    .map((c) => c.split(";")[0])
    .join("; ");
}

async function contactsNewerThan(cookie, watermark) {
  const fresh = [];
  for (let page = 1; ; page++) {
    const url = `${API}/projects/${OS_PROJECT_ID}/signups?page=${page}&per_page=100`;
    const res = await fetch(url, { headers: { cookie } });
    if (!res.ok) throw new Error(`list failed: ${res.status}`);
    const { items, total, per_page } = await res.json();

    for (const contact of items) {
      if (asEpoch(contact.created_at) <= watermark) return fresh; // caught up
      fresh.push(contact);
    }
    if (page * per_page >= total || items.length === 0) return fresh;
  }
}

const state = await readFile(STATE_FILE, "utf8")
  .then(JSON.parse)
  .catch(() => ({ watermark: 0 })); // first run: 0 backfills everyone

const cookie = await login();
const fresh = await contactsNewerThan(cookie, state.watermark);

for (const contact of fresh) {
  await pushToEsp(contact); // your ESP call, below
}

if (fresh.length > 0) {
  const newest = Math.max(...fresh.map((c) => asEpoch(c.created_at)));
  await writeFile(STATE_FILE, JSON.stringify({ watermark: newest }));
}
console.log(`synced ${fresh.length} contacts`);

Write the watermark only after the pushes succeed. If your ESP call throws halfway through, the file keeps the old value and the next run re-sends a handful of contacts your ESP will dedupe anyway. That is the failure mode you want.

Syncing a single channel is a query parameter, not a filter in your code. ?source=waitlist restricts the list to waitlist signups, and the valid values are waitlist, form, contact_form, and chat. Sending chat-captured emails to a launch list they never asked to be on is a good way to earn a spam complaint.

The Timestamp Bug You Will Otherwise Ship

Look closely at "created_at": "2026-09-20T14:05:32.481293". There is no Z and no +00:00.

That is not sloppiness in the response, it is the storage layer showing through: every datetime column uses a custom TZDateTime type that strips tzinfo on write so SQLite and PostgreSQL behave identically, and hands the naive value straight back on read. The value is UTC. It just does not say so.

JavaScript treats a date-time string with no offset as local time. On a runner in Los Angeles:

const raw = "2026-09-20T14:05:32.481293";
new Date(raw).toISOString(); // 2026-09-20T21:05:32.481Z  (7 hours late)
new Date(raw + "Z").toISOString(); // 2026-09-20T14:05:32.481Z  (correct)

Seven hours of drift in the wrong direction means your watermark sits in the future, and every contact who signs up in the next seven hours compares as older than it and never syncs. The failure is silent: the job exits 0 and reports zero new contacts, which is exactly what a quiet night looks like. Append the Z.

Mapping the Fields

Each contact carries ten fields, and most ESPs want four of them.

OperatorStackWhat it isTypical ESP target
emailthe address, unique per projectthe subscriber email
namewhatever they submitted, often nullfirst name, with a fallback
sourcewaitlist, form, contact_form, or chatlist, tag, or user group
source_detailthe form name or page, when knowncustom property
referral_codetheir share code, seen in URLs as ?ref=xk9p2mcustom property
referrals_counthow many signups came through their linkcustom property, useful for segments
created_atnaive UTC, no offsetsignup date

A Loops push from inside the loop above:

async function pushToEsp(contact) {
  const res = await fetch("https://app.loops.so/api/v1/contacts/create", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.LOOPS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      email: contact.email,
      firstName: contact.name ?? "",
      userGroup: contact.source,
      referralCode: contact.referral_code,
      referralsCount: contact.referrals_count,
    }),
  });
  if (!res.ok && res.status !== 409) {
    throw new Error(`loops push failed for ${contact.email}: ${res.status}`);
  }
}

The 409 pass-through matters. Loops returns a conflict when the contact already exists, which is the expected result of a retry after a partial failure, not something that should stop the run.

Three Things This Sync Will Not Do

Deletions do not propagate. Contacts are soft-deleted: the row gets a deleted_at value, and both the list and the export filter those rows out. A deleted contact stops appearing in the feed, and nothing says why. Your ESP keeps mailing them. Unsubscribes and removals have to be handled where the sending happens.

Edits do not re-sync. The watermark is on created_at, so a contact whose name is corrected next week is already behind the cutoff and never gets looked at again. If that matters, watermark on last_activity_at instead and accept re-pushing anyone who takes any action.

Nothing is real time. The gap between a signup and its arrival in your ESP is however often the cron runs. If you need a Slack ping the second someone joins, that is a different mechanism entirely: chain the notification onto the joinWaitlist() promise in the browser and leave this job for the bulk list.

Frequently Asked Questions

Does OperatorStack have a Mailchimp or Loops integration?

No. There is no integration tab and no outbound webhook field in the dashboard. The endpoints under /v1/webhooks/ are inbound only, for Stripe billing and Resend delivery events. Syncing to an email tool is a pull you run yourself: read the contacts endpoint on a schedule and push what is new.

Is there an API key for the OperatorStack dashboard API?

No. The project key in your script tag only works on public endpoints like form posts and waitlist signups. Private endpoints authenticate with a JWT in an httpOnly cookie, which you get from POST /v1/auth/login. The access cookie lasts 7 days and the refresh cookie 30, so a nightly job can just log in on every run.

How do I fetch only the contacts added since my last sync?

The contacts list has no date filter, but it is ordered newest first. Page from the top and stop at the first contact whose created_at is at or before your last run. Save the newest created_at you saw and use it as the next run's cutoff.

Why are my synced timestamps off by several hours?

created_at comes back as naive UTC with no Z suffix, like 2026-09-20T14:05:32.481293. JavaScript reads a timestamp with no offset as local time, so on a US Pacific machine every contact lands 7 hours late and your watermark skips people. Append a Z before parsing.

Will deleting a contact in OperatorStack remove it from my email tool?

No. Contacts are soft-deleted, and both the list and the export filter out rows with deleted_at set, so a deleted contact just stops appearing in the feed. Nothing tells your ESP to remove it. Treat the sync as append-only and handle unsubscribes and deletions on the ESP side.