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