Aller au contenu principal

Webhooks

Les webhooks sont là pour informer votre système des événements qui surviennent lors de la livraison. Ils vous permettent de les exploiter, par exemple pour informer votre acheteur et/ou votre vendeur de l'avancée de la livraison.

Définir des webhooks​

info

Les webhooks se définissent dans votre compte client > Webhook

Webhooks disponibles​

concernant les annonces​

EvénementParamètresCorrespondanceCommentaire
ride_publishedvoir détailsAnnonce crééeCocolis publie l’annonce. Les transporteurs sont informés.
ride_availabilities_pendingvoir détailsAnnonce en attente de confirmation de disponibilitésUn transporteur s'est proposé sur un créneau de livraison. Il nous manque la confirmation de disponibilité de l'expéditeur et du destinataire.
ride_startedvoir détailsAnnonce en cours de livraisonUn transporteur est prêt à effectué la livraison.
ride_expiredvoir détailsAnnonce expiréeL'annonce n'a pas trouvé de transporteur. Se rapprocher de notre support.
ride_completedvoir détailsAnnonce complétéeL'objet de l'annonce a été remis à son destinataire
ride_moderatedvoir détailsAnnonce modéréeL'annonce a été modéré par notre équipe car ne respectant pas nos conditions. Se rapprocher de notre support.
ride_removedvoir détailsAnnonce suppriméeL'annonce a été supprimée.

concernant les offres​

EvénementParamètresCorrespondanceCommentaire
offer_acceptedvoir détailsUn porteur a été sélectionné pour réaliser la livraisonLe transporteur livrera aux créneaux qu'il a proposé et que l'expéditeur et le destinataire ont confirmé. Cocolis affiche les coordonnées du transporteur sur les pages de tracking de l'annonce. Cocolis transmet les informations de livraison au transporteur.
offer_cancelledvoir détailsLa livraison a été annuléeLa livraison est annulée par le transporteur. L'expéditeur et le destinataire sont informés, leur page de tracking est mise à jour. L’annonce passe de nouveau dans le statut ride_published.
offer_completedvoir détailsLivraison effectuéeVous pouvez indiquer à l'expéditeur que la livraison a été effectuée.
offer_rejectedvoir détailsLa livraison a été annuléeL'expéditeur ou le destinataire a rejeté une proposition de créneau de passage d'un potentiel transporteur, leur page de tracking est mise à jour. L’annonce passe de nouveau dans le statut published. Le payload contient le nombre de refus déjà enregistrés et le nombre maximum avant suppression de l'annonce pour déterminer si le prochain refus donnera lieu à une suppression de l'annonce.

concernant la livraison​

EvénementParamètresCorrespondanceCommentaire
delivery_picked_upvoir détailsLe colis a été enlevéLe transporteur a récupéré le colis chez l'expéditeur.
delivery_to_delivervoir détailsLe colis va être livré dans la journéeLe transporteur est en route. La livraison est prête à être effectuée dans la journée.
delivery_cancelledvoir détailsLa livraison a été annulée
delivery_completedvoir détailsLa livraison a été effectuéeLe colis a été livré au destinataire.

concernant les disponibilités​

EvénementParamètresCorrespondanceCommentaire
pickup_slot_accepted_by_sendervoir détailsL'expéditeur a confirmé sa disponibilité pour le créneau de collecte.il nous manque maintenant celle du destinataire
deposit_slot_accepted_by_recipientvoir détailsLe destinataire a confirmé sa disponibilité pour le créneau de livraisonla livraison est prête à démarrer

Fonctionnement​

A chaque évènement, nous effectuons une requête POST sur l'url que vous avez spécifiée avec les paramètres en body comme spécifié ci-dessus. Cela vous permet de suivre la livraison.

  • Vous devez avoir un certificat SSL valide
  • La réponse doit être un 200 en moins de 10 secondes
  • Après la réception d'un événement, nous vous conseillons de faire la requête GET de la ressource (Offer ou Ride) afin de récupérer ses dernières informations
danger

Nos webhooks ont un timeout de 10 secondes. Donc vous ne devez pas faire de traitement synchrone sur nos webhooks, vous devez mettre en place un traitement asynchrone ou bien un traitement court.

Sécurité​

Chaque webhook que nous envoyons porte trois éléments qui permettent de l'identifier :

ÉlémentRôleOù l'utiliser
Signature HMAC (header X-Cocolis-Signature)Validation : prouve la provenance et l'intégrité du contenu, protège contre le rejeuDans votre application, pour chaque webhook reçu
Paramètre from_cocolis dans l'URLFiltrage en amont : jeton propre à votre applicationReverse proxy, load balancer, WAF
app_id dans le User-AgentFiltrage en amont : identifie Cocolis et votre applicationReverse proxy, load balancer, WAF
astuce

La signature HMAC est le mode de validation prioritaire et doit être vérifiée sur chaque webhook : c'est le seul mécanisme qui garantit que la requête provient de Cocolis et que son contenu n'a pas été altéré.

Le paramètre from_cocolis et le User-Agent ne remplacent pas la signature HMAC qui reste le moyen préférentiel de vérification.

Ces trois éléments sont présents sur chaque webhook que nous envoyons (y compris le webhook de test et les rejeux) : vous n'avez rien à activer.

Signature HMAC​

Chaque webhook que nous envoyons est signé, afin que vous puissiez vérifier qu'il provient bien de Cocolis et que son contenu n'a pas été altéré.

Récupérer votre clé de signature​

Une clé de signature (webhook_signing_secret) est générée automatiquement pour votre application. Vous pouvez la récupérer à tout moment :

{
"method": "get",
"url": "https://api.cocolis.fr/api/v1/applications/webhook_signing_secret",
"headers": {
"X-API-KEY": "VOTRE_TOKEN"
}
}
{
"data": {
"webhook_signing_secret": "3f9c1d0b8a7e6f5d4c3b2a1908f7e6d5c4b3a29180716253449586a7b8c9d0e1f"
}
}

Référence complète : GET /applications/webhook_signing_secret.

Cette clé est un secret au même titre que votre token d'API : stockez-la côté serveur (variable d'environnement, coffre-fort de secrets) et ne l'exposez jamais côté client.

Vous pouvez la regénérer :

{
"method": "post",
"url": "https://api.cocolis.fr/api/v1/applications/webhook_signing_secret/rotate",
"headers": {
"X-API-KEY": "VOTRE_TOKEN"
}
}

Référence complète : POST /applications/webhook_signing_secret/rotate.

attention

La rotation est immédiate et sans période de recouvrement : dès l'appel, tous les webhooks suivants sont signés avec la nouvelle clé et l'ancienne n'est plus jamais utilisée. Prévoyez de déployer la nouvelle clé sur votre serveur au moment de la rotation.

Le header X-Cocolis-Signature​

Chaque requête POST envoyée à votre URL de webhook contient un header de la forme :

X-Cocolis-Signature: t=1764000000,v1=8f2b3c1d...e9a0
ChampDescription
tHorodatage Unix (en secondes) du moment où la requête a été signée
v1Version du schéma de signature. La valeur est le HMAC-SHA256 de la chaîne <t>.<corps brut de la requête>, encodé en hexadécimal minuscule, avec votre webhook_signing_secret comme clé

Par exemple, pour t=1764000000 et un corps {"event":"offer_accepted","ride_id":42}, la chaîne signée est exactement :

1764000000.{"event":"offer_accepted","ride_id":42}
danger

Le HMAC est calculé sur le corps brut (raw body) de la requête, tel que nous l'envoyons. Si vous parsez le JSON puis le ré-encodez avant de calculer la signature, vous n'obtiendrez pas le même résultat (ordre des clés, espaces, échappement des caractères). Récupérez toujours le corps brut avant tout parsing.

Vérifier une signature​

  1. Lire le header X-Cocolis-Signature et en extraire t et v1.
  2. Rejeter la requête si t est trop ancien : nous recommandons une tolérance de 5 minutes. Cette étape vous protège contre les attaques par rejeu.
  3. Recalculer HMAC-SHA256(votre_secret, "<t>.<corps_brut>").
  4. Comparer le résultat à v1 avec une comparaison en temps constant (hash_equals en PHP, crypto.timingSafeEqual en Node.js, hmac.compare_digest en Python, Rack::Utils.secure_compare en Ruby). N'utilisez pas ==.
  5. Ne traiter l'événement que si la comparaison réussit ; sinon, répondez une erreur (401) et ignorez le payload.
PHP​
<?php

function verifyCocolisSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, null);
$parts[$key] = $value;
}

if (!isset($parts['t'], $parts['v1'])) {
return false;
}

if (abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}

$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);

return hash_equals($expected, $parts['v1']);
}

$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_COCOLIS_SIGNATURE'] ?? '';

if (!verifyCocolisSignature($rawBody, $header, getenv('COCOLIS_WEBHOOK_SIGNING_SECRET'))) {
http_response_code(401);
exit;
}

$event = json_decode($rawBody, true);
// Traitement (asynchrone de préférence), puis réponse 200
Node.js (Express)​
const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.COCOLIS_WEBHOOK_SIGNING_SECRET;
const TOLERANCE_IN_SECONDS = 300;

function isValidSignature(rawBody, header) {
const parts = Object.fromEntries(
String(header || '').split(',').map((part) => part.split('=', 2))
);

if (!parts.t || !parts.v1) return false;

const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (age > TOLERANCE_IN_SECONDS) return false;

const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.${rawBody}`)
.digest('hex');

const expectedBuffer = Buffer.from(expected, 'utf8');
const receivedBuffer = Buffer.from(parts.v1, 'utf8');

return expectedBuffer.length === receivedBuffer.length
&& crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}

// express.raw conserve le corps brut, indispensable pour la vérification
app.post('/cocolis/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');

if (!isValidSignature(rawBody, req.headers['x-cocolis-signature'])) {
return res.sendStatus(401);
}

const event = JSON.parse(rawBody);
// Traitement (asynchrone de préférence)
res.sendStatus(200);
});
info
  • En cas d'échec de livraison, nous rejouons le webhook. Chaque tentative est signée avec son propre horodatage : la signature d'un même événement change d'une tentative à l'autre.
  • Le webhook de test déclenché depuis votre compte client est signé exactement de la même manière.

Paramètre from_cocolis​

Nous ajoutons à la query string de l'URL de webhook que vous avez définie un paramètre from_cocolis, dont la valeur est un jeton d'identification propre à votre application. Il vous permet de rejeter en amont (reverse proxy, WAF…) les requêtes qui ne le contiennent pas.

Par exemple, pour une URL de webhook https://example.com/cocolis/webhooks?source=prod, nous appelons :

POST https://example.com/cocolis/webhooks?source=prod&from_cocolis=9f86d081884c7d659a2feaa0c55ad015
  • Les paramètres déjà présents dans votre URL sont conservés. Si votre URL contient déjà un paramètre from_cocolis, il est remplacé par votre jeton.
  • Le jeton est composé de 16 à 128 caractères parmi A-Z a-z 0-9 _ -.
  • Pour obtenir votre jeton, ou le faire changer, contactez notre support. Le changement est immédiat : coordonnez-le avec la mise à jour de votre configuration.

Exemple de filtrage avec nginx :

location /cocolis/webhooks {
if ($arg_from_cocolis != "VOTRE_JETON") {
return 403;
}

proxy_pass http://app;
}
attention

Ce jeton sert à filtrer, pas à valider : il ne protège ni l'intégrité du contenu, ni contre le rejeu, et comme il figure dans l'URL, il peut apparaître dans les logs de vos serveurs, proxys ou outils de monitoring. Les requêtes qui passent ce filtre doivent toujours être validées par la signature HMAC.

app_id dans le User-Agent​

Chaque webhook est envoyé avec un header User-Agent qui identifie Cocolis et votre application :

User-Agent: Cocolis-Webhooks/1.0 (+https://www.cocolis.fr; app_id=VOTRE_APP_UID)

VOTRE_APP_UID est l'identifiant de votre application, celui que vous utilisez pour vous authentifier.

Ce header vous permet de router ou de filtrer nos appels en amont, par exemple au niveau de votre pare-feu ou de votre reverse proxy :

location /cocolis/webhooks {
if ($http_user_agent !~ "^Cocolis-Webhooks/.*app_id=VOTRE_APP_UID") {
return 403;
}

proxy_pass http://app;
}
danger

L'app_id n'est pas un secret et le User-Agent peut être librement choisi par n'importe quel émetteur : ce header ne constitue pas une preuve que l'appel provient de Cocolis. Il permet d'écarter du trafic indésirable, mais les requêtes qui passent ce filtre doivent toujours être validées par la signature HMAC.

Utilisation​

A chaque webhook, nous vous renvoyons la valeur du external_id que vous avez spécifié quand vous avez créé votre ride afin que vous puissiez retrouver la commande associée. A partir de là, vous pouvez :

  • Changer le status d'une commande
  • Envoyer un email d'information à vos clients
  • etc...

Détails des paramètres​

Voici pour chaque événement le détail des informations envoyées à l'URL de webhook.

ride_published​

{
event: 'ride_published',
created_at: DateTime,
resource_id: ride_id,
resource_type: 'ride',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id
}

ride_availabilities_pending​

{
event: 'ride_availabilities_pending',
created_at: DateTime,
resource_id: ride_id,
resource_type: 'ride',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id
}

ride_started​

{
event: 'ride_started',
created_at: DateTime,
resource_id: ride_id,
resource_type: 'ride',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id
}

ride_expired​

{
event: 'ride_expired',
created_at: DateTime,
resource_id: ride_id,
resource_type: 'ride',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id
}

ride_completed​

{
event: 'ride_completed',
created_at: DateTime,
resource_id: ride_id,
resource_type: 'ride',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id
}

ride_moderated​

{
event: 'ride_moderated',
created_at: DateTime,
resource_id: ride_id,
resource_type: 'ride',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id
}

ride_removed​

{
event: 'ride_removed',
created_at: DateTime,
resource_id: ride_id,
resource_type: 'ride',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id
}

offer_accepted​

{
event: 'offer_accepted',
created_at: DateTime,
resource_id: offer_id,
resource_type: 'offer',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id,
pickup_slot: {
date: Date,
slot: Slot (morning|afternoon|evening) # deprecated,
start_time: DateTime,
end_time: DateTime
},
deposit_slot: {
date: Date,
slot: Slot (morning|afternoon|evening) # deprecated,
start_time: DateTime,
end_time: DateTime
}
}

offer_cancelled​

{
event: 'offer_cancelled',
created_at: DateTime,
resource_id: offer_id,
resource_type: 'offer',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id,
pickup_slot: {
date: Date,
slot: Slot (morning|afternoon|evening) # deprecated,
start_time: DateTime,
end_time: DateTime
},
deposit_slot: {
date: Date,
slot: Slot (morning|afternoon|evening) # deprecated,
start_time: DateTime,
end_time: DateTime
}
}

offer_completed​

{
event: 'offer_completed',
created_at: DateTime,
resource_id: offer_id,
resource_type: 'offer',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id,
pickup_slot: {
date: Date,
slot: Slot (morning|afternoon|evening) # deprecated,
start_time: DateTime,
end_time: DateTime
},
deposit_slot: {
date: Date,
slot: Slot (morning|afternoon|evening) # deprecated,
start_time: DateTime,
end_time: DateTime
}
}

offer_rejected​

{
event: 'offer_rejected',
created_at: DateTime,
resource_id: offer_id,
resource_type: 'offer',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id,
pickup_slot: {
date: Date,
slot: Slot (morning|afternoon|evening) # deprecated,
start_time: DateTime,
end_time: DateTime
},
deposit_slot: {
date: Date,
slot: Slot (morning|afternoon|evening) # deprecated,
start_time: DateTime,
end_time: DateTime
},
reject_reason: (pickup_slot_refused|pickup_slot_answer_timeout|deposit_slot_refused|deposit_slot_answer_timeout),
ride: {
id: Integer,
external_id: String,
external_description: Text,
pickup_slot_rejection_count: Integer,
deposit_slot_rejection_count: Integer,
max_pickup_slot_rejection: Integer,
max_deposit_slot_rejection: Integer,
}
}

delivery_to_deliver​

{
event: 'delivery_to_deliver',
created_at: DateTime,
resource_id: delivery_id,
resource_type: 'delivery',
ride_id: ride_id,
data: {
offer_id: offer_id,
state: String (picked_up | to_deliver | completed | cancelled),
to_deliver_at: DateTime | nil,
picked_up_at: DateTime | nil,
pick_up_date: Date,
delivery_date: Date
}
}

delivery_picked_up​

{
event: 'delivery_picked_up',
created_at: DateTime,
resource_id: delivery_id,
resource_type: 'delivery',
ride_id: ride_id,
data: {
offer_id: offer_id,
state: String (picked_up | to_deliver | completed | cancelled),
to_deliver_at: DateTime | nil,
picked_up_at: DateTime | nil,
pick_up_date: Date,
delivery_date: Date
}
}

delivery_cancelled​

{
event: 'delivery_cancelled',
created_at: DateTime,
resource_id: delivery_id,
resource_type: 'delivery',
ride_id: ride_id,
data: {
offer_id: offer_id,
state: String (picked_up | to_deliver | completed | cancelled),
to_deliver_at: DateTime | nil,
picked_up_at: DateTime | nil,
pick_up_date: Date,
delivery_date: Date
}
}

delivery_completed​

{
event: 'delivery_completed',
created_at: DateTime,
resource_id: delivery_id,
resource_type: 'delivery',
ride_id: ride_id,
data: {
offer_id: offer_id,
state: String (picked_up | to_deliver | completed | cancelled),
to_deliver_at: DateTime | nil,
picked_up_at: DateTime | nil,
pick_up_date: Date,
delivery_date: Date
}
}

pickup_slot_accepted_by_sender​

{
event: 'pickup_slot_accepted_by_sender',
created_at: DateTime,
resource_id: ride_id,
resource_type: 'ride',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id
}

deposit_slot_accepted_by_recipient​

{
event: 'deposit_slot_accepted_by_recipient',
created_at: DateTime,
resource_id: ride_id,
resource_type: 'ride',
external_id: ride_external_id,
external_description: Text,
ride_id: ride_id
}