Sync store customers and phone numbers
Why does the phone number format matter?
A contact with the right number ties the customer's WhatsApp conversations, events and journeys to their store data. One customer stored as two contacts splits their history, and your team answers without the full picture.
Create contact accepts phone only in E.164 format, such as +31612345678. A number stored in two formats also breaks the duplicate check, so normalize before you search or create.
Store customer → Phone normalized → Contact found or created → Fields and tags updated
How do I normalize phone numbers to E.164?
Parse each number with the customer's country as the default region: with libphonenumber-js in Node.js, phonenumbers in Python, or giggsey/libphonenumber-for-php in PHP.
import { parsePhoneNumberFromString } from "libphonenumber-js";
export function toE164(raw, country) {
const phone = raw ? parsePhoneNumberFromString(raw, country) : undefined;
return phone?.isValid() ? phone.number : null;
}
toE164("0612 345 678", "NL"); // "+31612345678"
toE164("(415) 555-2671", "US"); // "+14155552671"
toE164("+48 512 345 678"); // "+48512345678"Skip customers whose number doesn't parse, and report them to the store team instead of guessing.
Find an existing contact before you create one
Create contact makes a new contact on every call, even when another contact already has the number. Search first with Search contacts:
curl -X POST "https://api.flownally.com/v1/contacts:search" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": {
"match": "all",
"conditions": [
{ "type": "customFieldText", "fieldId": "cf_phone", "operator": "equals", "value": "+31612345678" },
{ "type": "archiveStatus", "status": "all" }
]
},
"pageSize": 1
}'- Match on
cf_phone. It's the predefined custom field that holds the contact's phone number, andequalscompares the whole value. - Include archived contacts. Searches leave archived contacts out unless the query has an
archiveStatuscondition, andallbrings them back, so a returning customer isn't created twice. - Don't use
searchTextfor duplicates. It matches names, emails and phone numbers that contain the text, which suits people lookups, not exact checks.
A match comes back in contacts:
{
"contacts": [
{
"id": "con_d7pibvs6gljrc2pfdc1g",
"name": "Sanne de Vries",
"phone": "+31612345678",
"tags": [],
"channels": ["whatsapp"],
"hasConversations": true,
"createdAt": "2026-10-12T09:20:00Z",
"updatedAt": "2026-11-02T14:05:00Z"
}
],
"totalSize": 1,
"evaluatedAt": "2026-11-04T10:00:00Z"
}Create the contact with its phone number
When the search returns no contacts, create one. phone is stored as the cf_phone custom field, and customFields takes values keyed by custom field ID, not by field name:
curl -X POST "https://api.flownally.com/v1/contacts" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Sanne de Vries",
"phone": "+31612345678",
"customFields": {
"cf_d7pgooc1v8t53cs68k5g": "shopify-7390152311",
"cf_d7pgpgin6fdi7ocdmdgg": "2026-11-02"
}
}'The response is the new contact with its id. An unknown custom field ID fails with 404 and code contacts_customfield_not_found.
Store order data in custom fields
Custom fields put store context next to the conversation, such as the store's customer ID or the last order date. Create the field definitions once with Create contact field definition. A field is text or date:
curl -X POST "https://api.flownally.com/v1/contacts/custom-fields" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Store customer ID", "type": "text" }'
curl -X POST "https://api.flownally.com/v1/contacts/custom-fields" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Last order date", "type": "date" }'Each response has the field's id, such as cf_d7pgooc1v8t53cs68k5g. List contact field definitions returns the existing fields, including predefined ones marked predefined: true.
To update a value on an existing contact, use Set contact field value. Date values use YYYY-MM-DD, and an empty string clears the value:
curl -X PUT "https://api.flownally.com/v1/contacts/$CONTACT_ID/custom-fields/cf_d7pgpgin6fdi7ocdmdgg" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "value": "2026-11-04" }'Journeys can wait for a date custom field, and segments can filter on it with a customFieldDate condition.
Tag store customers
Tags help the inbox team see who they're talking to, for example Repeat buyer or Subscriber. Create a tag once with Create tag, or find it with List tags:
curl -X POST "https://api.flownally.com/v1/tags" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Repeat buyer" }'Add the tag to a contact with Add tag to contact, and remove it with Remove tag from contact. Both return 204:
curl -X POST "https://api.flownally.com/v1/contacts/$CONTACT_ID/tags/$TAG_ID" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
curl -X DELETE "https://api.flownally.com/v1/contacts/$CONTACT_ID/tags/$TAG_ID" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN"Sync store customers one by one
Run this upsert when a customer signs up or places an order. It uses the E.164 helper from above and the custom field and tag IDs you created:
const API = "https://api.flownally.com/v1";
const STORE_ID_FIELD = "cf_d7pgooc1v8t53cs68k5g";
const LAST_ORDER_FIELD = "cf_d7pgpgin6fdi7ocdmdgg";
const REPEAT_BUYER_TAG = "tag_d7pj8kaicb30377174j0";
async function flownally(method, path, body) {
const response = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
"Content-Type": "application/json",
},
body: body && JSON.stringify(body),
});
if (!response.ok) throw new Error(`Flownally ${method} ${path}: ${response.status}`);
return response.status === 204 ? null : response.json();
}
async function findContactId(phone) {
const { contacts } = await flownally("POST", "/contacts:search", {
query: {
match: "all",
conditions: [
{ type: "customFieldText", fieldId: "cf_phone", operator: "equals", value: phone },
{ type: "archiveStatus", status: "all" },
],
},
pageSize: 1,
});
return contacts[0]?.id ?? null;
}
export async function upsertCustomer(customer) {
const phone = toE164(customer.phone, customer.country);
if (!phone) return null;
const fields = {
[STORE_ID_FIELD]: customer.id,
[LAST_ORDER_FIELD]: customer.lastOrderDate, // YYYY-MM-DD
};
let contactId = await findContactId(phone);
if (!contactId) {
const contact = await flownally("POST", "/contacts", { name: customer.name, phone, customFields: fields });
contactId = contact.id;
} else {
for (const [fieldId, value] of Object.entries(fields)) {
await flownally("PUT", `/contacts/${contactId}/custom-fields/${fieldId}`, { value });
}
}
if (customer.orderCount > 1) {
await flownally("POST", `/contacts/${contactId}/tags/${REPEAT_BUYER_TAG}`);
}
return contactId;
}- Run one upsert per phone number at a time. Two workers that create the same new customer at once can both miss the search and create two contacts.
- Leave names alone once the contact exists. Your team or the customer's WhatsApp profile may have set the name. Rename contacts with Update contact only if the store owns names.
- Back off on
429and5xx. Requests are rate limited per client IP address, and429responses carry noRetry-Afterheader, so retry with exponential backoff and jitter.
Import store customers in bulk from CSV
For a first load of many customers, upload a CSV with Import contacts from CSV. The file needs a header row and can be up to 5 MB. columnMapping maps CSV headers to name, phone or a custom field ID, and tagIds adds tags to every imported contact:
curl -X POST "https://api.flownally.com/v1/contacts:import" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-F "file=@customers.csv;type=text/csv" \
-F 'columnMapping={"Name":"name","Phone":"phone","Customer ID":"cf_d7pgooc1v8t53cs68k5g"}' \
-F "tagIds=tag_d7pj8kaicb30377174j0"The response has an importId. Poll Get import status until status is completed or failed:
curl "https://api.flownally.com/v1/contacts/imports/imp_d7pjdd4valnuhvhh1c8g" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN"{
"importId": "imp_d7pjdd4valnuhvhh1c8g",
"status": "completed",
"totalRows": 1200,
"successCount": 1194,
"failedCount": 6,
"errors": [
{
"row": 17,
"column": "phone",
"message": "must be a valid E.164 phone number"
}
]
}Normalize the phone column before you upload, so rows don't fail on number format.
Imports don't check for existing contacts
An import creates a contact for every valid row, even when a contact with that number already exists. Import only customers who aren't in Flownally yet, and use the one-by-one upsert for everyone else.
FAQ
Why did my sync create duplicate contacts?
Three causes are common: the number was searched in a different format than it was stored, the contact was archived and the search had no archiveStatus condition, or two workers created the same new customer at once. Normalize every number to E.164, search with archiveStatus all, and run one upsert per phone number at a time.
Why does Create contact reject my phone number?
phone must be E.164: a +, the country code and the number, without spaces or punctuation. Run it through the E.164 helper (toE164 or to_e164) first, and skip numbers that don't parse.
Why does the import status stay pending?
The status stays pending, with zero counts, until Flownally has processed the whole file. Keep polling Get import status until it's completed or failed, then read errors for rows that didn't import.
What format do date custom fields use?
Dates use YYYY-MM-DD, such as 2026-11-04, both when you create a contact and when you set a value. An empty string clears the value and keeps the field definition.