Data Management

Contacts

Manage customer contacts associated with accounts.

Contacts

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

Overview

When Billabex’s dunning 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
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 (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 French mobile: type: "Mobile" with country: "FR". 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 Yes Person’s full name
language Yes ISO 639-1 code (e.g., en, fr, de)
email No Email address
phones No Phone numbers, see above
jobTitle No Role on the customer side, free text

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.

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 dunning 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
}

Next Steps

Support

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

Finding a contact when nobody is reachable

An account whose contacts have no valid email address cannot be dunned at all. Two endpoints buy your way out of it. Only the second one costs credits: 1 for an email address, 2 as soon as a phone number is found, 0 when nothing is found.

1. Search candidates (free)

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. It fails when your credit balance could not pay for a single enrichment, since the list would lead nowhere.

curl "[baseURL]/api/public/v1/accounts/ACCOUNT_ID/contact-enrichment/candidates" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
[
  {
    "fullName": "Marie Dupont",
    "jobTitle": "Responsable comptabilité",
    "companyName": "ACME SAS",
    "profileUrl": "https://www.linkedin.com/in/marie-dupont"
  }
]

2. Enrich a contact (billed)

Pass back the profileUrl of the candidate you picked, or simply the name of someone you already know about. 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é",
    "emailKind": "Professional",
    "wantsPhone": true
  }'
Field Required Description
emailKind Yes Professional for a company, Personal for an individual
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. Required unless contactId or profileUrl identifies them
profileUrl No Public professional profile, the strongest input the provider can be given
jobTitle No Role of the person, when the candidate search returned one

Set emailKind deliberately: looking for a work address on a private individual, or the reverse, buys a lookup that finds nothing useful. A Personal lookup also costs the provider more, which is why the two are asked for separately rather than both at once.

The same two operations exist on the MCP server as search-contact-candidates and enrich-contact.