Le catalogue et les commandes Menu, dans votre produit.
Une API REST signée HMAC pour afficher les restaurants et leurs plats, créer des commandes et suivre leur statut en temps réel — depuis votre application, votre site ou votre caisse.
L'API Partenaires permet à une autre plateforme (super-app, site e-commerce, logiciel de caisse, agrégateur…) de lire le catalogue des restaurants vérifiés de Menu et d'y passer des commandes au nom de ses propres clients. Elle est distincte de l'API de l'application mobile : contrat stable, versionné (/v1/), authentification propre et limites dédiées.
URL de base
https://menu.cambus.cm/api/partners/v1
Format
JSON UTF-8
Authentification
Clé + signature HMAC-SHA256
Accès
Sur candidature (vérification KYB)
V1 — paiement en espèces uniquement. Les commandes partenaires sont créées en mode_paiement: "especes" : vous encaissez déjà votre client final, ou il règle à la livraison / au retrait. La collecte Mobile Money pour le compte d'un tiers n'est pas encore ouverte.
Remplissez le formulaire en choisissant votre plan. Joignez RCCM, NIU et pièce d'identité du dirigeant pour accélérer la revue.
Vérification KYB
Notre équipe examine votre dossier. À l'approbation, vous recevez par e-mail vos accès au portail partenaire.
Émettez une clé
Depuis le portail, générez un identifiant sandbox. Le secret n'est affiché qu'une seule fois.
Premier appel
Signez une requête GET /restaurants, ou testez directement depuis la console API du portail.
Votre premier appel
Les exemples ci-dessous définissent une petite fonction appel() réutilisée dans toute la référence. Choisissez votre langage : le choix est mémorisé sur toute la page.
Sans barre oblique finale : /api/partners/v1/plats, pas /plats/.
Corps
JSON UTF-8 avec Content-Type: application/json (sauf candidature : multipart/form-data).
Montants
Entiers sans décimales, dans la devise du restaurant (champ devise : XAF au Cameroun, XOF en Côte d’Ivoire ou au Sénégal…) : 6500 = 6 500 FCFA. Aucune conversion.
Dates
ISO 8601 avec fuseau, ex. 2026-10-11T12:30:00+01:00.
Identifiants
Entiers. Votre propre identifiant de commande passe dans reference_externe.
Listes
Tableaux JSON complets, sans pagination en v1 — affinez avec les filtres (q, restaurant, categorie).
Erreurs
Objet {"error": "…"} (erreur métier, parfois avec motif) ou {"detail": "…"} (authentification, droits, débit). Voir Codes d'erreur.
Rétrocompatibilité
De nouveaux champs peuvent apparaître dans les réponses à tout moment : ignorez les champs inconnus. Toute rupture passera par /v2/.
Pas d'OAuth ni de session : chaque requête porte trois en-têtes. Le secret ne circule jamais sur le réseau — seule la signature calculée avec lui est envoyée, ce qui empêche la falsification et le rejeu.
En-tête
Contenu
Exemple
X-Menu-Key
Votre clé API (identifiant public, pas un secret)
pk_sandbox_3f9a…
X-Menu-Timestamp
Horodatage Unix en secondes. Tolérance : ± 5 minutes
1791720000
X-Menu-Signature
HMAC-SHA256 hexadécimal (minuscules) du message ci-dessous, avec votre secret
9c1e…b07a
Le message à signer
timestamp|MÉTHODE|chemin?query|sha256(corps)
timestamp : exactement la valeur envoyée dans X-Menu-Timestamp.
MÉTHODE : en majuscules — GET, POST.
chemin?query : chemin complet avec la query string, tel qu'envoyé — /api/partners/v1/plats?restaurant=12&q=poulet. Pas de domaine.
sha256(corps) : SHA-256 hexadécimal des octets exacts du corps envoyé. Pour un corps vide (GET) : e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Signez côté serveur uniquement. Ne placez jamais votre secret dans une application mobile, un site front-end ou un dépôt Git : toute personne qui le récupère peut commander en votre nom. Votre application appelle votre serveur, qui appelle Menu.
Le corps signé n'est pas celui envoyé : sérialisez le JSON une seule fois, hachez cette chaîne et envoyez-la telle quelle (pas de re-sérialisation par votre client HTTP).
401 uniquement avec des filtres
La query string n'est pas incluse dans le message, ou elle est encodée différemment de ce qui part sur le réseau.
401 Requête expirée…
Horloge serveur décalée (synchronisez via NTP), ou timestamp en millisecondes au lieu de secondes.
401 Clé API inconnue ou révoquée.
Clé révoquée, faute de frappe, ou clé live utilisée alors que votre plan n'inclut pas le live.
Signature correcte mais 404
Barre oblique finale dans le chemin (/restaurants/).
Restaurants vérifiés et ouverts en ce moment, du plus récent au plus ancien. Utilisez is_open, livraison_active et plats_a_emporter_actifs pour savoir quel mode de commande proposer.
horaires : créneaux de livraison (et de retrait) du restaurant. toute_heure: true si le restaurant n'en a pas fixé. Sinon plages liste {"jour": 0, "debut": "12:00", "fin": "15:00"} (jour 0 = lundi … 6 = dimanche, heure locale du restaurant ; fin < debut : la plage finit le lendemain ; debut = fin : journée entière). decalage_minutes est le décalage UTC du restaurant, livrable_maintenant et prochain_creneau sont évalués à l'instant de la réponse.
Tous les plans120/min
Paramètres de requête
qstringoptionnel
Recherche partielle sur le nom, insensible à la casse.
menu_api GET "/api/partners/v1/restaurants?q=grill"
restaurants = appel("GET", "/api/partners/v1/restaurants?q=grill")
ouverts_en_livraison = [r for r in restaurants if r["livraison_active"]]
Même objet que la liste, enrichi d'un tableau plats contenant sa carte (plats disponibles et visibles). Contrairement à la liste, un restaurant fermé est renvoyé (avec is_open: false) pour que vous puissiez afficher sa fiche.
Tous les plans120/min
Paramètres de chemin
idintegerrequis
Identifiant du restaurant.
Erreurs
404
{"error": "Restaurant introuvable"} — inexistant ou non vérifié.
menu_api GET /api/partners/v1/restaurants/12
resto = appel("GET", "/api/partners/v1/restaurants/12")
for plat in resto["plats"]:
print(plat["nom"], plat["prix_client"], "F")
Plats disponibles des restaurants vérifiés, filtrables. Affichez prix_client à vos utilisateurs : c'est le prix réellement facturé (le champ prix est la base restaurant).
horaires (même format que pour les restaurants) est l'horaire effectif du plat : celui du restaurant, restreint par les créneaux propres du plat quand horaires_propres vaut true.
Tous les plans120/min
Paramètres de requête
restaurantintegeroptionnel
Plats d'un seul restaurant. Une valeur non numérique renvoie 400.
categoriestringoptionnel
Nom exact de catégorie (insensible à la casse), voir Catégories.
qstringoptionnel
Recherche partielle sur le nom du plat.
Champs utiles de l'objet Plat
prix_clientinteger
Prix unitaire facturé au client final, en F CFA.
groupes_complementsarray
Choix à proposer (accompagnement, cuisson…). Un seul choix par groupe ; un groupe obligatoire doit recevoir une option. Les supplement s'ajoutent au prix.
type_platstring
entree, resistance, diner, dessert, boisson ou vide ; libellé lisible dans type_plat_label.
elements_inclusstring[]
Ce qui est servi avec le plat (information seule).
image, imagesurl, url[]
Visuel principal et galerie.
menu_api GET "/api/partners/v1/plats?restaurant=12&q=poisson"
from urllib.parse import urlencode
query = urlencode({"restaurant": 12, "q": "poisson"})
plats = appel("GET", f"/api/partners/v1/plats?{query}")
const query = new URLSearchParams({ restaurant: "12", q: "poisson" });
const plats = await appel("GET", `/api/partners/v1/plats?${query}`);
Crée une commande pour un seul restaurant. Le restaurant la reçoit immédiatement dans son espace et la traite comme une commande de l'application.
Croissance, Business, Scale30/min
Corps JSON
restaurantintegerrequis
Identifiant du restaurant.
reference_externestring ≤ 100requis
Votre identifiant de commande, unique par partenaire. Sert de clé d'idempotence.
itemsarrayrequis
Lignes : plat_id (requis), quantite (défaut 1), complements (liste d'id d'options, une par groupe). Les plats indisponibles sont ignorés ; si aucun n'est valide → 400 aucun_plat_valide.
emporterbooleandéfaut false
true : retrait sur place, sans frais de livraison, avec un code_retrait. false : livraison — adresse_livraison, latitude et longitude deviennent obligatoires (sinon 400). Accepte aussi "true"/"false" en chaîne.
adresse_livraisonstringrequis si livraison
Adresse lisible, transmise au livreur.
latitude, longitudenumberrequis si livraison
Position de livraison. La distance retenue est la distance à vol d'oiseau multipliée par un coefficient routier (approximation du trajet réel) ; elle détermine les frais selon les paliers du restaurant (paliers_livraison) et le refus hors_zone au-delà du dernier palier.
mode_paiementstringdéfaut "especes"
Seule valeur acceptée en v1 : especes.
notesstringoptionnel
Consignes pour le restaurant (1 000 caractères max).
livraison_prevue_lestring ISO 8601optionnel
Commande programmée : heure de livraison (ou de retrait) avec fuseau, ex. "2026-10-12T12:30:00+01:00". Absente : dès que possible, refusée (hors_creneau) si le restaurant ne livre pas en ce moment. L'heure doit tomber dans les horaires de tous les plats, au moins temps_livraison_moyen minutes plus tard et au plus 7 jours plus tard.
Réponses
201
Commande créée.
200
Une commande avec cette reference_externe existe déjà : elle est renvoyée telle quelle, rien n'est dupliqué.
400
Payload invalide, ou refus métier avec motif stable : voir motifs.
403
Votre plan n'inclut pas l'écriture de commandes (Découverte).
menu_api POST /api/partners/v1/commandes '{"restaurant":12,"reference_externe":"ORD-2026-000123","items":[{"plat_id":45,"quantite":2,"complements":[22]}],"emporter":false,"adresse_livraison":"Akwa, rue Gallieni","latitude":4.05,"longitude":9.70,"mode_paiement":"especes","notes":"Sans piment"}'
État courant d'une de vos commandes (même objet qu'à la création). Une commande d'un autre partenaire renvoie 404. Préférez les webhooks au sondage ; si vous sondez, espacez les appels d'au moins 30 secondes.
Tous les plans30/min
menu_api GET /api/partners/v1/commandes/981
commande = appel("GET", "/api/partners/v1/commandes/981")
if commande["statut"] in ("livree", "recuperee"):
cloturer(commande["reference_externe"])
const commande = await appel("GET", "/api/partners/v1/commandes/981");
if (["livree", "recuperee"].includes(commande.statut)) cloturer(commande.reference_externe);
curl -X POST https://menu.cambus.cm/api/partners/v1/apply \
-F nom_commercial="Ma SuperApp" \
-F contact_nom="Awa Ndiaye" \
-F contact_email="awa@superapp.cm" \
-F description_cas_usage="Commande de repas depuis notre super-app" \
-F plan_souhaite="croissance" \
-F fichier_rccm=@rccm.pdf
201 Réponse
application/json
{
"id": 37,
"statut": "en_attente",
"plan_souhaite": "croissance",
"message": "Candidature reçue. Un identifiant API vous sera communiqué après vérification."
}
Un timeout réseau ne vous dit pas si la commande a été créée. Avec Menu, la règle est simple : relancez avec la même reference_externe. Si la commande existe déjà, vous recevez 200 et la commande d'origine — jamais un doublon, même en cas de requêtes simultanées.
import time
import requests
def creer_commande_sure(payload, essais=4):
for tentative in range(essais):
try:
return appel("POST", "/api/partners/v1/commandes", payload)
except (requests.Timeout, requests.ConnectionError):
time.sleep(2 ** tentative) # 1 s, 2 s, 4 s… même reference_externe
raise RuntimeError("Menu injoignable — réessayer plus tard")
async function creerCommandeSure(payload, essais = 4) {
for (let i = 0; i < essais; i++) {
try {
return await appel("POST", "/api/partners/v1/commandes", payload);
} catch (e) {
if (e.status && e.status < 500) throw e; // erreur métier : ne pas relancer
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}
throw new Error("Menu injoignable — réessayer plus tard");
}
Choisissez des références qui ne diffèrent pas uniquement par la casse : ORD-1 et ord-1 peuvent être considérées comme la même commande.
Ne relancez pas une erreur 4xx (sauf 429) : la même requête donnera le même refus. Corrigez d'abord le payload.
Répondez en moins de 3 secondes avec un code 2xx ; faites les traitements lourds en différé.
En v1, chaque événement est envoyé une fois (pas de relance automatique). Les échecs sont visibles dans le journal du portail : rattrapez-les avec GET /commandes/{id}.
Traitez les événements de façon idempotente et ne supposez pas un ordre strict : fiez-vous au timestamp.
Deux mécanismes indépendants, comptés par partenaire (toutes clés confondues pour le débit) :
Catalogue
120/min
Commandes
30/min
Candidature
5/hour / IP
Quota mensuel
Selon le plan, remis à zéro le 1er
Mettez le catalogue en cache de votre côté (quelques minutes suffisent) : c'est le meilleur moyen de rester loin des limites et d'offrir une interface rapide. Suivez votre consommation en temps réel depuis le tableau de bord du portail.
Développement et tests d'intégration. Disponible sur tous les plans.
pk_live_…
Production. Plans Croissance, Business et Scale. Désactivée si le plan est rétrogradé.
Le préfixe permet de repérer l'environnement d'un coup d'œil dans vos journaux. Gardez des clés et des secrets distincts par environnement, et ne réutilisez jamais une clé live en développement.
Stockez secret API et secret webhook dans un gestionnaire de secrets ou des variables d'environnement, jamais dans le code.
Faites une rotation régulière : émettez une nouvelle clé dans le portail, déployez-la, puis révoquez l'ancienne. Deux clés peuvent être actives pendant la transition.
En cas de fuite, révoquez immédiatement la clé depuis le portail : l'effet est instantané.
Comparez les signatures en temps constant (hmac.compare_digest, crypto.timingSafeEqual, hash_equals).
Vérifiez le timestamp des webhooks pour rejeter les rejeux.
La revue est manuelle. Un dossier complet (RCCM, NIU, pièce d'identité du dirigeant et un cas d'usage précis) est traité bien plus vite. Vous êtes notifié par e-mail à chaque étape.
Puis-je appeler l'API depuis mon application mobile ?
Non, pas directement : le secret serait extractible de l'application. Votre application appelle votre serveur, qui signe et relaie les requêtes vers Menu.
J'ai perdu mon secret, que faire ?
Il n'est stocké que chiffré et n'est jamais réaffiché. Émettez une nouvelle clé depuis le portail, mettez-la en production, puis révoquez l'ancienne.
Comment changer de plan ?
Depuis le portail, rubrique Plan & facturation : votre demande est transmise à notre équipe, qui l'active. Les droits (live, webhooks, quota) changent dès l'activation.
Comment est calculée la commission du plan Croissance ?
Un pourcentage du montant_total de chaque commande créée via l'API, figé au moment de la création. Le cumul du mois est visible dans votre tableau de bord.
Les prix incluent-ils la livraison ?
Non. prix_client est le prix du plat ; les frais de livraison dépendent de la distance et sont calculés à la création de la commande (frais_livraison, inclus dans montant_total).
Créneaux de livraison : horaires sur les restaurants et les plats, commandes programmées (livraison_prevue_le), motifs hors_creneau et heure_invalide.
v1.1
Choix du plan à la candidature (plan_souhaite), validation stricte des entrées (400 au lieu d'erreurs serveur), emporter accepte les booléens sous forme de chaîne, position obligatoire pour une livraison, console de test dans le portail.
v1.0
Catalogue, commandes en espèces, idempotence par reference_externe, webhooks signés, plans et quotas.
Prêt à intégrer Menu ?
Candidatez en quelques minutes et recevez vos clés sandbox après vérification.