Your Contacts page says 340 contacts: 210 waitlist, 80 form, 50 chat. The obvious reading is that 210 people came in through the waitlist widget and 80 came through a form. That is not what those numbers mean, and the gap matters as soon as you use them to decide where to spend next week.

source on a contact records the highest-intent action that person has taken, not the door they walked in through. A founder who submits your contact form in March and joins the waitlist in April counts once, under waitlist. The contact_form visit is gone from the breakdown.

Filter contacts with ?source=waitlist in the dashboard dropdown, the list endpoint, or the CSV export. Read the breakdown as intent rather than channel: source upgrades on higher-intent actions (contact_form to form or chat to waitlist) and never downgrades. The channel that sent someone lives in the os_attribution cookie, not in this field.

The Five Values and the Upgrade Rule

A contact's source is one of five strings, and each one is set by whichever tool created the record:

ValueSet byPriority
unknowndefault when nothing supplies a source0
contact_formthe contact widget1
formany custom form submission2
chatAI Chat capturing an email mid-conversation2
waitlistthe waitlist widget3

Contacts are deduplicated on (project_id, email), so one email is one row no matter how many times that person interacts. When an existing contact comes back through a different tool, OperatorStack compares priorities and keeps the higher one:

contact_form (1) then waitlist (3)  ->  waitlist
waitlist (3)     then form (2)      ->  waitlist
chat (2)         then form (2)      ->  chat

The third line is the one that surprises people. Equal priorities do not overwrite, so a tie keeps whatever got there first. form and chat are both priority 2, which means a chat capture followed by a form submission stays on chat.

This is why the source breakdown is not a funnel and does not sum to your total interactions. It is a headcount of people bucketed by their strongest signal. If you want to know how many form submissions you received, count form submissions, not contacts whose source is form.

The upgrade exists for a real sequencing problem. One of the built-in widget flows calls sendContactMessage before joinWaitlist, so without the upgrade a waitlist signup would get permanently stamped contact_form purely because that request landed a few hundred milliseconds earlier.

Filter in the Dashboard

Open Contacts in your project, which the sidebar labels Contacts even though the route is /signups. Two controls matter:

1

The Sources bar at the top renders source_breakdown, a straight count of contacts per source across your whole project with no date window. It is the number to check before you trust any per-source claim, because it shows you which buckets are big enough to reason about.

2

The source dropdown next to the search box filters the table. It offers All sources, Waitlist, Contact Form, Form, and Chat. Search and source combine, so you can filter to Form and then search gmail.com to see which of your form signups are on personal addresses.

The dropdown has no option for unknown. If you imported a list or created contacts through the API without passing a source, those records exist, they count toward your total, and the only way to isolate them in the UI is to page through All sources. Hit the endpoint directly instead.

Filter Through the API

The contacts list lives on the signups router, scoped to a project by its internal id rather than by your public project key:

GET /v1/projects/{project_id}/signups?source=waitlist&page=1&per_page=50

These are private endpoints, so authentication is a JWT in an httpOnly cookie. From the dashboard the call goes through the Nitro proxy at /api/v1/...; from a script you need to send the session cookie.

The response is the standard paginated shape:

{
  "items": [
    {
      "email": "founder@example.com",
      "name": "Sam",
      "source": "form",
      "source_detail": "Pricing Feedback",
      "referral_code": "a7bK2p",
      "referrals_count": 3,
      "invited_at": null,
      "created_at": "2026-09-14T10:22:05Z",
      "last_activity_at": "2026-09-27T16:04:11Z"
    }
  ],
  "total": 80,
  "page": 1,
  "per_page": 50
}

source=unknown works here even though the dropdown does not offer it. So does source=chat, and so does any other string, which brings up the one behaviour worth committing to memory.

The source filter is an unvalidated exact string match. source=Waitlist and source=waitlists both return {"items": [], "total": 0} with a 200 status. You do not get a 422, and nothing in the response tells you the value was wrong. If a segment comes back empty, check your spelling against the five values before concluding the segment is empty.

Export One Segment

The export endpoint takes the same two filters and streams a CSV:

GET /v1/projects/{project_id}/signups/export?source=form

The columns are fixed:

email,name,source,referral_code,referrals_count,created_at

Two limits to plan around. The export is capped at 10,000 rows, ordered newest first, so a larger segment silently truncates to your most recent 10,000. And source_detail is not in the CSV even though it is in the list response. If you need to know which form each contact came from, page the list endpoint rather than exporting.

source_detail Tells You Which Form, Not Which Channel

source_detail is a nullable 255-character string, and today exactly one thing writes it: a form submission stores the form's name. A contact created by a form called "Pricing Feedback" gets source: "form" and source_detail: "Pricing Feedback".

Waitlist, chat, and contact widget signups leave it null. So you can segment form contacts down to the individual form, but you cannot segment waitlist contacts any further using this field.

If you run several forms, name them for the segment you want later rather than for the page they sit on. "Pricing Feedback" and "Enterprise Interest" are useful values in source_detail. "Form 2" and "Homepage Form" are not.

When You Actually Want the Channel

If the question is "which channel sends signups that convert", source is the wrong field. Three other mechanisms carry that answer:

  • The os_attribution cookie. The embed script reads utm_source, utm_medium, utm_campaign, and rdt_cid off the URL and stores them in a JSON cookie with a 30-day expiry and SameSite=Lax. It is first-touch: once utm_source or rdt_cid is stored, a later visit with different parameters does not overwrite it. The visitor who found you on Reddit and came back through your newsletter stays attributed to Reddit.
  • The referrer on page view events. Every event records the referring URL, which covers traffic that arrives with no UTM parameters at all, such as most links posted in Slack or Discord.
  • The contact_created event. Every new contact emits one, with source and source_detail in its metadata. That gives you a dated conversion signal you can chart against your traffic, which the contact record alone cannot do because upgrading overwrites the original source.

Together those three answer the channel question. source answers a different and still useful one: of the people who gave you an email, how many were interested enough to ask for early access rather than just send a message.

Frequently Asked Questions

What are the possible contact source values?

Five: waitlist, form, chat, contact_form, and unknown. The dashboard dropdown exposes the first four. A contact only lands on unknown if it was created without a source, which normally means an import or a direct API call.

Does the source field tell me which marketing channel a signup came from?

No. Source records which OperatorStack tool captured the person. The channel that sent them lives in the os_attribution cookie (utm_source, utm_medium, utm_campaign, rdt_cid) and in the referrer on their page view events.

Why did a contact's source change from contact_form to waitlist?

Source upgrades on higher intent and never downgrades. The priority order is unknown 0, contact_form 1, form and chat 2, waitlist 3. If someone messages you and later joins the waitlist, the record moves to waitlist and stays there.

Is source_detail included in the CSV export?

No. The export ships email, name, source, referral_code, referrals_count, and created_at. If you need source_detail, which holds the form name, read it from the contacts list endpoint instead.

What happens if I pass a source value that does not exist?

You get an empty list, not an error. The filter is an exact string match with no validation, so source=Waitlist with a capital W returns zero rows and a 200 status.