Développeurs

Intégrez Daba Shipping : testez gratuitement avec une clé de test, sans vrai livreur ni vrai paiement.

Obtenir mes clés

Documentation d'intégration API

Le guide complet à copier-coller à votre développeur ou à votre assistant IA pour connecter votre site à Daba Shipping - authentification, création de commandes, webhooks, et exemples en Node.js, Python, PHP et cURL.

Master Prompt - Integration API Marchand Daba Shipping

Copiez tout ce document et collez-le a votre assistant IA (ou donnez-le a votre developpeur) en lui demandant d'implementer l'integration dans votre langage et votre framework. La specification est la source de verite ; les exemples de code ci-dessous existent pour verifier que l'IA a bien compris le contrat, pas pour etre copies tels quels.


Contexte

Tu integres l'API Daba Shipping, un service de livraison tiers pour boutiques en ligne au Cameroun. Le flux general est :

  1. Ton systeme cree une commande de livraison via POST /api/public/orders.
  2. Daba Shipping assigne un livreur partenaire et t'envoie des webhooks signes a chaque changement de statut (order.assigned, order.picked_up, order.delivered, ...).
  3. Optionnel : tu peux calculer le frais de livraison avant de creer la commande (POST /api/public/calculate) et lister les zones couvertes (GET /api/public/zones). Tu peux aussi comparer plusieurs transporteurs de la place de marche pour un colis (POST /api/public/quotes, section 3.6), puis reserver directement par l'API (POST /api/public/parcels, section 3.7) avec suivi en direct et webhooks parcel.*.
  4. Le client final peut suivre sa commande sans authentification via GET /api/public/tracking/{tracking_token}.

Environnement de test : une cle sk_test_ permet de tout essayer sans vrai livreur ni vrai paiement (section "Environnement de test").

URL de base : https://<votre-instance-shipping>.com (ex. https://shipping.daba.cm)


1. Authentification

Toutes les routes /api/public/* sauf le tracking exigent le header :

text
X-Api-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

La cle est generee une seule fois (a l'inscription ou apres regeneration) et n'est jamais ré-affichable en entier ensuite - stocke-la immediatement dans ton gestionnaire de secrets (variable d'environnement, vault). Si tu la perds, il faut en regenerer une nouvelle depuis le dashboard (/dashboard/parametres), ce qui invalide l'ancienne instantanement.

Une cle invalide ou manquante renvoie 401 { "error": "Cle API invalide ou manquante" }.

⚠️ JAMAIS depuis le navigateur. X-Api-Key est une cle secrete - appeler /api/public/calculate, /api/public/zones ou /api/public/orders directement depuis du JavaScript cote client (checkout, panier) expose cette cle a n'importe quel visiteur via l'onglet Reseau ou le code source de la page. Quelqu'un recuperant ta cle peut creer de fausses commandes en ton nom via /orders (declenche de vrais SMS/emails a des numeros/adresses arbitraires, consomme ton quota de livreurs) - pas juste un risque theorique.

Le flux correct : ton backend (celui que tu controles, ou tes serveurs) appelle l'API Daba Shipping avec la cle stockee cote serveur, et c'est TON frontend qui appelle TON propre backend (sans cle Daba Shipping impliquee a cette etape). Seule exception acceptee : le widget embarquable officiel (voir §3.5), concu pour cet usage avec ses propres limites.

2. Rate limiting

100 requetes / minute par marchand sur les endpoints authentifies. Au-dela :

text
429 { "error": "Limite de requetes atteinte (100/minute). Reessayez dans une minute." }

Headers de reponse a lire pour t'adapter proactivement : X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (epoch secondes).

Implemente un backoff exponentiel sur 429/5xx cote client.

2 bis. Facturation et suspension

Ta formule (choisie a l'inscription, modifiable dans /dashboard/parametres) s'applique aux commandes creees par POST /api/public/orders et livrees, en pourcentage de leur delivery_fee (ou forfait pour l'abonnement). Une facture du mois ecoule est emise le 1er, a regler sous 15 jours par Mobile Money depuis /dashboard/factures.

Passe l'echeance, POST /api/public/orders et POST /api/public/parcels repondent :

json
402 { "error": "Creation de commandes suspendue : une facture Daba Shipping est impayee...", "code": "billing_suspended" }

Les autres routes (/calculate, /zones, /quotes, suivi, webhooks) continuent de fonctionner. Des que la facture est reglee, la creation reprend automatiquement.

Affichage recommande : ne montre jamais ce message a un acheteur. Cote checkout, affiche "Livraison momentanement indisponible" et alerte-toi en interne (c'est un sujet de compte marchand, pas une erreur de l'acheteur).

Environnement de test

Une cle sk_test_... donne acces aux memes routes que la cle reelle, mais rien ne sort de Daba Shipping : aucun livreur n'est notifie, aucun SMS, aucun e-mail, aucun Mobile Money, aucune facturation. Genere-la dans /dashboard/parametres, onglet "API & Webhooks", bloc "Cle de test". Les commandes et envois de test sont conserves et separes des vrais : une cle de test ne voit que les donnees de test, une cle reelle que les vraies.

Ce qui change avec une cle de test :

RouteComportement en test
POST /ordersCommande creee avec "livemode": false, jamais proposee a un livreur, jamais annulee automatiquement
POST /calculate, GET /zones, POST /quotesIdentiques (lecture des vrais tarifs)
POST /parcelsPaiement simule et accepte aussitot. Le numero 690000001 simule un refus de l'operateur (502 payment_failed)
POST /parcels/{id}/cancelRemboursement simule
WebhooksVrais webhooks signes vers ton URL, avec "livemode": false

Faire avancer une commande ou un envoi de test (cle de test obligatoire, sinon 403 test_key_required) :

bash
# Commande : pending -> assigned -> picked_up -> in_transit -> delivered
curl -X POST https://shipping.daba.cm/api/public/sandbox/orders/{order_id}/advance \
  -H "X-Api-Key: sk_test_..."

# Envoi : searching_carrier -> accepted -> picked_up -> delivered
curl -X POST https://shipping.daba.cm/api/public/sandbox/parcels/{parcel_id}/advance \
  -H "X-Api-Key: sk_test_..."

# Etape precise : { "status": "failed" } ou { "status": "cancelled" } (commande),
# { "status": "failed" } ou { "status": "no_carrier" } (envoi, rembourse)
curl -X POST https://shipping.daba.cm/api/public/sandbox/parcels/{parcel_id}/advance \
  -H "X-Api-Key: sk_test_..." -H "Content-Type: application/json" \
  -d '{"status":"no_carrier"}'

Chaque etape envoie le webhook correspondant (order.* ou parcel.*). Pour un envoi accepte, la reponse contient un livreur fictif ("Livreur Test Daba"), un delivery_code et une position fictive a Douala, pour tester ta page de suivi.

Passer en production : remplace sk_test_ par ta cle sk_live_. Aucun autre changement de code. Verifie dans ton handler webhook que tu ignores (ou journalises a part) les evenements "livemode": false.


3. Endpoints

3.1 Creer une commande de livraison

POST /api/public/orders

Body (JSON) :

ChampTypeObligatoireDescription
external_order_idstringNonTon propre ID de commande. Fortement recommande pour la correlation.
customer_namestringOuiNom du client final
customer_phonestringOuiTelephone du client (format local, ex. 6XXXXXXXX)
customer_emailstringNonEmail du client (declenche un email de confirmation si fourni)
delivery_addressstringOuiAdresse complete de livraison
citystringOuiVille (doit correspondre a une zone couverte par un partenaire)
neighborhoodstringNonQuartier, ameliore le matching de zone
order_valuenumberOuiValeur de la commande en XAF
delivery_feenumberOuiFrais de livraison factures au client (calcule au prealable via /calculate). C'est sur ce montant que le livreur est remunere a la livraison (commission deduite)
itemsarrayNonArticles a livrer - affiches au livreur/entreprise avant qu'il accepte la mission. Voir forme ci-dessous.

Forme d'un element de items :

json
{
  "name": "T-shirt DabaShop taille M",
  "quantity": 2,
  "image_url": "https://cdn.exemple.com/produits/tshirt.jpg",
  "weight_kg": 0.3
}

Seul name est requis dans chaque element ; quantity par defaut a 1 si omis. image_url et weight_kg sont optionnels - envoie-les si tu les as, ca aide le livreur a preparer le retrait (poids total, encombrement) et a verifier qu'il recupere les bons articles chez le marchand.

Reponse succes 201 :

json
{
  "order_id": "uuid",
  "tracking_token": "TRK-XXXXXXXX",
  "tracking_url": "https://shipping.daba.cm/track/TRK-XXXXXXXX",
  "status": "pending",
  "created_at": "2026-07-13T10:00:00.000Z"
}

Erreurs : 400 champs manquants (customer_name, customer_phone, delivery_address, city), 401 cle invalide, 409 une requete avec la meme Idempotency-Key est encore en cours, 402 billing_suspended (facture impayee depuis plus de 15 jours, voir section 2 bis), 429 rate limit, 500 erreur serveur.

⚠️ delivery_fee est obligatoire et doit etre superieur a 0 : sinon l'API repond 400 (depuis le 25/10/2026 ; avant, elle enregistrait 0 et le livreur n'etait pas remunere). order_value est facultatif (0 par defaut).

Idempotence : envoie un header Idempotency-Key: <uuid-ou-chaine-unique> pour securiser tes retries (ex. apres un timeout reseau). Si tu renvoies la meme requete avec la meme cle, l'API rejoue la reponse d'origine (meme order_id/tracking_token, meme code HTTP) au lieu de creer une deuxieme commande. Exception : une erreur serveur (5xx) n'est pas rejouee, ton nouvel essai avec la meme cle est traite normalement. Deux appels simultanes avec la meme cle : le second recoit 409, attends puis reessaie - aucune nouvelle logique a ecrire cote toi, juste a generer une cle unique par tentative logique de creation (pas par retry individuel) et la renvoyer identique sur chaque retry de cette meme tentative.

La cle est scopee a ton compte marchand (deux marchands peuvent reutiliser la meme valeur sans collision) et n'expire pas automatiquement cote serveur pour l'instant - choisis une valeur reellement unique par commande (ex. ton propre external_order_id, ou un UUID genere a la creation de la commande dans ton systeme).

Header absent = comportement historique inchange (l'API n'est pas idempotente par defaut) : deux appels identiques sans header creent deux commandes distinctes. Deduplique cote client si tu n'envoies pas ce header.

3.2 Calculer le frais de livraison (avant creation)

POST /api/public/calculate

Body (JSON) :

ChampTypeObligatoireDescription
order_valuenumberOuiValeur de la commande en XAF (> 0, sinon 400)
citystringOuiVille de livraison (ville d'arrivee pour un calcul inter-villes)
neighborhoodstringNonQuartier - affine le matching de zone (mode standard uniquement)
from_citystringPour un calcul inter-villesVille d'expedition (depart du colis)
weight_kgnumberPour un calcul inter-villesPoids du colis en kg (> 0). Le prix depend des tranches de poids configurees par chaque transporteur, sans limite a 10 kg. Aucune tranche ne couvre ce poids : 404 no_tariff_match.
from_zonestringPour un calcul intra-villeZone de depart (quartier/secteur de retrait chez le marchand)
to_zonestringPour un calcul intra-villeZone d'arrivee (quartier/secteur de livraison au client)
vehicle_typestringNonType de vehicule (MOTO, etc.) - optionnel pour un calcul intra-ville, prend le premier tarif correspondant si absent

⚠️ Le calcul declenche n'est PAS determine par un mode choisi dans le dashboard du marchand, mais par les champs que TOI tu envoies dans cette requete :

  • Tu envoies from_city + weight_kg → calcul inter-villes par poids, meme si le marchand n'a jamais rien configure de special pour ca.
  • Tu envoies from_zone + to_zone → calcul intra-ville par zone.
  • Tu n'envoies ni l'un ni l'autre → mode de base du marchand (tarif fixe, depuis le livreur prioritaire, ou fourchette).

Ces trois cas de figure peuvent coexister sur une meme boutique : rien a activer, chaque appel determine lui-meme le type de calcul adapte selon ce que tu lui donnes. En pratique : propose from_city+weight_kg sur un flux colis inter-villes et from_zone+to_zone sur un flux livraison locale, dans la meme integration, sans rien reconfigurer entre les deux.

Reponse selon le type de calcul declenche :

json
// mode fixe
{ "delivery_fee": 1500, "currency": "XAF", "mode": "fixed" }

// mode depuis le livreur prioritaire (par tranche de valeur de commande)
{ "delivery_fee": 2000, "currency": "XAF", "mode": "from_partner" }

// mode fourchette (par tranche de valeur de commande)
{ "delivery_fee_min": 1500, "delivery_fee_max": 2500, "currency": "XAF", "mode": "range" }

// mode inter-villes (poids + trajet)
{ "delivery_fee": 3500, "currency": "XAF", "mode": "intercity", "weight_category": "less_2kg" }

// mode inter-villes fourchette (ton partenaire prioritaire n'a pas de tarif pour ce
// trajet/poids : fourchette des autres partenaires qui en ont un)
{ "delivery_fee_min": 3000, "delivery_fee_max": 4500, "currency": "XAF", "mode": "intercity_range", "weight_category": "2_to_10kg" }

// mode intra-ville par zone
{ "delivery_fee": 800, "currency": "XAF", "mode": "intracity", "from_zone": "Centre-Ville", "to_zone": "Bastos" }

// mode intra-ville par zone fourchette (meme principe : partenaire prioritaire sans tarif)
{ "delivery_fee_min": 700, "delivery_fee_max": 1000, "currency": "XAF", "mode": "intracity_range", "from_zone": "Centre-Ville", "to_zone": "Bastos" }

Exemples de requetes par mode :

json
// Mode intercity - livraison Yaounde → Douala, colis de 1.5 kg
{
  "order_value": 15000,
  "city": "Douala",
  "from_city": "Yaounde",
  "weight_kg": 1.5
}

// Mode intracity - livraison intra-Yaounde par zone
{
  "order_value": 8000,
  "city": "Yaounde",
  "from_zone": "Centre-Ville",
  "to_zone": "Bastos",
  "vehicle_type": "MOTO"
}

404 si aucune zone/tarif ne correspond - dans ce cas, n'affiche pas la livraison comme disponible pour ce client. Le corps de la reponse inclut un champ reason machine-lisible pour distinguer la cause exacte sans avoir a parser le message error (utile pour choisir le bon message a afficher a l'acheteur) :

reasonSignification
no_active_partnerLe marchand n'a aucun livreur/entreprise partenaire actif
zone_not_coveredMode from_partner/range : aucun partenaire ne couvre city/neighborhood
no_tier_match_for_valueMode from_partner/range : zone couverte mais order_value hors de toutes les tranches configurees
no_tariff_matchCalcul inter-villes/intra-ville : aucun tarif ne correspond au trajet/zone demande
json
{ "error": "Zone non couverte", "reason": "zone_not_covered" }

Suggestion de message a afficher a l'acheteur selon reason (adapte le ton a ta marque, l'important est de ne jamais montrer le error brut a un client final - il est pense pour un developpeur, pas pour un acheteur) :

reasonMessage suggere a l'acheteur
no_active_partner"Livraison indisponible pour le moment, reessayez plus tard." (masque le bouton livraison)
zone_not_covered"Nous ne livrons pas encore a cette adresse." (propose une autre ville/quartier ou le retrait)
no_tier_match_for_value"Impossible de calculer les frais pour ce montant de commande." (rare - signale-le toi-meme en interne, c'est un trou de config cote marchand)
no_tariff_match"Cette liaison n'est pas encore disponible." (pour inter-villes/intra-ville - propose le mode standard si zones le couvre)

400 si order_value ou city manque, ou si weight_kg est invalide en calcul inter-villes.

Quel prix est renvoye ? Le tarif de ton partenaire prioritaire (ordre defini dans ton dashboard) s'il en a un pour la demande. Sinon, si un seul autre partenaire en a un, son tarif ; s'ils sont plusieurs, la fourchette (*_range).

⚠️ Pas de livraison gratuite cote Daba Shipping. Le frais renvoye est toujours celui que touchera le livreur : il n'existe plus de mode free ni de seuil de gratuite. Si tu offres la livraison a ton client (ex. au-dela d'un montant de panier), affiche 0 a ton client et prends le frais a ta charge, mais envoie toujours le vrai frais comme delivery_fee a POST /orders. Un delivery_fee a 0 signifie un livreur non paye.

3.3 Lister les zones et routes couvertes

GET /api/public/zones

Retourne la configuration tarifaire du marchand (pricing_mode) et l'ensemble des zones/trajets desservis par ses livreurs partenaires actifs.

Réponse (JSON) :

json
{
  "pricing_mode": "from_partner",
  "zones": [
    { "city": "Douala", "neighborhood": "Akwa" },
    { "city": "Douala", "neighborhood": "Bonapriso" },
    { "city": "Yaounde", "neighborhood": "Bastos" }
  ],
  "intracity": [
    {
      "city": "Yaounde",
      "zones": ["Bastos", "Centre-Ville", "Mvan"],
      "from_zones": ["Centre-Ville"],
      "to_zones": ["Bastos", "Mvan"],
      "routes": [
        { "from_zone": "Centre-Ville", "to_zone": "Bastos" },
        { "from_zone": "Centre-Ville", "to_zone": "Mvan" }
      ]
    }
  ],
  "intercity": {
    "from_cities": ["Douala", "Yaounde"],
    "to_cities": ["Bafoussam", "Douala", "Yaounde"],
    "routes": [
      { "from_city": "Douala", "to_city": "Yaounde" },
      { "from_city": "Yaounde", "to_city": "Bafoussam" }
    ]
  }
}

Comment l'utiliser sur le site client (Checkout) :

  • pricing_mode : le mode de base du marchand, toujours fixed, from_partner ou range, utilise seulement quand tu n'envoies ni poids ni zone a /calculate. N'indique PAS si inter-villes/intra-ville sont disponibles - regarde intercity/intracity pour ca.
  • zones : liste des villes et quartiers couverts pour le mode de base.
  • intracity : liste par ville des zones de départ (from_zones), d'arrivée (to_zones) et des routes valides - si non vide, tu peux proposer un calcul intra-ville (envoie from_zone+to_zone a /calculate).
  • intercity : liste des villes de départ et d'arrivée possibles, avec validation des routes directes (routes) - si non vide, tu peux proposer un calcul inter-villes (envoie from_city+weight_kg a /calculate).

⚠️ /zones depend de la config de TON livreur partenaire (ou de l'entreprise partenaire), pas de la tienne :

Ton livreur/entreprise partenaire remplit, dans son propre tableau de bord, le bareme correspondant a chaque type de calcul - "Par valeur commande" pour le mode de base, "Inter-villes" pour intercity, "Par zone" pour intracity. Toi, tu n'as rien a "activer" de ton cote pour inter-villes/ intra-ville (voir §3.2) - mais si ton partenaire n'a rempli aucun bareme inter-villes, /zones te renverra intercity: {from_cities: [], to_cities: [], routes: []} et tes appels /calculate avec from_city+weight_kg renverront 404 avec reason: "no_tariff_match" (ou "no_active_partner" si tu n'as meme aucun partenaire actif) jusqu'a ce que ce soit rempli de son cote.

Construire le formulaire checkout pas a pas (cote ton backend, qui appelle Daba Shipping, puis sert ces donnees a ton frontend - voir l'avertissement securite §1) :

text
1. Au chargement de la page checkout (ou en cache cote toi, voir §4.1 pour
   l'invalidation via webhook) :
   → GET /api/public/zones

2. Determine quelles options proposer a l'acheteur :
   - intercity.routes non vide → propose l'option "Livraison inter-villes"
     (champ ville de depart du colis + poids estime)
   - intracity (array) non vide → propose l'option "Livraison locale par
     zone" (selecteur zone de depart / zone d'arrivee, limite aux valeurs
     listees dans intracity[i].from_zones / to_zones pour cette ville)
   - Dans tous les cas → le mode standard reste disponible (ville + quartier
     optionnel), limite aux entrees de `zones`

3. Selon ce que l'acheteur choisit/saisit, appelle POST /calculate avec
   UNIQUEMENT le jeu de champs correspondant :
   - Inter-villes  → { order_value, city, from_city, weight_kg }
   - Intra-ville   → { order_value, city, from_zone, to_zone, vehicle_type? }
   - Standard      → { order_value, city, neighborhood? }
   (N'envoie jamais les trois jeux de champs en meme temps "au cas ou" -
   from_city+weight_kg prioritaire si present, voir §3.2.)

4. Si mode === "range" / "intercity_range" / "intracity_range" : c'est TOI
   qui fixes le frais facture. Affiche par exemple "De 1 500 a 2 500 XAF"
   pendant la saisie, puis un prix unique au paiement (recommande :
   delivery_fee_max, pour couvrir le livreur le plus cher). Envoie ce montant
   comme delivery_fee a POST /orders : c'est celui que le livreur touchera.
   Le webhook order.assigned ne transmet pas de nouveau tarif.

5. Si 404 : utilise `reason` pour afficher le bon message (table ci-dessus)
   et proposer une alternative (ex. repli vers le mode standard si
   `no_tariff_match` sur un calcul inter-villes/intra-ville).

3.4 Suivi de commande (public, sans cle API)

GET /api/public/tracking/{tracking_token}

json
{
  "status": "in_transit",
  "tracking_token": "TRK-XXXXXXXX",
  "delivery_address": "...",
  "city": "Douala",
  "neighborhood": "Bonapriso",
  "driver": { "id": "...", "name": "...", "phone": "...", "vehicle": "moto", "lat": 4.05, "lng": 9.7 },
  "events": [{ "status": "created", "note": "...", "time": "..." }, ...],
  "created_at": "...",
  "updated_at": "..."
}

driver est null tant qu'aucun livreur n'est assigne. Utilise tracking_url recu a la creation pour rediriger directement le client final vers une page de suivi hebergee par Daba Shipping, ou consomme ce JSON dans ta propre UI.

3.5 Widget embarquable (alternative sans backend)

Si tu n'as pas de backend a toi (site vitrine statique, page de vente simple) ou veux juste afficher une estimation rapide sans integration complete, un widget pret a l'emploi existe :

html
<div id="daba-shipping-widget"></div>
<script
  src="https://shipping.daba.cm/widget.js"
  data-api-key="sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  data-currency="XAF"
  data-on-calculated="onDeliveryFeeCalculated"
></script>

Le widget affiche un formulaire (ville, quartier optionnel, montant commande), pre-remplissable avec data-amount, data-city et data-neighborhood, et appelle POST /api/public/calculate directement depuis le navigateur de l'acheteur, puis declenche ta fonction JS data-on-calculated (ou l'evenement DOM daba:calculated sur le conteneur) avec le resultat.

⚠️ Contrepartie assumee du §1 : ce widget expose ta cle API dans le HTML de la page, visible par n'importe quel visiteur. C'est un compromis volontaire pour permettre l'usage sans backend - si tu as deja un backend, prefere l'integration directe (§3.2) avec la cle gardee secrete. Si tu utilises le widget, limite les degats :

  • N'utilise jamais la meme cle API pour POST /orders sur un flux sensible si elle est aussi exposee via le widget sur une page publique - envisage de generer une cle dediee si ton usage le justifie.
  • Surveille /dashboard/commandes pour detecter une activite anormale si tu soupconnes un abus.
  • Le widget ne couvre que le calcul standard (ville/quartier/montant) - pas de support inter-villes ou intra-ville par zone pour l'instant ; pour ces cas, il faut l'integration backend complete.

3.6 Comparer les transporteurs (devis multi-transporteurs)

POST /api/public/quotes

Renvoie les meilleures offres de la place de marche Daba Shipping pour un colis : prix detaille, badges de confiance, note, assurance, et un lien de reservation a ouvrir pour ton client. C'est le meme moteur que le simulateur public https://shipping.daba.cm/envoyer.

Difference avec /api/public/calculate : calculate donne un frais de livraison a partir des tarifs de tes partenaires (a afficher dans ton checkout). quotes compare plusieurs transporteurs (tous ceux de la place de marche, ou seulement tes partenaires avec partners_only) pour laisser le client choisir.

Body (JSON) :

ChampTypeObligatoireDescription
from_citystringOuiVille de depart
from_neighborhoodstringNonQuartier de depart - conseille dans une meme ville (prix par quartier)
to_citystringOuiVille d'arrivee
to_neighborhoodstringNonQuartier d'arrivee
weight_kgnumberOuiPoids en kg (> 0, sans limite a 10 kg)
sizestringNonS, M, L ou XL (defaut M). L/XL excluent les deux-roues
categorystringNondocuments, electronique, vetements, alimentaire, cosmetiques, electromenager, meubles, pieces_auto, liquides, autre (defaut)
conditionstringNonEtat du contenu, sert a l'assurance : new (defaut), used, worn, damaged
declared_valuenumberNonValeur du contenu en XAF (calcul de l'assurance)
is_fragilebooleanNonExclut les transporteurs qui refusent le fragile
expressbooleanNonSeulement les transporteurs qui proposent l'express (supplement inclus)
cash_on_deliverybooleanNonSeulement ceux qui encaissent a la livraison (frais inclus)
sortstringNonrecommended (defaut : prix 50 %, note 20 %, experience 15 %, compte verifie 15 %) ou cheapest
limitnumberNonNombre d'offres renvoyees, 1 a 10 (defaut 5)
partners_onlybooleanNontrue = uniquement tes livreurs/entreprises partenaires actifs

Reponse 201 :

json
{
  "quote_id": "6f1c2d3e-...",
  "expires_at": "2026-10-16T18:30:00Z",
  "expired": false,
  "eligible_count": 7,
  "sort": "recommended",
  "partners_only": false,
  "results_url": "https://shipping.daba.cm/envoyer/resultats/6f1c2d3e-...",
  "input": {
    "from_city": "Douala",
    "to_city": "Yaounde",
    "weight_kg": 3,
    "size": "M",
    "...": "..."
  },
  "offers": [
    {
      "offer_id": "a1b2c3d4",
      "carrier_kind": "driver",
      "display_name": "Paul",
      "vehicle_type": "moto",
      "badges": ["verified", "secure"],
      "rating": 4.8,
      "total_deliveries": 132,
      "pricing_source": "intercity",
      "price": {
        "base": 3000,
        "express": 0,
        "insurance": 150,
        "cash_on_delivery": 0,
        "total": 3150
      },
      "insurance": { "covered": true, "rate_pct": 1, "covered_value": 15000 },
      "express_delay_hours": null,
      "score": 0.912,
      "booking_url": "https://shipping.daba.cm/envoyer/reserver/6f1c2d3e-...?offer=a1b2c3d4"
    }
  ],
  "excluded": { "weight_over_limit": 2, "no_express": 1 }
}
  • display_name : prenom du livreur ou nom commercial de l'entreprise. Le nom complet et le telephone ne sont jamais renvoyes : ils sont reveles au client uniquement quand le transporteur accepte la course.
  • badges : verified (documents valides par Daba Shipping), secure (verifie + note >= 4,5 + au moins 20 livraisons + au plus 5 % de litiges), express, company.
  • price.total est le prix final paye par le client.
  • excluded : nombre de transporteurs ecartes par raison (poids, taille, categorie, valeur, fragile, express, encaissement...). Utile pour expliquer une liste vide.
  • offers vide = aucun transporteur ne prend ce colis sur ce trajet.

Prix valables 30 minutes (expires_at). Passe ce delai, refais un appel.

Reserver : redirige ton client vers booking_url. Il cree (ou connecte) son compte expediteur, saisit les adresses et paie par Mobile Money ; l'argent est bloque jusqu'a la livraison et rembourse si personne n'accepte. Tu peux aussi reserver sans redirection, directement par l'API : section 3.7.

Relire un devis : GET /api/public/quotes/{quote_id}?sort=cheapest&limit=3 (meme cle API ; un marchand ne peut relire que ses propres devis, sinon 404).

Erreurs : 400 (champ manquant ou invalide, message dans error), 401 (cle API), 429 (limite de requetes, partagee avec les autres endpoints), 500 (erreur de calcul, reessaie).

Affichage recommande au client : liste les offres avec display_name, les badges ("Verifie", "Securise"), la note et price.total en gros ; montre le detail (transport, assurance, encaissement) au clic. Si offers est vide, affiche "Aucun livreur disponible pour ce colis sur ce trajet" et propose de modifier le poids, la taille ou les options (voir excluded). Ne montre jamais score ni offer_id.

bash
curl -X POST https://shipping.daba.cm/api/public/quotes \
  -H "X-Api-Key: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"from_city":"Douala","to_city":"Yaounde","weight_kg":3,"declared_value":15000,"sort":"cheapest","limit":3}'

3.7 Reserver un envoi par l'API (place de marche)

POST /api/public/parcels

Reserve une offre d'un devis (POST /api/public/quotes, section 3.6) sans rediriger ton client : l'envoi est cree et une demande de paiement Mobile Money part sur le numero que tu fournis (ton client final ou toi). L'argent reste bloque jusqu'a la livraison. Tu suis ensuite chaque etape par les webhooks parcel.* (section 4.1).

Body (JSON) :

ChampTypeObligatoireDescription
quote_idstringOuiDevis cree par TA cle (valable 30 minutes)
offer_idstringOuiOffre choisie dans offers
external_referencestringRecommandeTon identifiant de commande (100 car. max). Idempotent : renvoyer la meme valeur renvoie l'envoi deja cree, sans nouveau paiement
pickup.addressstringOuiAdresse precise du retrait
pickup.landmarkstringNonPoint de repere
pickup.contact_namestringOuiPersonne qui remet le colis
pickup.contact_phonestringOuiTelephone camerounais (9 chiffres, commence par 6)
recipient.namestringOuiDestinataire
recipient.phonestringOuiRecoit par SMS le code de remise et le lien de suivi
recipient.addressstringOuiAdresse precise de livraison
recipient.landmarkstringNonPoint de repere
payment.phonestringOuiNumero Mobile Money debite (client final ou marchand)
payment.operatorstringNonmtn_momo (defaut) ou orange_money
cod_amountnumberSi encaissementMontant encaisse aupres du destinataire, si le devis a ete fait avec cash_on_delivery: true. Reverse sur payment.phone apres la livraison
descriptionstringNonContenu et consignes (500 car. max)

Reponse 201 (200 + "idempotent": true si la reference existe deja) :

json
{
  "parcel": {
    "id": "b3f1...",
    "reference": "ENV-9A1C2B3D",
    "external_reference": "CMD-1042",
    "status": "awaiting_payment",
    "tracking_url": "https://shipping.daba.cm/suivi/5c0e...",
    "price_total": 1500,
    "offer": { "display_name": "Paul", "badges": ["verified"], "price": { "total": 1500 } },
    "carrier": null,
    "delivery_code": null,
    "position": null,
    "payment": {
      "status": "pending",
      "amount_paid": 0,
      "phone": "690000000",
      "operator": "mtn_momo"
    },
    "refund": { "status": "none", "amount": 0 },
    "can_cancel": true,
    "events": [{ "event": "payment_requested", "note": "1500 XAF", "at": "..." }]
  }
}

Cycle de vie (status) :

StatutSignification
awaiting_paymentDemande de paiement envoyee, a valider sur le telephone (15 min)
payment_failedPaiement refuse ou non valide a temps
searching_carrierPaye ; le livreur a 15 min pour accepter, sinon l'offre suivante (jamais plus chere)
acceptedLivreur confirme : carrier (nom, telephone) et delivery_code sont remplis
picked_upColis recupere (photo), en route
deliveredRemis contre le code du destinataire (photo)
failedLivraison echouee : argent bloque, decision par litige
no_carrierAucun livreur : remboursement automatique
cancelledAnnule : remboursement automatique si deja paye
  • delivery_code (4 chiffres) : le destinataire le recoit aussi par SMS. Il le donne au livreur uniquement colis en main : c'est ce qui valide la livraison. Ne l'affiche qu'a ton client final.
  • position : { lat, lng, heading, accuracy_m, updated_at } pendant la course (accepted, picked_up), null sinon. Effacee a la livraison.
  • tracking_url : page publique de suivi en direct (carte), sans adresse, sans code ni montant. Tu peux l'envoyer a ton client.

Autres routes (meme cle API) :

  • GET /api/public/parcels/{id} : etat complet + historique.
  • GET /api/public/parcels?external_reference=CMD-1042&limit=20 : tes envois.
  • POST /api/public/parcels/{id}/cancel : annulation tant qu'aucun livreur n'a accepte (409 not_cancellable sinon), remboursement automatique.

Erreurs : 400 champ invalide, 404 devis/offre introuvable (ou devis d'un autre marchand), 410 quote_expired, 502 payment_failed (l'envoi est cree en payment_failed mais la demande de paiement n'a pas pu partir), 503 payment_unavailable, 402 billing_suspended (facture impayee, voir section 2 bis).

⚠️ Nouvel essai apres un paiement non abouti (502, ou statut payment_failed plus tard) : l'envoi reste en payment_failed. Refais un devis et rappelle POST /parcels avec une nouvelle external_reference (ex. CMD-1042-2) : la meme reference renverrait l'envoi echoue.

Affichage recommande a ton client :

StatutMessage suggere
awaiting_payment"Validez le paiement de {price_total} XAF sur votre telephone ({payment.phone})."
payment_failed"Le paiement n'a pas abouti. Vous n'avez pas ete debite." + bouton reessayer
searching_carrier"Paiement recu. Nous confirmons votre livreur."
accepted"{carrier.name} prend en charge votre colis." + bouton appeler + lien tracking_url
picked_up"Votre colis est en route." + carte (position ou tracking_url)
delivered"Colis livre."
failed"La livraison n'a pas abouti. Notre equipe vous recontacte."
no_carrier"Aucun livreur n'a pu prendre votre colis. Vous etes rembourse."
cancelled"Envoi annule." (+ "Vous etes rembourse." si payment.amount_paid > 0)

Le delivery_code ne s'affiche qu'au destinataire (ou a l'acheteur s'il est le destinataire), avec la consigne "a donner au livreur uniquement colis en main". Ne l'affiche jamais au livreur ni sur une page publique.

bash
curl -X POST https://shipping.daba.cm/api/public/parcels \
  -H "X-Api-Key: sk_live_..." -H "Content-Type: application/json" \
  -d '{"quote_id":"...","offer_id":"a1b2c3d4","external_reference":"CMD-1042",
       "pickup":{"address":"Boutique, rue 12","contact_name":"Ma Boutique","contact_phone":"690000000"},
       "recipient":{"name":"Awa","phone":"677000000","address":"Bonamoussadi, carrefour Kotto"},
       "payment":{"phone":"677000000","operator":"mtn_momo"}}'

4. Webhooks - LA PARTIE CRITIQUE A BIEN IMPLEMENTER

Configure ton URL de reception dans le dashboard (/dashboard/parametres, section "Profil boutique" → champ "URL webhook"). Elle doit repondre 2xx sous 8 secondes.

⚠️ Daba Shipping n'impose aucun chemin precis. Le champ "URL webhook" attend l'URL complete d'une route que tu dois creer toi-meme sur ton serveur (ex. https://ton-domaine.com/webhooks/daba-shipping - le chemin /webhooks/daba-shipping utilise dans les exemples ci-dessous n'est qu'une convention, pas une obligation). Mettre l'URL racine de ton site (https://ton-domaine.com/) ne fonctionnera pas : ce sera generalement une page qui n'accepte que GET et renverra 405 Method Not Allowed au clic sur "Tester le webhook". Si la route n'existe pas encore cote toi, cree-la avant de tester (voir exemples de code section 7 plus bas).

4.1 Evenements emis

order.received, order.assigned, order.picked_up, order.in_transit, order.delivered, order.failed, order.cancelled, order.published, shop.pricing_mode_changed, test

Envois reserves par l'API (POST /api/public/parcels, section 3.7) : parcel.paid, parcel.payment_failed, parcel.accepted, parcel.picked_up, parcel.delivered, parcel.failed, parcel.cancelled, parcel.no_carrier, parcel.refunded, parcel.cod_remitted. Le payload contient parcel : le meme objet que GET /api/public/parcels/{id} (avec events vide). Meme signature X-DabaShipping-Signature que les autres evenements. parcel.refunded est aussi emis pour un remboursement partiel (offre de repli moins chere que le prix paye) : lis parcel.refund.amount.

L'evenement test est emis uniquement depuis le bouton "Tester le webhook" du dashboard. Il utilise le meme algorithme de signature que les vrais evenements - si ton handler le valide, il validera aussi les evenements reels.

order.published : la commande est proposee a tous les livreurs du reseau (par toi depuis le dashboard, ou automatiquement apres 3 refus ou absences de reponse). Son status dans GET /tracking reste pending.

order.cancelled peut contenir "reason": "expired" : commande annulee automatiquement apres 24 h sans livreur.

shop.pricing_mode_changed est emis des que tu changes de mode tarifaire dans ton dashboard (/dashboard/parametres). Utile si tu mets en cache la reponse de GET /api/public/zones (recommande, voir section 3.3) : plutot que de comparer manuellement a interval regulier, invalide ton cache des reception de cet evenement.

4.2 Forme du payload

json
{
  "event": "order.delivered",
  "order_id": "uuid",
  "external_order_id": "EXT-001",
  "tracking_token": "TRK-XXXXXXXX",
  "status": "delivered",
  "timestamp": "2026-07-13T10:05:00.000Z"
}

shop.pricing_mode_changed a une forme differente (pas de commande associee) :

json
{
  "event": "shop.pricing_mode_changed",
  "previous_mode": "from_partner",
  "new_mode": "range",
  "timestamp": "2026-10-02T10:05:00.000Z"
}

Evenements parcel.* (envois reserves par l'API) :

json
{
  "event": "parcel.accepted",
  "parcel": {
    "id": "b3f1...",
    "reference": "ENV-9A1C2B3D",
    "external_reference": "CMD-1042",
    "status": "accepted",
    "carrier": { "name": "Paul Mbarga", "phone": "690000000" },
    "delivery_code": "4821",
    "tracking_url": "https://shipping.daba.cm/suivi/...",
    "...": "..."
  },
  "timestamp": "2026-10-16T10:05:00.000Z"
}

4.3 Verification de la signature - SPEC EXACTE

Deux headers accompagnent chaque requete :

text
X-DabaShipping-Signature: t=1752400000000,v1=<hex hmac-sha256>
X-DabaShipping-Event: order.delivered

Algorithme exact (a reproduire au caractere pres) :

  1. Extrais t (timestamp) et v1 (signature) du header X-DabaShipping-Signature. ⚠️ t est un timestamp Unix en MILLISECONDES (Date.now() cote serveur), pas en secondes. C'est une source d'erreur frequente si tu copies un exemple Stripe (qui utilise des secondes) - ne le fais pas.
  2. Reconstruit la chaine signee : ${t}.${raw_body} - utilise le corps brut de la requete (pas le JSON re-serialise, l'ordre des cles ou les espaces changeraient le hash).
  3. Calcule HMAC-SHA256(secret, chaine_signee) encode en hexadecimal (pas en base64). C'est une autre source d'erreur frequente.
  4. Compare ce hash a v1 avec une comparaison a temps constant (crypto.timingSafeEqual / equivalent) - jamais === ou ==.
  5. Rejette si Date.now() - t > 5 minutes (fenetre anti-rejeu). Adapte le timestamp courant en millisecondes pour cette comparaison, coherent avec t.

Le secret de signature (webhook_secret) est visible dans le dashboard : /dashboard/parametres, onglet "API & Webhooks", bouton "Afficher".

4.4 Fiabilite de livraison - ce que tu dois savoir

  • Daba Shipping retente automatiquement jusqu'a 3 tentatives (immediat, +2s, +6s) si ton endpoint ne repond pas 2xx ou timeout. Au-dela, l'evenement est journalise comme echec et n'est pas retente automatiquement plus tard - il faut le rejouer manuellement depuis le dashboard (onglet "API & Webhooks", "Journal des webhooks", bouton "Rejouer").
  • Consequence pratique : ton endpoint doit etre idempotent. Tu peux recevoir le meme evenement plusieurs fois (retry, ou rejeu manuel) - dedupe sur order_id (ou parcel.id) + event, ou simplement rends chaque traitement idempotent (ex. UPSERT plutot que INSERT).
  • Ne fais jamais de traitement metier lourd de maniere synchrone dans le handler webhook : accuse reception (200) tout de suite, puis traite en arriere-plan (queue) si l'operation peut prendre du temps. Un timeout cote toi compte comme un echec de livraison.

4.5 Tester ton integration avant la prod

Le dashboard (onglet "API & Webhooks") a un bouton "Tester le webhook" qui envoie un evenement event: "test" signe avec le meme algorithme exact que les vrais evenements (meme fonction de signature cote serveur - donc si ton verificateur valide le test, il validera les vrais webhooks).

4.6 Troubleshooting - erreurs les plus frequentes

401 recu par Daba Shipping quand il envoie vers ton endpoint

C'est l'erreur la plus courante. Elle signifie que ton endpoint a rejete la requete de Daba Shipping. Causes probables par ordre de frequence :

SymptomeCause probableSolution
Signature toujours invalideTu lis req.json() au lieu du body brutLire le body brut AVANT tout parsing (voir exemples section 7)
Signature invalide en prod uniquementFramework (ex. Express sans express.raw()) parse automatiquement le bodyConfigurer le middleware pour passer le body brut a ton handler
Signature invalideTu utilises base64 au lieu de hexdigest('hex') pas digest('base64')
Signature invalideTu interpretes t en secondest est en millisecondes, pas en secondes
Toujours 401 meme avec bon secretLe webhook_secret en env var ne correspond pas a celui du dashboardCopier-coller le secret depuis le dashboard, pas le creer manuellement
401 sur test mais pas en prodFenetre anti-rejeu trop courteLa fenetre doit etre >= 5 minutes (300 000 ms)

405 Method Not Allowed

Ton endpoint n'accepte que GET. Cree une route POST explicite.

400 Bad Request

Ton parsing JSON echoue probablement parce que tu lis le body deux fois (une fois pour la signature, une fois pour le JSON). Lis le body brut une seule fois, stocke-le dans une variable, utilise-la pour les deux operations.

Timeout (aucune reponse dans les 8s)

Ton handler fait du traitement lourd de maniere synchrone. Retourne 200 immediatement apres avoir valide la signature, puis traite l'evenement en arriere-plan (queue, worker, etc.).


5. Cycle de vie d'une commande

text
pending --(un partenaire accepte)--> assigned --> picked_up --> in_transit --> delivered
   |                                     |            |              |
   |                                     +------------+--------------+--> failed
   +--(refus ou 5 min sans reponse)--> partenaire suivant (reste pending)
   +--(3 refus/absences de reponse)--> proposee a tout le reseau : webhook order.published
   |                                    (reste pending)
   +--(aucun partenaire disponible)--> reste pending, tu recois une notification
   |                                    dans ton dashboard (la rendre publique ou assigner)
   +--(24 h sans livreur)-------------> cancelled, webhook order.cancelled avec reason "expired"
   +--(annulation avant prise en charge)--> cancelled

picked_up passe toujours par in_transit avant delivered.

Ne suppose jamais qu'une commande passe lineairement d'un statut au suivant sans latence : entre pending et assigned, plusieurs partenaires peuvent etre sollicites successivement (chacun a jusqu'a 5 minutes pour repondre). Les envois reserves par l'API (POST /parcels) ont leur propre cycle, voir section 3.7. Un order.assigned peut donc arriver bien apres order.received.


6. Checklist d'integration (a valider avant mise en prod)

Authentification & API

  • Integration testee de bout en bout avec une cle sk_test_ (routes sandbox/advance), puis cle sk_live_ en production

  • Evenements webhook "livemode": false ignores ou journalises a part en production

  • Cle API stockee en variable d'environnement / secret manager, jamais en dur

  • Gestion des erreurs 400/401/429/5xx sur POST /orders avec retry+backoff

  • 402 billing_suspended gere : message neutre a l'acheteur, alerte interne, pas de retry

  • Header Idempotency-Key envoye sur POST /orders (cle stable par tentative de creation, rejouee sur retry) - sinon dedupliquer cote client

Calcul des frais - POST /api/public/calculate

  • Appeler GET /api/public/zones au chargement du checkout pour savoir quels calculs sont disponibles (zones = mode de base, intracity/intercity non vides = ces calculs sont utilisables)
  • Calcul inter-villes (si intercity non vide) : envoyer from_city (ville depart) + city (ville arrivee) + weight_kg (poids en kg) - declenche automatiquement, pas de mode a choisir
    • Gerer le 404 no_tariff_match si aucun transporteur n'a de tranche couvrant ce poids (les colis de plus de 10 kg sont acceptes si une tranche existe)
    • Gerer le mode fourchette (intercity_range) : afficher min/max au client si plusieurs livreurs ont des tarifs differents
  • Calcul intra-ville (si intracity non vide) : envoyer city + from_zone (zone depart) + to_zone (zone arrivee) - declenche automatiquement, pas de mode a choisir
    • vehicle_type optionnel - envoyer si connu (MOTO, etc.)
    • Gerer le mode fourchette (intracity_range) : afficher min/max si plusieurs livreurs couvrent la zone
  • Sinon (pas de poids/zone precis) : n'envoyer que order_value+city(+neighborhood) - utilise le mode de base (pricing_mode) du marchand
  • 404 sur /calculate = zone/tarif non couvert - ne pas proposer la livraison pour ce client
  • 400 sur /calculate = champs requis manquants - adapter le formulaire cote marchand

Devis et reservation - /quotes et /parcels (si tu les utilises)

  • Devis cote backend, offres affichees sans score ni offer_id, cas offers vide gere
  • Reservation dans les 30 minutes du devis (410 quote_expired sinon : refaire un devis)
  • external_reference envoyee ; nouvelle reference pour reessayer apres payment_failed
  • Message "validez le paiement sur votre telephone" affiche apres 201
  • Statuts suivis par webhooks parcel.* (et GET /parcels/{id} en secours)
  • delivery_code montre uniquement au destinataire ; tracking_url partage au client

Webhook - verification de signature

  • Body brut lu UNE SEULE FOIS avant tout JSON.parse (erreur la plus frequente)
  • Timestamp t interprete en millisecondes (pas en secondes)
  • Hash calcule en hexadecimal (pas en base64)
  • Comparaison de signature a temps constant (timingSafeEqual ou equivalent)
  • Fenetre anti-rejeu de 5 minutes appliquee (300 000 ms)
  • Secret webhook stocke en variable d'environnement, copie depuis le dashboard

Webhook - fiabilite

  • Handler repond 200 en < 8s systematiquement
  • Traitement lourd deporte en arriere-plan (queue/worker)
  • Handler idempotent (dedupe sur order_id ou parcel.id + event, ou upsert)

Tests

  • Test via le bouton "Tester le webhook" du dashboard : doit retourner ok: true
  • Test de rejet avec signature falsifiee : doit retourner 401
  • Test de rejet avec signature perimee (> 5 min) : doit retourner 401

Monitoring

  • Alerte interne si un ordre reste pending trop longtemps (fallback humain)
  • Logs des webhooks recus et de leur traitement (pour debug)

7. Exemples de reference par langage

Ces exemples montrent le contrat exact. Adapte-les au framework cible plutot que de les copier-coller integralement.

⚠️ Point critique commun a tous les exemples : le middleware/framework ne doit pas parser le body JSON avant que ton handler webhook ne le lise. Consulte la doc de ton framework pour configurer le "raw body" sur cette route.

Node.js / TypeScript (fetch + Express)

ts
// Creation de commande
async function createShippingOrder(order: {
  external_order_id: string
  customer_name: string
  customer_phone: string
  delivery_address: string
  city: string
  order_value: number
  delivery_fee: number
}) {
  const res = await fetch('https://shipping.daba.cm/api/public/orders', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': process.env.DABA_SHIPPING_API_KEY!,
    },
    body: JSON.stringify(order),
  })
  if (!res.ok) {
    const err = await res.json().catch(() => ({}))
    throw new Error(`Daba Shipping error ${res.status}: ${err.error}`)
  }
  return res.json()
}

// Verification webhook (Express, avec express.raw() pour garder le corps brut)
import { createHmac, timingSafeEqual } from 'crypto'

function verifyWebhook(rawBody: Buffer, signatureHeader: string, secret: string): boolean {
  const match = signatureHeader.match(/^t=(\d+),v1=([a-f0-9]+)$/)
  if (!match) return false
  const [, tsStr, signature] = match
  const timestamp = Number(tsStr) // millisecondes !

  if (Math.abs(Date.now() - timestamp) > 5 * 60 * 1000) return false // anti-rejeu

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody.toString()}`)
    .digest('hex')

  const a = Buffer.from(signature)
  const b = Buffer.from(expected)
  return a.length === b.length && timingSafeEqual(a, b)
}

app.post('/webhooks/daba-shipping', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook(
    req.body,
    req.header('X-DabaShipping-Signature') ?? '',
    process.env.DABA_SHIPPING_WEBHOOK_SECRET!
  )
  if (!ok) return res.status(401).end()

  res.status(200).end() // accuse reception immediatement
  const payload = JSON.parse(req.body.toString())
  queue.add('daba-shipping-event', payload) // traitement asynchrone, idempotent
})

Python (requests + Flask)

python
import hmac, hashlib, os, time
import requests
from flask import Flask, request, abort

def create_shipping_order(order: dict) -> dict:
    res = requests.post(
        "https://shipping.daba.cm/api/public/orders",
        json=order,
        headers={"X-Api-Key": os.environ["DABA_SHIPPING_API_KEY"]},
        timeout=10,
    )
    res.raise_for_status()
    return res.json()

def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in signature_header.split(","))
        timestamp_ms = int(parts["t"])  # millisecondes !
        signature = parts["v1"]
    except (KeyError, ValueError):
        return False

    if abs(time.time() * 1000 - timestamp_ms) > 5 * 60 * 1000:
        return False  # anti-rejeu

    to_sign = f"{timestamp_ms}.".encode() + raw_body
    expected = hmac.new(secret.encode(), to_sign, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)

app = Flask(__name__)

@app.post("/webhooks/daba-shipping")
def daba_webhook():
    raw_body = request.get_data()
    sig = request.headers.get("X-DabaShipping-Signature", "")
    if not verify_webhook(raw_body, sig, os.environ["DABA_SHIPPING_WEBHOOK_SECRET"]):
        abort(401)

    payload = request.get_json()
    enqueue_event(payload)  # traitement asynchrone, idempotent
    return "", 200

Python (FastAPI)

python
import hmac, hashlib, time
from fastapi import FastAPI, Request, HTTPException, Header
from typing import Optional

app = FastAPI()
WEBHOOK_SECRET = "votre_webhook_secret"

def verify_signature(raw_body: bytes, sig_header: str) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in sig_header.split(","))
        ts_ms = int(parts["t"])   # millisecondes !
        received = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(int(time.time() * 1000) - ts_ms) > 5 * 60 * 1000:
        return False  # anti-rejeu
    to_sign = f"{ts_ms}.".encode() + raw_body
    expected = hmac.new(WEBHOOK_SECRET.encode(), to_sign, hashlib.sha256).hexdigest()
    return hmac.compare_digest(received, expected)

@app.post("/webhooks/daba-shipping")
async def daba_webhook(
    request: Request,
    x_dabashipping_signature: Optional[str] = Header(None)
):
    raw_body = await request.body()  # body brut - NE PAS appeler await request.json() avant
    if not x_dabashipping_signature or not verify_signature(raw_body, x_dabashipping_signature):
        raise HTTPException(status_code=401, detail="Invalid signature")

    # Accuse reception IMMEDIATEMENT
    # Puis traiter de maniere asynchrone (BackgroundTasks, Celery, etc.)
    payload = await request.json()
    ref = payload.get("order_id") or payload.get("parcel", {}).get("id")
    print(f"Evenement recu : {payload['event']} | id: {ref}")
    return {"ok": True}

C# (.NET)

csharp
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("webhooks")]
public class DabaShippingWebhookController : ControllerBase
{
    private const string WebhookSecret = "votre_webhook_secret";

    [HttpPost("daba-shipping")]
    public async Task<IActionResult> Receive()
    {
        // Lire le body BRUT avant tout
        using var reader = new StreamReader(Request.Body, Encoding.UTF8);
        var rawBody = await reader.ReadToEndAsync();
        var sigHeader = Request.Headers["X-DabaShipping-Signature"].ToString();

        if (!VerifySignature(rawBody, sigHeader))
            return Unauthorized(new { error = "Invalid signature" });

        // Repondre 200 immediatement - traitement lourd en arriere-plan
        var payload = System.Text.Json.JsonSerializer.Deserialize<JsonElement>(rawBody);
        _ = Task.Run(() => ProcessEventAsync(payload)); // fire-and-forget idempotent
        return Ok(new { ok = true });
    }

    private static bool VerifySignature(string rawBody, string sigHeader)
    {
        var parts = sigHeader.Split(',')
            .Select(p => p.Split('=', 2))
            .Where(p => p.Length == 2)
            .ToDictionary(p => p[0], p => p[1]);

        if (!parts.TryGetValue("t", out var ts) || !parts.TryGetValue("v1", out var received))
            return false;

        // Anti-rejeu (t est en millisecondes)
        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - long.Parse(ts)) > 300_000)
            return false;

        var toSign = $"{ts}.{rawBody}";
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(WebhookSecret));
        var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(toSign))).ToLowerInvariant();

        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(received),
            Encoding.UTF8.GetBytes(expected)
        );
    }

    private static async Task ProcessEventAsync(JsonElement payload)
    {
        // Traitement idempotent ici (UPSERT sur order_id + event)
        await Task.CompletedTask;
    }
}

PHP (cURL + endpoint natif)

php
<?php
function createShippingOrder(array $order): array {
    $ch = curl_init('https://shipping.daba.cm/api/public/orders');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'X-Api-Key: ' . getenv('DABA_SHIPPING_API_KEY'),
        ],
        CURLOPT_POSTFIELDS => json_encode($order),
    ]);
    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new RuntimeException("Daba Shipping error {$status}: {$response}");
    }
    return json_decode($response, true);
}

function verifyWebhook(string $rawBody, string $signatureHeader, string $secret): bool {
    if (!preg_match('/^t=(\d+),v1=([a-f0-9]+)$/', $signatureHeader, $m)) {
        return false;
    }
    $timestampMs = (int) $m[1]; // millisecondes !
    $signature = $m[2];

    if (abs((microtime(true) * 1000) - $timestampMs) > 5 * 60 * 1000) {
        return false; // anti-rejeu
    }

    $expected = hash_hmac('sha256', "{$timestampMs}.{$rawBody}", $secret);
    return hash_equals($expected, $signature);
}

// Endpoint webhook (ex. routes/web.php avec un controller natif)
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_DABASHIPPING_SIGNATURE'] ?? '';
if (!verifyWebhook($rawBody, $signature, getenv('DABA_SHIPPING_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}
http_response_code(200);
$payload = json_decode($rawBody, true);
// Deporter le traitement (queue) plutot que de le faire ici en synchrone

cURL (test manuel rapide)

bash
curl -X POST https://shipping.daba.cm/api/public/orders \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $DABA_SHIPPING_API_KEY" \
  -d '{
    "external_order_id": "EXT-001",
    "customer_name": "Jean Dupont",
    "customer_phone": "677000000",
    "delivery_address": "Rue de la Joie, Bonapriso",
    "city": "Douala",
    "neighborhood": "Bonapriso",
    "order_value": 25000,
    "delivery_fee": 1500
  }'

8. Exemple de test unitaire pour la verification de signature

Avant de deployer, valide ta fonction de verification avec ce cas de test connu :

js
// Cas de test Node.js
const { createHmac } = require('crypto')

const secret = 'mon_webhook_secret_de_test'
const timestamp = 1724756400000 // ms fixes pour le test
const body = '{"event":"test","order_id":"test-00000000"}'

const toSign = `${timestamp}.${body}`
const signature = createHmac('sha256', secret).update(toSign).digest('hex')
const header = `t=${timestamp},v1=${signature}`

// Appelle ta fonction verifyWebhook(Buffer.from(body), header, secret)
// Elle doit retourner true.
// Modifie un caractere dans le body ou la signature -> doit retourner false.

Fin du prompt. Demande a ton assistant IA de generer le code pour ton langage/framework exact en respectant strictement les sections 3, 4 et 6 ci-dessus, puis de fournir des tests qui verifient : (a) la creation de commande avec cle valide/invalide, (b) la verification de signature avec un payload de test connu (section 8), (c) le rejet d'une signature perimee (> 5 min) ou falsifiee, (d) le rejet si le body a ete re-serialise.

Mis a jour le 2026-09-16 - Ajout section troubleshooting 4.6, exemples FastAPI et C#, section 8 test unitaire, checklist enrichie. Ajout des modes intercity (tarif inter-villes base sur le poids : weight_kg + from_city) et intracity (tarif intra-ville par zone : from_zone + to_zone + vehicle_type optionnel) dans l'endpoint POST /api/public/calculate (section 3.2).

Mis a jour le 2026-10-16 :

  • Nouveaux endpoints POST /api/public/quotes (section 3.6) et POST /api/public/parcels (section 3.7), webhooks parcel.*, suivi en direct.
  • Recommandations d'affichage pour les devis et les envois (3.6, 3.7).
  • Corrections : prix renvoye par /calculate (partenaire prioritaire puis fourchette), delivery_fee = remuneration du livreur, pricing_mode toujours fixed/from_partner/range, 409 et non-rejeu des 5xx pour Idempotency-Key, cycle de vie (publication automatique, expiration 24 h), emplacements exacts du dashboard, attributs du widget.

Mis a jour le 2026-10-02 :

  • from_city+weight_kg et from_zone+to_zone declenchent desormais le calcul inter-villes/intra-ville automatiquement selon les champs envoyes, independamment du mode de base du marchand (section 3.2) - les trois types de calcul peuvent coexister sur une meme boutique.
  • Champ reason machine-lisible sur les erreurs 404 de /calculate, avec table de correspondance vers un message acheteur (section 3.2).
  • Header Idempotency-Key sur POST /orders pour securiser les retries (section 3.1).
  • Nouveau webhook shop.pricing_mode_changed (section 4.1/4.2).
  • Avertissement securite sur l'usage de X-Api-Key depuis le navigateur (section 1), documentation du widget embarquable (section 3.5) et flow pas-a-pas pour construire le formulaire checkout (section 3.3).