Démarrer
- Contactez l'équipe Dalishap pour ouvrir un compte partenaire. Nous fixons ensemble votre grille tarifaire et l'URL où vous recevrez les webhooks.
- Vous recevez votre identifiant partenaire, une clé API (
dlp_test_…pour commencer), un secret de signature pour vos appels et un secret de webhookpour vérifier les nôtres. Ces secrets ne sont transmis qu'une seule fois : conservez-les chiffrés. - Intégrez et testez avec la clé de test (aucun vrai coursier n'est sollicité).
- Demandez votre clé
dlp_live_…pour passer en production.
URL de base : https://dalishap-api.silam.ci/api/v1/partner
Principes
| Sujet | Règle |
|---|---|
| Transport | HTTPS uniquement, JSON UTF-8 |
| Montants | Entiers en FCFA (champs *Fcfa) |
| Dates | ISO 8601 UTC (2026-11-14T12:30:00Z) |
| Coordonnées | WGS 84, degrés décimaux (latitude, longitude) |
| Téléphones | Format international conseillé (+2250700000001) ; accepté : 8 à 15 chiffres, + facultatif. Ceux que Dalishap renvoie sont toujours au format +225… |
| Identifiants | Dalishap renvoie ses propres id (UUID) ; vous passez votre référence dans externalRef |
| Versions | Préfixe /v1. Ajout de champs = compatible ; retrait ou renommage = nouvelle version |
| Quota | 60 requêtes par minute et par clé |
Authentification
Chaque requête porte votre clé et une signature HMAC du corps, horodatée :
Authorization: Bearer dlp_test_xxxxxxxx
Dalishap-Timestamp: 1763123400 # secondes Unix
Dalishap-Signature: v1=<hex> # HMAC-SHA256(signingSecret, timestamp + "." + corps brut)
Idempotency-Key: <uuid> # obligatoire sur tous les POST- Pour un
GETou unPOSTsans corps, on signetimestamp + "."(corps vide). - Signez exactement les octets envoyés : sérialisez le JSON une fois, signez-le, envoyez-le tel quel.
- L'horodatage doit être à moins de 300 s de l'heure du serveur.
import crypto from 'node:crypto';
const BASE = 'https://dalishap-api.silam.ci/api/v1/partner';
const API_KEY = process.env.DALISHAP_API_KEY; // dlp_live_… ou dlp_test_…
const SIGNING_SECRET = process.env.DALISHAP_SIGNING_SECRET;
/**
* idempotencyKey : une clé par action métier (ex. `delivery-${orderId}`),
* conservée pour réessayer la même action. Absente : une clé neuve par appel.
*/
export async function dalishap(method, path, body, idempotencyKey) {
const raw = body === undefined ? '' : JSON.stringify(body);
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(`${timestamp}.${raw}`)
.digest('hex');
const headers = {
Authorization: `Bearer ${API_KEY}`,
'Dalishap-Timestamp': timestamp,
'Dalishap-Signature': `v1=${signature}`,
};
if (raw) headers['Content-Type'] = 'application/json';
if (method === 'POST') headers['Idempotency-Key'] = idempotencyKey ?? crypto.randomUUID();
const res = await fetch(BASE + path, { method, headers, body: raw || undefined });
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error?.message), data.error);
return data;
}
// Exemples
const communes = await dalishap('GET', '/communes');
// Créer la livraison d'une commande : la clé dépend de la commande, pas de l'appel.
const key = `delivery-${order.id}-${quote.id}`;
let delivery;
try {
delivery = await dalishap('POST', '/deliveries', payload, key);
} catch (err) {
if (err.code && err.code !== 'INTERNAL') throw err; // erreur métier : ne pas réessayer
// Timeout, réseau ou 5xx : même clé, même corps → réponse rejouée, jamais de 2e livraison.
delivery = await dalishap('POST', '/deliveries', payload, key);
}TS=$(date +%s)
BODY='{"pickup":{"externalRef":"outlet_42","name":"Chez Tantie","phone":"+2250700000001","latitude":5.3712,"longitude":-3.9748,"address":"Riviera 2"},"dropoff":{"latitude":5.3540,"longitude":-3.9921}}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$DALISHAP_SIGNING_SECRET" | sed 's/^.* //')
curl -X POST https://dalishap-api.silam.ci/api/v1/partner/quotes \
-H "Authorization: Bearer $DALISHAP_API_KEY" \
-H "Dalishap-Timestamp: $TS" \
-H "Dalishap-Signature: v1=$SIG" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data "$BODY"Idempotence. Une réponse réussie est mémorisée 24 h par Idempotency-Key: renvoyer la même clé rejoue la même réponse sans refaire l'action (idéal après un timeout). La même clé avec un corps différent donne 409 IDEMPOTENCY_MISMATCH. Les erreurs ne sont pas mémorisées.
POST /deliveries avec une clé neuve ne rejoue pas la réponse : vous recevrez 409 QUOTE_USED ou 409 DELIVERY_ALREADY_ACTIVE (la livraison existe déjà, son id est dans error.details.deliveryId pour ce dernier).Endpoints
| Méthode | Route | Rôle |
|---|---|---|
| POST | /quotes | Devis d’une livraison |
| POST | /deliveries | Créer la livraison à partir d’un devis |
| GET | /deliveries/{id} | État, coursier, position |
| POST | /deliveries/{id}/pickup-confirmed | Le point de retrait a validé le code |
| POST | /deliveries/{id}/cancel | Annuler |
| POST | /deliveries/{id}/unlock-delivery-code | Débloquer le code de livraison |
| GET | /communes | Communes desservies |
Devis — POST /quotes
{
"pickup": {
"externalRef": "outlet_7f3c", // id stable du point de retrait chez vous
"name": "Chez Tantie — Riviera 2",
"phone": "+2250700000001",
"latitude": 5.3712,
"longitude": -3.9748,
"address": "Rue des Jardins, Riviera 2",
"landmark": "Face pharmacie Saint-Jean", // facultatif
"communeSlug": "cocody" // facultatif, sinon déduit des coordonnées
},
"dropoff": { "latitude": 5.3540, "longitude": -3.9921 }
}{
"id": "9d781392-1f65-…",
"priceFcfa": 1500,
"distanceKm": 4.2,
"etaMinutes": 32,
"pickupEtaMinutes": 12,
"expiresAt": "2026-11-14T12:40:00Z",
"pickupCommune": "cocody",
"dropoffCommune": "cocody",
"availability": "HIGH" // HIGH | LOW | NONE
}- Le prix vient de votre grille (tranches de distance, majoration inter-communes) et ne dépend pas du coursier.
- Le devis est garanti jusqu'à
expiresAt(10 min par défaut). availability: "NONE": aucun coursier disponible à proximité. Le devis reste valable, à vous de décider (attendre, ou passer par un autre moyen).- Le point de retrait n'a pas à être déclaré à l'avance : il est créé ou mis à jour à partir de son
externalRef. - Erreurs :
422 OUT_OF_ZONE(commune non desservie),422 DISTANCE_TOO_LONG.
Créer une livraison — POST /deliveries
À appeler une fois la commande payée et confirmée par le point de retrait.
{
"quoteId": "9d781392-1f65-…",
"externalRef": "ord_91c0", // id de commande chez vous, unique
"externalNumber": "CMD-10452", // affiché au coursier
"pickup": { …même point de retrait que le devis… },
"dropoff": {
"latitude": 5.3540,
"longitude": -3.9921,
"address": "Immeuble Les Palmiers, 3e étage",
"landmark": "Derrière la station Total",
"recipientName": "Awa K.",
"recipientPhone": "+2250501020304"
},
"parcel": {
"description": "2 paniers repas",
"itemsCount": 2,
"requiresInsulatedBag": true,
"valueFcfa": 4000
},
"pickupCode": "482913", // 4 à 8 chiffres : montré par le coursier au point de retrait
"deliveryCode": "7204", // 4 à 8 chiffres : donné par le client au coursier
"pickupQrToken": "Zk3…", // facultatif (32 à 64 car. base64url)
"deliveryQrToken": "p9X…", // facultatif
"deliveryCodeMaxAttempts": 5,
"readyAt": "2026-11-14T12:35:00Z", // facultatif
"notes": "Sonner deux fois"
}{
"id": "5a1d0c3e-77b2-…",
"externalRef": "ord_91c0",
"externalNumber": "CMD-10452",
"status": "SEARCHING",
"priceFcfa": 1500,
"searchEndsAt": "2026-11-14T12:50:00Z",
"courier": null,
"location": null,
"etaMinutes": null,
"deliveryCodeAttemptsLeft": 5,
"timestamps": { "createdAt": "…", "assignedAt": null, "pickedUpAt": null, "deliveredAt": null, "cancelledAt": null },
"cancellation": null,
"proof": null
}- Le prix est celui du devis. Devis expiré :
409 QUOTE_EXPIRED; déjà utilisé :409 QUOTE_USED; autre point de retrait ou livraison à plus de 500 m du devis :409 QUOTE_MISMATCH. - Une seule livraison active par
externalRef(409 DELIVERY_ALREADY_ACTIVE). AprèsEXPIREDouCANCELLED, relancez avec un nouveau devis. pickupCodeetdeliveryCode: 4 à 8 chiffres chacun (nous conseillons 6 pour le retrait, 4 pour le client). Générez-les aléatoirement pour chaque livraison.- Les codes sont chiffrés chez nous et ne sont jamais renvoyés, ni par l'API ni par les webhooks.
Suivre une livraison — GET /deliveries/{id}
{
"id": "5a1d0c3e-77b2-…",
"externalRef": "ord_91c0",
"externalNumber": "CMD-10452",
"status": "PICKED_UP",
"priceFcfa": 1500,
"searchEndsAt": null,
"deliveryCodeAttemptsLeft": 5,
"courier": {
"id": "33e9f4a1-…",
"firstName": "Koffi",
"phone": "+2250700000099",
"photoUrl": "https://…",
"vehicle": null,
"ratingAvg": 4.7
},
"location": { "latitude": 5.3601, "longitude": -3.9850, "at": "2026-11-14T12:52:10Z" },
"etaMinutes": 9,
"timestamps": { "createdAt": "…", "assignedAt": "…", "pickedUpAt": "…", "deliveredAt": null, "cancelledAt": null },
"cancellation": null,
"proof": null
}courier et location valent nulltant qu'aucun coursier n'a accepté. La position est fournie de COURIER_ASSIGNED à PICKED_UP; le téléphone du coursier jusqu'à 1 h après la livraison. En cas de doute sur un webhook, cette route fait foi.
courier.phone est au format international (+2250700000099), null hors de cette période. photoUrl, vehicle et ratingAvg peuvent valoir null (coursier sans photo ni note) : ne les rendez pas obligatoires chez vous.
cancellation est rempli quand la livraison est CANCELLED ou FAILED (alors by: "COURIER" et reason= motif d'échec) :
"cancellation": {
"by": "PARTNER", // PARTNER | COURIER | DALISHAP
"reason": "RESTAURANT_CANCELLED", // motif d'annulation, ou motif d'échec si FAILED
"note": "Rupture après confirmation",
"feeFcfa": 0
}proof.signatureUrl vaut pour l'instant toujours null : la preuve de remise est conservée chez Dalishap.
Retrait et livraison
Au retrait, le coursier présente le pickupCode (ou le QR pickup:<pickupQrToken>). Le point de retrait le saisit chez vous ; vous le vérifiez, puis vous appelez :
POST /deliveries/{id}/pickup-confirmed
{ "confirmedAt": "2026-11-14T12:47:00Z" }Réponse : la livraison au statut PICKED_UP. 409 INVALID_STATEsi aucun coursier n'est assigné ; un second appel renvoie simplement la livraison.
À la livraison, le client donne son deliveryCode au coursier (ou montre le QR delivery:<deliveryQrToken>), que Dalishap vérifie. Au-delà de deliveryCodeMaxAttempts essais faux, le code est bloqué (webhook delivery.code_locked) ; après vérification de l'identité du client par votre support :
POST /deliveries/{id}/unlock-delivery-code (corps vide)Annuler — POST /deliveries/{id}/cancel
{ "reason": "RESTAURANT_CANCELLED", "note": "Rupture après confirmation" }Motifs : CUSTOMER_CANCELLED, RESTAURANT_CANCELLED, PAYMENT_ISSUE, OTHER.
| Statut au moment de l’annulation | Effet | Frais |
|---|---|---|
SEARCHING | Annulée | 0 |
COURIER_ASSIGNED | Annulée, coursier prévenu | Forfait de déplacement |
PICKED_UP | Refusée : 409 CANNOT_CANCEL, contactez le support | — |
Cycle de vie
SEARCHING ──► COURIER_ASSIGNED ──► PICKED_UP ──► DELIVERED
│ │ │
├─► EXPIRED ├─► SEARCHING * └─► FAILED
└─► CANCELLED └─► CANCELLED
* le coursier s'est désisté avant le retrait : nouvelle recherche, même prix| Statut | Sens |
|---|---|
SEARCHING | Proposée aux coursiers |
COURIER_ASSIGNED | Un coursier a accepté et se rend au point de retrait |
PICKED_UP | Colis remis au coursier (code de retrait validé) |
DELIVERED | Remis au client (code de livraison validé) |
FAILED | Livraison impossible après retrait (client injoignable, refus…) |
CANCELLED | Annulée avant retrait |
EXPIRED | Aucun coursier trouvé avant searchEndsAt |
Webhooks
Dalishap appelle votre URL en POST à chaque étape, signé avec votre secret de webhook (même schéma que vos appels) et l'en-tête Dalishap-Event-Id.
{
"id": "8c2f6b0d-…", // = en-tête Dalishap-Event-Id
"type": "delivery.courier_assigned",
"createdAt": "2026-11-14T12:41:03Z",
"data": {
"deliveryId": "5a1d0c3e-77b2-…",
"externalRef": "ord_91c0",
"status": "COURIER_ASSIGNED",
"sequence": 3,
"…": "champs propres à l'événement"
}
}| type | Champs de data en plus |
|---|---|
delivery.courier_assigned | courier, etaPickupMinutes, location, etaMinutes |
delivery.courier_unassigned | reason |
delivery.courier_location | location, etaMinutes — au plus toutes les 30 s |
delivery.picked_up | pickedUpAt |
delivery.delivered | deliveredAt, deliveryCodeVerified, proof |
delivery.code_locked | attempts |
delivery.failed | reason (CLIENT_UNREACHABLE, CLIENT_REFUSED, ADDRESS_WRONG, VEHICLE_ISSUE, FORCE_MAJEURE, OTHER), note, returnToPickup (booléen, false pour l'instant) |
delivery.cancelled | cancelledBy (PARTNER | DALISHAP), reason, note, feeFcfa |
delivery.expired | — |
- Répondez
2xxen moins de 5 s. Sinon (redirections comprises), nouvel essai après 30 s, 2 min, 10 min, 30 min, 1 h, 3 h. - Livraison au moins une fois, ordre non garanti : dédoublonnez sur
id, ordonnez surdata.sequence. delivery.courier_locationn'est jamais rejoué : seule la dernière position compte.- Tant qu'aucune URL de webhook n'est enregistrée pour votre compte, les événements ne sont pas envoyés, et ils ne le seront pas plus tard : suivez alors vos livraisons avec
GET /deliveries/{id}.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Corps BRUT : la signature porte sur les octets reçus, pas sur le JSON reparsé.
app.post('/webhooks/dalishap', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('Dalishap-Timestamp') ?? '';
const header = req.get('Dalishap-Signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.DALISHAP_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
// Une seule valeur v1= aujourd'hui ; découper sur « , » reste compatible si d'autres s'ajoutent.
const valid = header.split(',').some((part) => {
const sig = part.trim().replace(/^v1=/, '');
return sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
if (!fresh || !valid) return res.sendStatus(401);
const event = JSON.parse(req.body);
// 1. Dédoublonner sur event.id (livraison « au moins une fois »).
// 2. Ignorer un event.data.sequence inférieur au dernier traité pour cette livraison.
// 3. Répondre vite (< 5 s) ; traiter en arrière-plan si besoin.
res.sendStatus(200);
});Erreurs
{
"error": {
"code": "QUOTE_EXPIRED",
"message": "Le devis a expiré, demandez-en un nouveau.",
"requestId": "req_…"
}
}| HTTP | code | Cas |
|---|---|---|
| 400 | VALIDATION_ERROR | Champ manquant ou invalide (details liste les champs), ou {id} qui n'est pas un UUID |
| 401 | UNAUTHENTICATED | Clé absente, inconnue ou révoquée |
| 401 | INVALID_SIGNATURE | Signature fausse ou horodatage hors tolérance |
| 403 | PARTNER_SUSPENDED | Compte partenaire suspendu |
| 404 | NOT_FOUND | Livraison ou devis inconnu (ou créé avec l’autre type de clé, test ou live) |
| 409 | QUOTE_EXPIRED QUOTE_USED QUOTE_MISMATCH DELIVERY_ALREADY_ACTIVE INVALID_STATE CANNOT_CANCEL IDEMPOTENCY_MISMATCH | Conflit d’état |
| 422 | OUT_OF_ZONE DISTANCE_TOO_LONG | Livraison non desservie |
| 429 | RATE_LIMITED | Quota dépassé (en-tête Retry-After) |
| 5xx | INTERNAL | Réessayez avec le même Idempotency-Key |
Mode test
Avec une clé dlp_test_…, les routes sont identiques mais les livraisons sont marquées test : elles ne sont jamais proposées aux vrais coursiers, et une clé de test ne voit pas les livraisons réelles (et inversement).
Pour tester vos webhooks de bout en bout, faites avancer une livraison de test à la main :
POST /deliveries/{id}/simulate
{ "event": "courier_assigned" } // courier_location | picked_up | delivered | failed | expiredUn « Coursier Test » fictif est assigné. Cette route est refusée avec une clé live.
- Une livraison de test reste en
SEARCHING15 minutes (searchEndsAt) : sanscourier_assignedsimulé avant, elle passeEXPIREDcomme une vraie livraison. Créez-en une nouvelle avec un nouveau devis. - Le « Coursier Test » a un faux numéro fixe par compte, au format
+22500XXXXXXXX(préfixe00attribué à aucun opérateur : ne l'appelez pas), sans photo ni note (photoUrletratingAvgànull). - En test, les devis renvoient toujours
availability: "HIGH"etpickupEtaMinutes: 10. Les prix suivent votre vraie grille. - Simulations possibles selon le statut :
courier_assignedetexpireddepuisSEARCHING;picked_updepuisCOURIER_ASSIGNED;deliveredetfaileddepuisPICKED_UP;courier_locationquand un coursier est en route. Sinon :409 INVALID_STATE.
Mise en production
- Signature vérifiée sur le corps brut de chaque webhook, horodatage contrôlé
- Webhooks dédoublonnés sur id et ordonnés sur sequence
- Idempotency-Key conservé et réutilisé lors des réessais
- Codes de retrait et de livraison générés aléatoirement, jamais réutilisés
- Cas EXPIRED, FAILED et availability NONE gérés côté client
- Clé live et secrets stockés chiffrés, hors du code source
