Webhooks
Webhooks
Arel appelle votre URL en POST sur cinq événements. La couverture dépend du chemin emprunté : seul shipping.created est systématique.
S'abonner
curl -X POST "https://api.arelpro.com/pro/api/v1/webhooks" \
-H "Authorization: Bearer sk_live_VOTRE_CLE" \
-H "X-Client-Id: VOTRE_CLIENT_ID" \
-H "Content-Type: application/json" \
-d '{
"url": "https://mon-site.fr/webhooks/arel",
"events": ["shipping.assigned", "shipping.delivered"]
}'
# Le secret est genere automatiquement et renvoye dans la reponse.
# Il reste lisible ensuite via GET /pro/api/v1/webhooks| Endpoint | Scope | Notes |
|---|---|---|
GET /webhooks | read | Liste — renvoie le secret en clair |
POST /webhooks | write | { url, events, secret?, isActive? } → 201 |
PUT /webhooks/:id | write | url, events, isActive |
DELETE /webhooks/:id | delete | Scope delete — absent du défaut |
Le secret est généré automatiquement si vous n'en fournissez pas. Il est renvoyé en clair par GET /webhooks ainsi qu'à la création : contrairement aux clés API, il reste récupérable a posteriori. Traitez-le comme un mot de passe. Un seul webhook par couple (compte, URL).
Événements
Ces cinq valeurs sont les seules acceptées par POST /webhooks. Il n'y a pas d'événement shipping.in_transit : in_transit est un statut, jamais un événement. Le passage en in_transit ne déclenche aucun webhook.
Avant de bâtir une réconciliation sur ces événements
Un webhook n'est pas émis par un changement de statut : il est émis par le code qui effectue ce changement. Or tous les chemins qui font avancer un bordereau n'émettent pas. shipping.picked_up et shipping.delivered ne partent que si le livreur passe par le chemin qui émet.
Ne considérez jamais l'absence de shipping.delivered comme la preuve qu'un colis n'est pas livré. Traitez ces événements comme une accélération — pas comme une source de vérité. L'état fiable est celui que renvoie GET /shipping/:id ; prévoyez une réconciliation périodique par sondage.
| Événement | Part quand… | Ne part pas quand… |
|---|---|---|
shipping.created | Toujours, une fois par bordereau, quelle que soit l'origine (API, /batch, dashboard, Shopify…). Point d'émission unique. | — |
shipping.assigned | Le dispatch automatique attribue la course ; un livreur l'accepte depuis l'app ; un administrateur (ré)assigne. Bonne couverture. | Assignation via le canal temps réel de l'app livreur. |
shipping.picked_up | Le livreur confirme l'enlèvement par le chemin HTTP de l'app. | Passage par le canal temps réel → aucun webhook. |
shipping.delivered | Le livreur confirme la livraison par le chemin HTTP de l'app. | Passage par le canal temps réel → aucun webhook. |
shipping.cancelled | Vous annulez — par PUT /shipping/:id/cancel ou depuis le dashboard. | Un livreur abandonne une course : le bordereau repart en created pour retrouver un livreur — ce n'est pas une annulation et rien n'est émis. |
shipping.created est le seul événement à la couverture totale. Il part exactement une fois par bordereau, quelle que soit l'origine — API unitaire, /batch, dashboard, import, Shopify, création programmée.
data.status y vaut created ou programmed. Un bordereau programmé émet l'événement une seule fois, à sa création, avec status: "programmed" ; son passage ultérieur à created ne ré-émet pas. shipping.created signifie « cette ressource existe », pas « ce statut vient de changer ».
L'ordre d'arrivée n'est pas garanti. Les émissions sont asynchrones et non séquencées : shipping.assigned peut arriver avant shipping.created. Ne construisez pas de machine à états sur l'ordre de réception — utilisez le champ timestamp de l'enveloppe.
{
"event": "shipping.assigned",
"timestamp": "2026-07-16T10:00:00.000Z",
"data": {
"id": "665f...",
"professionalId": "664a...",
"referenceNumber": "AREL-20260716-00042",
"status": "assigned",
"price": 9,
"externalReference": { "platform": "api", "orderId": "CMD-1001" },
"createdAt": "2026-07-16T10:00:00.000Z",
"updatedAt": "2026-07-16T10:02:00.000Z",
"pickupAddress": { },
"deliveryAddress": {
"recipientName": "Jean Dupont",
"street": "123 Rue de la République",
"city": "Paris",
"postalCode": "75001",
"country": "France",
"phoneNumber": "+33612345678"
},
"deliverer": {
"id": "6612a9f4c3b25d1e8a77f430",
"name": null,
"phoneNumber": null
}
}
}data est une liste blanche : les champs ci-dessus, et eux seuls. Ni deliveryCode, ni metadata, ni notes, ni pdfUrl.
externalReference est recopié intégralement — c'est votre pont vers votre commande : vous y retrouvez le orderId que vous aviez passé à la création, sans avoir à relire le bordereau. Attention au revers : tout ce que vous mettez dans externalReference ressortira dans la charge utile envoyée à votre URL. N'y mettez rien de sensible ; pour des données que vous ne voulez pas voir repartir, utilisez metadata — jamais inclus dans les webhooks (il reste lisible sur GET /shipping/:id).
deliverer.name et deliverer.phoneNumber valent toujours null
Aucun des points d'émission ne résout l'identité du livreur avant d'envoyer. Vous recevez donc null si aucun livreur n'est assigné, et sinon { id: "<identifiant>", name: null, phoneNumber: null }.
Seul deliverer.id porte de l'information. Pour le nom ou le téléphone, appelez GET /shipping/:id à réception de l'événement — n'affichez jamais data.deliverer.name à un utilisateur, il sera vide.
Vérifier la signature
Le schéma est de type Stripe (horodatage préfixé), malgré le préfixe sha256= d'allure GitHub. Une recette GitHub signe le corps seul — elle échouera.
signature = HMAC_SHA256(secret, X-Arel-Timestamp + "." + corps_brut)
| En-tête | Contenu |
|---|---|
X-Arel-Event | Type d'événement |
X-Arel-Timestamp | Horodatage ISO de la signature |
X-Arel-Signature | sha256=<hmac hex> |
Deux points d'attention pour la vérification
1. Le timestamp du corps n'est pas celui à signer. Le JSON contient un champ timestamp, distinct de l'en-tête X-Arel-Timestamp. Signez l'en-tête. À la première tentative, les deux sont produits dans la même milliseconde et sont donc souvent identiques. Aux réessais, l'en-tête est recalculé et les deux divergent — votre vérification se met à rejeter. Cette erreur ne se voit pas en test : elle se révèle en production, sous charge.
2. La signature change à chaque tentative. X-Arel-Timestamp est recalculé à chaque réessai : deux tentatives du même événement portent le même corps mais des signatures différentes. Ne dédupliquez jamais sur la signature — dédupliquez sur data.id + event.
Signez le corps brut, tel qu'octets reçus, avant tout parsing : ré-encoder l'objet parsé peut modifier le JSON et invalider la signature.
const express = require('express');
const crypto = require('crypto');
const WEBHOOK_SECRET = 'votre_secret_webhook';
const app = express();
function verifyArelSignature(rawBody, timestamp, signature) {
if (!timestamp || !signature) return false;
// Le corps signe est : X-Arel-Timestamp + "." + corps brut
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = signature.startsWith('sha256=') ? signature.slice(7) : signature;
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
'/webhooks/arel',
express.raw({ type: 'application/json' }), // corps brut obligatoire
(req, res) => {
const ok = verifyArelSignature(
req.body.toString('utf8'),
req.get('X-Arel-Timestamp'),
req.get('X-Arel-Signature')
);
if (!ok) return res.sendStatus(401);
// Repondez AVANT de traiter : le timeout est de 5 s, et un traitement
// lent provoquerait un reessai, donc un doublon. L'accuse de reception
// dit « signature valide, evenement recu », pas « traitement termine ».
res.sendStatus(200);
// Tout ce qui suit se fait apres la reponse, hors du chemin critique.
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch (err) {
console.error('Corps illisible :', err);
return;
}
// Dedupliquez sur data.id + event, JAMAIS sur la signature.
if (event.event === 'shipping.created') {
console.log('Bordereau cree :', event.data.referenceNumber);
}
}
);Réessais
- 3 tentatives maximum.
- Délais avant tentative : 0 ms, 1 000 ms, 2 000 ms. Fixes, sans jitter.
- Timeout : 5 000 ms par tentative.
- Est un échec tout statut HTTP non-2xx, ainsi que le timeout.
- Après 3 échecs, Arel abandonne : l'événement est perdu. Il n'y a ni file de rejeu, ni rattrapage.
Tout se joue en ~3 secondes : votre endpoint doit répondre 2xx immédiatement et traiter en asynchrone. Un traitement lourd effectué avant la réponse provoquera le timeout, donc un doublon. Compte tenu des réessais, votre endpoint doit être idempotent — dédupliquez sur data.id + event.