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
- Upsert Operations – Create or update in one request
- Account Sources – Link accounts to external systems
- Getting Started – API basics
- API Reference – Full endpoint documentation
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.