Documentation API KourtierBase : https://api.kourtier-group.comMise à jour : 26/10/2026

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 CrmOutbox dans 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 id unique. Le CRM doit ignorer un id déjà reçu (en-tête Idempotency-Key). L'API Kourtier fait de même avec la table CrmInboundEvent.
  • 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ême crmId met à jour au lieu de dupliquer. Un champ facultatif absent laisse la valeur actuelle ; null l'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ême id accepté).
  • Un crmId inconnu pour transaction.deleted ou agent.deactivated n'est pas une erreur : réponse 200, rien à faire (le CRM peut rejouer sans risque).
  • Montants en USD uniquement (currency facultatif, "USD" si présent) ; surfaces en m² ; dates ISO 8601 (2026-09-28 ou 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

  1. Appliquer les migrations 20260930090000_k_estim_crm, 20261005090000_market_transactions_agents, 20261006090000_visit_requests puis celles de l'API v2 : 20261007090000_api_v2 (paiements, règles de frais, Kollect, annonces ; ajoute aussi deux colonnes sans valeur à perdre : User.mustChangePassword avec défaut false et des colonnes de frais nullables sur VisitRequest), 20261007090100_visit_status_pending_payment et 20261007090110_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 historique Payment n'est pas touchée : les paiements AvadaPay sont dans PaymentTransaction.
  2. node prisma/seed-locations.js pour Kinshasa et ses 24 communes (sans --demo en production), puis node prisma/seed-fee-rules.js pour 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).
  3. Déployer l'API, puis le site ; vérifier GET /crm/status.
  4. Quand le CRM expose POST /events : renseigner CRM_BASE_URL, CRM_OUTBOUND_SECRET, CRM_WEBHOOK_SECRET et 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
}
  • summary porte 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 ; null si pas de référence (ou period=all).
  • pricePerSqm = price / area arrondi (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 et hash / salt sont vidés (événement d'audit PASSWORD_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_ERROR avec details.fields.password (ou newPassword) = raison.
  • Ancien chemin obsolète : POST /users ou PATCH /password/reset/:token avec { hash, salt } calculés par le client ne sont acceptés que si ALLOW_CLIENT_HASHED_PASSWORDS=true (défaut false, y compris en production). Sinon 422 avec details.fields.password. PATCH /users/:id n'accepte plus de mot de passe : utiliser POST /auth/password/change.
  • POST /users accepte password (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" } avec Retry-After (secondes) et details.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 (le 429 ne révèle pas qu'un compte existe). POST /auth/register répond 409 EMAIL_TAKEN pour 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) renvoie 429 TOO_MANY_REQUESTS + Retry-After. Le site transmet l'IP du visiteur signée (x-kourtier-client-ip + x-kourtier-client-ip-signature, HMAC avec INTERNAL_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 } ; roles est déduit des données : tenant si un bail à son nom, owner si un bail dont il est propriétaire ou userType = OWNER, publisher si au moins une annonce. Tout compte peut commencer à publier.
  • (précision) POST /auth/password/change : un mot de passe actuel incorrect répond 422 (details.fields.currentPassword, pas 401, 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 par PATCH /password/reset/:token remet aussi mustChangePassword à false.
  • (précision) mustChangePassword = true n'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/revoke exige 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'email exige currentPassword (sauf administrateur) et remet emailVerified à 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 champ hash, salt, passwordHash, accessTokenHash, sessionToken, phoneIndex de 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-token de la demande payée. Kollect : Bearer du payeur (ou admin). Sinon 404.
  • 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épond 409 PAYMENT_IN_PROGRESS (avec details.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_ERROR et paiement FAILED ; la passerelle qui répond « échoué » (numéro invalide…) renvoie 201 avec payment.status = FAILED et payment.failureReason ; réseau coupé ou 5xx vers 201 avec PENDING (état inconnu : le suivi tranche). Paiements non disponibles (production sans clés complètes) renvoie 503 PAYMENTS_UNAVAILABLE.
  • Entrée { method, operator?, phone } : MOBILE_MONEY exige operator et un numéro congolais (+243…, 9 chiffres nationaux) ; CARD n'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 porte merchant_id (identifiant marchand, distinct du public_id de l'URL) et signature.
  • Signature : HMAC-SHA512, hexadécimal minuscule, clé = AVADAPAY_SECRET_KEY, message = pour chaque paramètre dans l'ordre d'envoi, clé + valeur concaténées sans séparateur, signature exclue ; 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 (9248 possible : AVADAPAY_PROVIDER_ORANGE), Airtel 17, Vodacom 9, Africell 19, carte 5002 (Equity VISA, page de paiement https://hpp.avadapay.tech).
  • Numéro client (customer_id) selon l'opérateur : Orange et Africell 0 + 9 chiffres (0812345678), Airtel 9 chiffres sans 0 (812345678), Vodacom 243 + 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 : 0 initié vers PENDING, 1 en cours vers PROCESSING, 2 réussi vers SUCCEEDED, 3 échoué vers FAILED, 4 annulé vers CANCELLED (seuls 2, 3, 4 sont définitifs), 5 renvoie CANCELLED, 6 en transit vers PROCESSING, -1 : sans effet.
  • Rappel (operation_type 17 = 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 — si AVADAPAY_CALLBACK_IPS est défini (adresses ou plages CIDR IPv4 séparées par des virgules) — adresse IP de l'appelant (403 FORBIDDEN sinon ; derrière un proxy, définir TRUST_PROXY). Réponses : 200 { received: true, handled, duplicate?, reason? } ; 401 si la signature ou le merchant_id est invalide (AvadaPay réessaie) ; 200 avec handled: false pour une commande inconnue ou un montant différent (AMOUNT_MISMATCH, paiement laissé ouvert pour examen).
  • Les effets métier (visite PAID, frais Kollect PAID, 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 } — fee figure dans request (donc aussi dans GET /visit-requests/:id) et au premier niveau. Une demande historique (statut PENDING, sans frais) a fee: null : elle n'est pas payable (409) mais reste confirmable par le CRM.
  • (précision) Bien visité : annonce du site (table Property : transactionType, price en 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érence KL-… ; un brouillon est introuvable), sinon annonce du seul CRM : le visiteur peut alors envoyer purpose (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, graine prisma/seed-fee-rules.js) : RENT_APARTMENT 10 $, RENT_HOUSE 15 $, RENT_OFFICE 20 $, RENT_COMMERCE 20 $, RENT_DEFAULT 10 $ (terrain, type inconnu), SALE_PERCENT 1 % du prix (plancher 20 $, converti au taux FX_CDF_PER_USD pour un prix en CDF ; prix inconnu : le plancher). Tant que la table FeeRule est 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-rules renvoie { 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 par code (désactiver : active: false), renvoie la même forme. Réservé au rôle ADMIN.
  • (précision) POST /visit-requests/:id/payment : 404 sans jeton valide, 409 INVALID_STATE si la demande n'attend pas de paiement (déjà payée, annulée, historique), 409 PAYMENT_IN_PROGRESS, 422 entrée invalide. Limite : 20 requêtes / 10 minutes par visiteur.
  • (précision) visit.requested ajoute fee et status. 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 ; CONFIRMED et DONE sont refusés en 422 VISIT_NOT_PAID tant que la demande est PENDING_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 :

  1. Code : POST /kollect/owner/verification-codes renvoie 201 { verification: { purpose, expiresAt, channels: { email, whatsapp } } }. Le code (6 chiffres) est envoyé par email (Hostinger via lib/mailer.js) et par WhatsApp si Infobip est configuré (modèle kourtier_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.
  2. 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 = code ADD_DESTINATION) renvoie 201 { 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: false d'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).
  3. Liste / retrait d'une destination : GET /kollect/owner/payout-destinations renvoie { destinations: [...] } (mêmes champs) ; DELETE /kollect/owner/payout-destinations/:id renvoie 204 (404 sinon). Une destination retirée puis ajoutée de nouveau repart pour 24 h.
  4. Retrait : POST /kollect/owner/payouts { amount, currency, destinationId, code } (code = code PAYOUT demandé pour ce même destinationId, amount, currency) renvoie 201 { 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 / code obligatoires).

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ête Authorization: App <clé>), INFOBIP_WHATSAPP_SENDER.
  • POST /infobip/webhooks/whatsapp : messages entrants (secret partagé INFOBIP_WEBHOOK_SECRET dans l'en-tête x-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, fr par défaut ; nom surchargeable par INFOBIP_TEMPLATE_<TYPE> ; INFOBIP_USE_TEMPLATES=false envoie 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é vers 401 (et tout est refusé tant que INFOBIP_WEBHOOK_SECRET n'est pas défini) ; réponse 200 { received, forwarded, duplicates } ; l'id de l'événement CRM est evt_wa_<messageId> : un message rejoué par Infobip n'est transmis qu'une fois.
  • Chatbot (x-bot-key absent, faux ou BOT_API_KEY non défini vers 401) : GET /bot/listings/:reference renvoie { listing } (vue publique, 404 si 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, sinon 404 identique à « inconnue » ; 422 sans 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 }, 404 si 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? } renvoie 201 { lead: { id } } (l'identifiant est celui de l'événement chat.lead.created, consentAt seulement si consent: true).