Recevoir des webhooks
Un webhook est l'alternative au fait de demander. Interroger un marché toutes les cinq minutes pour attraper un changement qui survient deux fois par jour est la source la plus courante de quota gaspillé, et une livraison ne coûte aucun quota.
Créer un endpoint
curl -sS -X POST "https://api.skautik.com/v1/api/webhooks" \
-H "Authorization: Bearer $SKAUTIK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/skautik",
"events": ["property.created", "property.updated", "property.withdrawn"]
}'La réponse porte le secret de signature une seule fois :
{
"data": {
"webhook": { "id": "whk_1b7e6c19a1", "url": "…", "active": true },
"secret": "whsec_uEw7ugUA3saVyNo087Sg3XvhBnWI0txICtik-ySKD2k"
}
}Stockez-le avant de fermer la réponse. Il n'est plus jamais affiché, et le seul recours est de le renouveler, ce qui invalide l'ancien.
Événements
| Événement | Quand |
|---|---|
property.created | Un bien a été publié, par vous, par un import, ou par un agent. |
property.updated | N'importe quel champ a changé, y compris un changement de prix. |
property.withdrawn | Un bien a quitté le marché. Pas une suppression : la fiche reste lisible. |
inquiry.created | Quelqu'un s'est renseigné sur l'un de vos biens. |
Abonnez-vous à ce sur quoi vous agirez. Un endpoint abonné à tout qui
filtre dans le code paie quand même le coût de tout recevoir, et un import chargé
fait de property.updated l'événement le plus bruyant de la plateforme.
Vérifier la signature
Faites-le avant de lire le corps. Un endpoint de webhook non vérifié est une API publique qui écrit dans votre base de données à la demande de quiconque devine l'URL.
Chaque livraison porte trois en-têtes :
Skautik-Signature: t=1786680348,v1=8f2a41c9d0b7e6c19a139404cb173d23fcb3331c4e…
Skautik-Event: property.updated
Skautik-Delivery: dlv_9c4e1f2308La signature est HMAC-SHA256(secret, timestamp + "." + body), encodée en hexadécimal.
L'horodatage est à l'intérieur de la signature et pas seulement à côté, et c'est la partie
qui compte : si seul le corps était signé, une livraison capturée pourrait être rejouée
sur votre endpoint indéfiniment et la signature correspondrait toujours.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((piece) => piece.split("=") as [string, string]),
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) {
return false;
}
// Refuser tout ce qui a plus de cinq minutes. Sans cela la signature est
// valable pour toujours et une requête capturée ne cesse jamais de fonctionner.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) {
return false;
}
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// En temps constant. Un === ordinaire fuite la position du premier octet
// erroné, ce qui suffit à forger une signature avec assez de tentatives.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(piece.split("=", 1) for piece in header.split(","))
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)Signez les octets bruts, pas un objet re-sérialisé. Analyser le JSON puis le réémettre change l'ordre des clés et les espaces, et la signature ne correspondra jamais. La plupart des frameworks doivent recevoir la consigne de conserver le corps brut.
Répondre
Répondez 2xx dès que vous avez vérifié la signature et écrit la charge utile
quelque part de durable. Faites le vrai travail ensuite, sur une file.
Un endpoint qui géocode une adresse, écrit dans trois tables et envoie un e-mail avant de répondre est un endpoint qui expire sous charge, ce qui transforme une livraison en six quand nous réessayons, ce qui aggrave la charge.
Tout ce qui n'est pas un 2xx est un échec et sera réessayé.
Nouvelles tentatives
Une livraison échouée est réessayée jusqu'à six tentatives avec temporisation exponentielle.
next_retry_at sur la fiche de livraison indique quand la suivante est prévue.
Les livraisons sont au moins une fois, pas exactement une fois. Un délai dépassé de votre côté après
que vous avez validé compte quand même comme un échec pour nous, et vous reverrez l'événement.
Rendez le traitement idempotent : indexez sur Skautik-Delivery, ou sur l'id de la fiche et son
updated_at, et traitez une répétition comme sans effet.
L'ordre n'est pas garanti non plus. Deux mises à jour d'un même bien peuvent arriver dans le
mauvais ordre : comparez donc updated_at avant d'écraser plutôt que de vous fier à
l'ordre d'arrivée.
Quand quelque chose cloche
GET /v1/api/webhooks/{webhook_id}/deliveries liste ce que nous avons tenté, avec le
code de statut que vous avez renvoyé et le nombre de tentatives nécessaires. C'est le premier endroit où
regarder quand les données ont cessé d'arriver, et cela répond généralement à la question avant que vous
n'ouvriez vos propres journaux.
POST /v1/api/webhooks/{webhook_id}/test envoie une livraison synthétique, la
façon rapide de vérifier qu'un nouvel endpoint est joignable et vérifie correctement
avant d'en dépendre.
Une liste de contrôle
- Vérifiez la signature avant de lire le corps.
- Rejetez les livraisons de plus de quelques minutes.
- Comparez les signatures en temps constant.
- Répondez vite ; travaillez ensuite.
- Gérez les répétitions et les arrivées dans le désordre.
- Abonnez-vous uniquement aux événements sur lesquels vous agissez.
- Gardez le secret hors de votre dépôt, comme tout autre identifiant.