Les contacts sont les personnes de l’organisation cliente rattachées à un compte Billabex. Ce sont eux qui reçoivent les relances de paiement et les autres communications de l’agent.
Vue d’ensemble
Quand l’agent Billabex envoie une relance, il doit savoir qui contacter chez le client. Les contacts portent cette information.
Propriétés d’un contact
| Propriété | Type | Description |
|---|---|---|
id |
chaîne | Identifiant unique (UUID) |
fullName |
chaîne | Nom complet de la personne, ou null |
email |
objet | Adresse email et statut de validation |
phones |
tableau | Tous les numéros de téléphone du contact |
language |
chaîne | Code ISO 639-1 de la langue de communication |
jobTitle |
chaîne | Fonction côté client, ou null |
Statut de l’email
L’objet email contient l’adresse et son statut de validation :
{
"value": "john@acme.com",
"status": "Valid",
"isValid": true
}
Valeurs possibles :
| Statut | Description |
|---|---|
Valid |
L’email est délivrable |
Bounced |
L’email a rebondi, il n’est pas délivrable |
Complained |
Le destinataire a signalé les emails comme indésirables |
Le statut et isValid sont en lecture seule et mis à jour automatiquement d’après les résultats de livraison.
Numéros de téléphone
Un contact peut porter autant de numéros que nécessaire, dans n’importe quel pays. Chaque entrée ressemble à ceci :
{
"number": "+33612345678",
"type": "Mobile",
"country": "FR",
"origin": "Manual"
}
| Champ | Description |
|---|---|
number |
Au format E.164, la forme canonique que Billabex stocke et la clé sur laquelle il déduplique |
type |
Mobile, Landline ou Unknown, déduit du numéro et en lecture seule |
country |
ISO 3166-1 alpha-2, déduit lui aussi, null quand le numéro n’appartient à aucun pays unique |
origin |
Manual pour ce que vous envoyez par l’API, Connector pour ce qu’une intégration a apporté |
type et country sont calculés à partir des métadonnées d’opérateur, jamais de ce que vous
envoyez. Unknown est une vraie réponse et non un échec : plusieurs plans de numérotation, dont
celui d’Amérique du Nord, ne distinguent pas du tout les mobiles des fixes.
Écrire des numéros
Envoyez phones sous forme d’une liste d’objets { number, countryCode? } :
{
"phones": [
{ "number": "+33612345678" },
{ "number": "06 12 34 56 78", "countryCode": "FR" },
{ "number": "0475 12 34 56", "countryCode": "BE" }
]
}
Un numéro commençant par + ou 00 porte son propre indicatif et n’a besoin de rien d’autre.
Tout autre numéro est un numéro national et exige countryCode (ISO 3166-1 alpha-2). Il n’y a
pas de pays par défaut : en deviner un reviendrait à enregistrer en silence un autre numéro que
celui que vous vouliez.
La liste fait autorité. Omettez phones pour laisser les numéros du contact intacts, envoyez
[] pour les effacer.
Numéros apportés par un connecteur
Si le compte est synchronisé avec un outil de facturation (Odoo, Pennylane, Qonto, Sellsy, Stripe,
Zoho Books), les numéros que cet outil porte apparaissent avec origin: "Connector". Écrire
phones ne les remplace jamais : cela ne remplace que les entrées Manual, parce que la
synchronisation suivante écraserait de toute façon votre modification. Déliez le compte de sa source
pour reprendre la main sur ses numéros.
Un client détenant le scope sync écrit lui-même dans la pile Connector : ses numéros n’entrent
donc jamais en collision avec ce qu’une personne a saisi dans l’application. Voyez
Scopes.
Écritures partiellement appliquées : le tableau warnings
Créer ou mettre à jour un contact renvoie 200 même quand Billabex n’a gardé qu’une partie de ce
que vous avez envoyé. Dans ce cas, la réponse porte un tableau warnings, entièrement absent quand
rien n’a été écarté :
"warnings": [
{ "field": "fullName", "value": "ACME Purchasing", "reason": "MANUALLY_SET" },
{ "field": "phones", "value": "voir Jean", "reason": "UNPARSEABLE" }
]
reason |
Ce qui s’est passé |
|---|---|
MANUALLY_SET |
Une personne a saisi ce nom ou cette langue dans l’application, et un client sync ne l’écrase jamais |
UNPARSEABLE |
Le numéro n’a pas pu être lu comme un numéro : il a été écarté, et le reste du contact a bien été écrit |
UNPARSEABLE n’apparaît que pour un client détenant le scope sync. Sans ce scope, un numéro
illisible donne un 400 sur toute la requête, parce qu’une personne qui saisit un numéro doit être
avertie qu’il est erroné plutôt que de le voir disparaître en silence.
Joignabilité par SMS
Le canal SMS ne peut délivrer qu’à un mobile de France ou de Guyane : type: "Mobile" avec
country: "FR" ou country: "GF". Tout autre numéro est stocké et renvoyé normalement, il ne peut
simplement pas recevoir de message. L’indicateur hasValidPhoneContact du compte vous dit si au
moins un contact actif est joignable de cette façon.
Endpoints
| Méthode | Endpoint | Description |
|---|---|---|
| GET | /accounts/{accountId}/contacts |
Lister les contacts |
| POST | /accounts/{accountId}/contacts |
Créer un contact |
| PATCH | /accounts/{accountId}/contacts/{contactId} |
Modifier un contact |
| DELETE | /accounts/{accountId}/contacts/{contactId} |
Supprimer un contact |
| PUT | /accounts/{accountId}/contacts/upsert |
Créer ou mettre à jour |
Créer un 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: 'fr',
jobTitle: 'Responsable comptabilité',
phones: [{ number: '+33612345678' }],
}),
},
);
const contact = await response.json();
Avec 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": "fr",
"phones": [{ "number": "06 12 34 56 78", "countryCode": "FR" }]
}'
Champs
| Champ | Obligatoire | Description |
|---|---|---|
fullName |
l’un des deux | Nom complet de la personne |
email |
l’un des deux | Adresse email |
language |
non | Code ISO 639-1 (en, fr, de…), voir la valeur par défaut plus bas |
phones |
non | Numéros de téléphone, voir plus haut |
jobTitle |
non | Fonction côté client, en texte libre |
Un contact a besoin d’un nom ou d’une adresse email, pas des deux. Envoyez l’adresse seule quand
c’est tout ce dont vous disposez : aucun nom n’en est déduit, et fullName se relit à null, ce qui
vous permet de distinguer « pas de nom » d’un nom qui ressemblerait à une adresse. N’envoyer ni l’un
ni l’autre renvoie 400.
language vaut par défaut la langue du premier contact actif du compte, puis la langue de contact
par défaut de l’organisation, puis fr.
Modifier un contact
Utilisez PATCH pour ne mettre à jour que certains champs :
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',
}),
},
);
Seuls les champs fournis sont modifiés. Pour phones, cela signifie : omettre le champ laisse les
numéros tels quels, envoyer la liste complète les remplace, envoyer [] les efface tous. Envoyer
fullName: null efface le nom, ce qui est refusé si le contact n’a plus d’adresse email pour
l’identifier.
Lister les 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 contient le tableau des contacts
// data.pageInfo contient les informations de pagination
Supprimer un contact
const response = await fetch(
'[baseURL]/api/public/v1/accounts/ACCOUNT_ID/contacts/CONTACT_ID',
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
// Renvoie 204 No Content en cas de succès
Langue
Le champ language détermine la langue dans laquelle Billabex communique avec le contact. Utilisez les codes ISO 639-1 à deux lettres :
| Code | Langue |
|---|---|
en |
Anglais |
fr |
Français |
de |
Allemand |
es |
Espagnol |
it |
Italien |
nl |
Néerlandais |
pt |
Portugais |
L’agent traduit automatiquement ses relances dans la langue préférée du contact.
Utiliser l’upsert pour synchroniser
Pour synchroniser des contacts depuis un système externe, l’upsert simplifie votre logique :
// Synchroniser les contacts depuis votre 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 || 'fr',
// Votre CRM sait à quel pays appartient chaque numéro ; Billabex ne le devinera pas.
phones: crmContact.phoneNumbers.map((number) => ({
number,
countryCode: crmContact.countryCode,
})),
}),
},
);
}
Le guide des opérations upsert détaille la logique de correspondance.
Bonnes pratiques
Renseigner la bonne langue
Une langue correcte garantit que le client reçoit une communication qu’il comprend :
// Faire correspondre la langue réellement préférée du contact
"language": "fr" // Pour un contact francophone
Traiter les rebonds d’email
Surveillez le champ email.status et mettez à jour vos contacts quand un email rebondit :
if (contact.email?.status === 'Bounced') {
// Alerter votre équipe pour mettre à jour l'adresse du contact
// Ou marquer automatiquement le contact comme à traiter
}
Trouver un contact quand personne n’est joignable
Un compte dont aucun contact n’a d’adresse email valide ne peut pas être relancé du tout. Le premier endpoint cherche des contacts possibles pour 0,25 crédit par résultat. Le second cherche leurs coordonnées pour 1 crédit pour une adresse email, 2 dès qu’un numéro de téléphone est trouvé, et 0 quand rien n’est trouvé.
1. Rechercher des candidats (facturé)
Renvoie jusqu’à dix personnes côté client qui semblent s’occuper du paiement des fournisseurs
(comptabilité, recouvrement, trésorerie, direction financière), avec leur fonction et l’URL de leur
profil public, et aucune coordonnée. L’appel dépense 0,25 crédit par résultat renvoyé et exige
le scope accounts:all. Il échoue avant d’appeler le prestataire si le portefeuille contient moins
de 1,25 crédit, soit le prix d’un résultat plus l’enrichissement le moins cher : une liste de noms
qu’on ne peut convertir en aucune adresse email ne vaut rien. Pour la même raison, l’appel ne
renvoie jamais plus de résultats que le portefeuille ne peut ensuite en enrichir : un petit solde
donne donc une liste courte plutôt que dix noms et un portefeuille vide.
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 vous dit qui vous avez sous les yeux. Une entreprise de trois personnes n’emploie
personne dont la fonction est « Finance » : c’est son gérant qui paie les factures, et son titre dit
« Gérant ». Quand la recherche finance ne trouve personne, l’appel élargit la question de lui-même
et dit jusqu’où il est allé :
searchScope |
Qui sont les candidats |
|---|---|
Finance |
Des profils finance, assez seniors pour agir sur un paiement. La réponse visée. |
FinanceWide |
Des profils finance de toute spécialité : le client n’étiquette rien précisément. |
Decisionmakers |
Dirigeants et C-level de toute fonction : qui que ce soit qui dirige l’entreprise. |
L’élargissement est gratuit et ne remplace jamais une vraie réponse : un seul profil finance
l’arrête. Traitez toute valeur autre que Finance comme le signal que vous vous apprêtez à
contacter un dirigeant plutôt qu’un comptable.
isLegalRepresentative est posé quand le registre public français désigne cette personne comme
dirigeant légal de l’entreprise, et officialRole porte alors sa qualité (« Président de SAS »,
« Gérant »). Ces candidats sont listés en premier. L’indicateur est informatif : il ne coûte rien,
ne change aucun prix, et une personne que le registre connaît mais que le prestataire ignore n’est
simplement pas renvoyée.
2. Enrichir un contact (facturé)
Renvoyez le profileUrl du candidat que vous avez retenu, ou le nom complet d’une personne que vous
connaissez déjà, sur un compte qui déclare un domaine d’entreprise. L’appel rend la main dès que le
prestataire l’a accepté ; le contact apparaît sur le compte une minute plus tard environ, tout
seul. Il n’y a rien à interroger en boucle, et fermer la connexion ne perd rien.
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
}'
| Champ | Obligatoire | Description |
|---|---|---|
wantsPhone |
oui | Chercher aussi un numéro : 2 crédits au lieu d’1, et seulement si un numéro est trouvé |
contactId |
non | Compléter un contact existant plutôt qu’en ajouter un |
fullName |
non | Qui chercher, prénom et nom. Obligatoire sauf si contactId ou profileUrl identifie la personne |
profileUrl |
non | Profil LinkedIn, issu de la recherche de candidats ou de vos propres données |
jobTitle |
non | Fonction de la personne, quand la recherche de candidats en a renvoyé une |
La recherche doit être ancrée sur le débiteur. L’appel exige soit un profileUrl, qui nomme une
personne, soit un domain d’entreprise sur le compte, qui nomme une entreprise. Sans l’un des deux,
il est refusé en 400 (app-invoicing.contact.enrichment.not-anchored) et rien n’est dépensé :
un nom seul correspond à une entreprise homonyme n’importe où dans le monde, le prestataire répond
avec son adresse, et rien dans cette réponse ne dit que ce n’est pas la bonne entreprise. Renseigner
le domain du compte est en général le correctif le moins cher, et cela donne aussi son logo au
compte.
Un nom, c’est un prénom et un nom. Le prestataire accepte soit les deux ensemble, soit une
linkedin_url. Un mot unique est refusé en 400
(app-invoicing.contact.enrichment.identity-incomplete) plutôt qu’envoyé pour revenir vide. Passez
un profileUrl quand vous ne détenez qu’un seul mot.
Une adresse professionnelle et une adresse personnelle sont cherchées à chaque appel, si bien qu’une personne joignable uniquement chez elle est trouvée aussi. Le prix ne change pas : 1 crédit pour une adresse, 2 dès qu’un numéro revient, et l’adresse professionnelle l’emporte quand les deux sont trouvées.
profileUrl est l’entrée la plus forte qu’on puisse donner au prestataire, et elle n’a pas à venir
de la recherche de candidats : passez le profil LinkedIn que vous détenez déjà. Tout ce qui n’est
pas une adresse LinkedIn est refusé en 400, plutôt que dépensé sur une recherche qui ne pouvait pas
aboutir.
Les deux mêmes opérations facturées existent sur le serveur MCP sous les noms
search-contact-candidates et enrich-contact. Toutes deux exigent mcp:write, et le client doit
demander confirmation avant de dépenser des crédits.
search-contact-candidates exige en outre un domain d’entreprise sur le compte. Sans lui, l’appel
renvoie app-invoicing.contact.candidate-search.not-anchored avant de facturer quoi que ce soit.
Renseignez le vrai site du client par PATCH /accounts/:id, à partir d’une information fournie par
l’utilisateur ou par un document, jamais d’un domaine deviné à partir du nom de l’entreprise.
Pour aller plus loin
- Opérations upsert : créer ou mettre à jour en une requête
- Sources de compte : relier un compte à un système externe
- Démarrage : les bases de l’API
- Référence de l’API : documentation complète des endpoints
Support
Une question sur la gestion des contacts ? Écrivez-nous via le formulaire de contact.