Data Management5 min

Contacts

Manage customer contacts associated with accounts.

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:

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

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

Writing numbers

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

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

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:

"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

const response = await fetch(
  '[baseURL]/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

curl -X POST "[baseURL]/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:

const response = await fetch(
  '[baseURL]/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

const response = await fetch(
  '[baseURL]/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

const response = await fetch(
  '[baseURL]/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:

// Sync contacts from your CRM
for (const crmContact of crmContacts) {
  await fetch(
    `[baseURL]/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 guide for details on matching logic.

Best Practices

Set Correct Languages

Accurate language settings ensure customers receive communications they can understand:

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

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.

curl -X POST "[baseURL]/api/public/v1/accounts/ACCOUNT_ID/contact-enrichment/candidates" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
  "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.

curl -X POST "[baseURL]/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.

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 PATCH /accounts/:id, using information supplied by the user or a document, never a domain guessed from the company name.

Next Steps

Support

Questions about managing contacts?
Contact us via the website contact form.