---
title: "Contacts"
description: "Manage customer contacts associated with accounts."
canonical: https://developer.billabex.com/en/guides/contacts/
lang: en
alternate: https://developer.billabex.com/fr/guides/contacts/
last-updated: 2026-10-03
---

# Contacts

> Manage customer contacts associated with accounts.

Source: https://developer.billabex.com/en/guides/contacts/
Language: English (en)
French version: https://developer.billabex.com/fr/guides/contacts/

Contacts are people at the **customer's organization** associated with a Billabex account. They receive payment reminders and other communications from the agent.

## Overview

When the Billabex agent sends payment reminders, it needs to know **who to contact** at the customer's organization. Contacts provide that information.

## Contact Properties

| Property   | Type   | Description                               |
| ---------- | ------ | ----------------------------------------- |
| `id`       | string | Unique identifier (UUID)                  |
| `fullName` | string | Person's full name, or `null`             |
| `email`    | object | Email address and validation status       |
| `phones`   | array  | Every phone number of the contact         |
| `language` | string | ISO 639-1 code for communication language |
| `jobTitle` | string | Role on the customer side, or `null`      |

### Email Status

The `email` object contains the address value and its validation status:

```json
{
  "value": "john@acme.com",
  "status": "Valid",
  "isValid": true
}
```

Possible status values:

| Status       | Description                     |
| ------------ | ------------------------------- |
| `Valid`      | Email is deliverable            |
| `Bounced`    | Email bounced (undeliverable)   |
| `Complained` | Recipient marked emails as spam |

Email status and `isValid` are read-only and updated automatically based on delivery results.

## Phone Numbers

A contact can hold **any number of phone numbers, in any country**. Each entry looks like this:

```json
{
  "number": "+33612345678",
  "type": "Mobile",
  "country": "FR",
  "origin": "Manual"
}
```

| Field     | Description                                                                                    |
| --------- | ---------------------------------------------------------------------------------------------- |
| `number`  | E.164, the canonical form Billabex stores and the key it deduplicates on                       |
| `type`    | `Mobile`, `Landline` or `Unknown`, **derived from the number** and read-only                   |
| `country` | ISO 3166-1 alpha-2, derived too, `null` when the number belongs to no single country           |
| `origin`  | `Manual` for what you send through the API, `Connector` for what a billing integration brought |

`type` and `country` are computed from carrier metadata, never from what you send. `Unknown` is a
real answer, not a failure: several numbering plans, North America among them, do not separate
mobiles from landlines at all.

Parseable numbers with a possible length are accepted even when their prefix is not recognized.
French ten-digit numbers starting with `06`/`07` are classified as `Mobile` and can be submitted
to the SMS provider, which determines deliverability.

### Writing numbers

Send `phones` as a list of `{ number, countryCode? }`:

```json
{
  "phones": [
    { "number": "+33612345678" },
    { "number": "06 12 34 56 78", "countryCode": "FR" },
    { "number": "0475 12 34 56", "countryCode": "BE" }
  ]
}
```

A number starting with `+` or `00` carries its own country code and needs nothing else. **Any other
number is a national number and requires `countryCode`** (ISO 3166-1 alpha-2). There is no default
country: guessing one would silently store a different number than the one you meant.

The list is **authoritative**. Omit `phones` to leave the contact's numbers untouched, send `[]` to
clear them.

### Numbers brought by a connector

If the account is synchronized with a billing tool (Odoo, Pennylane, Qonto, Sellsy, Stripe, Zoho Books), the numbers that
tool carries appear with `origin: "Connector"`. Writing `phones` **never replaces them**, it only
replaces the `Manual` entries, because the next synchronization would overwrite your change anyway.
Unlink the account from its source to take ownership of its numbers.

A client holding the `sync` scope writes into the `Connector` pile itself, so its numbers never
collide with what a human typed in the app. See [Scopes](/en/guides/scopes/).

### Partially applied writes: the `warnings` array

Creating or updating a contact returns `200` even when Billabex kept part of what you sent and
dropped the rest. When that happens the response carries a `warnings` array, omitted entirely when
nothing was dropped:

```json
"warnings": [
  { "field": "fullName", "value": "ACME Purchasing", "reason": "MANUALLY_SET" },
  { "field": "phones", "value": "see Jean", "reason": "UNPARSEABLE" }
]
```

| `reason`       | What happened                                                                                             |
| -------------- | --------------------------------------------------------------------------------------------------------- |
| `MANUALLY_SET` | A person set this name or language in the app, and a `sync` client never overwrites it                    |
| `UNPARSEABLE`  | The phone number could not be read as a number, so it was skipped and the rest of the contact was written |

`UNPARSEABLE` only ever appears for a client holding the `sync` scope. Without that scope an
unreadable number is a `400` on the whole request, because a person typing a number should be told
it is wrong rather than have it silently dropped.

### SMS reachability

The SMS channel can only deliver to a mobile from France or French Guiana: `type: "Mobile"` with
`country: "FR"` or `country: "GF"`. Any other number is stored and returned normally, it simply
cannot receive a text message. The account's `hasValidPhoneContact` flag tells you whether at least
one enabled contact is reachable that way.

## API Endpoints

| Method | Endpoint                                     | Description       |
| ------ | -------------------------------------------- | ----------------- |
| GET    | `/accounts/{accountId}/contacts`             | List all contacts |
| POST   | `/accounts/{accountId}/contacts`             | Create a contact  |
| PATCH  | `/accounts/{accountId}/contacts/{contactId}` | Update a contact  |
| DELETE | `/accounts/{accountId}/contacts/{contactId}` | Delete a contact  |
| PUT    | `/accounts/{accountId}/contacts/upsert`      | Create or update  |

## Creating a Contact

```javascript
const response = await fetch(
  'https://next.billabex.com/api/public/v1/accounts/ACCOUNT_ID/contacts',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      fullName: 'John Smith',
      email: 'john.smith@acme.com',
      language: 'en',
      jobTitle: 'Accounting Manager',
      phones: [{ number: '+33612345678' }],
    }),
  },
);

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

### Using cURL

```bash
curl -X POST "https://next.billabex.com/api/public/v1/accounts/ACCOUNT_ID/contacts" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fullName": "John Smith",
    "email": "john.smith@acme.com",
    "language": "en",
    "phones": [{ "number": "06 12 34 56 78", "countryCode": "FR" }]
  }'
```

### Required Fields

| Field      | Required       | Description                                                    |
| ---------- | -------------- | -------------------------------------------------------------- |
| `fullName` | One of the two | Person's full name                                             |
| `email`    | One of the two | Email address                                                  |
| `language` | No             | ISO 639-1 code (e.g., `en`, `fr`, `de`), see the default below |
| `phones`   | No             | Phone numbers, see above                                       |
| `jobTitle` | No             | Role on the customer side, free text                           |

A contact needs **a name or an email address**, not both. Send the address alone when that is all you
have: no name is derived from it, and `fullName` reads back as `null` so you can tell "no name" from a
name that happens to look like an address. Sending neither returns a `400`.

`language` defaults to the language of the account's first enabled contact, then to the
organization's default contact language, then to `fr`.

## Updating a Contact

Use PATCH to update specific fields:

```javascript
const response = await fetch(
  'https://next.billabex.com/api/public/v1/accounts/ACCOUNT_ID/contacts/CONTACT_ID',
  {
    method: 'PATCH',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      fullName: 'John A. Smith',
    }),
  },
);
```

Only the provided fields will be updated. For `phones` that means: omit the field to leave the
numbers alone, send the full list to replace them, send `[]` to remove them all. Sending
`fullName: null` clears the name, which is refused when the contact has no email address left to
identify it.

## Listing Contacts

```javascript
const response = await fetch(
  'https://next.billabex.com/api/public/v1/accounts/ACCOUNT_ID/contacts?first=10',
  {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  },
);

const data = await response.json();
// data.nodes contains the contacts array
// data.pageInfo contains pagination info
```

## Deleting a Contact

```javascript
const response = await fetch(
  'https://next.billabex.com/api/public/v1/accounts/ACCOUNT_ID/contacts/CONTACT_ID',
  {
    method: 'DELETE',
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  },
);

// Returns 204 No Content on success
```

## Language

The `language` field determines which language Billabex uses when communicating with the contact. Use ISO 639-1 two-letter codes:

| Code | Language   |
| ---- | ---------- |
| `en` | English    |
| `fr` | French     |
| `de` | German     |
| `es` | Spanish    |
| `it` | Italian    |
| `nl` | Dutch      |
| `pt` | Portuguese |

The agent automatically translates payment reminders to the contact's preferred language.

## Using Upsert for Sync

When synchronizing contacts from external systems, use the upsert endpoints to simplify your logic:

```javascript
// Sync contacts from your CRM
for (const crmContact of crmContacts) {
  await fetch(
    `https://next.billabex.com/api/public/v1/accounts/${crmContact.billabexAccountId}/contacts/upsert`,
    {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        fullName: crmContact.name,
        email: crmContact.email,
        language: crmContact.preferredLanguage || 'en',
        // Your CRM knows which country each number belongs to; Billabex will not guess.
        phones: crmContact.phoneNumbers.map((number) => ({
          number,
          countryCode: crmContact.countryCode,
        })),
      }),
    },
  );
}
```

See the [Upsert Operations](/en/guides/upsert/) guide for details on matching logic.

## Best Practices

### Set Correct Languages

Accurate language settings ensure customers receive communications they can understand:

```javascript
// Match the contact's actual language preference
"language": "fr"  // For French-speaking contacts
```

### Handle Email Bounces

Monitor the `email.status` field and update contacts when emails bounce:

```javascript
if (contact.email?.status === 'Bounced') {
  // Alert your team to update the contact's email
  // Or automatically mark as needing attention
}
```

## Finding a contact when nobody is reachable

An account whose contacts have no valid email address cannot be dunned at all. The first endpoint
finds possible contacts for **0.25 credit per result**. The second finds their details for **1**
credit for an email address, **2** as soon as a phone number is found, and **0** when nothing is
found.

### 1. Search candidates (billed)

Returns up to ten people on the customer side who look like they handle supplier payments
(accounting, collections, treasury, finance leadership), with their role and public profile URL,
and **no contact details**. The call spends 0.25 credit per returned result and requires the
`accounts:all` scope. It fails before calling the provider when the wallet holds less than 1.25
credit, the price of one result plus the cheapest enrichment: a list of names that cannot be
turned into a single email address is worth nothing. For the same reason the call never returns
more results than the wallet can still enrich afterwards, so a small balance yields a short list
rather than ten names and an empty wallet.

```bash
curl -X POST "https://next.billabex.com/api/public/v1/accounts/ACCOUNT_ID/contact-enrichment/candidates" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

```json
{
  "candidates": [
    {
      "fullName": "Marie Dupont",
      "jobTitle": "Responsable comptabilité",
      "companyName": "ACME SAS",
      "profileUrl": "https://www.linkedin.com/in/marie-dupont",
      "isLegalRepresentative": false,
      "officialRole": null
    }
  ],
  "creditsCharged": 0.25,
  "balanceAfter": 9.75,
  "searchScope": "Finance"
}
```

**`searchScope` tells you who you are looking at.** A company of three people employs nobody whose
job function is "Finance": its manager pays the invoices and their title says "Gérant". When the
finance search finds no one, the call widens the question by itself and says how far it went:

| `searchScope`    | Who the candidates are                                                 |
| ---------------- | ---------------------------------------------------------------------- |
| `Finance`        | Finance staff, senior enough to act on a payment. The intended answer. |
| `FinanceWide`    | Finance staff of any specialty: the customer labels nothing precisely. |
| `Decisionmakers` | Owners and C-level of any function: whoever runs the company.          |

Widening is free and never replaces a real answer: a single finance profile stops it. Treat anything
but `Finance` as a signal that you are about to contact a decision maker rather than an accountant.

**`isLegalRepresentative`** is set when the French public company registry names that person as
legally running the company, and `officialRole` then carries their capacity ("Président de SAS",
"Gérant"). These candidates are listed first. The flag is informational: it costs nothing, changes
no price, and a person the registry knows but the provider does not is simply not returned.

### 2. Enrich a contact (billed)

Pass back the `profileUrl` of the candidate you picked, or the full name of someone you already
know about on an account that states a company domain. The call returns as soon as the provider accepted it; the contact appears on the
account **about a minute later, on its own**. There is nothing to poll, and closing the connection
loses nothing.

```bash
curl -X POST "https://next.billabex.com/api/public/v1/accounts/ACCOUNT_ID/contact-enrichment" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fullName": "Marie Dupont",
    "profileUrl": "https://www.linkedin.com/in/marie-dupont",
    "jobTitle": "Responsable comptabilité",
    "wantsPhone": true
  }'
```

| Field        | Required | Description                                                                                                 |
| ------------ | -------- | ----------------------------------------------------------------------------------------------------------- |
| `wantsPhone` | Yes      | Also look for a phone number: 2 credits instead of 1, only if one is found                                  |
| `contactId`  | No       | Complete an existing contact instead of adding one                                                          |
| `fullName`   | No       | Who to look for, first name and last name both. Required unless `contactId` or `profileUrl` identifies them |
| `profileUrl` | No       | LinkedIn profile, from the candidate search or from your own records                                        |
| `jobTitle`   | No       | Role of the person, when the candidate search returned one                                                  |

**The search has to be anchored on the debtor.** The call needs either a `profileUrl`, which names
one person, or a company `domain` on the account, which names one company. Without one of the two
it is refused with a 400 (`app-invoicing.contact.enrichment.not-anchored`) and **nothing is
spent**: a name on its own matches a namesake company anywhere in the world, the provider answers
with its address, and nothing in that answer says it is the wrong company. Setting the account's
`domain` is usually the cheapest fix, and it also gives the account its logo. It stays writable on
an account a connector syncs, unlike the account's other fields, and a value a person entered is
never overwritten by a sync: a client holding the `sync` scope that tries to replace it gets
`warnings: [{ "field": "domain", "reason": "MANUALLY_SET" }]` in the response of
`PUT /accounts/{accountId}` or of the MCP `update-account` tool.

**A name is a first name and a last name.** The provider accepts either both together, or a
`linkedin_url`. A single word is refused with a 400
(`app-invoicing.contact.enrichment.identity-incomplete`) instead of being sent and coming back
empty. Pass a `profileUrl` when all you hold is one word.

Both a work address and a personal one are looked for on every call, so a person only reachable at
home is found too. The price does not change: 1 credit for an address, 2 as soon as a phone number
comes back, and the work address wins when both are found.

`profileUrl` is the strongest input the provider can be given, and it does not have to come from the
candidate search: pass the LinkedIn profile you already hold. Anything that is not a LinkedIn
address is refused with a 400 rather than spent on a lookup that could not match.

The same two paid operations exist on the MCP server as `search-contact-candidates` and
`enrich-contact`. Both require `mcp:write`, and clients must ask for confirmation before spending
credits.

`search-contact-candidates` also requires a company `domain` on the account. Without one, the call
returns `app-invoicing.contact.candidate-search.not-anchored` before charging anything. Set the
customer's real website through `PUT /accounts/{accountId}` (or the `update-account` tool), using information supplied by the user or a
document, never a domain guessed from the company name.

## Next Steps

- **[Upsert Operations](/en/guides/upsert/)** : Create or update in one request
- **[Account Sources](/en/guides/sources/)** : Link accounts to external systems
- **[Getting Started](/en/guides/getting-started/)** : API basics
- **[API Reference](/en/api-reference/)** : Full endpoint documentation

## Support

Questions about managing contacts?  
Contact us via the [website contact form](https://billabex.com).

---

Billabex developer portal. OpenAPI specification: https://developer.billabex.com/openapi.json.
Agent instructions: https://developer.billabex.com/llms.txt. Complete documentation: https://developer.billabex.com/llms-full.txt.
