Home / Guides / Importing users and data
Product guide

Importing users and data

Your app creates profiles as people identify. This page is for the other direction: a backend or data team loading people and what you already know about them, such as phone numbers and consent from your CRM, salary bands from core banking, or a list of customers to suppress. Every route here is on the management API, not the SDK, so there are no app code samples: the samples are curl calls you run from a server or a data pipeline.

In short
  • Contact details and consent go in with POST /api/v1/users/bulk/contacts (up to 5,000 per request); attributes for targeting go in through an enrichment import.
  • The staged import (/api/v1/enrichment/imports) checks and matches every row and shows a dry run before anything is written; nothing changes until you commit.
  • When two sources hold the same attribute, the higher-priority source wins: CRM first, then loyalty, ERP, manual, CSV and analytics, and an admin can change the order.
  • Every route needs a person's portal sign-in token and X-Tenant-Id, with the admin or editor role; SDK, write and read keys do not open them.
  • The most common trap: an attribute import never gives anyone consent. A profile reaches push, SMS, WhatsApp or email only once its channel consent is recorded.
Overview

Which route to use

You want toUseIn the dashboard
Give people an email, phone or push token, and record their channel consentPOST /users/bulk/contactsAudience → People → Import contacts
Load attributes to target on (salary band, product holdings, tier) from a file, with a preview firstThe staged import, /enrichment/importsAudience → Data Import → Open the import wizard
Load attributes from a pipeline that already has parsed rows keyed by user idPOST /enrichment/uploadNone (API only)
Put a known list of people into a segment, or take them outPOST /users/bulk/segmentsAudience → People, select, Add to segment
Stop all outbound marketing to a list of people, or undo thatPOST /users/bulk/suppress, /bulk/unsuppressAudience → People, select, Suppress

Profiles your app creates itself, and the traits it sends, are covered in Audience & privacy: profiles.

Access

Credentials and roles

All routes on this page are on the management API, https://api.contentflow.click/api/v1, and take two headers: Authorization: Bearer <token> and X-Tenant-Id: <workspaceId>. The token is a person's portal sign-in token from POST /api/v1/auth/login; it lasts one day and ends when that session is signed out. There is no service account or server key for these routes: the SDK key, secret pk_ keys, the write key and the read key are all refused here. See Campaigns: REST endpoints for signing in, and Security: keys for what each key opens.

RouteRole
POST /users/bulk/contacts, /bulk/segments, /bulk/suppress, /bulk/unsuppressAdmin or editor
Staged import: start, stage rows, match, commit, cancel; save or re-map a sourceAdmin or editor
Read an import, list saved sources, list upload historyAny member
Delete a saved source; roll back or re-run an upload; change source priorityAdmin
POST /enrichment/uploadAdmin or editor

Check success in every answer, not only the HTTP status: several refusals here come back with the route's normal status and { "success": false, "error": … }. The samples below assume:

export CF_API=https://api.contentflow.click/api/v1
export CF_PORTAL_TOKEN=…        # from POST /api/v1/auth/login
export CF_WORKSPACE_ID=…        # your workspace id, sent as X-Tenant-Id

People

Contacts and consent

Campaign dispatch reads a person's phone, email and push token, and their channel consent, from the profile. POST /users/bulk/contacts is how you put them there in bulk. It upserts by user id: a person who doesn't exist yet is created, and an existing profile only gets the fields you send (its demographics, segments and traits are left alone).

curl -X POST $CF_API/users/bulk/contacts \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID" \
  -H 'Content-Type: application/json' \
  -d '{"defaultCountry":"SA","contacts":[
        {"userId":"cust_1042","email":"a@example.com","phone":"+966500000001",
         "consent":{"sms":true,"email":true,"marketing":true}},
        {"userId":"cust_1043","deviceToken":"…","platform":"android","consent":{"push":true}}]}'
# {"success":true,"data":{"received":2,"processed":2,"created":1,"updated":1,"errorCount":0,"errors":[]}}
  • Fields per contact: userId (required), email, phone, deviceToken, platform, country, and consent with any of push, sms, whatsapp, email, inApp, marketing and locationTracking as true or false. A consent flag you leave out is not changed.
  • Limits: 1 to 5,000 contacts per request. A row without userId is skipped. A malformed email is not stored, and the row's other fields still import; both are reported in errors (the first 50).
  • Country: a row's country is stored; defaultCountry only fills a newly created profile. Nothing is invented when neither is given.
  • Plan: new people count against the plan's customer limit. Over it, the answer is success: false with UPGRADE_REQUIRED.
  • Segments that use consent, platform or country are re-evaluated for the imported people in the background.

Consent recorded this way is the workspace's own statement that the person agreed. Record it only when you hold that consent, for example from your onboarding records. How each flag is used is in Identity and consent.

People

Segment membership

Put a known list of people into a segment, or take them out, without changing the segment's rules:

curl -X POST $CF_API/users/bulk/segments \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID" \
  -H 'Content-Type: application/json' \
  -d '{"segmentId":"Branch pilot","action":"add","userIds":["cust_1042","cust_1043"]}'
# {"success":true,"data":{"segmentId":"…","segment":"Branch pilot","action":"add","matched":2,"modified":2,…}}
  • segmentId takes the segment's id or its name; action is add (the default) or remove. An unknown segment answers 404.
  • A person added this way stays in the segment when it refreshes, even if they don't match its rules. Removing them takes away only that manual membership: someone who also matches the rules, or was added by another source such as a sensor rule, stays in.
  • Only people who already have a profile can be added; the others are reported as not found.
People

Suppress and unsuppress

POST /users/bulk/suppress with { "userIds": [...] } switches off marketing and the push, SMS, WhatsApp and email channels for those people, and first saves what each of them had agreed to. In-app is left on, because in-app blocks also carry functional content. Suppressing someone twice keeps the first saved state.

POST /users/bulk/unsuppress with the same body puts each channel back exactly as it was before the suppression, including "never asked". A person who was never suppressed with this route, or has already been restored, is left unchanged and listed in skipped / skippedUserIds: unsuppress never turns a person's own refusal into consent.

Suppression is different from a PDPL opt-out, which blocks every outbound channel; see Identity and consent: PDPL notes.


Attributes

Two ways to load attributes

Attributes you load are stored next to each profile, not inside it, tagged with the source they came from, and become segment fields named enrichment.<name>. Campaigns, journeys and dashboard counts can target them; in-app blocks can't (see How fresh is a segment?). There are two ways in:

  • The staged import takes the raw columns of a file, applies a column mapping on the server, matches people by user id, email or phone, and shows a dry run. Use it for files, for anything matched on email or phone, and whenever someone should check the result before it is written. The dashboard uses it.
  • The simple upload takes rows you have already parsed, keyed by user id, and writes them at once. Use it from a pipeline that already knows its user ids.

Both record an entry in upload history, which an admin can roll back.

Attributes

The staged import

An import moves through four steps. Rows are checked as they arrive, matched in the background, and written only when you commit.

Create → stage rows → match → dry run → commit
  1. Send the file name, its header row, and a column mapping (or a saved source's id). The import starts in staging.

    If this step is skipped: A mapping that names a column the file doesn't have, a bad attribute name or type, or no included column is refused with 400 and no import is created.

  2. Send the data rows in chunks of up to 2,000, each with its 1-based startRow. A chunk sent twice is not counted twice.

    If this step is skipped: A row with no or a malformed identifier, an invalid value, or no values at all is staged as rejected with a reason. It is never written.

  3. Starts matching in the background and answers 202.
  4. Duplicate identifiers in the file are resolved (the last row wins), then each row is matched to a profile in the import's environment.

    If this step is skipped: An email or phone that belongs to two profiles is rejected as ambiguous_identity rather than guessed. Only profiles in the import's environment (live or test) are matched.

  5. Poll until status is ready. The answer is the dry run: counts, reject reasons, per-column stats and a masked sample of the first 20 rows. Nothing has been written.
  6. Starts the write and answers 202. Poll again until status is committed.

    If this step is skipped: If no row matched a profile, commit is refused: there is nothing to import. With createMissing, a profile is created for each unmatched user id first.

  7. Matched rows are stored under their source, upload history gets an entry you can roll back, and only the segments that read the imported columns are recalculated, for the imported people.

The same steps as calls. The file here has a header customer_id,salary_band,has_card:

# 1. Start the import with a mapping
curl -X POST $CF_API/enrichment/imports \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID" \
  -H 'Content-Type: application/json' \
  -d '{"fileName":"crm-2026-10.csv","headers":["customer_id","salary_band","has_card"],
       "mapping":{"source":"crm_webhook","identity":{"column":"customer_id","type":"userId"},
         "columns":[{"column":"salary_band","name":"salary_band","type":"enum","enumValues":["A","B","C"]},
                    {"column":"has_card","name":"has_card","type":"boolean"}]}}'
# 201 {"success":true,"data":{"importId":"imp_…","status":"staging",…}}

# 2. Stage rows (repeat per chunk of up to 2,000; startRow is the first data row's number)
curl -X POST $CF_API/enrichment/imports/$IMPORT_ID/rows \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID" \
  -H 'Content-Type: application/json' \
  -d '{"startRow":1,"rows":[["cust_1042","B","yes"],["cust_1043","A","no"]]}'
# {"success":true,"data":{"staged":2,"skipped":0,"counts":{…}}}

# 3. Match, then poll until "status":"ready" and read the dry run
curl -X POST $CF_API/enrichment/imports/$IMPORT_ID/match \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID"
curl $CF_API/enrichment/imports/$IMPORT_ID \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID"

# 4. Commit, then poll until "status":"committed"
curl -X POST $CF_API/enrichment/imports/$IMPORT_ID/commit \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID" \
  -H 'Content-Type: application/json' -d '{}'
  • Limits: up to 500 columns in the file, 100 imported columns, 2,000 rows per chunk and 200,000 rows per import. Split a bigger file into several imports.
  • Statuses: staging → matching → ready → committing → committed, or failed / cancelled. You can stage more rows only while staging; to add rows after matching, start a new import. A ready import can be matched again.
  • Staged rows are kept for 24 hours. Commit within a day of staging, or start again.
  • Cancel with DELETE /enrichment/imports/{id} while it is staging, ready or failed; its staged rows are discarded.
  • Test environment: send "env": "test" when you start the import to match and write test profiles only.
Attributes

Column mapping

FieldRules
sourceWhich system the data comes from: csv_upload (the default), crm_webhook, erp_sync, loyalty_api or manual_api. It decides who wins a conflict; see Source priority.
identity.column, identity.typeThe column that identifies each person, and how: userId, email, phone, email_sha256 or phone_sha256. Emails and phones are hashed as soon as a row arrives, so the raw value is never stored. For a hashed column, hash the trimmed, lower-cased email, or the phone in + E.164 form; a Saudi 05XXXXXXXX number is read as +9665XXXXXXXX.
columns[]One entry per file column: column (its header), name (the attribute name), type, and optionally include: false to skip it, pii: true to mark it as personal data, label ({ en, ar }) and, for enum, enumValues. At least one column must be included.
typestring, number (thousands separators allowed), boolean (true/false, yes/no, 1/0), date (an ISO date such as 2026-05-31), enum (one of enumValues, when given), or list (values separated by |).
Attribute nameLetters, digits, _, - and spaces, starting with a letter, digit or _, at most 64 characters; no . and no leading $. Unique within the import.
expiresInDaysOptional, 1 to 3650. The values stop counting after that many days, and a rule on an expired value doesn't match. You can send an expiresAt date when starting the import instead.

A blank cell means "no value": it never overwrites an existing value with an empty one.

Attributes

Read the dry run

Once matching finishes, GET /enrichment/imports/{id} answers with what a commit would do:

  • counts: rows, valid, rejected, duplicates, matched and unmatched, and matchRate (the percentage of valid rows that matched a profile).
  • rejectReasons: rejected rows per reason, from the list below.
  • columnStats: for each imported column, how many cells had a value and how many were invalid.
  • sample: the first 20 rows with their status, a masked identifier, and values, where columns marked pii show as •••.
ReasonDashboard labelMeans
missing_identityNo identifierThe identity cell is empty.
invalid_email, invalid_phone, invalid_hash, invalid_user_idInvalid email, Invalid phone number, Invalid SHA-256 hash, Invalid user IDThe identifier is not in the expected form (a user id is at most 256 characters; a hash is 64 hex characters).
invalid_value:<name>Invalid value in nameA cell could not be read as its column's type, or is not one of the enum values. The whole row is rejected.
no_valuesNo values to importEvery imported cell in the row is blank.
duplicate_identityDuplicate identifierThe same person appears again later in the file; the last row is kept.
ambiguous_identityMatches more than one profileTwo profiles share that email or phone, so the row is not guessed onto either.

Unmatched rows are valid rows with no profile for their identifier. They are not written unless you commit with createMissing.

Attributes

Commit, roll back, re-run

  • Commit writes the matched rows. With { "createMissing": true }, and only when the identity type is userId, it first creates a profile for each unmatched user id (for example dormant customers who never opened the app). New profiles count against the plan's customer limit (UPGRADE_REQUIRED over it), carry only the imported attributes, and have no consent: they join segments at once but receive no outbound message until consent is recorded, for example with bulk contacts.
  • Upload history: GET /enrichment/uploads lists every upload, staged or simple, and GET /enrichment/uploads/{jobRef} shows one with its first 50 row errors. For a staged import the jobRef is the import id.
  • Roll back (admin): DELETE /enrichment/uploads/{jobRef} removes every attribute value that upload wrote. Profiles it created with createMissing are kept.
  • Re-run (admin): POST /enrichment/uploads/{jobRef}/rerun writes a past simple upload again under a new job reference. It works only for simple uploads of up to 5,000 records, whose rows were kept; anything else answers 400 and you upload the file again.
  • If a commit fails part way, the import shows failed: roll it back from upload history, then import again.
Attributes

Saved sources

A saved source is a named column mapping you reuse when the next file arrives from the same system. Save one with POST /enrichment/sources ({ "name": "Core banking", "mapping": {…} }), or pass "saveAsSource": "Core banking" when you start an import. Then start later imports with "sourceId": "src_…" instead of a mapping.

  • GET /enrichment/sources lists them, with when each was last used. PUT /enrichment/sources/{sourceId} re-maps or renames one. DELETE (admin) removes the mapping; data already imported with it stays.
  • Names are unique in a workspace (409 otherwise) and at most 120 characters.
  • The mapping is checked against each new file's headers: a source whose identity column is missing from the file is refused.
Attributes

The simple upload

When your pipeline already has rows keyed by user id, send them in one call:

curl -X POST $CF_API/enrichment/upload \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID" \
  -H 'Content-Type: application/json' \
  -d '{"source":"erp_sync","sourceRef":"nightly-2026-10-05",
       "attributeTypes":{"salary_band":"string","monthly_salary":"number"},
       "records":[{"userId":"cust_1042","attributes":{"salary_band":"B","monthly_salary":5200}}]}'
# 201 {"success":true,"data":{"jobRef":"nightly-2026-10-05_…","totalRecords":1,…}}
  • records (non-empty) and sourceRef (your own label for the batch) are required; source defaults to csv_upload. expiresAt is optional.
  • Rows are written straight away, in batches of 200; the answer carries a jobRef, counts and up to 50 row errors. There is no dry run and no email or phone matching: use the staged import for those.
  • An upload of up to 5,000 records can be re-run later from upload history.
Attributes

Source priority

The same attribute can arrive from several sources, for example a tier from the CRM and from a CSV upload. Each source has a priority, and the value from the highest-priority source that has not expired wins. Between uploads from the same source, the newest wins.

SourceDashboard labelDefault priority
crm_webhookCRM100
loyalty_apiLoyalty platform90
erp_syncERP / core banking80
manual_apiManual / back-office70
csv_uploadCSV upload50
analyticsAnalytics (derived)40

To change the order in the dashboard, open Source priority (in Audience → Data Import or on the Data Enrichment page), drag the sources or use the arrow keys, and choose Save order. Only admins can save. With the API:

# Current ranking, the defaults and the known sources (any member)
curl $CF_API/enrichment/source-priorities \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID"

# Let core banking beat the CRM (admin)
curl -X PUT $CF_API/enrichment/source-priorities \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID" \
  -H 'Content-Type: application/json' \
  -d '{"priorities":{"erp_sync":110}}'
# {"success":true,"data":{"priorities":{"crm_webhook":100,"loyalty_api":90,"erp_sync":110,…}}}
  • A PUT replaces all of the workspace's overrides with the ones you send; sources you leave out go back to their defaults. Higher numbers win.
  • After a change, every uploaded attribute is resolved again and the segments that use uploaded attributes are recalculated in the background. Each server keeps the ranking for up to a minute, so a change can take that long to apply everywhere.
In the dashboard

In the dashboard

  • Audience → People → Import contacts takes a CSV of user ids with phone, email, push token and consent flags (Download CSV template gives the columns) and sends it to bulk contacts. Selecting people in the list offers Add to segment, Remove from segment, Suppress and Unsuppress.
  • Audience → Data Import shows upload counts and has three tabs: Upload data (with Open the import wizard), Upload history and Source priority.
  • The import wizard, on the Data Enrichment page, runs the staged import in four steps: Upload file (CSV, optionally Use a saved source), Map columns (Match people by column, Identifier type, a type, name, PII flag and Labels per column, Values expire, Save this mapping as a source), Dry run (Run dry run, then the counts, rejected rows by reason, column stats and masked sample rows) and Result. Commit to n profiles writes it, and Create n new customers for the unmatched user IDs sets createMissing.
  • The same page has Upload history (Roll back this upload, Run this upload again), Saved sources (Use with a new file, Rename, Delete), Attributes (the attribute catalog: every field segments can target, with its coverage) and Source priority.
  • With Test mode on, an import writes to test profiles only.
Troubleshooting

When it doesn't work

What you seeWhy, and the fix
401 AUTHENTICATION_REQUIRED or INVALID_CREDENTIALSNo portal sign-in token, or an expired one. These routes don't take SDK, pk_, write or read keys. Sign in again (tokens last a day).
403 on a writeThe signed-in person's role can't do it: imports and bulk actions need admin or editor; rollback, re-run, deleting a source and changing source priority need admin.
The dry run shows most rows Not matchedThe identifiers don't match any profile in this environment: check the identifier type, that emails and phones are stored on the profiles (bulk contacts), and that Test mode matches where your profiles are. Or commit with createMissing for user ids.
Commit refused: "No rows matched a customer; nothing to import"Zero matched rows. Fix the identity column or type and start a new import.
409 "Rows can only be added while staging" or "can't be committed now"The import has moved on. Poll GET /enrichment/imports/{id} for its status; start a new import to add rows after matching.
Imported people get no SMS, email or pushNo channel consent or no contact address on their profiles. Attribute imports never record consent; use bulk contacts, then check the campaign's skip reasons in Campaigns.

Next