Développeurs · API v1

Confiez vos livraisons à Dalishap

Plateformes, applications de commande, marketplaces : l'API partenaires vous donne un devis instantané, trouve un coursier à Abidjan, et vous tient informé en temps réel jusqu'à la remise au client.

Devis garanti
Prix fixe selon la distance, valable 10 min
Remise sécurisée
Code de retrait et code de livraison
Temps réel
Webhooks signés à chaque étape
Mode test
Simulez une livraison de bout en bout

Démarrer

  1. Contactez l'équipe Dalishap pour ouvrir un compte partenaire. Nous fixons ensemble votre grille tarifaire et l'URL où vous recevrez les webhooks.
  2. 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.
  3. Intégrez et testez avec la clé de test (aucun vrai coursier n'est sollicité).
  4. Demandez votre clé dlp_live_… pour passer en production.

URL de base : https://dalishap-api.silam.ci/api/v1/partner

Principes

SujetRègle
TransportHTTPS uniquement, JSON UTF-8
MontantsEntiers en FCFA (champs *Fcfa)
DatesISO 8601 UTC (2026-11-14T12:30:00Z)
CoordonnéesWGS 84, degrés décimaux (latitude, longitude)
TéléphonesFormat international conseillé (+2250700000001) ; accepté : 8 à 15 chiffres, + facultatif. Ceux que Dalishap renvoie sont toujours au format +225…
IdentifiantsDalishap renvoie ses propres id (UUID) ; vous passez votre référence dans externalRef
VersionsPréfixe /v1. Ajout de champs = compatible ; retrait ou renommage = nouvelle version
Quota60 requêtes par minute et par clé
Le client a déjà payéchez vous : le coursier n'encaisse rien à la livraison. Le prix des livraisons vous est facturé sur relevé mensuel.

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 GET ou un POST sans corps, on signe timestamp + "." (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.
Node.js (18+)
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);
}
Shell (curl + openssl)
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.

Dérivez la clé de l'action (commande, devis), pas de l'appel. Réessayer 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éthodeRouteRôle
POST/quotesDevis d’une livraison
POST/deliveriesCréer la livraison à partir d’un devis
GET/deliveries/{id}État, coursier, position
POST/deliveries/{id}/pickup-confirmedLe point de retrait a validé le code
POST/deliveries/{id}/cancelAnnuler
POST/deliveries/{id}/unlock-delivery-codeDébloquer le code de livraison
GET/communesCommunes desservies

Devis — POST /quotes

Requête
{
  "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 }
}
Réponse 201
{
  "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.

Requête
{
  "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"
}
Réponse 201 — la livraison complète, au même format que GET /deliveries/{id}
{
  "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ès EXPIRED ou CANCELLED, relancez avec un nouveau devis.
  • pickupCode et deliveryCode : 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}

Réponse 200
{
  "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’annulationEffetFrais
SEARCHINGAnnulée0
COURIER_ASSIGNEDAnnulée, coursier prévenuForfait de déplacement
PICKED_UPRefusé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
StatutSens
SEARCHINGProposée aux coursiers
COURIER_ASSIGNEDUn coursier a accepté et se rend au point de retrait
PICKED_UPColis remis au coursier (code de retrait validé)
DELIVEREDRemis au client (code de livraison validé)
FAILEDLivraison impossible après retrait (client injoignable, refus…)
CANCELLEDAnnulée avant retrait
EXPIREDAucun 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.

Enveloppe
{
  "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"
  }
}
typeChamps de data en plus
delivery.courier_assignedcourier, etaPickupMinutes, location, etaMinutes
delivery.courier_unassignedreason
delivery.courier_locationlocation, etaMinutes — au plus toutes les 30 s
delivery.picked_uppickedUpAt
delivery.delivereddeliveredAt, deliveryCodeVerified, proof
delivery.code_lockedattempts
delivery.failedreason (CLIENT_UNREACHABLE, CLIENT_REFUSED, ADDRESS_WRONG, VEHICLE_ISSUE, FORCE_MAJEURE, OTHER), note, returnToPickup (booléen, false pour l'instant)
delivery.cancelledcancelledBy (PARTNER | DALISHAP), reason, note, feeFcfa
delivery.expired—
  • Répondez 2xx en 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 sur data.sequence.
  • delivery.courier_location n'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}.
Vérifier la signature (Node.js + Express)
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_…"
  }
}
HTTPcodeCas
400VALIDATION_ERRORChamp manquant ou invalide (details liste les champs), ou {id} qui n'est pas un UUID
401UNAUTHENTICATEDClé absente, inconnue ou révoquée
401INVALID_SIGNATURESignature fausse ou horodatage hors tolérance
403PARTNER_SUSPENDEDCompte partenaire suspendu
404NOT_FOUNDLivraison ou devis inconnu (ou créé avec l’autre type de clé, test ou live)
409QUOTE_EXPIRED QUOTE_USED QUOTE_MISMATCH DELIVERY_ALREADY_ACTIVE INVALID_STATE CANNOT_CANCEL IDEMPOTENCY_MISMATCHConflit d’état
422OUT_OF_ZONE DISTANCE_TOO_LONGLivraison non desservie
429RATE_LIMITEDQuota dépassé (en-tête Retry-After)
5xxINTERNALRé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 | expired

Un « Coursier Test » fictif est assigné. Cette route est refusée avec une clé live.

  • Une livraison de test reste en SEARCHING 15 minutes (searchEndsAt) : sans courier_assigned simulé avant, elle passe EXPIRED comme 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éfixe 00 attribué à aucun opérateur : ne l'appelez pas), sans photo ni note (photoUrl et ratingAvg à null).
  • En test, les devis renvoient toujours availability: "HIGH" et pickupEtaMinutes: 10. Les prix suivent votre vraie grille.
  • Simulations possibles selon le statut : courier_assigned et expired depuis SEARCHING ; picked_up depuis COURIER_ASSIGNED ; delivered et failed depuis PICKED_UP ; courier_location quand 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
Une clé ou un secret compromis ? Prévenez-nous immédiatement : la clé est révoquée et de nouveaux secrets générés, avec effet immédiat.