Contrat API — Espace partenaire
Version 1 — 22/08/2026. Base : https://live.jmoai.net
Authorization: Bearer <jeton partenaire> obligatoire sur toutes les routes ci-dessous.
Un seul écran, des sections qui apparaissent selon l’offre. Il n’y a pas un tableau de bord par niveau : un écran se définit par qui le regarde, pas par ce qu’on lui a vendu. Trois niveaux × trois hébergements × quatre options donneraient une combinatoire absurde, et un écran de plus à chaque option ajoutée.
0. Le parcours, dans l’ordre
Un marchand ne devient pas vendeur d’un coup. Quatre étapes, et le serveur dit toujours où il en est — l’interface n’a jamais à le déduire :
dossier APPROUVÉ ──▶ option sells_hardware ──▶ ouvrir la boutique ──▶ déclarer la caisse ──▶ vendre
GET /api/v1/partner/me renvoie boutique.prochaine_etape, qui vaut exactement l’une de ces
valeurs :
prochaine_etape |
Ce que l’écran doit proposer |
|---|---|
OPTION_REQUISE |
l’offre ne comporte pas la vente — proposer la montée de niveau |
OUVRIR_BOUTIQUE |
un bouton « Ouvrir ma boutique » → POST /api/v1/partner/boutique |
DECLARER_CAISSE |
un formulaire « Où encaissez-vous ? » → POST …/paiement/comptes |
PRET_A_VENDRE |
la boutique et l’encaissement sont prêts — afficher l’adresse et les ventes |
Ne codez pas cette logique dans l’interface. Elle change avec l’offre du partenaire, et un écran qui devine finit par se tromper d’étape.
1. Le modèle — deux axes indépendants
| Axe | Valeurs | Ce que ça décrit |
|---|---|---|
tier |
ACCES · BUSINESS · ENTREPRISE |
ce qu’on lui vend |
hosting |
SHARED · SILO · OWN_VPS |
où ça tourne |
Les deux sont volontairement indépendants : un revendeur peut être en silo, un opérateur peut garder notre marque. Les coupler obligerait à inventer un palier par combinaison.
Les options sont des drapeaux, jamais des paliers :
| Option | Ce qu’elle ouvre | Niveau minimal |
|---|---|---|
in_app_store |
vitrine matériel intégrée | ACCES |
white_label |
marque, logo, couleurs | BUSINESS |
can_distribute_licenses |
distribution à des sous-comptes | ENTREPRISE |
sells_hardware |
vente de matériel | ENTREPRISE |
Le niveau est un plafond, pas une configuration : une option n’est jamais activée d’office, elle est seulement refusée quand le niveau ne la couvre pas. Un partenaire rétrogradé garde sa fiche, ses clients et son historique — ce sont ses droits qui se réduisent, pas son existence.
2. L’écran — GET /api/v1/partner/me
{
"success": true,
"data": {
"id": "00000000-0000-0000-0000-000000000000",
"display_name": "Exemple Réseaux",
"contact_email": "contact@example.invalid",
"tier": "BUSINESS",
"hosting": "SHARED",
"options": {
"white_label": true,
"in_app_store": true,
"can_distribute_licenses": false,
"sells_hardware": false
},
"max_clients": 50,
"commission_rate": 15.0,
"contract_expires_at": "2027-01-31T00:00:00Z",
"provider": {
"slug": "exemple-reseaux-a1b2c3",
"tier": "tier1_shared",
"silo_status": "ACTIVE",
"domain": null,
"brand_display_name": "Exemple Réseaux",
"brand_logo_url": null,
"brand_primary_color": null,
"brand_secondary_color": null
},
"boutique": {
"ouverte": true,
"url": "https://market.jmoai.net/b/chez-fatou",
"handle": "chez-fatou",
"caisse": { "declaree": true, "verifiee": true, "provider": "fedapay", "mode": "live" },
"prochaine_etape": "PRET_A_VENDRE"
},
"sections": [
{ "cle": "clients", "libelle": "Mes clients", "actif": true, "debloque_par": null },
{
"cle": "store",
"libelle": "Ma vitrine",
"actif": true,
"option": "in_app_store",
"debloque_par": "ACCES"
},
{
"cle": "resellers",
"libelle": "Mes revendeurs",
"actif": false,
"option": "can_distribute_licenses",
"debloque_par": "ENTREPRISE"
}
]
}
}
sections : la navigation vient du serveur
L’interface n’a aucune condition à coder. Elle affiche les sections dans l’ordre reçu,
et grise celles dont actif vaut false.
debloque_par nomme le niveau minimal qui ouvre l’option. Une section verrouillée
présentée avec le niveau qui la débloque vaut mieux qu’un menu amputé : l’espace devient un
argumentaire au lieu d’une frustration. Ne jamais masquer une section inactive.
provider vaut null tant que le partenaire n’a pas d’espace de marque — c’est le cas normal
d’un ACCES en hébergement partagé.
3. La vitrine — option in_app_store
GET /api/v1/partner/store # ses articles + le catalogue maison
POST /api/v1/partner/store # publier
DELETE /api/v1/partner/store/:id # retirer
Le partenaire voit le catalogue commun PLUS le sien, jamais celui d’un concurrent.
Les articles du catalogue maison ont partner_id: null : ils sont lisibles, pas supprimables.
Publication — slug, title, category et price sont requis :
{
"slug": "exemple-ap-ax",
"title": "Point d'accès Wi-Fi 6",
"category": "AccessPoint",
"model": "EX-AX",
"price": 85000,
"currency": "XOF",
"original_price": 100000,
"image_url": "https://cdn.example.invalid/ap.png",
"specs": { "ports": "1× Gigabit" },
"stock_status": "in_stock",
"order_priority": 10
}
Réponse 201 avec l’article créé.
| HTTP | Code | Quand |
|---|---|---|
| 400 | CHAMPS_REQUIS |
un champ obligatoire manque |
| 403 | OPTION_NOT_ENABLED |
l’offre ne comporte pas in_app_store |
| 403 | PARTNER_NOT_APPROVED |
dossier non approuvé |
| 409 | ARTICLE_DEJA_PUBLIE |
ce slug existe déjà dans SA vitrine |
| 404 | ARTICLE_NOT_FOUND |
suppression d’un article qui n’est pas le sien |
La suppression filtre toujours sur le partenaire. Un identifiant deviné ne donne accès à rien :
404, pas403— nous ne confirmons pas l’existence de l’article d’un autre.
4. La marque — option white_label
PATCH /api/v1/partner/branding
{ "brand_display_name": "Exemple Réseaux",
"brand_logo_url": "https://cdn.example.invalid/logo.svg",
"brand_primary_color": "#0B5FFF",
"brand_secondary_color": "#101828" }
Tous les champs sont optionnels, mais au moins un est attendu (400 RIEN_A_METTRE_A_JOUR).
La marque est écrite sur le provider, jamais sur la fiche partenaire : c’est le provider que l’application résout pour habiller son interface. La dupliquer produirait deux vérités qui divergent.
409 PROVIDER_MISSING signifie que l’espace de marque n’est pas encore initialisé — cas
transitoire, à traiter comme « contactez le support », pas comme une panne.
5. Clients et revendeurs
GET /api/v1/partner/clients # licences rattachées à son provider (200 max)
GET /api/v1/partner/resellers # option can_distribute_licenses
clients renvoie data: [] avec meta.raison = "PROVIDER_MISSING" quand le partenaire n’a pas
encore de provider : il n’a pas zéro client par erreur, il n’a pas encore d’infrastructure.
C’est plus honnête qu’une liste vide sans explication.
meta.max_clients est le plafond contractuel. Le décompte réel des sièges appartient au
silo : le central dit combien, le silo dit qui.
6. Encaissement — option sells_hardware
Le marchand vend sur la plateforme de distribution et encaisse sur SON propre compte agrégateur. J+SERVICES ne détient jamais ses fonds : nous créons la transaction en son nom et nous en relisons l’état pour fiabiliser la vente.
GET /api/v1/partner/paiement/comptes
POST /api/v1/partner/paiement/comptes
POST /api/v1/partner/paiement/comptes/:id/verifier
DELETE /api/v1/partner/paiement/comptes/:id
Agrégateurs supportés : fedapay, geniuspay.
Déclaration — GeniusPay exige les deux clés, FedaPay la clé secrète seule :
{
"provider": "geniuspay",
"public_key": "pk_exemple_0000",
"secret_key": "sk_exemple_0000",
"webhook_secret": "wh_exemple_0000"
}
Réponse 201 — la clé secrète ne ressort jamais, seulement de quoi la reconnaître :
{
"id": "…",
"provider": "geniuspay",
"mode": "live",
"status": "VERIFIED",
"account_label": "Exemple Distribution SARL",
"public_key": "pk_exemple_0000",
"secret_key_indice": "••••0000",
"webhook_configure": true,
"verified_at": "2026-08-22T15:00:00Z"
}
Trois comportements à connaître
La clé est éprouvée AVANT d’être enregistrée. Le serveur interroge l’agrégateur ; s’il
refuse, rien n’est stocké et la réponse est 422 CREDENTIALS_REJECTED. Une clé acceptée « parce
qu’elle a été saisie » est une caisse morte que le premier acheteur découvrirait à notre place.
mode se lit sur la clé, jamais sur le formulaire. Une clé sk_sandbox_… donne
mode: "sandbox" et un avertissement : aucune vente réelle ne sera encaissée. La déclarer
« live » ne la rendrait pas réelle, cela rendrait la panne invisible.
account_label est le nom du compte tel que l’agrégateur le renvoie. C’est la seule preuve
lisible que le marchand a branché le bon compte — à afficher, toujours.
| HTTP | Code | Quand |
|---|---|---|
| 400 | CHAMPS_REQUIS |
provider ou secret_key manquant |
| 400 | PUBLIC_KEY_REQUIRED |
GeniusPay sans clé publique |
| 403 | HOUSE_KEY_REFUSED |
la clé appartient à un compte J+SERVICES |
| 409 | ACCOUNT_ALREADY_CLAIMED |
ce compte agrégateur est déjà déclaré par un autre marchand |
| 422 | CREDENTIALS_REJECTED |
l’agrégateur a refusé les identifiants |
Remplacer une clé désactive l’ancienne au lieu de l’écraser : le jour où un paiement ancien doit être expliqué, on sait ce qui encaissait ce jour-là.
7. La boutique en ligne — option sells_hardware
POST /api/v1/partner/boutique
Aucun corps de requête. Le dossier est déjà chez nous : nom, e-mail, téléphone, pays. Le serveur crée la boutique et renvoie ses accès.
{
"deja": false,
"seller_ref": "sel_01M0NC45CF5QMG0GJHFESX2E0F",
"handle": "chez-fatou",
"boutique_url": "https://market.jmoai.net/b/chez-fatou",
"email": "contact@example.invalid",
"mot_de_passe_provisoire": "K3n8Rp2xQv41"
}
Trois choses à savoir avant de dessiner cet écran
Le mot de passe n’apparaît qu’UNE fois. Il n’est stocké nulle part chez nous — seule la boutique en garde l’empreinte. Affichez-le en clair, proposez la copie, et dites qu’il doit être changé à la première connexion. Il part aussi par e-mail, mais ne comptez pas dessus : l’e-mail peut arriver en retard, ou jamais.
Il n’y a pas d’inscription. Personne ne crée de compte sur la boutique : c’est notre backend qui l’ouvre, pour un dossier approuvé. Ne proposez donc jamais de lien « créer un compte vendeur » — la route publique correspondante est fermée.
deja: true signifie que la boutique existait déjà : rendez son adresse, ne rejouez pas
l’ouverture, et n’affichez pas de mot de passe (il n’y en a pas de nouveau).
| HTTP | Code | Sens |
|---|---|---|
| 201 | — | boutique créée, mot_de_passe_provisoire présent |
| 200 | — | boutique déjà ouverte (deja: true) |
| 403 | PARTENAIRE_NON_APPROUVE / OPTION_NON_ACTIVE |
le dossier ne permet pas de vendre |
| 409 | IDENTITE_EXISTANTE |
un compte porte déjà cet e-mail côté boutique — contactez le support |
| 409 | EMAIL_MANQUANT |
le dossier n’a pas d’e-mail : rien où envoyer les accès |
| 502 | BOUTIQUE_REFUSEE |
la boutique n’a pas répondu — réessayer plus tard |
8. Le relevé de commission
GET /api/v1/partner/paiement/commissions
{
"data": [
{
"periode_debut": "2026-07-01",
"periode_fin": "2026-08-01",
"nb_ventes": 42,
"base_montant": 1250000,
"taux": 5.0,
"montant": 62500,
"devise": "XOF",
"status": "INVOICED",
"invoiced_at": "2026-08-02T04:17:00Z"
}
],
"meta": { "taux_courant": 5.0, "explication": "…" }
}
À afficher avec l’assiette, pas seulement le montant : le marchand doit pouvoir vérifier
nb_ventes × base × taux. La commission est facturée, jamais prélevée sur ses ventes — ses
encaissements arrivent entiers sur son compte agrégateur. Dites-le sur l’écran : c’est la
question qu’il posera de toute façon.
status : DRAFT (calculée, document pas encore émis) · INVOICED (facturée) · PAID ·
CANCELLED.
9. Erreurs communes à toutes les routes
| HTTP | Code | Que faire |
|---|---|---|
| 401 | UNAUTHORIZED |
session expirée → réauthentifier |
| 403 | PARTNER_PROFILE_MISSING |
compte valide, aucun dossier commercial → écran d’inscription |
| 403 | PARTNER_NOT_APPROVED |
dossier DRAFT/PENDING/SUSPENDED → écran d’attente, error.status porte l’état |
| 403 | OPTION_NOT_ENABLED |
error.required_option et error.current_tier → proposer la montée de niveau |
Un refus qui nomme l’option manquante vaut mieux qu’une porte fermée : l’interface peut proposer l’offre supérieure au lieu d’afficher une erreur muette.
10. Administration (hors espace partenaire)
Création, approbation, changement de niveau et suspension se font depuis le panel admin
(/admin/api/partners/…, session admin requise, control plane uniquement). L’approbation
est un geste : rien n’est jamais approuvé par défaut, et c’est elle qui crée l’espace de
marque quand l’offre le demande.