Skip to content
Norbelys
Esc
↑↓navigate↵open⌘Jpreview
On this page

People, imports and campaign audiences

Import people from a CSV file or JSON, suppress addresses in bulk, and enroll people into campaigns.

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 ID returns 404; it never silently broadens the audience. JSON field names and enum values are snake_case, such as completed. Every effectful POST takes an Idempotency-Key header: reuse it when you retry the same request.

A person is an address: the workspace holds one person per email address, compared ignoring ASCII case over the whole address (dots and +tags are kept as written). Suppressions are kept by address too, apart from people: deleting a person, or importing the address again, never lifts a suppression.

Import people

POST /imports takes either a CSV file as the request body or JSON with up to 1,000 people. The import runs as a job: the request answers 202 with the import in status queued and its Location, and nothing is imported during the request.

A CSV file is the body itself, sent as Content-Type: text/csv, at most 16 MiB. Its first record is the header and must have an email column. Pass group_id as a query parameter to add every imported person to a group:

curl -X POST "https://api.norbelys.com/v1/imports?group_id=$GROUP_ID" \
  -H "Authorization: Bearer $NORBELYS_API_KEY" \
  -H 'Content-Type: text/csv' \
  -H 'Idempotency-Key: conference-csv-2026-09' \
  --data-binary @leads.csv

There is no mapping parameter: the header names the columns. Names are compared trimmed and in lowercase, with spaces, hyphens and dots read as underscores, and the common spellings are understood: email, email_address, first_name or given_name, last_name, surname or family_name, company or organization. A column named by a custom field’s key (or fields.<key>) fills that field, read as its type. Other columns are ignored, so a file exported from a CRM or a spreadsheet imports without editing. A file without an email column, or with two columns meaning the same thing, is refused with 422.

JSON names the people directly, each {email, given_name?, family_name?, company?, fields?}, with an optional group_id:

curl -X POST https://api.norbelys.com/v1/imports \
  -H "Authorization: Bearer $NORBELYS_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: crm-sync-2026-09-14' \
  -d '{"people":[{"email":"ada@example.com","given_name":"Ada","fields":{"tier":"gold"}}],"group_id":"grp_0190f8a2b4c87a10b6d2e4f6a8c0e2f4"}'

Poll GET /imports/{id} until status is completed or failed, or listen for the import.completed webhook. counts tells what happened to each row: imported (it created a person or merged into the existing one), skipped (its address came earlier in the same file) and invalid; together they make total. A person who exists already is merged: the values a row gives overwrite theirs, and an empty cell or an absent member leaves theirs as it was. errors.data lists the first 100 problems (row, field, problem; rows numbered as a spreadsheet numbers them, the header being row 1, or from 1 in a JSON import), and errors.url, once the import completed, links a CSV of every problem for 15 minutes from that read.

An import is not an audience of its own. To reach the people of one file, import them into a group and enroll the group.

Suppress addresses

A suppression stops every message of the workspace to an address, whoever the person is. Unsubscribes, authenticated bounces and complaints suppress addresses on their own. You suppress more with POST /suppressions, whose reason is always manual, either one address or a list:

curl -X POST https://api.norbelys.com/v1/suppressions \
  -H "Authorization: Bearer $NORBELYS_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: opt-outs-2026-09-14' \
  -d '{"emails":["no-mail@example.com","Former.Customer@example.org","not an address"]}'

With email (one address) the answer is the suppression (201), or 409 when the address is suppressed already. With emails (1 to 1,000 entries) the list is one decision, answered 200 with what it did: created (addresses suppressed now), already (addresses suppressed before, for any reason) and invalid, each entry that is not an address with its index, value and detail. The other entries are suppressed even when some are invalid, and an address given twice, in any case, counts once. Send a longer list as consecutive calls of 1,000.

Suppressing is immediate. Every live enrollment of a suppressed address stops (status: stopped, “The address is suppressed.”) and its queued message is cancelled, in the same transaction; the sender also checks suppressions again before each submission. DELETE /suppressions/{id} lifts a manual suppression; the others record what the recipient or their provider said and cannot be lifted. Lifting one does not restart enrollments it stopped.

Add people to a campaign

POST /enrollments takes the campaign’s campaign_id and exactly one source of people: person_ids (up to 1,000), emails (up to 1,000; an address that is no person of the workspace is skipped), a group_id or a segment_id:

{
  "campaign_id": "cmp_0190f8a2b4c87a10b6d2e4f6a8c0e2f4",
  "group_id": "grp_0190f8a2b4c87a10b6d2e4f6a8c0e2f4"
}

Up to 100 people are enrolled in the request, answered 201 with the enrollments made and the people skipped, each with its reason: not_found, suppressed or already_enrolled (a live enrollment in this campaign; a person whose earlier enrollment ended may be enrolled again). More people go to a job: the answer is 202 with the job and its Location, and GET /jobs/{id} follows it. A group or a segment is read as the job runs, not frozen at the request. An archived campaign takes no new people (409).

List and combine people

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

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 'page_size=100'

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

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. 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:

{
  "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, 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.

Was this page helpful?