Intégration CRM et API Kourtier
Contrat d'échange entre l'API Kourtier (kourtier-backend) et le CRM en conception.
Version 1 · 30 septembre 2026.
Principes
- Événements, pas d'appels synchrones. Chaque système publie des événements ; l'autre les traite quand il les reçoit.
- Rien ne se perd. Côté Kourtier, un événement sortant est écrit dans la table
CrmOutboxdans la même transaction que la donnée métier, puis livré avec reprises (30 s, 1 min, 2 min… jusqu'à 6 h, 10 tentatives). - Idempotence. Chaque événement a un
idunique. Le CRM doit ignorer uniddéjà reçu (en-têteIdempotency-Key). L'API Kourtier fait de même avec la tableCrmInboundEvent. - Signature obligatoire. Tous les appels sont signés en HMAC-SHA256 avec horodatage (tolérance 5 minutes).
Enveloppe commune
{
"id": "evt_4f1c…",
"type": "kestim.lead.created",
"version": 1,
"occurredAt": "2026-09-30T12:00:00.000Z",
"source": "kourtier-api",
"data": { }
}
Signature
En-tête X-Kourtier-Signature: t=<secondes unix>,v1=<hex>.
v1 = HMAC_SHA256(secret, "<t>.<corps brut de la requête>"), comparé en temps constant.
Implémentation de référence : lib/signature.js (signMessage, verifySignedMessage).
| Sens | Secret | Variable côté API |
|---|---|---|
| Kourtier vers CRM | secret sortant | CRM_OUTBOUND_SECRET |
| CRM vers Kourtier | secret des webhooks | CRM_WEBHOOK_SECRET |
Kourtier vers CRM
POST {CRM_BASE_URL}/events — le CRM répond 2xx s'il a accepté l'événement (même s'il le traite plus tard). Toute autre réponse déclenche une reprise.
En-têtes : Content-Type: application/json, Idempotency-Key: <id>, X-Kourtier-Event: <type>, X-Kourtier-Signature.
kestim.lead.created
Émis quand un visiteur saisit ses coordonnées à l'étape 3 de K-Estim. C'est un prospect qualifié à attribuer au Kourtier de la commune.
{
"requestId": "clx…",
"reference": "KE-2026-3FA91C07",
"purpose": "SALE",
"method": "UNIT",
"property": {
"locationId": "clx…",
"locationName": "Résidence du Fleuve",
"locationPath": ["Golf", "Gombe", "Kinshasa"],
"unitNumber": "A-101",
"certificate": null,
"documentNumber": null,
"documentYear": null,
"meterNumber": null,
"propertyType": "APPARTEMENT",
"details": { "bedrooms": 3, "area": 142, "condition": "BON_ETAT", "equipment": "GROUPE", "mortgage": false }
},
"contact": {
"name": "…",
"email": "…",
"phone": "+243…",
"role": "OWNER",
"consentAt": "2026-09-30T12:00:00.000Z"
},
"createdAt": "2026-09-30T11:58:00.000Z"
}
Valeurs : purpose SALE | RENT ; method UNIT | CERTIFICATE | OFFPLAN_CONTRACT | LEASE_CONTRACT | METER ; role OWNER | BUYER | TENANT | INVESTOR | AGENT.
Également envoyé : visit.requested (demande de visite d'un visiteur, voir docs/api-marche.md). Le CRM répond par visit.status_changed.
Autres événements envoyés (paiements, Kollect, publication, messagerie) : voir le tableau complet des événements. Événements prévus ensuite (même mécanisme) : user.created, konnecteur.signup, training.purchased.
CRM vers Kourtier
POST {API}/crm/webhooks, corps = enveloppe commune, en-tête X-Kourtier-Signature signé avec CRM_WEBHOOK_SECRET.
Réponses : 200 traité · 202 reçu mais type inconnu (conservé) · 200 {"duplicate": true} déjà traité (un événement refusé en 422 peut être renvoyé corrigé avec le même id, il est alors retraité) · 401 signature invalide · 422 données invalides (details.fields indique les champs en cause).
Types reconnus : kestim.lead.assigned, kestim.lead.status_changed, kestim.report.ready, transaction.recorded, transaction.deleted, agent.upserted, agent.deactivated, visit.status_changed, kollect.lease.upserted, kollect.lease.ended, kollect.charge.created, kollect.charge.cancelled, kollect.ticket.updated, listing.assigned, listing.moderated. Aiguillage : lib/crm/inbound.js.
data désigne la demande par requestId ou reference.
kestim.lead.assigned
{ "reference": "KE-2026-3FA91C07", "crmId": "crm-42", "agent": { "name": "…", "phone": "+243…", "photoUrl": null } }
Effet : statut ASSIGNED, Kourtier visible par le client dans le suivi.
kestim.report.ready
{
"requestId": "clx…",
"report": {
"currency": "USD",
"value": 286000,
"low": 262000,
"high": 309000,
"pricePerSqm": 2014,
"rent": { "monthly": 2400, "grossYield": 10.1 },
"confidence": "MEDIUM",
"comparables": [{ "label": "…", "price": 270000, "area": 130, "date": "2026-06-01", "kind": "SOLD" }],
"trend": [{ "month": "2026-01", "pricePerSqm": 1900 }],
"note": "Texte du Kourtier"
}
}
Effet : statut REPORT_READY, rapport affiché au client.
kestim.lead.status_changed
{ "requestId": "clx…", "status": "CANCELLED" }
Statuts acceptés : ASSIGNED, REPORT_READY, CANCELLED.
Événements « marché » : transactions et Kourtiers
Quatre événements alimentent les endpoints publics GET /transactions et GET /agents. Contrat des endpoints, paramètres et réponses : api-marche.md.
Règles communes :
- La clé est
crmId(identifiant de l'objet côté CRM) : renvoyer le mêmecrmIdmet à jour au lieu de dupliquer. Un champ facultatif absent laisse la valeur actuelle ;nulll'efface ; à la création, les valeurs par défaut s'appliquent. - Le lieu se donne par identifiant Kourtier (table
Location,GET /k-estim/locations?q=) ou par slug (ex.kinshasa/gombe) pour un CRM qui ne connaît pas nos identifiants. Lieu inconnu :422, et l'événement est conservé avec son erreur (une fois le lieu créé ou la donnée corrigée, renvoyer l'événement, mêmeidaccepté). - Un
crmIdinconnu pourtransaction.deletedouagent.deactivatedn'est pas une erreur : réponse200, rien à faire (le CRM peut rejouer sans risque). - Montants en USD uniquement (
currencyfacultatif,"USD"si présent) ; surfaces en m² ; dates ISO 8601 (2026-09-28ou avec fuseau, ex.2026-09-28T10:30:00+01:00).
transaction.recorded
{
"crmId": "tx-8841",
"purpose": "SALE",
"completion": "READY",
"propertyType": "APPARTEMENT",
"locationSlug": "kinshasa/gombe/golf/residence-du-fleuve",
"label": "Résidence du Fleuve, appartement 402",
"bedrooms": 3,
"area": 142,
"plotArea": null,
"price": 286000,
"currency": "USD",
"date": "2026-09-28",
"occupancy": "VACANT"
}
Valeurs : purpose SALE | RENT · completion READY | OFFPLAN · propertyType TERRAIN | APPARTEMENT | MAISON_VILLA | BUREAU | COMMERCE · occupancy VACANT | OCCUPIED. Obligatoires : crmId, purpose, completion, propertyType, locationId ou locationSlug, label, price (> 0), date. Pour un terrain, area est la surface de la parcelle (si seule plotArea est envoyée, elle sert d'area).
Effet : crée ou met à jour la transaction, aussitôt visible dans GET /transactions.
transaction.deleted
{ "crmId": "tx-8841" }
Effet : supprime la transaction.
agent.upserted
{
"crmId": "agent-312",
"type": "AGENT",
"name": "Grâce Mbuyi",
"agencyName": "Kourtier Gombe",
"photoUrl": "https://…",
"phone": "+243812345678",
"whatsapp": "+243812345678",
"email": "grace@kourtier-group.com",
"bio": "…",
"languages": ["Français", "Lingala"],
"specialties": ["SALE", "RENT"],
"communeSlugs": ["kinshasa/gombe"],
"listingsCount": 15,
"verified": true,
"badges": ["KOURTIER_CERTIFIE", "REACTIF"]
}
Valeurs : type AGENT | AGENCY · specialties SALE | RENT | OFFPLAN | COMMERCIAL | LAND. Obligatoires : crmId, type, name. Les communes se donnent par communeIds ou communeSlugs (ou les deux). photoUrl doit être une URL http(s).
Effet : crée ou met à jour l'agent et le rend actif (un agent désactivé revient avec ce même événement).
agent.deactivated
{ "crmId": "agent-312" }
Effet : active = false, l'agent disparaît de GET /agents (il n'est pas supprimé).
Tableau complet des événements
Tous les événements utilisent l'enveloppe commune ci-dessus. Contrats détaillés des données : api-marche.md (marché, visites) et api-v2.md (paiements, Kollect, publication, messagerie). Les montants sont des nombres décimaux avec une devise explicite (USD ou CDF).
Kourtier vers CRM (sortants)
| Type | Déclencheur | data (champs principaux) |
|---|---|---|
kestim.lead.created |
coordonnées saisies à l'étape 3 de K-Estim | voir plus haut |
visit.requested |
demande de visite créée (statut PENDING_PAYMENT) |
requestId, reference, source, property { id, label, municipality }, preferredDate, slot, contact { name, phone, email, consentAt }, message, fee { amount, currency, rule }, status, createdAt |
visit.paid |
frais de visite payés (rappel AvadaPay confirmé) ; le CRM peut alors confirmer la visite | requestId, reference, source, property, preferredDate, slot, contact, fee, payment { id, reference, amount, currency, method, operator, paidAt }, platformShare { percent, amount }, agentShare { percent, amount }, paidAt (partage 10 % / 90 % par défaut, VISIT_FEE_PLATFORM_SHARE) |
kollect.account.created |
compte locataire ou propriétaire créé automatiquement avec un bail (mot de passe provisoire, jamais transmis) | userId, role (OWNER ou TENANT), name, email, phone, lease { id, reference, crmId }, createdAt |
kollect.payment.received |
frais de location payés par le locataire | paymentId, reference, amount, currency, method, operator, paidAt, payer { userId, name, email, phone }, charges [{ chargeId, crmId, type, label, period, amount, currency, lease { id, reference, crmId, label } }], ownerCredits [{ ownerUserId, amount, kourtierFee }] |
kollect.ticket.created |
signalement du locataire | ticketId, category, title, description, photos, urgency, status, lease { id, reference, crmId, label, address, commune }, tenant { userId, name, phone }, owner { userId, name, phone }, createdAt |
kollect.ticket.updated |
le propriétaire fait avancer un signalement (jamais renvoyé pour une mise à jour venue du CRM) | ticketId, crmId, status, comment, updatedBy (OWNER), leaseId, updatedAt |
kollect.payout.requested |
retrait demandé (fonds réservés) | payoutId, reference, ownerUserId, amount, currency, method, operator, bankCode, requestedAt |
kollect.payout.completed |
retrait terminé (la recréditation d'un retrait échoué est faite côté API) | payoutId, reference, ownerUserId, amount, currency, method, status (SENT ou FAILED), failureReason, completedAt |
listing.submitted |
annonce soumise à modération (charte photo vérifiée) | listingId, reference, status, publisher { type, badge, agencyName, verified, userId, name, email, phone, agencyRccm }, purpose, propertyType, title, description, price, currency, location { id, name, path }, address, bedrooms, bathrooms, area, plotArea, photos [{ url, slot, width, height }] |
listing.published |
annonce mise en ligne : approbation par l'équipe (from, to, note, moderatedBy: ADMIN) ou publication directe Kourtier VIP (directPublication: true) |
idem listing.submitted |
listing.status_changed |
modifications demandées, refus, archivage | idem + from, to, note, moderatedBy (ADMIN ou PUBLISHER) |
chat.message.received |
message WhatsApp entrant (webhook Infobip) ; l'id de l'événement est evt_wa_<messageId> : un message rejoué n'est transmis qu'une fois |
channel (WHATSAPP), messageId, from, to, receivedAt, contact { name }, message (le message Infobip tel quel : type, text, url…) |
chat.lead.created |
prospect créé depuis la conversation du chatbot (POST /bot/leads) |
channel, source, contact { name, phone, email, consentAt }, interest, listingReference, message, createdAt |
lead.created |
demande de contact qualifiée du site (panneau « Nous contacter », recherche sans résultat, pages d'offre) via POST /leads ; un email part aussi à l'équipe |
reference (KL-AAAA-XXXXXXXX), profile (entreprise, proprietaire), need, contact { company, ownerProfile, name, phone, email }, project { surface, communes, propertyType, budget, deadline }, followUp { channel (WhatsApp, Appel, E-mail, Rendez-vous), date, slot, place }, context { source, page, summary } |
Les décisions et mises à jour reçues du CRM (listing.moderated, kollect.ticket.updated) ne lui sont pas renvoyées.
CRM vers Kourtier (entrants)
Réponses : celles de la section « CRM vers Kourtier ». Un événement refusé en 422 est conservé avec son erreur et peut être renvoyé corrigé avec le même id.
| Type | data |
Effet |
|---|---|---|
kestim.lead.assigned · kestim.lead.status_changed · kestim.report.ready |
voir plus haut | suivi K-Estim |
transaction.recorded · transaction.deleted · agent.upserted · agent.deactivated |
voir api-marche.md |
API publique « marché » |
visit.status_changed |
{ requestId ou reference, status, scheduledAt?, agent?, crmId? } ; status : CONFIRMED, CANCELLED, DONE (ou PENDING, demandes historiques) |
met à jour la visite ; le visiteur est prévenu par WhatsApp à la confirmation. CONFIRMED et DONE sont refusés en 422 VISIT_NOT_PAID tant que les frais ne sont pas payés ; PENDING_PAYMENT et PAID ne s'imposent pas depuis le CRM |
kollect.lease.upserted |
{ crmId, reference?, propertyId?, label, address, commune, rentAmount, currency?, dueDay?, startDate, endDate?, status?, owner: { name, email, phone? }, tenant: { name, email, phone? } } (owner / tenant peuvent être { userId } pour un compte existant) |
crée ou met à jour le bail (clé crmId) ; crée les comptes absents avec un mot de passe provisoire (mustChangePassword) et envoie l'invitation par email et WhatsApp ; émet kollect.account.created |
kollect.lease.ended |
{ crmId ou reference ou leaseId, endDate? } |
bail ENDED |
kollect.charge.created |
{ crmId, leaseCrmId ou leaseReference ou leaseId, type (RENT, ENERGY, WATER, SYNDIC, OTHER), period (AAAA-MM), amount, dueDate, currency?, label? } |
crée ou met à jour le frais (clé crmId) ; un frais payé n'est jamais modifié ; bail inconnu : 422 (renvoyer après le bail) |
kollect.charge.cancelled |
{ crmId ou chargeId } |
frais CANCELLED ; un frais déjà payé est refusé en 422 (remboursement à traiter à part) |
kollect.ticket.updated |
{ ticketId ou crmId, status? (OPEN, IN_PROGRESS, RESOLVED, CLOSED), comment?, crmId? } |
met à jour le signalement (crmId mémorisé la première fois) ; le locataire est prévenu par WhatsApp |
listing.assigned |
{ reference, agent: { crmId, name, phone?, whatsapp? } } |
Kourtier assigné : son nom et ses numéros s'affichent sur l'annonce ; un Kourtier encore inconnu est créé hors annuaire public (active = false) jusqu'à son agent.upserted |
listing.moderated |
{ reference, decision (APPROVED, CHANGES_REQUESTED, REJECTED), note?, verified? } (note obligatoire sauf approbation) |
même effet que la modération de l'équipe, uniquement sur une annonce SUBMITTED (sinon 409) |
Administration
GET /crm/status(admin) : état de la file d'envoi (en attente, échoués, envoyés).POST /crm/outbox/flush(admin) : livraison immédiate.
Variables d'environnement (API)
| Variable | Rôle |
|---|---|
CRM_BASE_URL |
URL de l'API du CRM. Vide : intégration inactive, événements conservés en attente. |
CRM_OUTBOUND_SECRET |
Secret de signature des événements envoyés. |
CRM_WEBHOOK_SECRET |
Secret de vérification des webhooks reçus. |
CRM_OUTBOX_INTERVAL_MS |
Fréquence de livraison (30 000 par défaut). |
Variables des autres services (API v2)
Paiements AvadaPay, Infobip, emails, stockage des photos, partage des frais de visite, commission Kollect : liste complète et valeurs par défaut dans .env.example ; rôle de chacune dans api-v2.md.
Autres variables liées
| Variable | Rôle |
|---|---|
INTERNAL_API_SECRET |
Secret partagé avec le site : signature des appels internes (x-internal-signature) et de l'IP visiteur (x-kourtier-client-ip-signature). |
INTERNAL_AUTH_ALLOW_LEGACY |
false refuse l'ancien en-tête x-internal-api-secret (à faire une fois le site signé déployé). |
TRUST_PROXY |
1 sur Render pour que req.ip soit l'adresse réelle. |
Mise en service
- Appliquer les migrations
20260930090000_k_estim_crm,20261005090000_market_transactions_agents,20261006090000_visit_requestspuis celles de l'API v2 :20261007090000_api_v2(paiements, règles de frais, Kollect, annonces ; ajoute aussi deux colonnes sans valeur à perdre :User.mustChangePasswordavec défautfalseet des colonnes de frais nullables surVisitRequest),20261007090100_visit_status_pending_paymentet20261007090110_visit_status_paid(une valeur d'énumération chacune) (npx prisma migrate deploy) sur la base cible — elles n'ajoutent que des types, des tables, des index, des clés étrangères, des valeurs d'énumération et des colonnes facultatives (aucune colonne existante n'est modifiée ou supprimée). La table historiquePaymentn'est pas touchée : les paiements AvadaPay sont dansPaymentTransaction. node prisma/seed-locations.jspour Kinshasa et ses 24 communes (sans--demoen production), puisnode prisma/seed-fee-rules.jspour les règles de frais de visite par défaut (idempotent, ne remplace jamais une règle modifiée ; sans lui, l'API applique les mêmes règles en mémoire).- Déployer l'API, puis le site ; vérifier
GET /crm/status. - Quand le CRM expose
POST /events: renseignerCRM_BASE_URL,CRM_OUTBOUND_SECRET,CRM_WEBHOOK_SECRETet redémarrer. Les prospects mis en attente avant cette date partent automatiquement.
API publique « marché » — Transactions et Agents (v1)
Contrat partagé par l'API (kourtier-backend), le site (kourtier-frontend, via /api/*)
et l'application mobile (kourtier-mobile, appels directs). Toute évolution passe d'abord ici.
Inspiré des onglets Transactions et Agents de la page d'accueil Bayut, adapté à Kinshasa.
Conventions : JSON, montants en USD, surfaces en m², dates ISO 8601.
Erreurs : { "error": "message", "code": "VALIDATION_ERROR", "details": { "fields": { "champ": "raison" } }, "status": 422 }.
Lieux : identifiants de la table Location (mêmes lieux que K-Estim, GET /k-estim/locations?q=).
Filtrer par un lieu inclut tous ses descendants (commune vers quartiers vers immeubles).
Endpoints publics (pas de connexion), limités à 120 requêtes/minute par visiteur.
GET /transactions
Transactions conclues (ventes et locations), alimentées par le CRM (transaction.recorded).
Équivalent de bayut.com/property-market-analysis/transactions/.
Paramètres
| Paramètre | Valeurs | Défaut |
|---|---|---|
purpose |
SALE (vendu) · RENT (loué) |
SALE |
locationId |
id de Location |
tout Kinshasa |
completion |
ALL · READY (prêt) · OFFPLAN (sur plan) |
ALL |
propertyType |
TERRAIN · APPARTEMENT · MAISON_VILLA · BUREAU · COMMERCE |
tous |
bedrooms |
0 (studio) … 7, 8 = 8 et plus |
tous |
minPrice, maxPrice |
nombres (USD ; loyer mensuel si RENT) |
— |
period |
1m · 3m · 6m · 12m · all |
12m |
sort |
date_desc · price_desc · price_asc |
date_desc |
page |
≥ 1 | 1 |
pageSize |
1 … 50 | 20 |
Réponse 200
{
"transactions": [
{
"id": "clx…",
"date": "2026-09-28T00:00:00.000Z",
"purpose": "SALE",
"completion": "READY",
"propertyType": "APPARTEMENT",
"label": "Résidence du Fleuve, appartement 402",
"location": { "id": "clx…", "name": "Golf", "path": ["Gombe", "Kinshasa"] },
"price": 286000,
"currency": "USD",
"bedrooms": 3,
"area": 142,
"plotArea": null,
"pricePerSqm": 2014,
"occupancy": "VACANT"
}
],
"summary": {
"count": 1240,
"averagePrice": 214000,
"averagePricePerSqm": 1650,
"change": { "count": -12.4, "averagePrice": 4.5, "averagePricePerSqm": null }
},
"topLocations": [{ "id": "clx…", "name": "Gombe", "count": 312 }],
"period": { "from": "2025-10-01T00:00:00.000Z", "to": "2026-10-01T00:00:00.000Z" },
"page": 1,
"pageSize": 20,
"total": 1240
}
summaryporte sur toutes les transactions filtrées (pas seulement la page).change: variation en % par rapport à la période précédente de même durée ;nullsi pas de référence (ouperiod=all).pricePerSqm=price / areaarrondi (null si pas de surface). Pour un terrain,area= surface de la parcelle.topLocations: 6 sous-lieux les plus actifs du lieu choisi (les communes si aucun lieu).occupancy:VACANT·OCCUPIED·null(affiché « Libre à la vente » / « Loué au moment de la vente »).
GET /agents
Kourtiers et agences partenaires, alimentés par le CRM (agent.upserted). Équivalent de bayut.com/brokers/.
Paramètres
| Paramètre | Valeurs | Défaut |
|---|---|---|
type |
AGENT · AGENCY |
AGENT |
locationId |
id de Location (commune servie) |
toutes |
q |
nom (contient, sans accents ni casse) | — |
language |
ex. Français, Lingala, Anglais, Swahili |
— |
specialty |
SALE · RENT · OFFPLAN · COMMERCIAL · LAND |
— |
page, pageSize |
comme ci-dessus (pageSize ≤ 50, défaut 20) |
Réponse 200
{
"agents": [
{
"id": "clx…",
"type": "AGENT",
"name": "Grâce Mbuyi",
"agencyName": "Kourtier Gombe",
"photoUrl": "https://…",
"phone": "+243812345678",
"whatsapp": "+243812345678",
"email": "grace@kourtier-group.com",
"bio": "…",
"languages": ["Français", "Lingala"],
"specialties": ["SALE", "RENT"],
"communes": [{ "id": "clx…", "name": "Gombe" }],
"listingsCount": 15,
"verified": true,
"badges": ["KOURTIER_CERTIFIE", "REACTIF"]
}
],
"topLocations": [{ "id": "clx…", "name": "Gombe", "count": 18 }],
"page": 1,
"pageSize": 20,
"total": 64
}
Seuls les agents active = true sont renvoyés. Tri : verified d'abord, puis listingsCount décroissant, puis nom.
Événements CRM vers Kourtier (POST /crm/webhooks, voir crm-integration.md)
| Type | data |
Effet |
|---|---|---|
transaction.recorded |
{ crmId, purpose, completion, propertyType, locationId \| locationSlug, label, bedrooms?, area?, plotArea?, price, currency?, date, occupancy? } |
crée ou met à jour (clé crmId) |
transaction.deleted |
{ crmId } |
supprime |
agent.upserted |
{ crmId, type, name, agencyName?, photoUrl?, phone?, whatsapp?, email?, bio?, languages[], specialties[], communeIds[] \| communeSlugs[], listingsCount?, verified?, badges[] } |
crée ou met à jour (clé crmId), active = true |
agent.deactivated |
{ crmId } |
active = false |
locationSlug / communeSlugs : slugs de Location (ex. kinshasa/gombe), pour un CRM qui ne connaît pas les identifiants internes.
POST /visit-requests — demande de visite (site et app mobile)
Visiteur non connecté. Limite : 20 demandes / 10 minutes par visiteur. Transmise au CRM (visit.requested).
Corps : { propertyId, propertyLabel?, date: "AAAA-MM-JJ" (aujourd'hui vers +90 jours), slot: "MORNING" | "AFTERNOON", name, phone, email?, message?, consent: true, source?: "web" | "mobile" }.
propertyId est l'id d'une annonce du site ; pour une annonce connue du seul CRM, envoyer aussi propertyLabel.
Réponse 201 : { request: { id, reference: "KV-2026-3FA91C07", propertyId, propertyLabel, date, slot, status: "PENDING_PAYMENT", fee: { amount, currency, rule }, scheduledAt: null, assignedAgent: null, paidAt: null, createdAt }, fee, accessToken } (fee répété au premier niveau). Visites payantes depuis l'API v2 : voir api-v2.md, section 2 (frais, POST /visit-requests/:id/payment, événement visit.paid).
Suivi : GET /visit-requests/:id avec l'en-tête x-visit-token renvoie { request } (404 sans jeton valide).
Statuts : PENDING_PAYMENT (frais à payer) · PAID (frais payés) · CONFIRMED (date fixée, Kourtier assigné, uniquement après paiement) · CANCELLED · DONE ; PENDING ne subsiste que pour les demandes créées avant les visites payantes.
| Événement | Sens | data |
|---|---|---|
visit.requested |
Kourtier vers CRM | { requestId, reference, source, property: { id, label, municipality }, preferredDate, slot, contact: { name, phone, email, consentAt }, message, fee, status, createdAt } |
visit.status_changed |
CRM vers Kourtier | { requestId \| reference, status, scheduledAt?, agent?, crmId? } |
GET /agents/:id
Un Kourtier ou une agence actifs, même forme que les éléments de GET /agents, dans { "agent": { … } }.
404 NOT_FOUND si l'identifiant est inconnu ou l'agent désactivé (agent.deactivated). Le site retire téléphone,
WhatsApp et email avant affichage : le visiteur passe par la ligne Kourtier, un rendez-vous ou une visite payante.
API v2 — visites payantes, Kollect, publication, paiements, messagerie
Contrat commun à l'API (kourtier-backend), au site (kourtier-frontend, via /api/*) et à l'app
(kourtier-mobile, appels directs). Complète api-marche.md et crm-integration.md.
Montants : nombres décimaux, devise explicite (USD ou CDF). Dates ISO 8601.
Erreurs : { error, code, details: { fields }, status } (422 VALIDATION_ERROR avec details.fields = { champ: raison },
401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 409 INVALID_STATE ou PAYMENT_IN_PROGRESS, 413 PAYLOAD_TOO_LARGE, 422 CODE_INVALID, 423 DESTINATION_COOLING_DOWN,
429 TOO_MANY_REQUESTS, ACCOUNT_LOCKED ou DAILY_LIMIT, 502 PAYMENT_PROVIDER_ERROR, 503 PAYMENTS_UNAVAILABLE). Une erreur 500 ne contient jamais de message interne ni de pile.
Les ressources d'un autre utilisateur répondent 404 (comme une ressource inexistante) quand leur existence ne doit pas se déduire.
Légende des « Écarts et précisions » : les points marqués (précision) complètent le contrat initial sans le contredire ; (écart) signale ce qui diffère du texte initial, avec la raison.
0. Authentification des comptes (site, app, Kollect, annonceurs)
Le mot de passe est toujours envoyé en clair sur HTTPS et haché uniquement par l'API (Argon2id). Le site et l'app ne calculent plus aucun hachage
et l'API ne renvoie plus jamais de hash, de sel, de passwordHash, d'empreinte de jeton ni de jeton (hors réponse de connexion). Détails de sécurité : docs/securite.md.
| Route | Corps | Réponse |
|---|---|---|
POST /auth/login (site) |
{ email, password } |
200 { id, email, name, image, phone, userType, mustChangePassword } — vérification côté serveur ; le site ouvre ensuite sa propre session |
POST /auth/register |
{ name, email, phone, password, userType: "CUSTOMER" \| "OWNER" } |
201 { user: { id, name, email, userType, mustChangePassword } } (aucun jeton : se connecter ensuite) |
POST /auth/token (app) |
{ email, password, device? } |
200 { token, expiresAt, deviceId, user: { id, name, email, userType, mustChangePassword } } (session 30 jours) |
POST /auth/token/revoke |
— (Bearer) | 204 (déconnecte l'appareil courant) |
GET /auth/devices |
— (Bearer) | 200 { devices: [{ id, name, createdAt, lastUsedAt, expiresAt, current }] } |
DELETE /auth/devices/:id |
— (Bearer) | 204 ; 404 si l'appareil n'est pas à l'utilisateur |
GET /auth/me |
— (Bearer) | { user, roles: { tenant, owner, publisher } } |
POST /auth/password/change |
{ currentPassword, newPassword } (Bearer) |
204, remet mustChangePassword à false, ferme les autres sessions |
POST /password/request |
{ email } |
202 { ok: true } — réponse identique que le compte existe ou non ; le lien (valable 1 h, usage unique) est envoyé par email |
PATCH /password/reset/:token |
{ password } |
200 { ok: true, updatedCount } ; ferme toutes les sessions et déverrouille le compte |
Authorization: Bearer <token> est accepté sur toutes les routes protégées. Le jeton de l'app est kt_ + 32 octets aléatoires (base64url) ; seule son empreinte SHA-256 est conservée
côté serveur (Session.accessTokenHash) : il ne se retrouve jamais en clair et ne s'affiche qu'une fois, dans la réponse de POST /auth/token. Les anciens jetons de session en clair
(cookie du site, ancienne app) restent acceptés jusqu'à leur expiration.
Rôles : un même compte peut être locataire, propriétaire et annonceur.
Mots de passe
- Argon2id (m = 19 456 KiB, t = 2, p = 1), chaîne PHC dans
User.passwordHash.User.hash/User.salt(ancien PBKDF2-SHA256 à 1 000 itérations calculé dans le navigateur) ne servent plus qu'à vérifier les comptes existants : à la première connexion réussie (/auth/login,/auth/token), le mot de passe est réécrit en Argon2id ethash/saltsont vidés (événement d'auditPASSWORD_UPGRADED). - Politique (création, changement, réinitialisation) : 10 caractères au minimum (128 au plus), refus des mots de passe courants (liste embarquée courte + variantes « P@ssw0rd2024 », suites, répétitions) et de tout mot de passe qui contient l'adresse email du compte. Refus :
422 VALIDATION_ERRORavecdetails.fields.password(ounewPassword) = raison. - Ancien chemin obsolète :
POST /usersouPATCH /password/reset/:tokenavec{ hash, salt }calculés par le client ne sont acceptés que siALLOW_CLIENT_HASHED_PASSWORDS=true(défautfalse, y compris en production). Sinon422avecdetails.fields.password.PATCH /users/:idn'accepte plus de mot de passe : utiliserPOST /auth/password/change. POST /usersacceptepassword(même politique) ; un compte créé par l'API (Kollect) reçoit un mot de passe provisoire (Argon2id) et se connecte sur le site et dans l'app avec le même mot de passe.
Verrouillage et limites
- 5 mots de passe erronés consécutifs verrouillent le compte 15 minutes (
User.failedLoginCount,lockedUntil) :429 { code: "ACCOUNT_LOCKED" }avecRetry-After(secondes) etdetails.retryAfterSeconds, même avec le bon mot de passe ; le déverrouillage est automatique (ou par la réinitialisation du mot de passe). Le 5e échec renvoie déjà429. Une connexion réussie remet le compteur à zéro. - Compte inconnu = mauvais mot de passe : même réponse
401 { error: "Invalid credentials", code: "UNAUTHORIZED" }, même calcul Argon2id (temps uniforme), et un compte inconnu se verrouille de la même façon (le429ne révèle pas qu'un compte existe).POST /auth/registerrépond409 EMAIL_TAKENpour une adresse déjà utilisée (limité par IP). - Limite par IP : 10 requêtes d'écriture par minute sur
/auth/*et/password/*(connexion, jeton, inscription, changement et réinitialisation), 120 lectures par minute (GET /auth/me,/auth/devices) renvoie429 TOO_MANY_REQUESTS+Retry-After. Le site transmet l'IP du visiteur signée (x-kourtier-client-ip+x-kourtier-client-ip-signature, HMAC avecINTERNAL_API_SECRET) pour que les visiteurs ne partagent pas la limite de son serveur. - 10 appareils connectés au plus par compte (le plus ancien est déconnecté).
Détails
- (précision) Adresse email : correspondance exacte d'abord (comme le site), puis sans tenir compte de la casse.
device: texte de 100 caractères au plus, affiché dans la liste des appareils. - (précision)
GET /auth/me:user={ id, name, email, userType, mustChangePassword, phone, image };rolesest déduit des données :tenantsi un bail à son nom,ownersi un bail dont il est propriétaire ouuserType = OWNER,publishersi au moins une annonce. Tout compte peut commencer à publier. - (précision)
POST /auth/password/change: un mot de passe actuel incorrect répond422(details.fields.currentPassword, pas401, pour ne pas déconnecter l'app) mais compte comme un échec de connexion (verrouillage après 5). Toutes les autres sessions du compte sont fermées et un email d'alerte est envoyé. Un mot de passe défini parPATCH /password/reset/:tokenremet aussimustChangePasswordàfalse. - (précision)
mustChangePassword = truen'est pas imposé par l'API : c'est à l'app et au site d'obliger à changer le mot de passe (écran bloquant tant que le drapeau est vrai). - (précision)
POST /auth/token/revokeexige un jeton Bearer (les appels internes signés du site n'ont pas de session en base :401). - (précision)
PATCH /users/:id: changer l'emailexigecurrentPassword(sauf administrateur) et remetemailVerifiedà vide ; changer l'email ou le téléphone note la date (contactChangedAt) : pendant 24 h, les codes de sécurité ne sont plus envoyés par WhatsApp (email seulement). - (précision) Toutes les réponses qui décrivent un compte (
/users,/auth/*, administrateurs, propriétaires…) sont limitées àid, email, name, image, phone, userType, emailVerified, mustChangePassword, createdAt, updatedAt; un filet de sécurité retire en plus tout champhash,salt,passwordHash,accessTokenHash,sessionToken,phoneIndexde n'importe quelle réponse JSON.
1. Paiements (AvadaPay) — module commun
Modèle Payment (en base : table PaymentTransaction, car une table historique Payment existe déjà, inutilisée, et reste intacte — écart de nom interne, sans effet sur l'API) :
{ id, reference (order_id AvadaPay), purpose: VISIT_FEE | KOLLECT_CHARGE | LISTING_BOOST, amount, currency, method: MOBILE_MONEY | CARD, operator: ORANGE | AIRTEL | VODACOM | AFRICELL | null, phone, status: PENDING | PROCESSING | SUCCEEDED | FAILED | CANCELLED, providerTransactionId, redirectUrl, aggregateType, aggregateId, createdAt, paidAt }.
Vue publique : ces champs plus instructions et failureReason. LISTING_BOOST est réservé (aucun produit « mise en avant » : le paiement se conclurait sans effet).
| Route | Rôle |
|---|---|
GET /payments/:id |
suivi (jeton de la ressource payée ou Bearer du payeur) ; interroge AvadaPay /status si encore en attente |
POST /payments/avadapay/callback |
rappel AvadaPay : signature HMAC-SHA512 vérifiée, idempotent, répond 200 |
Réponse type d'une initiation (visites, Kollect) : 201 { payment: { id, reference, status, amount, currency, method, operator, redirectUrl, instructions } }.
- Mobile money : le client confirme sur son téléphone (USSD / push) ; l'interface affiche « Confirmez le paiement sur votre téléphone » et interroge GET /payments/:id toutes les 3 s pendant 2 min.
- Carte : redirectUrl vers la page sécurisée AvadaPay.
- Mode simulation (AVADAPAY_MODE=simulation, défaut tant que les clés sont absentes) : le paiement passe à SUCCEEDED après ~5 s ; jamais en production.
Autorisation et suivi de GET /payments/:id (précision)
- Visite : en-tête
x-visit-tokende la demande payée. Kollect : Bearer du payeur (ou admin). Sinon404. - Mise à jour auprès d'AvadaPay (
/status) au plus toutes les 10 s par paiement ; une opération que la passerelle ne connaît toujours pas après 30 minutes passe àFAILED. - Tant qu'un paiement est ouvert (
PENDING/PROCESSING) pour une visite ou des frais, une nouvelle tentative répond409 PAYMENT_IN_PROGRESS(avecdetails.payment) : elle pourrait aboutir et facturer deux fois. Un double clic simultané est neutralisé de même. - Échec d'initiation : refus de la passerelle (erreur de configuration, ex. code 10201) renvoie
502 PAYMENT_PROVIDER_ERRORet paiementFAILED; la passerelle qui répond « échoué » (numéro invalide…) renvoie201avecpayment.status = FAILEDetpayment.failureReason; réseau coupé ou 5xx vers201avecPENDING(état inconnu : le suivi tranche). Paiements non disponibles (production sans clés complètes) renvoie503 PAYMENTS_UNAVAILABLE. - Entrée
{ method, operator?, phone }:MOBILE_MONEYexigeoperatoret un numéro congolais (+243…, 9 chiffres nationaux) ;CARDn'a besoin ni de l'un ni de l'autre.
Détails AvadaPay retenus (documentation : https://docs-drc.avadapay.tech)
- Requêtes :
POST {AVADAPAY_BASE_URL}/{public_id}/{payment_c2b | payment_b2c | status | banks}; le corps portemerchant_id(identifiant marchand, distinct dupublic_idde l'URL) etsignature. - Signature : HMAC-SHA512, hexadécimal minuscule, clé =
AVADAPAY_SECRET_KEY, message = pour chaque paramètre dans l'ordre d'envoi,clé+valeurconcaténées sans séparateur,signatureexclue ; paramètres imbriqués préfixés par leur parent (extra.customer_name). Même règle pour les requêtes, la page de paiement par carte et les rappels. - Fournisseurs RDC : Orange
10(9248possible :AVADAPAY_PROVIDER_ORANGE), Airtel17, Vodacom9, Africell19, carte5002(Equity VISA, page de paiementhttps://hpp.avadapay.tech). - Numéro client (
customer_id) selon l'opérateur : Orange et Africell0+ 9 chiffres (0812345678), Airtel 9 chiffres sans0(812345678), Vodacom243+ 9 chiffres (243812345678). order_id=payment.reference, alphanumérique (KP+ année + 12 caractères hexadécimaux ;KO…pour les retraits) : exigé par la page de paiement par carte, et clé d'idempotence côté passerelle.amount: texte à deux décimales ("10.00").customer_user_id(obligatoire) :visit-<id de la demande>ou l'id du compte payeur.- Statuts de la passerelle :
0initié versPENDING,1en cours versPROCESSING,2réussi versSUCCEEDED,3échoué versFAILED,4annulé versCANCELLED(seuls 2, 3, 4 sont définitifs),5renvoieCANCELLED,6en transit versPROCESSING,-1: sans effet. - Rappel (
operation_type17 = C2B, 16 = B2C) :merchant_id,customer_id,amount,order_id,transaction_id,transaction_ref,status,provider_id,destination_id,result { code, message },provider_result { code, message },service_id,service_version,service_date_time,signature. Contrôles : signature (comparaison en temps constant),merchant_id, montant égal à celui du paiement, type d'opération, et — siAVADAPAY_CALLBACK_IPSest défini (adresses ou plages CIDR IPv4 séparées par des virgules) — adresse IP de l'appelant (403 FORBIDDENsinon ; derrière un proxy, définirTRUST_PROXY). Réponses :200 { received: true, handled, duplicate?, reason? };401si la signature ou lemerchant_idest invalide (AvadaPay réessaie) ;200avechandled: falsepour une commande inconnue ou un montant différent (AMOUNT_MISMATCH, paiement laissé ouvert pour examen). - Les effets métier (visite
PAID, frais KollectPAID, crédit du portefeuille, événements CRM) s'appliquent une seule fois, dans la transaction qui fait passer le paiement à son état final, même si le rappel est reçu plusieurs fois ou en parallèle. - Variables :
AVADAPAY_CALLBACK_IPS(facultative),AVADAPAY_MODE,AVADAPAY_BASE_URL(https://api.avadapay.tech),AVADAPAY_PUBLIC_ID,AVADAPAY_MERCHANT_ID,AVADAPAY_SECRET_KEY,AVADAPAY_CALLBACK_URL,AVADAPAY_RETURN_URL,AVADAPAY_PAYMENT_PAGE_URL,AVADAPAY_PROVIDER_*,AVADAPAY_BANK_PROVIDER_ID. - À savoir côté AvadaPay : le paiement et le retrait mobile money doivent se faire avec des numéros vérifiés par le client (la documentation l'exige contre la fraude). L'API ne vérifie pas la possession du numéro avant l'envoi (la confirmation par code PIN sur le téléphone fait foi pour un paiement) ; pour les retraits, voir la section 3.
2. Demandes de visite payantes
Évolution de POST /visit-requests (api-marche.md) :
- La réponse ajoute fee: { amount, currency, rule }. Statuts : PENDING_PAYMENT renvoie PAID renvoie CONFIRMED (date + Kourtier, par le CRM) renvoie DONE ; CANCELLED à tout moment.
- POST /visit-requests/:id/payment (en-tête x-visit-token) { method, operator?, phone } renvoie 201 { payment }.
- Frais : règles en base (FeeRule), modifiables par l'admin (GET/PUT /admin/fee-rules). Défauts, à valider par Kourtier :
location vers forfait 10 $ (studio, appartement), 15 $ (maison, villa), 20 $ (bureau, commerce) ; vente vers 1 % du prix, avec plancher 20 $.
Partage communiqué au CRM : platformShare 10 %, agentShare 90 % (VISIT_FEE_PLATFORM_SHARE).
- CRM : visit.requested (à la création), visit.paid (paiement confirmé, avec montant, partage, référence de paiement) ; le CRM répond visit.status_changed (CONFIRMED + scheduledAt + agent). La visite n'est confirmée qu'après paiement.
Précisions
- (précision) Réponse de création :
201 { request: { id, reference, propertyId, propertyLabel, date, slot, status, fee, scheduledAt, assignedAgent, paidAt, createdAt }, fee, accessToken }—feefigure dansrequest(donc aussi dansGET /visit-requests/:id) et au premier niveau. Une demande historique (statutPENDING, sans frais) afee: null: elle n'est pas payable (409) mais reste confirmable par le CRM. - (précision) Bien visité : annonce du site (table
Property:transactionType,priceen USD, type déduit de la fiche de détail — appartement, maison, terrain, bureau, commerce — sinon du nom de la catégorie), sinon annonce publiée sur Kourtier (Listing, par id ou référenceKL-…; un brouillon est introuvable), sinon annonce du seul CRM : le visiteur peut alors envoyerpurpose(SALE/RENT),propertyType(TERRAIN,APPARTEMENT,MAISON_VILLA,BUREAU,COMMERCE),price,currency, ignorés pour une annonce connue. Sans indication : forfait de location par défaut. - (précision) Règles par défaut en code (
lib/visits/fees.js, graineprisma/seed-fee-rules.js) :RENT_APARTMENT10 $,RENT_HOUSE15 $,RENT_OFFICE20 $,RENT_COMMERCE20 $,RENT_DEFAULT10 $ (terrain, type inconnu),SALE_PERCENT1 % du prix (plancher 20 $, converti au tauxFX_CDF_PER_USDpour un prix en CDF ; prix inconnu : le plancher). Tant que la tableFeeRuleest vide, ces règles s'appliquent en mémoire. La règle la plus précise (type de bien renseigné) l'emporte, puis la plus prioritaire ; si aucune règle active ne convient, repli sur ces règles par défaut (une demande de visite ne peut pas échouer faute de règle). - (précision)
GET /admin/fee-rulesrenvoie{ rules: [{ code, label, transactionType, propertyType, kind (FLAT | PERCENT), amount, currency, minAmount, maxAmount, priority, active }] };PUT /admin/fee-rules{ rules: [...] }crée ou met à jour chaque règle parcode(désactiver :active: false), renvoie la même forme. Réservé au rôleADMIN. - (précision)
POST /visit-requests/:id/payment:404sans jeton valide,409 INVALID_STATEsi la demande n'attend pas de paiement (déjà payée, annulée, historique),409 PAYMENT_IN_PROGRESS,422entrée invalide. Limite : 20 requêtes / 10 minutes par visiteur. - (précision)
visit.requestedajoutefeeetstatus.visit.paid:{ requestId, reference, source, property, preferredDate, slot, contact, fee, payment { id, reference, amount, currency, method, operator, paidAt }, platformShare { percent, amount }, agentShare { percent, amount }, paidAt }. - (précision) Le CRM ne peut imposer que
PENDING,CONFIRMED,CANCELLED,DONE;CONFIRMEDetDONEsont refusés en422 VISIT_NOT_PAIDtant que la demande estPENDING_PAYMENT. À la confirmation, le visiteur reçoit un message WhatsApp (si Infobip est configuré). Un paiement qui aboutit après l'annulation d'une visite ne change pas son statut (à rembourser à la main : journalisé).
3. Kollect — locataires et propriétaires
Modèles
Lease { id, reference, propertyId?, label, address, commune, ownerUserId, tenantUserId, rentAmount, currency, dueDay, startDate, endDate?, status: ACTIVE | ENDED, qrToken }
Charge { id, leaseId, type: RENT | ENERGY | WATER | SYNDIC | OTHER, label, period (AAAA-MM), amount, currency, dueDate, status: DUE | PAID | OVERDUE | CANCELLED, paidAt, paymentId }
Ticket { id, leaseId, category: PLOMBERIE | ELECTRICITE | SERRURERIE | TOITURE | NUISIBLES | AUTRE, title, description, photos[], urgency: NORMALE | URGENTE, status: OPEN | IN_PROGRESS | RESOLVED | CLOSED, createdAt, updatedAt }
Wallet { id, ownerUserId } + WalletEntry { id, walletId, type: CREDIT | DEBIT, amount, currency, source: CHARGE_PAYMENT | PAYOUT | KOURTIER_FEE | ADJUSTMENT, reference, createdAt } (solde = somme des écritures, par devise)
Payout { id, walletId, amount, currency, method: MOBILE_MONEY | BANK, operator?, phone?, bankCode?, accountNumber?, accountName?, status: REQUESTED | PROCESSING | SENT | FAILED, createdAt }
(précision) OVERDUE n'est pas écrit en base : un frais DUE dont l'échéance est passée est servi OVERDUE (et ?status=OVERDUE filtre sur cette valeur). Le portefeuille est un journal d'écritures jamais modifiées ; une même écriture n'est jamais passée deux fois ((source, reference) unique).
Création des comptes
Un bail créé (par le CRM : kollect.lease.upserted, ou par l'admin : POST /admin/kollect/leases) crée si besoin le compte du locataire et celui du propriétaire, avec un mot de passe provisoire aléatoire et mustChangePassword = true, puis envoie l'invitation par email (et WhatsApp si Infobip est configuré) : identifiant, mot de passe provisoire, lien de connexion, obligation de le changer.
(précision)
- Compte repéré par son email (sans tenir compte de la casse) ; un compte existant est réutilisé sans être modifié ni réinvité. owner et tenant : { name, email, phone? } (l'email est l'identifiant de connexion, obligatoire) ou { userId }. Les comptes créés sont userType = CUSTOMER (aucun droit de gestion sur le site).
- Mot de passe provisoire : 12 caractères (alphabet sans caractères ambigus), haché en Argon2id ; jamais stocké, journalisé ni transmis au CRM. L'événement kollect.account.created annonce le compte, pas le mot de passe.
- Une invitation qui n'a pu partir par aucun canal ne perd pas le compte : POST /admin/kollect/users/:id/invite génère un nouveau mot de passe provisoire (compte encore à mustChangePassword, sessions ouvertes fermées) et renvoie l'invitation.
Seule la réponse des routes admin (POST /admin/kollect/leases, …/invite) rend le mot de passe (provisionalPassword), et seulement si ni l'email ni WhatsApp n'a livré, pour que l'équipe le transmette elle-même.
- Aucun mécanisme d'envoi d'emails n'existait dans l'API (la réinitialisation de mot de passe n'écrit que dans les journaux hors production) : lib/mailer.js ajoute un envoi minimal par l'API HTTP de Resend (EMAIL_PROVIDER=resend, EMAIL_FROM, RESEND_API_KEY), un mode console de développement (EMAIL_PROVIDER=log, refusé en production) et un
remplaçant possible en un seul endroit. Sans fournisseur, les invitations partent seulement par WhatsApp.
Routes locataire (Bearer)
| Route | Réponse |
|---|---|
GET /kollect/home |
{ leases: [{ lease, dueTotal: {USD, CDF}, nextCharge, charges: [...6 derniers mois], openTickets }] } |
GET /kollect/charges?leaseId=&status= |
{ charges } |
POST /kollect/charges/:id/pay |
{ method, operator?, phone } renvoie 201 { payment } ; POST /kollect/charges/pay { chargeIds[], … } pour payer plusieurs frais en une fois |
GET /kollect/receipts/:paymentId |
reçu (JSON ; le site et l'app le mettent en page) |
POST /kollect/tickets |
{ leaseId, category, title, description, urgency, photos[] (URL déjà téléversées) } renvoie 201 { ticket } |
GET /kollect/tickets?leaseId= |
{ tickets } |
(précision)
- lease = { id, reference, propertyId, label, address, commune, rentAmount, currency, dueDay, startDate, endDate, status } ; charge = { id, leaseId, type, label, period, amount, currency, dueDate, status, paidAt, paymentId } ; ticket = { id, leaseId, category, title, description, photos, urgency, status, comment, createdAt, updatedAt }.
- home : tous les baux du locataire, actifs d'abord ; dueTotal = frais DUE (retards compris), nextCharge = le plus ancien frais à payer, charges = frais non annulés échus depuis 6 mois ou encore à payer, openTickets = signalements OPEN / IN_PROGRESS.
- Paiement : les frais doivent être au locataire (403 sinon, 404 frais inconnu), DUE (409 sinon), dans une seule devise (422), 20 au plus par paiement. Ils sont rattachés au paiement tant qu'il est ouvert (409 PAYMENT_IN_PROGRESS pour un second paiement) ; un paiement échoué ou annulé les libère.
- GET /kollect/receipts/:paymentId renvoie { receipt: { number (= reference du paiement), paymentId, paidAt, amount, currency, method, operator, payer { name, email }, items [{ chargeId, type, label, period, amount, currency, lease { reference, label, address, commune }, owner { name } }], issuer } } ; accessible au payeur,
au propriétaire des baux concernés et à l'admin (404 sinon), seulement une fois le paiement abouti (409 avant).
- Signalement : le bail doit être celui du locataire (403) et actif (409) ; 6 photos au plus (URL http(s)).
Code QR de la maison
GET /kollect/qr/:qrToken (public) renvoie { lease: { reference, label, commune } } seulement (aucun montant). Le site/l'app ouvre alors l'écran de paiement : connexion requise, et l'API vérifie que l'utilisateur est bien le locataire de ce bail (sinon 403).
GET /admin/kollect/leases/:id/qr renvoie le contenu à encoder : https://www.kourtier-group.com/kollect/m/<qrToken>.
(précision) Le jeton QR (128 bits aléatoires) ne change jamais pour un bail ; un bail terminé ne répond plus (404). L'écran de paiement du QR s'alimente par GET /kollect/qr/:qrToken/pay (Bearer) renvoie { lease, dueTotal, charges } (frais à payer ; 403 si l'utilisateur n'est pas le locataire, 401 sans connexion),
puis POST /kollect/charges/pay. Contenu du QR : https://<PUBLIC_SITE_URL>/kollect/m/<qrToken> (PUBLIC_SITE_URL, https://www.kourtier-group.com par défaut). Le endpoint admin répond { content, qrToken, lease: { id, reference, label } }.
Routes propriétaire (Bearer)
| Route | Réponse |
|---|---|
GET /kollect/owner/overview |
{ properties: [{ lease, tenant: {name, phone}, dueTotal, lastPayment, openTickets }], collectedThisMonth, overdueTotal } |
GET /kollect/owner/wallet |
{ balances: { USD, CDF }, entries (paginées) } |
POST /kollect/owner/verification-codes |
{ purpose: "ADD_DESTINATION" } ou { purpose: "PAYOUT", destinationId, amount, currency } renvoie 201 { verification } (code envoyé par email / WhatsApp) |
GET / POST / DELETE /kollect/owner/payout-destinations[/:id] |
destinations de retrait enregistrées (numéros masqués, utilisables 24 h après l'ajout, code ADD_DESTINATION exigé) |
POST /kollect/owner/payouts |
{ amount, currency, destinationId, code } renvoie 201 { payout } (solde, délai de 24 h, plafond et code vérifiés ; AvadaPay B2C) — voir « Retraits sécurisés » ci-dessous |
GET /kollect/owner/payouts |
{ payouts } |
GET /kollect/banks |
liste des banques AvadaPay (pour les virements) |
GET /kollect/owner/tickets, PATCH /kollect/owner/tickets/:id |
suivi des signalements (status, commentaire) |
Paiement d'un frais : Charge renvoie PAID, écriture CREDIT dans le portefeuille du propriétaire (moins la commission Kourtier éventuelle, KOLLECT_FEE_PERCENT, défaut 0), reçu disponible.
(précision)
- lastPayment = { chargeId, label, period, amount, currency, paidAt } ou null ; collectedThisMonth et overdueTotal = { USD, CDF } ; un compte sans bail reçoit des listes vides (pas d'erreur).
- wallet?page=&pageSize= (défaut 20, 100 au plus) renvoie { balances, entries: [{ id, type, amount, currency, source, reference, createdAt }], page, pageSize, total }, écritures les plus récentes d'abord. Commission : une écriture CREDIT du montant du frais et, si KOLLECT_FEE_PERCENT > 0, une écriture DEBIT de source KOURTIER_FEE.
- Retrait : le solde de la devise demandée est vérifié et les fonds sont débités dès la demande (DEBIT, source PAYOUT) dans une transaction sérialisable (422 INSUFFICIENT_FUNDS avec details.balance). Le retrait part ensuite en AvadaPay B2C : REQUESTED renvoie PROCESSING renvoie SENT ou FAILED
(rappel, ou /status interrogé à la lecture de GET /kollect/owner/payouts). Un retrait FAILED est recrédité (CREDIT, source PAYOUT, référence reversal:<référence>) une seule fois. Si l'envoi vers la passerelle échoue sans réponse (réseau), l'état est inconnu : le retrait reste ouvert, fonds réservés, et le suivi tranche
(jamais de recréditation à l'aveugle). MOBILE_MONEY : opérateur + numéro congolais ; minimum 100 CDF (minimum AvadaPay). BANK : bankCode (code de GET /kollect/banks), accountNumber, accountName ; la passerelle reçoit le numéro de compte comme customer_id, le titulaire et bank_code dans extra, et le fournisseur
AVADAPAY_BANK_PROVIDER_ID (à demander à AvadaPay : la documentation indique « check provider id with your manager » pour Equity 20 et Ecobank 23 ; sans lui, 503 BANK_PAYOUT_NOT_CONFIGURED, rien n'est débité). Liste des banques : POST /{public_id}/banks avec ce fournisseur, mise en cache 10 minutes.
- Sécurité des retraits (résolu) : le risque d'un compte compromis vidant son portefeuille vers un numéro quelconque est traité par les destinations enregistrées, le délai de 24 h, le code à usage unique lié au montant et les plafonds (voir « Retraits sécurisés »).
- PATCH /kollect/owner/tickets/:id { status?, comment? } (au moins un) : le propriétaire du bien seulement (404 sinon) ; le locataire est prévenu par WhatsApp. GET /kollect/owner/tickets?status= renvoie { tickets: [{ …ticket, lease { reference, label, address }, tenant { name, phone } }] }.
Retraits sécurisés (destination enregistrée + code à usage unique + délai de 24 h + plafond)
Un retrait ne part jamais vers un numéro ou un compte saisi à la volée. Parcours :
- Code :
POST /kollect/owner/verification-codesrenvoie201 { verification: { purpose, expiresAt, channels: { email, whatsapp } } }. Le code (6 chiffres) est envoyé par email (Hostinger vialib/mailer.js) et par WhatsApp si Infobip est configuré (modèlekourtier_security_code: {{1}} nom, {{2}} code, {{3}} opération, {{4}} durée) ; il n'est jamais renvoyé par l'API. Corps :{ "purpose": "ADD_DESTINATION" }ou{ "purpose": "PAYOUT", "destinationId", "amount", "currency" }(le code est lié à cette destination, ce montant et cette devise). Valable 10 minutes, 5 essais, une seule utilisation ; un nouveau code remplace le précédent ; 5 codes au plus par compte et par 15 minutes (429 TOO_MANY_REQUESTS). Aucun canal disponible :503 CODE_DELIVERY_UNAVAILABLE. Destination inconnue :404. - Ajout d'une destination :
POST /kollect/owner/payout-destinations{ method: "MOBILE_MONEY", operator, phone, label?, code }ou{ method: "BANK", bankCode, accountNumber, accountName, label?, code }(code= codeADD_DESTINATION) renvoie201 { destination: { id, method, label, operator, phone, bankCode, accountNumber, accountName, usableFrom, usable, createdAt, lastUsedAt } }. Numéros masqués dans toute réponse (phone: "••••567",accountNumber: "••••6789").usableFrom= ajout + 24 h (usable: falsed'ici là). Un email d'alerte part à l'ajout. Erreurs :422 CODE_INVALID(code faux, expiré, déjà utilisé, trop d'essais ou lié à une autre opération ;details.attemptsLeft),409 CONFLICT(déjà enregistrée),422 TOO_MANY_DESTINATIONS(10 au plus). - Liste / retrait d'une destination :
GET /kollect/owner/payout-destinationsrenvoie{ destinations: [...] }(mêmes champs) ;DELETE /kollect/owner/payout-destinations/:idrenvoie204(404sinon). Une destination retirée puis ajoutée de nouveau repart pour 24 h. - Retrait :
POST /kollect/owner/payouts{ amount, currency, destinationId, code }(code= codePAYOUTdemandé pour ce mêmedestinationId,amount,currency) renvoie201 { payout }.payout={ id, reference, amount, currency, method, operator, destinationId, phone (masqué), bankCode, accountNumber (masqué), accountName, status, failureReason, createdAt, completedAt }. L'ancien corps{ method, operator, phone, bankCode, accountNumber, accountName }est supprimé (422,details.fields.destinationId/codeobligatoires).
Erreurs du retrait (contrôlées dans cet ordre) :
| Statut | code |
Cas |
|---|---|---|
404 |
NOT_FOUND |
destination inconnue, retirée ou d'un autre compte |
423 |
DESTINATION_COOLING_DOWN |
destination ajoutée il y a moins de 24 h ; details.usableFrom |
422 |
INSUFFICIENT_FUNDS |
solde insuffisant (details.balance) |
429 |
DAILY_LIMIT |
plafond atteint sur les 24 dernières heures (glissantes), par devise : details = { currency, limit, used, remaining, availableAgainAt } |
422 |
CODE_INVALID |
code absent du corps (validation), faux, expiré, déjà utilisé, ou émis pour un autre montant / devise / destination |
503 |
BANK_PAYOUT_NOT_CONFIGURED, PAYMENTS_UNAVAILABLE |
comme avant |
Plafonds : PAYOUT_DAILY_LIMIT_USD (défaut 1 000) et PAYOUT_DAILY_LIMIT_CDF (défaut 2 800 000) ; les retraits FAILED (recrédités) ne comptent pas. Les refus (CODE_INVALID, DESTINATION_COOLING_DOWN, DAILY_LIMIT, INSUFFICIENT_FUNDS) sont inscrits au journal d'audit (PAYOUT_REFUSED).
Un email de notification part à chaque retrait demandé, puis à son issue (envoyé / échoué et recrédité). Les numéros et comptes sont chiffrés au repos (AES-256-GCM, docs/securite.md).
Administration (précision)
Réservées au rôle ADMIN :
| Route | Rôle |
|---|---|
POST /admin/kollect/leases |
crée ou met à jour un bail (même corps que kollect.lease.upserted, crmId facultatif) renvoie 201 { lease, created, accounts: [{ role, userId, email, created, invitation: { email, whatsapp }, provisionalPassword? }], qr: { token, content } } (200 si mise à jour) |
GET /admin/kollect/leases?status=&page=&pageSize= |
liste des baux |
GET /admin/kollect/leases/:id/qr |
contenu du code QR |
POST /admin/kollect/leases/:id/charges |
crée ou met à jour un frais (même corps que kollect.charge.created, sans le bail) renvoie { charge } |
POST /admin/kollect/users/:id/invite |
renvoie l'invitation d'un compte encore provisoire (409 si déjà activé) |
POST /admin/kollect/reminders/run |
rappels de loyer par WhatsApp vers { considered, sent, skipped, failed } : frais DUE échus dans les 3 jours ou en retard, un rappel tous les 3 jours par frais ; planification périodique facultative : KOLLECT_REMINDERS_INTERVAL_MS |
CRM
Sortants : kollect.payment.received, kollect.ticket.created, kollect.ticket.updated, kollect.payout.requested, kollect.payout.completed, kollect.account.created.
Entrants : kollect.lease.upserted, kollect.lease.ended, kollect.charge.created (loyer mensuel, énergie, eau, syndic), kollect.charge.cancelled, kollect.ticket.updated.
Données de chaque événement : crm-integration.md (tableau complet). (précision) kollect.payout.completed porte status (SENT ou FAILED) ; il n'y a pas d'événement distinct pour l'échec. L'API ne génère pas les loyers mensuels : le CRM envoie un kollect.charge.created par frais.
4. Publication des annonces
Qui publie
| Annonceur | Compte | Badge affiché | Publication |
|---|---|---|---|
| Kourtier VIP (agents du groupe) | back-office Kourtier | « Kourtier VIP » | directe, par l'équipe |
| Propriétaire | compte annonceur (créé dans le parcours de publication) | « Propriétaire » | après modération |
| Promoteur | compte annonceur + fiche promoteur | « Promoteur » | après modération |
| Kourtier externe (agence) | compte agence (plusieurs agents, RCCM fourni) | « Agence partenaire » + nom de l'agence | après modération ; « Vérifiée » quand les documents sont contrôlés |
Modèle Listing
{ id, reference, publisherType: KOURTIER_VIP | OWNER | DEVELOPER | EXTERNAL_AGENCY, publisherUserId, agencyName?, purpose: SALE | RENT, propertyType, title, description, price, currency, locationId, address?, latitude?, longitude?, bedrooms?, bathrooms?, area?, plotArea?, amenities[], photos: [{ url, slot, width, height }], status: DRAFT | SUBMITTED | CHANGES_REQUESTED | PUBLISHED | REJECTED | ARCHIVED, moderationNote?, assignedAgentId?, publishedAt?, createdAt, updatedAt }
(précision) reference = KL-AAAA-XXXXXXXX. propertyType : TERRAIN, APPARTEMENT, MAISON_VILLA, BUREAU, COMMERCE (mêmes valeurs que l'API marché). locationId : id de Location (GET /k-estim/locations?q=). Champs ajoutés : agencyRccm (agence), publisherVerified (« Vérifiée »),
submittedAt. agencyName désigne l'agence (EXTERNAL_AGENCY) ou la société (DEVELOPER). Les photos sont des lignes distinctes (ListingPhoto, avec id côté annonceur).
Charte photo Kourtier (contrôlée par l'API)
8 photos minimum, 30 maximum ; JPEG/PNG/WEBP ; paysage ; 1600 × 1200 px minimum ; ratio 4:3 (tolérance 3:2 à 16:9) ; 10 Mo maximum.
Emplacements, dans l'ordre : FACADE (obligatoire), SALON (obligatoire), CUISINE (obligatoire), CHAMBRE (obligatoire), SALLE_DE_BAIN (obligatoire), AUTRE_PIECE, EXTERIEUR, VUE, PLAN.
Conseils affichés à l'annonceur : lumière du jour, lumières allumées, téléphone à l'horizontale et à hauteur de poitrine, pièces rangées, aucune personne, aucun logo ni filigrane, aucun filtre.
(précision) Contrôles faits par lib/listings/charter.js sur le contenu du fichier (type reconnu à l'en-tête, pas à l'extension ni au type annoncé) : poids ≤ 10 Mo ; largeur ≥ 1600 et hauteur ≥ 1200 ; paysage (largeur > hauteur) ; ratio entre 4:3 (1,33) et 16:9 (1,78), tolérance ±0,02
(3:2 compris) ; la rotation EXIF des téléphones (valeurs 5 à 8) est prise en compte (une photo « tournée » est vue en portrait). Codes de problèmes d'une photo : FILE_TOO_LARGE, UNSUPPORTED_FORMAT, NOT_LANDSCAPE, TOO_SMALL, BAD_RATIO, MAX_PHOTOS.
Plusieurs photos par emplacement sont acceptées (30 au total). GET /listings/photo-charter (public) renvoie { charter: { minPhotos, maxPhotos, maxBytes, minWidth, minHeight, ratio, orientation, mimeTypes, slots, mandatorySlots, mandatorySlotsLand, tips } }.
(écart) Un terrain n'a ni salon ni cuisine : pour propertyType = TERRAIN, les emplacements obligatoires sont EXTERIEUR et VUE (au lieu des cinq pièces) ; le minimum de 8 photos reste. Facile à retirer (LAND_MANDATORY_SLOTS dans lib/listings/charter.js).
Routes annonceur (Bearer)
POST /listings (brouillon) · PATCH /listings/:id · POST /listings/:id/photos (multipart file, slot) renvoie { photo, checks: { ok, problems[] } } · DELETE /listings/:id/photos/:photoId · POST /listings/:id/submit (refusé si la charte n'est pas respectée : 422 avec la liste des problèmes) · GET /listings/mine · GET /listings/:id (propriétaire de l'annonce).
Public : GET /listings?… (publiées) et GET /listings/public/:reference renvoie avec publisher: { type, badge, agencyName } et contact: { agentName, phone, whatsapp } (le Kourtier assigné ; à défaut la ligne Kourtier KOURTIER_HOTLINE).
Admin : GET /admin/listings?status= · POST /admin/listings/:id/approve | request-changes | reject ({ note }) · POST /admin/listings (Kourtier VIP, publication directe).
CRM : sortants listing.submitted, listing.published, listing.status_changed ; entrants listing.assigned ({ reference, agent: { crmId, name, phone, whatsapp } }), listing.moderated (si la modération se fait dans le CRM).
(précision)
- Enveloppes : POST /listings renvoie 201 { listing } ; PATCH renvoie { listing } ; GET /listings/:id renvoie { listing } ; GET /listings/mine renvoie { listings } ; GET /listings/public/:reference renvoie { listing } ; GET /listings renvoie { listings, page, pageSize, total } ; DELETE …/photos/:photoId renvoie 204.
- Création : publisherType (OWNER par défaut, DEVELOPER, EXTERNAL_AGENCY ; KOURTIER_VIP refusé en 422 pour un annonceur), purpose, propertyType, title (5–150), price, currency (USD par défaut), locationId obligatoires ; description, address, latitude, longitude, bedrooms, bathrooms, area, plotArea, amenities, agencyName, agencyRccm facultatifs.
Modifiable (PATCH, photos) seulement en DRAFT ou CHANGES_REQUESTED (409 sinon).
- Photos : POST /listings/:id/photos renvoie 201 { photo: { id, url, slot, width, height }, checks: { ok: true, problems: [] } }. Une photo non conforme n'est pas conservée : 422 { error, code: "PHOTO_NON_COMPLIANT", details: { problems }, status: 422, photo: null, checks: { ok: false, problems } }, problems = [{ code, message }] (messages en français, destinés à l'annonceur).
Le fichier dépassant 10 Mo est refusé de même (FILE_TOO_LARGE). Un champ file absent ou un slot inconnu vers 422 VALIDATION_ERROR.
- Soumission : 422 CHARTER_NOT_RESPECTED avec details.problems = [{ code, message, field?, slot? }] : TITLE, DESCRIPTION (20 caractères au moins), AGENCY_NAME et AGENCY_RCCM (agence), DEVELOPER_NAME (promoteur), MIN_PHOTOS, MAX_PHOTOS, MISSING_SLOT (un par emplacement obligatoire manquant, avec slot). Réussie : SUBMITTED (événement listing.submitted).
- Modération (admin) : approve (SUBMITTED renvoie PUBLISHED, { note?, verified? } ; verified: true pose le badge « Vérifiée »), request-changes renvoie CHANGES_REQUESTED et reject renvoie REJECTED (note obligatoire, visible par l'annonceur dans moderationNote), archive (PUBLISHED renvoie ARCHIVED, aussi POST /listings/:id/archive pour l'annonceur).
Autre statut de départ : 409. Événements : approve renvoie listing.published ; les autres vers listing.status_changed (from, to, note) ; une décision venue du CRM (listing.moderated) n'est pas renvoyée au CRM.
- Kourtier VIP : POST /admin/listings crée un brouillon KOURTIER_VIP (même corps ; publisherType implicite). L'équipe ajoute ses photos par les mêmes routes (la charte s'applique à tous) puis POST /listings/:id/submit publie directement (listing.published avec directPublication: true, sans modération). Un administrateur peut agir sur toute annonce.
- Public : purpose, propertyType, currency, locationId (inclut tous les descendants), minPrice, maxPrice, bedrooms (minimum), q (titre ou description, sans tenir compte de la casse), sort (date_desc par défaut, price_asc, price_desc), page, pageSize (≤ 50, défaut 20).
Vue publique : { id, reference, purpose, propertyType, title, description, price, currency, location { id, name, path }, address, latitude, longitude, bedrooms, bathrooms, area, plotArea, amenities, photos [{ url, slot, width, height }], publisher { type, badge, agencyName, verified }, contact { agentName, phone, whatsapp }, publishedAt }
(photos dans l'ordre de la charte ; jamais d'email ni de téléphone de l'annonceur). badge : « Kourtier VIP », « Propriétaire », « Promoteur » ou « Agence partenaire » (+ agencyName) ; verified : « Vérifiée ». Vue annonceur : la vue publique plus status, moderationNote, publisherType, agencyName, agencyRccm, publisherVerified, assignedAgentId, submittedAt, createdAt, updatedAt et l'id de chaque photo.
- Contact : le Kourtier assigné (listing.assigned, son nom, son téléphone, son WhatsApp) ; à défaut, ou s'il n'a pas de numéro, KOURTIER_HOTLINE (téléphone et WhatsApp) ; sans ligne configurée, phone et whatsapp valent null. Un Kourtier encore inconnu est créé hors annuaire public (active = false) jusqu'à son agent.upserted.
- Stockage des photos : aucun stockage de fichiers n'existait (routes/images.js n'enregistre que des URL, les images vont vers S3 depuis le site). lib/storage.js propose un stockage local (UPLOADS_DIR, servi sous /uploads) derrière une interface { put, remove, urlFor } : sur Render, le disque est éphémère, donc monter un disque persistant ou écrire un pilote S3 (un fichier).
URL d'une photo : PUBLIC_API_URL + /uploads/listings/<id>/<uuid>.<ext> (relative sans PUBLIC_API_URL).
- Compte annonceur : l'envoi d'une photo et chaque route annonceur exigent une connexion (Bearer). Le parcours « compte créé en fin d'assistant » doit donc garder les photos côté client jusqu'à la création du compte (POST /users puis POST /auth/token), puis les envoyer.
5. Messagerie (Infobip)
- Envoi WhatsApp (texte et modèles approuvés) pour : invitation de compte, visite confirmée, paiement reçu, rappel de loyer, avancement d'un signalement. Variables :
INFOBIP_BASE_URL,INFOBIP_API_KEY(en-têteAuthorization: App <clé>),INFOBIP_WHATSAPP_SENDER. POST /infobip/webhooks/whatsapp: messages entrants (secret partagéINFOBIP_WEBHOOK_SECRETdans l'en-têtex-kourtier-webhook-secret), transmis au CRM (chat.message.received).- API du chatbot (appelée par Infobip Answers, en-tête
x-bot-key=BOT_API_KEY) :GET /bot/listings/:reference,GET /bot/visits/:reference?phone=,GET /bot/kollect/balance?phone=,POST /bot/leads(prospect créé depuis la conversation, transmis au CRM). - Site et app : bouton « Discuter » vers WhatsApp du chatbot (
NEXT_PUBLIC_WHATSAPP_BOT_NUMBER/WHATSAPP_BOT_NUMBER), message prérempli avec la référence de l'annonce ; option widget Live Chat Infobip (NEXT_PUBLIC_INFOBIP_LIVECHAT_WIDGET_ID). Sur une annonce, « Appeler » compose le numéro du Kourtier assigné.
Précisions (précision)
- Client (
lib/infobip/client.js) :POST {INFOBIP_BASE_URL}/whatsapp/1/message/text({ from, to, content: { text } }) et…/message/template({ messages: [{ from, to, content: { templateName, templateData: { body: { placeholders } }, language } }] }) ; numéros en chiffres avec indicatif, sans+. Sans les trois variables, aucun envoi : toutes les notifications répondent « ignoré » (jamais d'erreur côté métier ; un envoi raté n'annule jamais une opération). - Modèles WhatsApp à faire approuver (hors fenêtre de 24 h seuls les modèles passent ; langue
INFOBIP_TEMPLATE_LANGUAGE,frpar défaut ; nom surchargeable parINFOBIP_TEMPLATE_<TYPE>;INFOBIP_USE_TEMPLATES=falseenvoie du texte libre) — variables dans cet ordre :kourtier_account_invitation{{1}} nom, {{2}} identifiant (email), {{3}} mot de passe provisoire, {{4}} lien de connexion ·kourtier_visit_confirmed{{1}} nom, {{2}} bien, {{3}} date et heure (Kinshasa), {{4}} Kourtier ·kourtier_payment_received{{1}} nom, {{2}} montant (10.00 USD), {{3}} objet, {{4}} référence ·kourtier_rent_reminder{{1}} nom, {{2}} frais, {{3}} montant, {{4}} échéance ·kourtier_ticket_update{{1}} nom, {{2}} signalement, {{3}} statut, {{4}} commentaire ·kourtier_security_code{{1}} nom, {{2}} code à 6 chiffres, {{3}} opération concernée, {{4}} durée de validité. - Webhook entrant : corps Infobip
{ results: [{ messageId, from, to, receivedAt, message, contact }] }; secret absent ou erroné vers401(et tout est refusé tant queINFOBIP_WEBHOOK_SECRETn'est pas défini) ; réponse200 { received, forwarded, duplicates }; l'idde l'événement CRM estevt_wa_<messageId>: un message rejoué par Infobip n'est transmis qu'une fois. - Chatbot (
x-bot-keyabsent, faux ouBOT_API_KEYnon défini vers401) :GET /bot/listings/:referencerenvoie{ listing }(vue publique,404si non publiée) ;GET /bot/visits/:reference?phone=renvoie{ visit: { id, reference, propertyId, propertyLabel, date, slot, status, fee, scheduledAt, assignedAgent, paidAt, createdAt, paid, agent: { name, phone } } }; le numéro (n'importe quel format :+243…,0…,243…) doit être celui de la demande, sinon404identique à « inconnue » ;422sans numéro ;GET /bot/kollect/balance?phone=renvoie{ found: true, name, tenant: { dueTotal: { USD, CDF }, nextCharge: { label, amount, currency, dueDate, status }, leases: [{ reference, label }] } | null, owner: { balances: { USD, CDF }, properties } | null },404si aucun compte Kollect (comptes retrouvés par numéro : les comptes créés par Kollect portent+243…; un compte du site au numéro écrit librement peut ne pas être retrouvé) ;POST /bot/leads{ name, phone, email?, message?, interest?, listingReference?, source?, consent? }renvoie201 { lead: { id } }(l'identifiant est celui de l'événementchat.lead.created,consentAtseulement siconsent: true).