Menu API v1
Portail Obtenir une clé
API Partenaires · v1

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.

8endpoints
HMAC-SHA256requêtes signées
Sandbox + Liveenvironnements
Webhooksstatuts en push
Introduction

Bienvenue sur l'API Partenaires #

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.

En 5 minutes

Démarrage rapide #

Candidatez

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.

# À placer dans ~/.bashrc ou un script — nécessite openssl et curl
export MENU_API_KEY="pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export MENU_SECRET="votre-secret"

# usage : menu_api METHODE CHEMIN [CORPS_JSON]
menu_api() {
  local ts body_hash sig
  ts=$(date +%s)
  body_hash=$(printf '%s' "${3:-}" | openssl dgst -sha256 | awk '{print $NF}')
  sig=$(printf '%s' "$ts|$1|$2|$body_hash" | openssl dgst -sha256 -hmac "$MENU_SECRET" | awk '{print $NF}')
  curl -sS -X "$1" "https://menu.cambus.cm$2" \
    -H "X-Menu-Key: $MENU_API_KEY" \
    -H "X-Menu-Timestamp: $ts" \
    -H "X-Menu-Signature: $sig" \
    ${3:+-H "Content-Type: application/json" --data-raw "$3"}
}

menu_api GET /api/partners/v1/restaurants
# pip install requests
import hashlib
import hmac
import json
import os
import time

import requests

BASE_URL = "https://menu.cambus.cm"
API_KEY = os.environ["MENU_API_KEY"]
SECRET = os.environ["MENU_SECRET"]


def appel(method, path, payload=None):
    """path inclut la query string, ex. "/api/partners/v1/plats?restaurant=12"."""
    body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode()
    ts = str(int(time.time()))
    message = f"{ts}|{method}|{path}|{hashlib.sha256(body).hexdigest()}"
    signature = hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()
    headers = {"X-Menu-Key": API_KEY, "X-Menu-Timestamp": ts, "X-Menu-Signature": signature}
    if body:
        headers["Content-Type"] = "application/json"
    r = requests.request(method, BASE_URL + path, data=body or None, headers=headers, timeout=15)
    r.raise_for_status()
    return r.json()


restaurants = appel("GET", "/api/partners/v1/restaurants")
print(len(restaurants), "restaurants ouverts")
// Node.js 18+ (fetch natif) — fichier menu.mjs
import crypto from "node:crypto";

const BASE_URL = "https://menu.cambus.cm";
const API_KEY = process.env.MENU_API_KEY;
const SECRET = process.env.MENU_SECRET;

export async function appel(method, path, payload) {
  const body = payload === undefined ? "" : JSON.stringify(payload);
  const ts = Math.floor(Date.now() / 1000).toString();
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const signature = crypto
    .createHmac("sha256", SECRET)
    .update(`${ts}|${method}|${path}|${bodyHash}`)
    .digest("hex");

  const headers = { "X-Menu-Key": API_KEY, "X-Menu-Timestamp": ts, "X-Menu-Signature": signature };
  if (body) headers["Content-Type"] = "application/json";

  const res = await fetch(BASE_URL + path, { method, headers, body: body || undefined });
  const data = await res.json();
  if (!res.ok) throw Object.assign(new Error(data.error || data.detail), { status: res.status, data });
  return data;
}

const restaurants = await appel("GET", "/api/partners/v1/restaurants");
console.log(restaurants.length, "restaurants ouverts");
<?php
// PHP 7.4+ avec l'extension curl
function menu_appel(string $method, string $path, ?array $payload = null): array
{
    $body = $payload === null ? "" : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $ts = (string) time();
    $message = $ts . "|" . $method . "|" . $path . "|" . hash("sha256", $body);
    $signature = hash_hmac("sha256", $message, getenv("MENU_SECRET"));

    $headers = [
        "X-Menu-Key: " . getenv("MENU_API_KEY"),
        "X-Menu-Timestamp: " . $ts,
        "X-Menu-Signature: " . $signature,
    ];
    if ($body !== "") $headers[] = "Content-Type: application/json";

    $ch = curl_init("https://menu.cambus.cm" . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 15,
    ]);
    if ($body !== "") curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    $reponse = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $data = json_decode($reponse, true);
    if ($code >= 400) throw new RuntimeException("HTTP $code : " . $reponse);
    return $data;
}

$restaurants = menu_appel("GET", "/api/partners/v1/restaurants");
echo count($restaurants) . " restaurants ouverts\n";
Les bases

Conventions #

SujetRègle
CheminsSans barre oblique finale : /api/partners/v1/plats, pas /plats/.
CorpsJSON UTF-8 avec Content-Type: application/json (sauf candidature : multipart/form-data).
MontantsEntiers 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.
DatesISO 8601 avec fuseau, ex. 2026-10-11T12:30:00+01:00.
IdentifiantsEntiers. Votre propre identifiant de commande passe dans reference_externe.
ListesTableaux JSON complets, sans pagination en v1 — affinez avec les filtres (q, restaurant, categorie).
ErreursObjet {"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/.
Sécurité

Authentification : clé + signature HMAC #

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êteContenuExemple
X-Menu-KeyVotre clé API (identifiant public, pas un secret)pk_sandbox_3f9a…
X-Menu-TimestampHorodatage Unix en secondes. Tolérance : ± 5 minutes1791720000
X-Menu-SignatureHMAC-SHA256 hexadécimal (minuscules) du message ci-dessous, avec votre secret9c1e…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.
Exemple de message (GET, corps vide)
1791720000|GET|/api/partners/v1/plats?restaurant=12|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.

Générateur de signature #

Calculez une signature dans votre navigateur pour la comparer à celle de votre code. Rien n'est envoyé : le calcul se fait localement (Web Crypto).

sha256(corps)—
Message signé—
X-Menu-SignatureSaisissez un secret

Erreurs de signature fréquentes #

SymptômeCause probable
401 Signature invalide. sur un POST uniquementLe 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 filtresLa 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 404Barre oblique finale dans le chemin (/restaurants/).
Référence

Endpoints #

Tous les chemins sont relatifs à https://menu.cambus.cm. Les exemples utilisent la fonction appel() du démarrage rapide.

Lister les restaurants #

GET/api/partners/v1/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"]]
const restaurants = await appel("GET", "/api/partners/v1/restaurants?q=grill");
const enLivraison = restaurants.filter((r) => r.livraison_active);
$restaurants = menu_appel("GET", "/api/partners/v1/restaurants?q=grill");
200 Réponse
application/json
[
  {
    "id": 12,
    "nom": "Le Grill du Wouri",
    "description": "Grillades et poissons braisés",
    "adresse": "Rue Joss, Bonanjo",
    "ville": "Douala",
    "latitude": "4.043500",
    "longitude": "9.692000",
    "logo": "https://menu.cambus.cm/media/restaurants/logo_12.jpg",
    "cover_image": "https://menu.cambus.cm/media/restaurants/cover_12.jpg",
    "is_open": true,
    "note_moyenne": 4.6,
    "temps_livraison_moyen": 35,
    "frais_livraison": "700",
    "paliers_livraison": [
      { "jusqu_a_km": "3.0", "prix": "500" },
      { "jusqu_a_km": "10.0", "prix": "1000" }
    ],
    "prix_reservation": "0",
    "nombre_plats": 24,
    "nombre_abonnes": 310,
    "plat_du_jour": 45,
    "reservations_actives": false,
    "plats_a_emporter_actifs": true,
    "livraison_active": true,
    "devise": "XAF",
    "horaires": {
      "toute_heure": true,
      "plages": null,
      "decalage_minutes": 60,
      "livrable_maintenant": true,
      "prochain_creneau": "2026-10-11T12:30:00+01:00"
    }
  }
]

Détail d'un restaurant #

GET/api/partners/v1/restaurants/{id}

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")
const resto = await appel("GET", "/api/partners/v1/restaurants/12");
resto.plats.forEach((p) => console.log(p.nom, p.prix_client, "F"));
$resto = menu_appel("GET", "/api/partners/v1/restaurants/12");
foreach ($resto["plats"] as $plat) echo $plat["nom"] . " — " . $plat["prix_client"] . " F\n";
200 Réponse (extrait)
application/json
{
  "id": 12,
  "nom": "Le Grill du Wouri",
  "is_open": true,
  "livraison_active": true,
  "plats_a_emporter_actifs": true,
  "plats": [
    { "id": 45, "nom": "Poisson braisé", "prix_client": 4950, "...": "voir l'objet Plat" }
  ]
}

Lister les plats #

GET/api/partners/v1/plats

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}`);
$query = http_build_query(["restaurant" => 12, "q" => "poisson"]);
$plats = menu_appel("GET", "/api/partners/v1/plats?" . $query);
200 Réponse
application/json
[
  {
    "id": 45,
    "restaurant": 12,
    "restaurant_nom": "Le Grill du Wouri",
    "categorie": 3,
    "categorie_nom": "Poissons",
    "type_plat": "resistance",
    "type_plat_label": "Plat de résistance",
    "nom": "Poisson braisé",
    "description": "Bar entier braisé, sauce maison",
    "prix": "4500",
    "prix_client": 4950,
    "frais_livraison": "500",
    "image": "https://menu.cambus.cm/media/plats/45.jpg",
    "images": ["https://menu.cambus.cm/media/plats/45_apercu.jpg"],
    "flou": "",
    "groupes_complements": [
      {
        "id": 8,
        "nom": "Accompagnement",
        "obligatoire": true,
        "options": [
          { "id": 21, "nom": "Plantain mûr", "supplement": 0 },
          { "id": 22, "nom": "Bâton de manioc", "supplement": 200 }
        ]
      }
    ],
    "elements_inclus": ["Piment", "Oignons"],
    "is_available": true,
    "is_popular": true,
    "allergies": "Poisson",
    "ingredients": "Bar, tomate, oignon, piment",
    "composition": ["Bar", "tomate", "oignon", "piment"],
    "note": 4.7,
    "nombre_notes": 58,
    "nombre_commandes": 312,
    "nombre_likes": 87,
    "created_at": "2026-03-02T10:14:00+01:00",
    "devise": "XAF",
    "horaires_propres": false,
    "horaires": {
      "toute_heure": true,
      "plages": null,
      "decalage_minutes": 60,
      "livrable_maintenant": true,
      "prochain_creneau": "2026-10-11T12:30:00+01:00"
    }
  }
]

Détail d'un plat #

GET/api/partners/v1/plats/{id}

Renvoie un objet Plat. Appelez-le juste avant la validation du panier pour vérifier is_available et le prix à jour.

Tous les plans120/min

Erreurs

404

{"error": "Plat introuvable"}

menu_api GET /api/partners/v1/plats/45
plat = appel("GET", "/api/partners/v1/plats/45")
const plat = await appel("GET", "/api/partners/v1/plats/45");
$plat = menu_appel("GET", "/api/partners/v1/plats/45");

Lister les catégories #

GET/api/partners/v1/categories

Catégories de plats, à utiliser comme filtre categorie sur /plats.

Tous les plans120/min
menu_api GET /api/partners/v1/categories
categories = appel("GET", "/api/partners/v1/categories")
const categories = await appel("GET", "/api/partners/v1/categories");
$categories = menu_appel("GET", "/api/partners/v1/categories");
200 Réponse
application/json
[
  {
    "id": 3,
    "nom": "Poissons",
    "description": "",
    "icone_type": "emoji",
    "icone": "🐟",
    "icone_image": null,
    "image_presentation": null,
    "nb_plats": 0
  }
]

Créer une commande #

POST/api/partners/v1/commandes

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"}'
commande = appel("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",
})
print(commande["id"], commande["statut"], commande["montant_total"])
const commande = await appel("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",
});
$commande = menu_appel("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",
]);
201 Réponse
application/json
{
  "id": 981,
  "reference_externe": "ORD-2026-000123",
  "restaurant": 12,
  "statut": "en_attente",
  "montant_total": "10800",
  "frais_livraison": "500",
  "emporter": false,
  "code_retrait": "",
  "adresse_livraison": "Akwa, rue Gallieni",
  "notes": "Sans piment",
  "delai_estime": 35,
  "livraison_prevue_le": null,
  "paiement_confirme": true,
  "lignes": [
    { "plat": 45, "plat_nom": "Poisson braisé", "quantite": 2, "prix_unitaire": "5150" }
  ],
  "created_at": "2026-10-11T12:30:00+01:00"
}
400 Refus métier
application/json
{
  "error": "Le Grill du Wouri ne livre pas jusqu'à cette adresse.",
  "motif": "hors_zone"
}

Suivre une commande #

GET/api/partners/v1/commandes/{id}

É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);
$commande = menu_appel("GET", "/api/partners/v1/commandes/981");
echo $commande["statut"];

Déposer une candidature #

POST/api/partners/v1/apply

Équivalent programmatique du formulaire de candidature. Sans authentification, en multipart/form-data.

Public5/hour par IP

Champs

nom_commercial, contact_nom, contact_email, description_cas_usagerequis
plan_souhaitestringrecommandé

decouverte, croissance, business ou scale. Le plan est confirmé par notre équipe à l'approbation.

raison_sociale, pays, numero_rccm, numero_contribuable, secteur, site_web, app_url, contact_telephone, adresseoptionnels
fichier_rccm, fichier_niu, fichier_piece_identite, fichier_autrefichieroptionnels

PDF, JPG ou PNG, 10 Mo max chacun.

cURL
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."
}
Guide

Cycle de vie d'une commande #

Le champ statut évolue au fil du traitement par le restaurant et le livreur. Chaque transition déclenche un webhook.

en_attente→acceptee→en_preparation→prete→en_livraison→livree
prete→recuperee(à emporter : le client présente son code_retrait)
en_attente→refuseeou, à tout moment avant remise :annulee
StatutSignificationFinal ?
en_attenteReçue, en attente d'acceptation par le restaurant—
accepteeAcceptée par le restaurant—
en_preparationEn cuisine—
pretePrête : attend le livreur ou le client—
en_livraisonPrise en charge par un livreur—
livreeRemise au clientOui
recupereeRetirée sur place par le clientOui
refuseeRefusée par le restaurant (rupture, fermeture…)Oui
annuleeAnnuléeOui
Guide

Idempotence & relances #

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.

Guide · Business & Scale

Webhooks #

Configurez une URL HTTPS depuis le portail (rubrique Webhook). À chaque changement de statut d'une de vos commandes, Menu envoie un POST JSON signé :

POST https://votre-serveur/webhooks/menu
{
  "commande_id": 981,
  "reference_externe": "ORD-2026-000123",
  "statut": "acceptee",
  "montant_total": 10800,
  "timestamp": 1791720000
}

En-tête X-Menu-Signature = HMAC-SHA256, avec votre secret webhook (distinct du secret API), de la chaîne :

commande_id|reference_externe|statut|timestamp

Vérifier la signature

# Flask — même logique avec Django/FastAPI
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["MENU_WEBHOOK_SECRET"]


@app.post("/webhooks/menu")
def webhook_menu():
    evt = request.get_json(force=True)
    message = f'{evt["commande_id"]}|{evt["reference_externe"]}|{evt["statut"]}|{evt["timestamp"]}'
    attendu = hmac.new(WEBHOOK_SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(attendu, request.headers.get("X-Menu-Signature", "")):
        abort(401)
    if abs(time.time() - evt["timestamp"]) > 300:
        abort(401)  # trop ancien : rejeu probable
    mettre_a_jour_commande(evt["reference_externe"], evt["statut"])
    return "", 204
// Express
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/webhooks/menu", express.json(), (req, res) => {
  const { commande_id, reference_externe, statut, timestamp } = req.body;
  const attendu = crypto
    .createHmac("sha256", process.env.MENU_WEBHOOK_SECRET)
    .update(`${commande_id}|${reference_externe}|${statut}|${timestamp}`)
    .digest("hex");
  const recu = req.get("X-Menu-Signature") || "";
  const valide = recu.length === attendu.length &&
    crypto.timingSafeEqual(Buffer.from(recu), Buffer.from(attendu));
  if (!valide || Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(401);

  mettreAJourCommande(reference_externe, statut);
  res.sendStatus(204);
});
<?php
$evt = json_decode(file_get_contents("php://input"), true);
$message = $evt["commande_id"] . "|" . $evt["reference_externe"] . "|" . $evt["statut"] . "|" . $evt["timestamp"];
$attendu = hash_hmac("sha256", $message, getenv("MENU_WEBHOOK_SECRET"));
$recu = $_SERVER["HTTP_X_Menu_SIGNATURE"] ?? "";

if (!hash_equals($attendu, $recu) || abs(time() - $evt["timestamp"]) > 300) {
    http_response_code(401);
    exit;
}
mettre_a_jour_commande($evt["reference_externe"], $evt["statut"]);
http_response_code(204);

Livraison des événements

  • 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.
Référence

Codes d'erreur #

HTTPCorpsSignificationRelancer ?
400{"error", "motif"?}Payload invalide ou refus métierNon — corriger
401{"detail"}En-têtes manquants, signature invalide, horodatage hors tolérance, clé révoquée, partenaire suspenduNon — corriger
403{"detail"}Action hors de votre plan (ex. commande sur Découverte)Non — changer de plan
404{"error"}Ressource inexistante ou appartenant à un autre partenaireNon
429{"detail"}Débit court terme dépassé (en-tête Retry-After) ou quota mensuel atteintOui — après le délai
5xx—Incident côté MenuOui — avec backoff

Motifs de refus d'une commande

Le champ motif est un code stable, à utiliser dans votre logique ; error est un texte prêt à afficher.

motifQuandQue faire
fermeLe restaurant est ferméProposer un autre restaurant ou plus tard
livraison_indisponibleemporter: false mais le restaurant ne livre pasProposer le retrait sur place
emporter_indisponibleemporter: true mais pas de vente à emporterProposer la livraison
hors_zoneAdresse hors de la zone de livraisonProposer le retrait ou un restaurant plus proche
hors_creneauLe restaurant ne livre pas ces plats en ce moment (sans livraison_prevue_le) ou à l'heure demandéeProgrammer la commande dans les horaires
heure_invalidelivraison_prevue_le illisible, sans fuseau, trop proche ou à plus de 7 joursCorriger l'heure
aucun_plat_valideAucun plat du panier n'est disponible chez ce restaurantRafraîchir le catalogue
complement_invalideOption inconnue, indisponible, deux options pour un groupe, groupe obligatoire vide, ou format invalideRelire groupes_complements
Référence

Limites & quotas #

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.

Tarifs

Plans & tarifs #

Choisissez votre plan au moment de la candidature — vous pourrez en changer ensuite depuis le portail.

Découverte

0 F
Gratuit
  • 2 000 requêtes / mois
  • Lecture du catalogue
  • Création de commandes
  • Environnement live
  • Webhooks
Choisir Découverte

Croissance

0 F
+ 1 % par commande
  • Requêtes illimitées
  • Lecture du catalogue
  • Création de commandes
  • Environnement live
  • Webhooks
Choisir Croissance

Scale

Sur devis
 
  • Requêtes illimitées
  • Lecture du catalogue
  • Création de commandes
  • Environnement live
  • Webhooks
Nous contacter

Grille indicative, susceptible d'ajustement. Plan Scale sur mesure : menu@cambus.cm.

Ressources

Sandbox ou live #

pk_sandbox_…
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.

Ressources

Sécurité : bonnes pratiques #

  • 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.
Ressources

Spécification OpenAPI #

Le schéma OpenAPI 3 est généré à partir du code : il est toujours à jour. Importez-le dans Postman, Insomnia ou un générateur de SDK.

Swagger UI ReDoc Schéma JSON
Questions

FAQ #

Combien de temps prend la vérification ?
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).
Historique

Historique des versions #

v1.2Créneaux de livraison : horaires sur les restaurants et les plats, commandes programmées (livraison_prevue_le), motifs hors_creneau et heure_invalide.
v1.1Choix 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.0Catalogue, 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.

Obtenir une clé API Parler à l'équipe