> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brudcast.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync contacts from your app

> Create and update Brudcast contacts from your own user records: custom fields, identities, lists, a safe upsert, bulk backfills and opt-outs.

export const Screenshot = ({id, src, srcDark, alt, caption}) => {
  if (!src) {
    return <Placeholder id={id} kind="screenshot" label="Screenshot coming soon" description={alt} icon={<ImageIcon />} />;
  }
  return <figure className="brd-media" data-media-id={id}>
      <img className="brd-media-frame block dark:hidden" src={src} alt={alt} />
      <img className="brd-media-frame hidden dark:block" src={srcDark || src} alt={alt} />
      {caption && <figcaption className="brd-media-caption">{caption}</figcaption>}
    </figure>;
};

Keep Brudcast's contacts in step with the users in your app. Each time someone signs up or changes
their profile, you'll create or update the matching contact, its custom fields, its identities and
its list memberships.

<Screenshot id="ss-developers-sync-contacts-hero-contact" alt="A contact created through the API, showing its email identity, custom field values and list membership in Contacts" />

<Info>
  **Before you start:** you need an API key with the `contacts:read` and `contacts:write` scopes.
  See [API keys](/developers/api-keys).
</Info>

<Snippet file="auth-headers.mdx" />

<Steps>
  <Step title="Define your custom fields once">
    Custom fields hold the data from your app that you want to segment or personalize on, such as a
    plan name. Create each one with a `key`, which is how you'll address it in a contact's
    `customFields` object. The `type` is `text`, `number`, `date`, `boolean` or `select`. A
    `select` field needs `options`.

    ```bash theme={"system"}
    curl https://core-service.prod.brudcast.com/api/v1/user/contact-custom-fields \
      -H "X-API-Key: bk_live_your_api_key" \
      -H "Content-Type: application/json" \
      -d '{ "name": "Plan", "key": "plan", "type": "select", "options": ["free", "pro", "team"] }'
    ```

    A key that's already taken returns `400`. To see what exists, call
    `GET /contact-custom-fields`. The values show on each contact in the dashboard. See
    [Add and edit contacts](/contacts/add-and-edit-contacts).
  </Step>

  <Step title="Look the contact up by email">
    `GET /contacts?search=` matches a full email address exactly, ignoring case. The same search
    also matches parts of first, last and display names, so confirm the match in the returned
    `emails` array.

    ```bash theme={"system"}
    curl "https://core-service.prod.brudcast.com/api/v1/user/contacts?search=jane%40example.com" \
      -H "X-API-Key: bk_live_your_api_key"
    ```
  </Step>

  <Step title="Create the contact if it doesn't exist">
    `POST /contacts` creates the contact with its identities and tags in one call. It needs at
    least one identity. Tags that don't exist yet are created.

    ```bash theme={"system"}
    curl https://core-service.prod.brudcast.com/api/v1/user/contacts \
      -H "X-API-Key: bk_live_your_api_key" \
      -H "Content-Type: application/json" \
      -d '{
        "firstName": "Jane",
        "lastName": "Doe",
        "emails": [{ "email": "jane@example.com", "isPrimary": true }],
        "smsNumbers": [{ "phoneNumber": "+2348000000001", "countryCode": "NG" }],
        "customFields": { "plan": "pro" },
        "tags": ["app-user"]
      }'
    ```

    The new contact is in `data.contact`. Store its `id` against your user record, so later updates
    don't need a search.
  </Step>

  <Step title="Update it if it does">
    `PATCH /contacts/{id}` changes profile fields only. Identities are managed separately.

    ```bash theme={"system"}
    curl -X PATCH https://core-service.prod.brudcast.com/api/v1/user/contacts/CONTACT_ID \
      -H "X-API-Key: bk_live_your_api_key" \
      -H "Content-Type: application/json" \
      -d '{ "firstName": "Jane", "customFields": { "plan": "team", "signup_source": "web" } }'
    ```

    <Warning>
      `customFields` replaces the whole object. Any key you leave out is removed, including values
      your team set in the dashboard. Merge your changes into the contact's current `customFields`
      before you send them. `tags`, when you include it, also replaces the full set of tags. Leave
      it out to keep the existing tags.
    </Warning>
  </Step>

  <Step title="Add another identity">
    Add an email address or phone number to an existing contact.

    ```bash theme={"system"}
    curl https://core-service.prod.brudcast.com/api/v1/user/contacts/CONTACT_ID/sms \
      -H "X-API-Key: bk_live_your_api_key" \
      -H "Content-Type: application/json" \
      -d '{ "phoneNumber": "08000000001", "countryCode": "NG" }'
    ```

    Brudcast stores numbers in E.164 format. `countryCode` can be a two-letter country code, such
    as `NG`, or a dialing code, such as `234`. It's used when the number isn't already in
    international format. Use `POST /contacts/{contactId}/emails` with `email` for addresses. For
    push device tokens, see [Register device tokens](/channels/push/register-device-tokens).
  </Step>

  <Step title="Add the contact to a list">
    `POST /contact-lists/{id}/contacts` takes up to 1,000 contact IDs. If any ID doesn't belong to
    your organization, the whole call is refused. Contacts already subscribed are left alone.

    ```bash theme={"system"}
    curl https://core-service.prod.brudcast.com/api/v1/user/contact-lists/LIST_ID/contacts \
      -H "X-API-Key: bk_live_your_api_key" \
      -H "Content-Type: application/json" \
      -d '{ "contactIds": ["CONTACT_ID"] }'
    ```

    <Warning>
      Adding a contact who unsubscribed from the list subscribes them again. Only add people your
      app knows have opted in.
    </Warning>

    `POST /contacts/bulk-add-to-list` does the same with `listId` and `contactIds`, but skips unknown
    IDs instead of refusing the call.
  </Step>
</Steps>

## Put it together: a safe upsert

An email address belongs to at most one contact in an organization. A `POST /contacts` with an
address that's already in use fails, so retrying a create never makes a duplicate. That makes this
pattern safe to run whenever your user changes:

1. If you've stored the contact's `id`, `PATCH` it.
2. Otherwise, search by email and `PATCH` the match.
3. Otherwise, `POST` a new contact and store its `id`.
4. If the `POST` fails because the address is taken, for example because an earlier request timed
   out after it succeeded, search again and `PATCH`.

<CodeGroup>
  ```javascript Node.js theme={"system"}
  const BASE = "https://core-service.prod.brudcast.com/api/v1/user";
  const headers = {
    "X-API-Key": process.env.BRUDCAST_API_KEY,
    "Content-Type": "application/json",
  };

  async function call(method, path, body) {
    const res = await fetch(`${BASE}${path}`, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
    });
    const json = await res.json();
    if (!res.ok) throw new Error(`${res.status} ${json.message}`);
    return json;
  }

  async function findByEmail(email) {
    const { data } = await call("GET", `/contacts?search=${encodeURIComponent(email)}&limit=10`);
    return data.find((c) => (c.emails ?? []).some((e) => e.email.toLowerCase() === email));
  }

  export async function upsertContact(user) {
    const email = user.email.trim().toLowerCase();
    const fields = { plan: user.plan };

    let contact = user.brudcastContactId
      ? (await call("GET", `/contacts/${user.brudcastContactId}`)).data
      : await findByEmail(email);

    if (!contact) {
      try {
        const created = await call("POST", "/contacts", {
          firstName: user.firstName || undefined,
          lastName: user.lastName || undefined,
          emails: [{ email, isPrimary: true }],
          customFields: fields,
        });
        return created.data.contact;
      } catch (err) {
        contact = await findByEmail(email); // created by an earlier attempt
        if (!contact) throw err;
      }
    }

    const updated = await call("PATCH", `/contacts/${contact.id}`, {
      firstName: user.firstName || undefined,
      lastName: user.lastName || undefined,
      customFields: { ...(contact.customFields ?? {}), ...fields },
    });
    return updated.data;
  }
  ```

  ```python Python theme={"system"}
  import os
  import requests

  BASE = "https://core-service.prod.brudcast.com/api/v1/user"
  HEADERS = {"X-API-Key": os.environ["BRUDCAST_API_KEY"]}


  def call(method, path, body=None, params=None):
      res = requests.request(method, f"{BASE}{path}", headers=HEADERS, json=body, params=params)
      if not res.ok:
          raise RuntimeError(f"{res.status_code} {res.json().get('message')}")
      return res.json()


  def find_by_email(email):
      data = call("GET", "/contacts", params={"search": email, "limit": 10})["data"]
      for contact in data:
          if any(e["email"].lower() == email for e in contact.get("emails") or []):
              return contact
      return None


  def upsert_contact(user):
      email = user["email"].strip().lower()
      fields = {"plan": user["plan"]}
      profile = {k: v for k, v in {"firstName": user.get("first_name"),
                                   "lastName": user.get("last_name")}.items() if v}

      if user.get("brudcast_contact_id"):
          contact = call("GET", f"/contacts/{user['brudcast_contact_id']}")["data"]
      else:
          contact = find_by_email(email)

      if contact is None:
          try:
              created = call("POST", "/contacts", {
                  **profile,
                  "emails": [{"email": email, "isPrimary": True}],
                  "customFields": fields,
              })
              return created["data"]["contact"]
          except RuntimeError:
              contact = find_by_email(email)  # created by an earlier attempt
              if contact is None:
                  raise

      merged = {**(contact.get("customFields") or {}), **fields}
      return call("PATCH", f"/contacts/{contact['id']}", {**profile, "customFields": merged})["data"]
  ```

  ```php PHP theme={"system"}
  <?php
  function brudcast(string $method, string $path, ?array $body = null): array
  {
      $ch = curl_init('https://core-service.prod.brudcast.com/api/v1/user' . $path);
      curl_setopt_array($ch, [
          CURLOPT_CUSTOMREQUEST => $method,
          CURLOPT_RETURNTRANSFER => true,
          CURLOPT_HTTPHEADER => [
              'X-API-Key: ' . getenv('BRUDCAST_API_KEY'),
              'Content-Type: application/json',
          ],
          CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
      ]);
      $payload = json_decode(curl_exec($ch), true);
      if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
          throw new RuntimeException($payload['message'] ?? 'Request failed');
      }
      return $payload;
  }

  function findByEmail(string $email): ?array
  {
      $result = brudcast('GET', '/contacts?' . http_build_query(['search' => $email, 'limit' => 10]));
      foreach ($result['data'] as $contact) {
          foreach ($contact['emails'] ?? [] as $identity) {
              if (strtolower($identity['email']) === $email) {
                  return $contact;
              }
          }
      }
      return null;
  }

  function upsertContact(array $user): array
  {
      $email = strtolower(trim($user['email']));
      $fields = ['plan' => $user['plan']];
      $profile = array_filter(['firstName' => $user['first_name'] ?? null, 'lastName' => $user['last_name'] ?? null]);

      $contact = !empty($user['brudcast_contact_id'])
          ? brudcast('GET', '/contacts/' . $user['brudcast_contact_id'])['data']
          : findByEmail($email);

      if ($contact === null) {
          try {
              $created = brudcast('POST', '/contacts', $profile + [
                  'emails' => [['email' => $email, 'isPrimary' => true]],
                  'customFields' => $fields,
              ]);
              return $created['data']['contact'];
          } catch (RuntimeException $e) {
              $contact = findByEmail($email); // created by an earlier attempt
              if ($contact === null) {
                  throw $e;
              }
          }
      }

      $merged = array_merge($contact['customFields'] ?? [], $fields);
      return brudcast('PATCH', '/contacts/' . $contact['id'], $profile + ['customFields' => $merged])['data'];
  }
  ```
</CodeGroup>

<Note>
  The duplicate protection comes from email addresses. Searching doesn't match phone numbers, so for
  contacts without an email address, store the contact `id` in your app and always update by `id`.
</Note>

## Backfill existing users in bulk

For a first import from your app, `POST /contacts/bulk` creates up to 500 contacts per request,
with the same fields as `POST /contacts`.

```bash theme={"system"}
curl https://core-service.prod.brudcast.com/api/v1/user/contacts/bulk \
  -H "X-API-Key: bk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      { "firstName": "Jane", "emails": [{ "email": "jane@example.com" }], "customFields": { "plan": "pro" } },
      { "firstName": "Ade", "emails": [{ "email": "ade@example.com" }], "customFields": { "plan": "free" } }
    ]
  }'
```

The status is always `201`, even when every row failed. Read `data.createdCount`,
`data.failedCount` and `data.errors`. Each error gives the `index` of the failed row in your
`contacts` array and a `message`. Rows whose email address already belongs to a contact fail, so
send those through the upsert above. For a one-off spreadsheet import, the dashboard's
[CSV import](/contacts/importing) can also update existing contacts.

## Rules that affect a sync

| Rule                       | Detail                                                                          |
| -------------------------- | ------------------------------------------------------------------------------- |
| Email addresses are unique | One contact per address in the organization                                     |
| Email search               | `search` matches a full address, ignoring case, and also matches parts of names |
| `customFields` on `PATCH`  | Replaces the whole object                                                       |
| `tags` on `PATCH`          | Replaces the whole set when present. Leave it out to keep the tags              |
| Adding to a list           | Subscribes people again if they had unsubscribed from that list                 |
| Bulk create                | Up to 500 contacts per request. Always `201`; check `failedCount`               |
| Phone numbers              | Stored as E.164. A number that isn't valid for the country is refused           |
| Identity endpoints         | An unknown `contactId` returns a server error, not `404`. Check the ID first    |

## Keep opt-outs in step

Two separate mechanisms keep mail away from an address, and a sync has to respect both.

* **The organization's suppression list** covers every kind of email: campaigns, the send API and
  SMTP. Brudcast adds an address automatically after a hard bounce (any `5xx` reply) or a spam
  complaint. The platform API has no suppression-list endpoints, so addresses your app blocks are
  added by hand under **Contacts > Suppressions**. See [Suppression list](/contacts/suppressions).
* **A contact's email identity status** only affects campaigns. Campaigns address identities whose
  status is `verified` or `unverified`, and skip `bounced`, `complained` and `suppressed` ones. The
  send API and SMTP don't look at it.

When someone unsubscribes in your app, mirror it in Brudcast.

**From one list**, mark the membership unsubscribed. Campaigns aimed at a list only reach subscribed
members, and calling this again changes nothing.

```bash theme={"system"}
curl https://core-service.prod.brudcast.com/api/v1/user/contact-lists/LIST_ID/contacts/remove \
  -H "X-API-Key: bk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "contactIds": ["CONTACT_ID"], "unsubscribeReason": "Unsubscribed in app settings" }'
```

**From all campaign email**, set the contact's email identity to `suppressed`. Find the identity's
ID in the contact's `emails` array.

```bash theme={"system"}
curl -X PATCH https://core-service.prod.brudcast.com/api/v1/user/contacts/CONTACT_ID/emails/EMAIL_ID \
  -H "X-API-Key: bk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "status": "suppressed" }'
```

If the person must receive nothing at all, including transactional mail your app sends through the
send API or SMTP, add the address under **Contacts > Suppressions** as well.

To pull bounces the other way, read the message log with `GET /messages?status=bounced`. A row with
a `5xx` `smtpCode` is permanent, and Brudcast has already suppressed that address, so mark it
undeliverable in your app too. See [Message status](/developers/sending/message-status).

## Troubleshooting

<AccordionGroup>
  <Accordion title="“… is not a valid phone number for the country given”" icon="circle-alert">
    **Why:** the number couldn't be turned into an international number using the `countryCode`
    you sent.

    **Fix:** send the number in international format, such as `+2348000000001`, or send the right
    country code with a national number.
  </Accordion>

  <Accordion title="Custom field values disappeared after an update" icon="circle-alert">
    **Why:** `PATCH /contacts/{id}` replaced `customFields` with the object you sent.

    **Fix:** read the contact, merge your changes into its `customFields`, and send the full
    object.
  </Accordion>

  <Accordion title="Bulk create returned 201 but created nothing" icon="circle-alert">
    **Why:** the endpoint reports row failures in the body, not in the status code.

    **Fix:** check `data.errors`. Rows that failed because the address already exists need an
    update, not a create.
  </Accordion>

  <Accordion title="422 when a user has no first name" icon="circle-alert">
    **Why:** `firstName` and `lastName` must be at least one character long when you send them.

    **Fix:** leave out empty fields instead of sending `""`.
  </Accordion>
</AccordionGroup>

<Columns cols={2}>
  <Card title="Add and edit contacts" icon="contact" href="/contacts/add-and-edit-contacts">
    Identities, custom fields and lists on a contact in the dashboard.
  </Card>

  <Card title="Suppression list" icon="ban" href="/contacts/suppressions">
    The addresses Brudcast won't send to, and why each one is there.
  </Card>

  <Card title="Import contacts from a CSV" icon="upload" href="/contacts/importing">
    A one-off spreadsheet import, which can also update existing contacts.
  </Card>

  <Card title="Create and send a campaign" icon="send" href="/campaigns/create-a-campaign">
    Send to the contacts you've synced.
  </Card>
</Columns>
