# Sync store customers and phone numbers

:::summary Quick answer
To sync store customers and phone numbers, format every number as E.164, search for a contact with that number in `cf_phone`, and create the contact only when the search finds none. Keep store data such as customer IDs and order dates in custom fields, and groups such as repeat buyers in tags. Use a CSV import only for customers who aren't in Flownally yet.

**Search before you create: a new contact is created even when the number already exists.**
:::

## 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](/api/reference/contacts#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.

:::code
```js
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"
```

```python
import phonenumbers


def to_e164(raw, country):
    try:
        number = phonenumbers.parse(raw, country)
    except phonenumbers.NumberParseException:
        return None
    if not phonenumbers.is_valid_number(number):
        return None
    return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)


to_e164("0612 345 678", "NL")  # "+31612345678"
```

```php
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;

function toE164(string $raw, ?string $country = null): ?string
{
    $util = PhoneNumberUtil::getInstance();
    try {
        $number = $util->parse($raw, $country);
    } catch (NumberParseException) {
        return null;
    }
    return $util->isValidNumber($number) ? $util->format($number, PhoneNumberFormat::E164) : null;
}

toE164('0612 345 678', 'NL'); // "+31612345678"
```
:::

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](/api/reference/contacts#search-contacts):

:::code
```bash
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
  }'
```

```js
const response = await fetch("https://api.flownally.com/v1/contacts:search", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query: {
      match: "all",
      conditions: [
        { type: "customFieldText", fieldId: "cf_phone", operator: "equals", value: "+31612345678" },
        { type: "archiveStatus", status: "all" },
      ],
    },
    pageSize: 1,
  }),
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const { contacts } = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/contacts:search",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={
        "query": {
            "match": "all",
            "conditions": [
                {"type": "customFieldText", "fieldId": "cf_phone", "operator": "equals", "value": "+31612345678"},
                {"type": "archiveStatus", "status": "all"},
            ],
        },
        "pageSize": 1,
    },
)
response.raise_for_status()
contacts = response.json()["contacts"]
```

```php
$client = new GuzzleHttp\Client();

$response = $client->post('https://api.flownally.com/v1/contacts:search', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => [
        'query' => [
            'match' => 'all',
            'conditions' => [
                ['type' => 'customFieldText', 'fieldId' => 'cf_phone', 'operator' => 'equals', 'value' => '+31612345678'],
                ['type' => 'archiveStatus', 'status' => 'all'],
            ],
        ],
        'pageSize' => 1,
    ],
]);

$contacts = json_decode((string) $response->getBody(), true)['contacts'];
```
:::

- **Match on `cf_phone`.** It's the predefined custom field that holds the contact's phone number, and `equals` compares the whole value.
- **Include archived contacts.** Searches leave archived contacts out unless the query has an `archiveStatus` condition, and `all` brings them back, so a returning customer isn't created twice.
- **Don't use `searchText` for 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`:

```json
{
  "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:

:::code
```bash
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"
    }
  }'
```

```js
const response = await fetch("https://api.flownally.com/v1/contacts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Sanne de Vries",
    phone: "+31612345678",
    customFields: {
      cf_d7pgooc1v8t53cs68k5g: "shopify-7390152311",
      cf_d7pgpgin6fdi7ocdmdgg: "2026-11-02",
    },
  }),
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const contact = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/contacts",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={
        "name": "Sanne de Vries",
        "phone": "+31612345678",
        "customFields": {
            "cf_d7pgooc1v8t53cs68k5g": "shopify-7390152311",
            "cf_d7pgpgin6fdi7ocdmdgg": "2026-11-02",
        },
    },
)
response.raise_for_status()
contact = response.json()
```

```php
$client = new GuzzleHttp\Client();

$response = $client->post('https://api.flownally.com/v1/contacts', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => [
        'name' => 'Sanne de Vries',
        'phone' => '+31612345678',
        'customFields' => [
            'cf_d7pgooc1v8t53cs68k5g' => 'shopify-7390152311',
            'cf_d7pgpgin6fdi7ocdmdgg' => '2026-11-02',
        ],
    ],
]);

$contact = json_decode((string) $response->getBody(), true);
```
:::

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](/api/reference/contacts#create-contact-field-definition). A field is `text` or `date`:

:::code
```bash
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" }'
```

```js
const fieldIds = {};

for (const field of [
  { name: "Store customer ID", type: "text" },
  { name: "Last order date", type: "date" },
]) {
  const response = await fetch("https://api.flownally.com/v1/contacts/custom-fields", {
    method: "POST",
    headers: {
        Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(field),
  });
  if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);
  fieldIds[field.name] = (await response.json()).id;
}
```

```python
import os

import requests

field_ids = {}

for field in [{"name": "Store customer ID", "type": "text"}, {"name": "Last order date", "type": "date"}]:
    response = requests.post(
        "https://api.flownally.com/v1/contacts/custom-fields",
        headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
        json=field,
    )
    response.raise_for_status()
    field_ids[field["name"]] = response.json()["id"]
```

```php
$client = new GuzzleHttp\Client();
$fieldIds = [];

foreach ([['name' => 'Store customer ID', 'type' => 'text'], ['name' => 'Last order date', 'type' => 'date']] as $field) {
    $response = $client->post('https://api.flownally.com/v1/contacts/custom-fields', [
        'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
        'json' => $field,
    ]);
    $fieldIds[$field['name']] = json_decode((string) $response->getBody(), true)['id'];
}
```
:::

Each response has the field's `id`, such as `cf_d7pgooc1v8t53cs68k5g`. [List contact field definitions](/api/reference/contacts#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](/api/reference/contacts#set-contact-custom-field-value). Date values use `YYYY-MM-DD`, and an empty string clears the value:

:::code
```bash
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" }'
```

```js
const response = await fetch(
  "https://api.flownally.com/v1/contacts/CONTACT_ID/custom-fields/cf_d7pgpgin6fdi7ocdmdgg",
  {
    method: "PUT",
    headers: {
            Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ value: "2026-11-04" }),
  },
);
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);
```

```python
import os

import requests

response = requests.put(
    "https://api.flownally.com/v1/contacts/CONTACT_ID/custom-fields/cf_d7pgpgin6fdi7ocdmdgg",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={"value": "2026-11-04"},
)
response.raise_for_status()
```

```php
$client = new GuzzleHttp\Client();

$client->put('https://api.flownally.com/v1/contacts/CONTACT_ID/custom-fields/cf_d7pgpgin6fdi7ocdmdgg', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => ['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](/api/reference/contacts#create-tag), or find it with [List tags](/api/reference/contacts#list-tags):

:::code
```bash
curl -X POST "https://api.flownally.com/v1/tags" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Repeat buyer" }'
```

```js
const response = await fetch("https://api.flownally.com/v1/tags", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "Repeat buyer" }),
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const tag = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/tags",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={"name": "Repeat buyer"},
)
response.raise_for_status()
tag = response.json()
```

```php
$client = new GuzzleHttp\Client();

$response = $client->post('https://api.flownally.com/v1/tags', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => ['name' => 'Repeat buyer'],
]);

$tag = json_decode((string) $response->getBody(), true);
```
:::

Add the tag to a contact with [Add tag to contact](/api/reference/contacts#tag-contact), and remove it with [Remove tag from contact](/api/reference/contacts#untag-contact). Both return `204`:

:::code
```bash
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"
```

```js
const url = "https://api.flownally.com/v1/contacts/CONTACT_ID/tags/TAG_ID";
const headers = { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` };

for (const method of ["POST", "DELETE"]) {
  const response = await fetch(url, { method, headers });
  if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);
}
```

```python
import os

import requests

url = "https://api.flownally.com/v1/contacts/CONTACT_ID/tags/TAG_ID"
headers = {"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"}

requests.post(url, headers=headers).raise_for_status()
requests.delete(url, headers=headers).raise_for_status()
```

```php
$client = new GuzzleHttp\Client();
$url = 'https://api.flownally.com/v1/contacts/CONTACT_ID/tags/TAG_ID';
$options = ['headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')]];

$client->post($url, $options);
$client->delete($url, $options);
```
:::

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

:::code
```js
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;
}
```

```python
import os

import requests

API = "https://api.flownally.com/v1"
STORE_ID_FIELD = "cf_d7pgooc1v8t53cs68k5g"
LAST_ORDER_FIELD = "cf_d7pgpgin6fdi7ocdmdgg"
REPEAT_BUYER_TAG = "tag_d7pj8kaicb30377174j0"

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"


def find_contact_id(phone):
    response = session.post(
        f"{API}/contacts:search",
        json={
            "query": {
                "match": "all",
                "conditions": [
                    {"type": "customFieldText", "fieldId": "cf_phone", "operator": "equals", "value": phone},
                    {"type": "archiveStatus", "status": "all"},
                ],
            },
            "pageSize": 1,
        },
    )
    response.raise_for_status()
    contacts = response.json()["contacts"]
    return contacts[0]["id"] if contacts else None


def upsert_customer(customer):
    phone = to_e164(customer["phone"], customer["country"])
    if phone is None:
        return None

    fields = {
        STORE_ID_FIELD: customer["id"],
        LAST_ORDER_FIELD: customer["last_order_date"],  # YYYY-MM-DD
    }

    contact_id = find_contact_id(phone)
    if contact_id is None:
        response = session.post(
            f"{API}/contacts",
            json={"name": customer["name"], "phone": phone, "customFields": fields},
        )
        response.raise_for_status()
        contact_id = response.json()["id"]
    else:
        for field_id, value in fields.items():
            session.put(
                f"{API}/contacts/{contact_id}/custom-fields/{field_id}",
                json={"value": value},
            ).raise_for_status()

    if customer["order_count"] > 1:
        session.post(f"{API}/contacts/{contact_id}/tags/{REPEAT_BUYER_TAG}").raise_for_status()

    return contact_id
```

```php
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';

function flownally(string $method, string $path, ?array $body = null): ?array
{
    static $client;
    $client ??= new GuzzleHttp\Client([
        'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    ]);

    $response = $client->request($method, API . $path, $body === null ? [] : ['json' => $body]);
    return json_decode((string) $response->getBody(), true);
}

function findContactId(string $phone): ?string
{
    $result = flownally('POST', '/contacts:search', [
        'query' => [
            'match' => 'all',
            'conditions' => [
                ['type' => 'customFieldText', 'fieldId' => 'cf_phone', 'operator' => 'equals', 'value' => $phone],
                ['type' => 'archiveStatus', 'status' => 'all'],
            ],
        ],
        'pageSize' => 1,
    ]);
    return $result['contacts'][0]['id'] ?? null;
}

function upsertCustomer(array $customer): ?string
{
    $phone = toE164($customer['phone'], $customer['country']);
    if ($phone === null) {
        return null;
    }

    $fields = [
        STORE_ID_FIELD => $customer['id'],
        LAST_ORDER_FIELD => $customer['last_order_date'], // YYYY-MM-DD
    ];

    $contactId = findContactId($phone);
    if ($contactId === null) {
        $contactId = flownally('POST', '/contacts', [
            'name' => $customer['name'],
            'phone' => $phone,
            'customFields' => $fields,
        ])['id'];
    } else {
        foreach ($fields as $fieldId => $value) {
            flownally('PUT', "/contacts/{$contactId}/custom-fields/{$fieldId}", ['value' => $value]);
        }
    }

    if ($customer['order_count'] > 1) {
        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](/api/reference/contacts#update-contact) only if the store owns names.
- **Back off on `429` and `5xx`.** Requests are rate limited per client IP address, and `429` responses carry no `Retry-After` header, 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](/api/reference/contacts#import-contacts). 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:

:::code
```bash
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"
```

```js
import { openAsBlob } from "node:fs";

const form = new FormData();
form.append("file", await openAsBlob("customers.csv", { type: "text/csv" }), "customers.csv");
form.append("columnMapping", '{"Name":"name","Phone":"phone","Customer ID":"cf_d7pgooc1v8t53cs68k5g"}');
form.append("tagIds", "tag_d7pj8kaicb30377174j0");

const response = await fetch("https://api.flownally.com/v1/contacts:import", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` },
  body: form,
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const { importId } = await response.json();
```

```python
import os

import requests

with open("customers.csv", "rb") as file:
    response = requests.post(
        "https://api.flownally.com/v1/contacts:import",
        headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
        files={"file": ("customers.csv", file, "text/csv")},
        data={
            "columnMapping": '{"Name":"name","Phone":"phone","Customer ID":"cf_d7pgooc1v8t53cs68k5g"}',
            "tagIds": "tag_d7pj8kaicb30377174j0",
        },
    )
response.raise_for_status()
import_id = response.json()["importId"]
```

```php
$client = new GuzzleHttp\Client();

$response = $client->post('https://api.flownally.com/v1/contacts:import', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'multipart' => [
        ['name' => 'file', 'contents' => fopen('customers.csv', 'r'), 'headers' => ['Content-Type' => 'text/csv']],
        ['name' => 'columnMapping', 'contents' => '{"Name":"name","Phone":"phone","Customer ID":"cf_d7pgooc1v8t53cs68k5g"}'],
        ['name' => 'tagIds', 'contents' => 'tag_d7pj8kaicb30377174j0'],
    ],
]);

$importId = json_decode((string) $response->getBody(), true)['importId'];
```
:::

The response has an `importId`. Poll [Get import status](/api/reference/contacts#get-import-status) until `status` is `completed` or `failed`:

:::code
```bash
curl "https://api.flownally.com/v1/contacts/imports/imp_d7pjdd4valnuhvhh1c8g" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
```

```js
const response = await fetch("https://api.flownally.com/v1/contacts/imports/imp_d7pjdd4valnuhvhh1c8g", {
  headers: { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` },
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const result = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.flownally.com/v1/contacts/imports/imp_d7pjdd4valnuhvhh1c8g",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
)
response.raise_for_status()
result = response.json()
```

```php
$client = new GuzzleHttp\Client();

$response = $client->get('https://api.flownally.com/v1/contacts/imports/imp_d7pjdd4valnuhvhh1c8g', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
]);

$result = json_decode((string) $response->getBody(), true);
```
:::

```json
{
  "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.

:::callout 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](/api/reference/contacts#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.

## Related articles

- [Send WooCommerce order updates on WhatsApp](/api/woocommerce-order-updates)
- [Send Shopify order updates on WhatsApp](/api/shopify-order-updates)
- [Recover abandoned carts with events and journeys](/api/abandoned-cart-events)
- [Trigger reorder reminders from purchase events](/api/reorder-reminders)
- [Pagination](/api/pagination)
