Documentation J+SERVICES Guides Référence API

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.

Publicationslug, 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, pas 403 — 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 201la 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.