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 :
- Ton systeme cree une commande de livraison via
POST /api/public/orders. - Daba Shipping assigne un livreur partenaire et t'envoie des webhooks signes
a chaque changement de statut (
order.assigned,order.picked_up,order.delivered, ...). - 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 webhooksparcel.*. - 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 :
X-Api-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxLa 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 :
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 :
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 :
| Route | Comportement en test |
|---|---|
POST /orders | Commande creee avec "livemode": false, jamais proposee a un livreur, jamais annulee automatiquement |
POST /calculate, GET /zones, POST /quotes | Identiques (lecture des vrais tarifs) |
POST /parcels | Paiement simule et accepte aussitot. Le numero 690000001 simule un refus de l'operateur (502 payment_failed) |
POST /parcels/{id}/cancel | Remboursement simule |
| Webhooks | Vrais 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) :
# 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) :
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
external_order_id | string | Non | Ton propre ID de commande. Fortement recommande pour la correlation. |
customer_name | string | Oui | Nom du client final |
customer_phone | string | Oui | Telephone du client (format local, ex. 6XXXXXXXX) |
customer_email | string | Non | Email du client (declenche un email de confirmation si fourni) |
delivery_address | string | Oui | Adresse complete de livraison |
city | string | Oui | Ville (doit correspondre a une zone couverte par un partenaire) |
neighborhood | string | Non | Quartier, ameliore le matching de zone |
order_value | number | Oui | Valeur de la commande en XAF |
delivery_fee | number | Oui | Frais 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) |
items | array | Non | Articles a livrer - affiches au livreur/entreprise avant qu'il accepte la mission. Voir forme ci-dessous. |
Forme d'un element de items :
{
"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 :
{
"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) :
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
order_value | number | Oui | Valeur de la commande en XAF (> 0, sinon 400) |
city | string | Oui | Ville de livraison (ville d'arrivee pour un calcul inter-villes) |
neighborhood | string | Non | Quartier - affine le matching de zone (mode standard uniquement) |
from_city | string | Pour un calcul inter-villes | Ville d'expedition (depart du colis) |
weight_kg | number | Pour un calcul inter-villes | Poids 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_zone | string | Pour un calcul intra-ville | Zone de depart (quartier/secteur de retrait chez le marchand) |
to_zone | string | Pour un calcul intra-ville | Zone d'arrivee (quartier/secteur de livraison au client) |
vehicle_type | string | Non | Type 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 :
// 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 :
// 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) :
reason | Signification |
|---|---|
no_active_partner | Le marchand n'a aucun livreur/entreprise partenaire actif |
zone_not_covered | Mode from_partner/range : aucun partenaire ne couvre city/neighborhood |
no_tier_match_for_value | Mode from_partner/range : zone couverte mais order_value hors de toutes les tranches configurees |
no_tariff_match | Calcul inter-villes/intra-ville : aucun tarif ne correspond au trajet/zone demande |
{ "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) :
reason | Message 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) :
{
"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, toujoursfixed,from_partnerourange, utilise seulement quand tu n'envoies ni poids ni zone a/calculate. N'indique PAS si inter-villes/intra-ville sont disponibles - regardeintercity/intracitypour 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 (envoiefrom_zone+to_zonea/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 (envoiefrom_city+weight_kga/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) :
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}
{
"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 :
<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 /orderssur 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/commandespour 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) :
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
from_city | string | Oui | Ville de depart |
from_neighborhood | string | Non | Quartier de depart - conseille dans une meme ville (prix par quartier) |
to_city | string | Oui | Ville d'arrivee |
to_neighborhood | string | Non | Quartier d'arrivee |
weight_kg | number | Oui | Poids en kg (> 0, sans limite a 10 kg) |
size | string | Non | S, M, L ou XL (defaut M). L/XL excluent les deux-roues |
category | string | Non | documents, electronique, vetements, alimentaire, cosmetiques, electromenager, meubles, pieces_auto, liquides, autre (defaut) |
condition | string | Non | Etat du contenu, sert a l'assurance : new (defaut), used, worn, damaged |
declared_value | number | Non | Valeur du contenu en XAF (calcul de l'assurance) |
is_fragile | boolean | Non | Exclut les transporteurs qui refusent le fragile |
express | boolean | Non | Seulement les transporteurs qui proposent l'express (supplement inclus) |
cash_on_delivery | boolean | Non | Seulement ceux qui encaissent a la livraison (frais inclus) |
sort | string | Non | recommended (defaut : prix 50 %, note 20 %, experience 15 %, compte verifie 15 %) ou cheapest |
limit | number | Non | Nombre d'offres renvoyees, 1 a 10 (defaut 5) |
partners_only | boolean | Non | true = uniquement tes livreurs/entreprises partenaires actifs |
Reponse 201 :
{
"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.totalest 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.offersvide = 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.
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) :
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
quote_id | string | Oui | Devis cree par TA cle (valable 30 minutes) |
offer_id | string | Oui | Offre choisie dans offers |
external_reference | string | Recommande | Ton identifiant de commande (100 car. max). Idempotent : renvoyer la meme valeur renvoie l'envoi deja cree, sans nouveau paiement |
pickup.address | string | Oui | Adresse precise du retrait |
pickup.landmark | string | Non | Point de repere |
pickup.contact_name | string | Oui | Personne qui remet le colis |
pickup.contact_phone | string | Oui | Telephone camerounais (9 chiffres, commence par 6) |
recipient.name | string | Oui | Destinataire |
recipient.phone | string | Oui | Recoit par SMS le code de remise et le lien de suivi |
recipient.address | string | Oui | Adresse precise de livraison |
recipient.landmark | string | Non | Point de repere |
payment.phone | string | Oui | Numero Mobile Money debite (client final ou marchand) |
payment.operator | string | Non | mtn_momo (defaut) ou orange_money |
cod_amount | number | Si encaissement | Montant encaisse aupres du destinataire, si le devis a ete fait avec cash_on_delivery: true. Reverse sur payment.phone apres la livraison |
description | string | Non | Contenu et consignes (500 car. max) |
Reponse 201 (200 + "idempotent": true si la reference existe deja) :
{
"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) :
| Statut | Signification |
|---|---|
awaiting_payment | Demande de paiement envoyee, a valider sur le telephone (15 min) |
payment_failed | Paiement refuse ou non valide a temps |
searching_carrier | Paye ; le livreur a 15 min pour accepter, sinon l'offre suivante (jamais plus chere) |
accepted | Livreur confirme : carrier (nom, telephone) et delivery_code sont remplis |
picked_up | Colis recupere (photo), en route |
delivered | Remis contre le code du destinataire (photo) |
failed | Livraison echouee : argent bloque, decision par litige |
no_carrier | Aucun livreur : remboursement automatique |
cancelled | Annule : 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),nullsinon. 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 (409not_cancellablesinon), 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 :
| Statut | Message 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.
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
testest 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). SonstatusdansGET /trackingrestepending.
order.cancelledpeut contenir"reason": "expired": commande annulee automatiquement apres 24 h sans livreur.
shop.pricing_mode_changedest emis des que tu changes de mode tarifaire dans ton dashboard (/dashboard/parametres). Utile si tu mets en cache la reponse deGET /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
{
"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) :
{
"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) :
{
"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 :
X-DabaShipping-Signature: t=1752400000000,v1=<hex hmac-sha256>
X-DabaShipping-Event: order.deliveredAlgorithme exact (a reproduire au caractere pres) :
- Extrais
t(timestamp) etv1(signature) du headerX-DabaShipping-Signature. ⚠️test 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. - 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). - Calcule
HMAC-SHA256(secret, chaine_signee)encode en hexadecimal (pas en base64). C'est une autre source d'erreur frequente. - Compare ce hash a
v1avec une comparaison a temps constant (crypto.timingSafeEqual/ equivalent) - jamais===ou==. - Rejette si
Date.now() - t > 5 minutes(fenetre anti-rejeu). Adapte le timestamp courant en millisecondes pour cette comparaison, coherent avect.
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
2xxou 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(ouparcel.id) +event, ou simplement rends chaque traitement idempotent (ex.UPSERTplutot queINSERT). - 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 :
| Symptome | Cause probable | Solution |
|---|---|---|
| Signature toujours invalide | Tu lis req.json() au lieu du body brut | Lire le body brut AVANT tout parsing (voir exemples section 7) |
| Signature invalide en prod uniquement | Framework (ex. Express sans express.raw()) parse automatiquement le body | Configurer le middleware pour passer le body brut a ton handler |
| Signature invalide | Tu utilises base64 au lieu de hex | digest('hex') pas digest('base64') |
| Signature invalide | Tu interpretes t en secondes | t est en millisecondes, pas en secondes |
| Toujours 401 meme avec bon secret | Le webhook_secret en env var ne correspond pas a celui du dashboard | Copier-coller le secret depuis le dashboard, pas le creer manuellement |
| 401 sur test mais pas en prod | Fenetre anti-rejeu trop courte | La 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
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)--> cancelledpicked_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 clesk_live_en production -
Evenements webhook
"livemode": falseignores ou journalises a part en production -
Cle API stockee en variable d'environnement / secret manager, jamais en dur
-
Gestion des erreurs
400/401/429/5xxsurPOST /ordersavec retry+backoff -
402billing_suspendedgere : message neutre a l'acheteur, alerte interne, pas de retry -
Header
Idempotency-Keyenvoye surPOST /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/zonesau chargement du checkout pour savoir quels calculs sont disponibles (zones= mode de base,intracity/intercitynon vides = ces calculs sont utilisables) - Calcul inter-villes (si
intercitynon vide) : envoyerfrom_city(ville depart) +city(ville arrivee) +weight_kg(poids en kg) - declenche automatiquement, pas de mode a choisir- Gerer le
404no_tariff_matchsi 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
- Gerer le
- Calcul intra-ville (si
intracitynon vide) : envoyercity+from_zone(zone depart) +to_zone(zone arrivee) - declenche automatiquement, pas de mode a choisir-
vehicle_typeoptionnel - 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 -
404sur/calculate= zone/tarif non couvert - ne pas proposer la livraison pour ce client -
400sur/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
scorenioffer_id, casoffersvide gere - Reservation dans les 30 minutes du devis (
410quote_expiredsinon : refaire un devis) -
external_referenceenvoyee ; nouvelle reference pour reessayer aprespayment_failed - Message "validez le paiement sur votre telephone" affiche apres
201 - Statuts suivis par webhooks
parcel.*(etGET /parcels/{id}en secours) -
delivery_codemontre uniquement au destinataire ;tracking_urlpartage au client
Webhook - verification de signature
- Body brut lu UNE SEULE FOIS avant tout JSON.parse (erreur la plus frequente)
- Timestamp
tinterprete en millisecondes (pas en secondes) - Hash calcule en hexadecimal (pas en base64)
- Comparaison de signature a temps constant (
timingSafeEqualou 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
200en < 8s systematiquement - Traitement lourd deporte en arriere-plan (queue/worker)
- Handler idempotent (dedupe sur
order_idouparcel.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
pendingtrop 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)
// 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)
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 "", 200Python (FastAPI)
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)
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
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 synchronecURL (test manuel rapide)
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 :
// 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) etintracity(tarif intra-ville par zone :from_zone+to_zone+vehicle_typeoptionnel) dans l'endpointPOST /api/public/calculate(section 3.2).Mis a jour le 2026-10-16 :
- Nouveaux endpoints
POST /api/public/quotes(section 3.6) etPOST /api/public/parcels(section 3.7), webhooksparcel.*, 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_modetoujoursfixed/from_partner/range,409et non-rejeu des5xxpourIdempotency-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_kgetfrom_zone+to_zonedeclenchent 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
reasonmachine-lisible sur les erreurs 404 de/calculate, avec table de correspondance vers un message acheteur (section 3.2).- Header
Idempotency-KeysurPOST /orderspour 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-Keydepuis le navigateur (section 1), documentation du widget embarquable (section 3.5) et flow pas-a-pas pour construire le formulaire checkout (section 3.3).