---
title: People, imports and campaign audiences
description: Import a CSV once, combine groups and live segments, and enroll each contact once.
---

All examples use `https://api.norbelys.com/v1` and an `Authorization: Bearer $NORBELYS_API_KEY` header. IDs belong to the authenticated workspace. A foreign or missing audience ID returns `404`; it never silently broadens the audience. JSON field names are snake_case and enum values keep their Rust spelling, such as `Csv`, `All` and `Completed`.

People have a read-only `status` derived from the workspace suppression list: `Active` (unsuppressed), `Bounced`, `Unsubscribed`, `Complained` or `Suppressed` (manual). `Active` does not verify that an inbox exists. Use `suppressed=false` to exclude blocked addresses from an audience. Keep bounced contacts for their history; deleting or reimporting one never clears its address-level suppression. Suppression stops active enrollments as `Failed` and queued messages as `Suppressed`. Explicitly removing it allows new work but does not restart those terminated messages or sequences.

## Import a CSV and use its ID

Create an import from a URL on an operator-approved file host. A signed URL stays encrypted and is not returned by the API. CSV limits are 20 MiB and 50,000 records; inline JSON accepts 1–1,000 contacts. The server downloads and processes the source asynchronously.

```bash
curl -X POST https://api.norbelys.com/v1/people/imports \
  -H "Authorization: Bearer $NORBELYS_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: conference-csv-2026-09' \
  -d '{"source":{"type":"Csv","url":"https://files.example.com/leads.csv","mapping":{"email":"Email","given_name":"First name"}}}'
```

The `202` response contains an `imp_…` ID and a `Location` header. Poll that URL until `status` is `Completed` and `people_available` is `true`. This ID identifies the import cohort; there is no separate public file resource to create first.

Existing emails, compared without ASCII letter case, are reused without overwriting names or custom fields. Repeated rows create no duplicate contact. Every valid existing or new contact belongs to the import cohort once. Optional `group_id` also adds those contacts to a static group. `imported` counts new contacts; `skipped` counts existing/repeated valid rows; `invalid` counts rejected rows. For a completed import, their sum equals `total`.

An import's cohort is independent of later group changes. Deleting a person removes that person from the cohort. Older imports created before cohort storage report `people_available: false`; import the source again to record the cohort safely. The API does not guess historical membership from a mutable group.

## Add contacts to a campaign

Configure the campaign's initial sequence and sending accounts first. Then send one `PATCH /campaigns/{id}` with an `Idempotency-Key`:

```json
{
  "audience": {
    "import_ids": ["imp_0195f30ed98470008000000000000001"],
    "group_ids": ["grp_0195f30ed98470008000000000000001"],
    "segment_ids": ["seg_0195f30ed98470008000000000000001"],
    "person_ids": ["per_0195f30ed98470008000000000000001"]
  }
}
```

Use only the arrays you need. The result is the **union** of all sources, with each contact once. A one-item `person_ids` array adds one contact. An empty audience is rejected; it never means “everyone.” Limits are 20 groups, 20 segments, 20 imports and 1,000 explicit people per selection. The same `audience` object is accepted when creating a campaign with its sequence definitions. Optional `start_at` sets the admission's initial time.

Read campaign detail to follow `audience.status`, `matched`, `enrolled`, `skipped` and `status_detail`. Admission processes at most 500 contacts per transaction, retaining a cursor across worker restarts. People already enrolled in this campaign are skipped, including completed or cancelled enrollments. A new audience does not reactivate them. Use the explicit single-contact enrollment operation when you intentionally need a new enrollment after an earlier one ended. Active duplicates remain disallowed.

Repeating a PATCH with the same key and body resumes/replays the same admission. A changed body with the same key returns `409`; a different admission must wait until the active one finishes. Pending, failed or legacy imports without a recorded cohort also return `409`. Admission does not activate a paused campaign. Suppressions still apply before delivery.

Groups and segments are evaluated while the worker walks the audience; this is a one-time admission, not an ongoing subscription or a transaction-wide snapshot of a changing dataset. If membership changes during that walk, submit another selection afterward to admit new matches. Imports provide a stable cohort once completed, except for contact deletions.

## List and combine people

Use the people collection for segment membership; a separate segment-members endpoint is unnecessary. Direct filters combine with **AND**:

```bash
curl --get https://api.norbelys.com/v1/people \
  -H "Authorization: Bearer $NORBELYS_API_KEY" \
  --data-urlencode "segment_id=$SEGMENT_ID" \
  --data-urlencode "group_id=$GROUP_ID" \
  --data-urlencode 'suppressed=false' \
  --data-urlencode 'page_size=100'
```

To select members of either of two groups who also match a saved segment:

```bash
curl --get https://api.norbelys.com/v1/people \
  -H "Authorization: Bearer $NORBELYS_API_KEY" \
  --data-urlencode "audience={\"group_ids\":[\"$GROUP_A\",\"$GROUP_B\"]}" \
  --data-urlencode "segment_id=$SEGMENT_ID"
```

The `audience` query uses the same `PeopleSelection` as campaigns, encoded as JSON with a maximum of 8,192 bytes. It supports imports and explicit people too. Its union is intersected with all other parameters. `segment_id=A` plus `audience={"segment_ids":[B,C]}` means `A AND (B OR C)`.

For an unsaved filter, pass `filter` as URL-encoded JSON. This is also how clients preview a segment before saving it:

```json
{
  "match": "All",
  "rules": [
    { "field": "CustomField", "key": "score", "operator": "Gte", "value": 50 },
    { "field": "Email", "operator": "EndsWith", "value": "@example.com" }
  ]
}
```

`All` requires every rule; `Any` requires one. Filters support 1–20 rules. The OpenAPI extension `x-norbelys-people-filter` lists supported fields and operators. Saved segments use this same grammar. Rules compile to bound SQL predicates; clients never send raw SQL.

Other filters include literal substring `q`, exact `email`, `suppressed`, created/updated timestamp bounds, and `min_group_count`/`max_group_count`. `max_group_count=0` finds ungrouped contacts. Timestamp bounds are exclusive. Field values, contact data and counts stay scoped to the workspace; the same email can independently exist in another workspace.

The response contains `data` and `meta: {total, has_more, next_cursor}`. `total` is the exact matching count before pagination, evaluated with the page in one database snapshot. Pages are newest first. Keep the filters unchanged and pass `cursor=meta.next_cursor` until `has_more` is false. Separate requests see current data, so edits between pages can change the total. Complex substring/custom-field searches may scan their candidate audience.
