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
Les webhooks se définissent dans votre compte client > Webhook
Webhooks disponibles
concernant les annonces
| Evénement | Paramètres | Correspondance | Commentaire |
|---|---|---|---|
ride_published | voir détails | Annonce créée | Cocolis publie l’annonce. Les transporteurs sont informés. |
ride_availabilities_pending | voir détails | Annonce en attente de confirmation de disponibilités | Un 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_started | voir détails | Annonce en cours de livraison | Un transporteur est prêt à effectué la livraison. |
ride_expired | voir détails | Annonce expirée | L'annonce n'a pas trouvé de transporteur. Se rapprocher de notre support. |
ride_completed | voir détails | Annonce complétée | L'objet de l'annonce a été remis à son destinataire |
ride_moderated | voir détails | Annonce modérée | L'annonce a été modéré par notre équipe car ne respectant pas nos conditions. Se rapprocher de notre support. |
ride_removed | voir détails | Annonce supprimée | L'annonce a été supprimée. |
concernant les offres
| Evénement | Paramètres | Correspondance | Commentaire |
|---|---|---|---|
offer_accepted | voir détails | Un porteur a été sélectionné pour réaliser la livraison | Le 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_cancelled | voir détails | La livraison a été annulée | La 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_completed | voir détails | Livraison effectuée | Vous pouvez indiquer à l'expéditeur que la livraison a été effectuée. |
offer_rejected | voir détails | La livraison a été annulée | L'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énement | Paramètres | Correspondance | Commentaire |
|---|---|---|---|
delivery_picked_up | voir détails | Le colis a été enlevé | Le transporteur a récupéré le colis chez l'expéditeur. |
delivery_to_deliver | voir détails | Le colis va être livré dans la journée | Le transporteur est en route. La livraison est prête à être effectuée dans la journée. |
delivery_cancelled | voir détails | La livraison a été annulée | |
delivery_completed | voir détails | La livraison a été effectuée | Le colis a été livré au destinataire. |
concernant les disponibilités
| Evénement | Paramètres | Correspondance | Commentaire |
|---|---|---|---|
pickup_slot_accepted_by_sender | voir détails | L'expéditeur a confirmé sa disponibilité pour le créneau de collecte. | il nous manque maintenant celle du destinataire |
deposit_slot_accepted_by_recipient | voir détails | Le destinataire a confirmé sa disponibilité pour le créneau de livraison | la 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 (
OfferouRide) afin de récupérer ses dernières informations
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ément | Rôle | Où l'utiliser |
|---|---|---|
Signature HMAC (header X-Cocolis-Signature) | Validation : prouve la provenance et l'intégrité du contenu, protège contre le rejeu | Dans votre application, pour chaque webhook reçu |
Paramètre from_cocolis dans l'URL | Filtrage en amont : jeton propre à votre application | Reverse proxy, load balancer, WAF |
app_id dans le User-Agent | Filtrage en amont : identifie Cocolis et votre application | Reverse proxy, load balancer, WAF |
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.
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
| Champ | Description |
|---|---|
t | Horodatage Unix (en secondes) du moment où la requête a été signée |
v1 | Version 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}
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
- Lire le header
X-Cocolis-Signatureet en extrairetetv1. - Rejeter la requête si
test trop ancien : nous recommandons une tolérance de 5 minutes. Cette étape vous protège contre les attaques par rejeu. - Recalculer
HMAC-SHA256(votre_secret, "<t>.<corps_brut>"). - Comparer le résultat à
v1avec une comparaison en temps constant (hash_equalsen PHP,crypto.timingSafeEqualen Node.js,hmac.compare_digesten Python,Rack::Utils.secure_compareen Ruby). N'utilisez pas==. - 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);
});
- 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;
}
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;
}
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
}