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.
- 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.
Which route to use
| You want to | Use | In the dashboard |
|---|---|---|
| Give people an email, phone or push token, and record their channel consent | POST /users/bulk/contacts | Audience → People → Import contacts |
| Load attributes to target on (salary band, product holdings, tier) from a file, with a preview first | The staged import, /enrichment/imports | Audience → Data Import → Open the import wizard |
| Load attributes from a pipeline that already has parsed rows keyed by user id | POST /enrichment/upload | None (API only) |
| Put a known list of people into a segment, or take them out | POST /users/bulk/segments | Audience → People, select, Add to segment |
| Stop all outbound marketing to a list of people, or undo that | POST /users/bulk/suppress, /bulk/unsuppress | Audience → People, select, Suppress |
Profiles your app creates itself, and the traits it sends, are covered in Audience & privacy: profiles.
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.
| Route | Role |
|---|---|
POST /users/bulk/contacts, /bulk/segments, /bulk/suppress, /bulk/unsuppress | Admin or editor |
| Staged import: start, stage rows, match, commit, cancel; save or re-map a source | Admin or editor |
| Read an import, list saved sources, list upload history | Any member |
| Delete a saved source; roll back or re-run an upload; change source priority | Admin |
POST /enrichment/upload | Admin 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-IdContacts 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, andconsentwith any ofpush,sms,whatsapp,email,inApp,marketingandlocationTrackingastrueorfalse. A consent flag you leave out is not changed. - Limits: 1 to 5,000 contacts per request. A row without
userIdis skipped. A malformed email is not stored, and the row's other fields still import; both are reported inerrors(the first 50). - Country: a row's
countryis stored;defaultCountryonly 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: falsewithUPGRADE_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.
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,…}}segmentIdtakes the segment's id or its name;actionisadd(the default) orremove. An unknown segment answers404.- 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.
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.
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.
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.
- 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.
- 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.
- Starts matching in the background and answers
202. - 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.
- Poll until
statusisready. 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. - Starts the write and answers
202. Poll again untilstatusiscommitted.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.
- 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, orfailed/cancelled. You can stage more rows only whilestaging; to add rows after matching, start a new import. Areadyimport 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.
Column mapping
| Field | Rules |
|---|---|
source | Which 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.type | The 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. |
type | string, 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 name | Letters, digits, _, - and spaces, starting with a letter, digit or _, at most 64 characters; no . and no leading $. Unique within the import. |
expiresInDays | Optional, 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.
Read the dry run
Once matching finishes, GET /enrichment/imports/{id} answers with what a commit would do:
counts:rows,valid,rejected,duplicates,matchedandunmatched, andmatchRate(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 markedpiishow as•••.
| Reason | Dashboard label | Means |
|---|---|---|
missing_identity | No identifier | The identity cell is empty. |
invalid_email, invalid_phone, invalid_hash, invalid_user_id | Invalid email, Invalid phone number, Invalid SHA-256 hash, Invalid user ID | The 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 name | A cell could not be read as its column's type, or is not one of the enum values. The whole row is rejected. |
no_values | No values to import | Every imported cell in the row is blank. |
duplicate_identity | Duplicate identifier | The same person appears again later in the file; the last row is kept. |
ambiguous_identity | Matches more than one profile | Two 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.
Commit, roll back, re-run
- Commit writes the matched rows. With
{ "createMissing": true }, and only when the identity type isuserId, 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_REQUIREDover 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/uploadslists every upload, staged or simple, andGET /enrichment/uploads/{jobRef}shows one with its first 50 row errors. For a staged import thejobRefis the import id. - Roll back (admin):
DELETE /enrichment/uploads/{jobRef}removes every attribute value that upload wrote. Profiles it created withcreateMissingare kept. - Re-run (admin):
POST /enrichment/uploads/{jobRef}/rerunwrites 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 answers400and you upload the file again. - If a commit fails part way, the import shows
failed: roll it back from upload history, then import again.
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/sourceslists 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 (
409otherwise) 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.
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) andsourceRef(your own label for the batch) are required;sourcedefaults tocsv_upload.expiresAtis 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.
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.
| Source | Dashboard label | Default priority |
|---|---|---|
crm_webhook | CRM | 100 |
loyalty_api | Loyalty platform | 90 |
erp_sync | ERP / core banking | 80 |
manual_api | Manual / back-office | 70 |
csv_upload | CSV upload | 50 |
analytics | Analytics (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
PUTreplaces 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
- 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.
When it doesn't work
| What you see | Why, and the fix |
|---|---|
401 AUTHENTICATION_REQUIRED or INVALID_CREDENTIALS | No 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 write | The 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 matched | The 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 push | No 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
- Audience & privacy: segments: build segments on the attributes you imported, and see how fresh each one is.
- Reports and exports: get people, segments and delivery results back out as CSV.