Webhooks
Recevoir les événements d'alerte en temps réel via webhook signé HMAC
Les alertes Stream Estate peuvent livrer chaque match sur un webhook HTTP que vous hébergez. La requête est signée HMAC-SHA256, retry-able, et fournit un eventId pour l'idempotence.
Cycle de vie
- Créez une destination — soit directement via
POST /alertsavecnotificationDestination: { "webhookUrl": "..." }, soit à l'avance viaPOST /account/event-destinationsavec l'URL de votre endpoint - Stockez la
signingKeyrenvoyée dans la réponse — elle n'apparaît qu'une fois, à la création de la première destination webhook du compte, et signe tous vos webhooks - Recevez les événements sur votre URL. À chaque alerte qui match, l'API envoie un
POSTJSON signé - Répondez 2xx sous 30 secondes — toute autre réponse déclenche jusqu'à 4 retries en backoff exponentiel
- Renouvelez la clé à tout moment via
POST /account/webhook-signing-key/rotate(l'ancienne est invalidée immédiatement)
Headers de la requête
| Header | Exemple | Description |
|---|---|---|
X-Webhook-Timestamp | 1716304200 | Timestamp Unix en secondes (epoch) — un instant absolu, aucune conversion de fuseau nécessaire. Fait partie du payload signé, pour empêcher les replays. |
X-Webhook-Signature | sha256=5d41402a... | sha256={hex_hmac} — HMAC-SHA256 de {timestamp}.{raw_body} avec votre signingKey. |
User-Agent | StreamEstate-Webhook/1.0 | Toujours cette valeur exacte. |
Types d'événement
eventType | Déclencheur |
|---|---|
NEW_MATCH | Un nouveau bien correspond aux critères de l'alerte |
ADDITIONAL_LISTING | Un listing supplémentaire est apparu sur un bien déjà matché |
PRICE_CHANGED | Le prix d'un bien matché a changé |
ANY_ATTRIBUTE_CHANGED | Un autre attribut (surface, statut…) a changé |
LISTING_EXPIRED | Un listing matché a été retiré ou expiré |
LISTING_REACTIVATED | Un listing matché précédemment expiré est réactivé |
Exemple de payload
{
"eventType": "NEW_MATCH",
"eventId": "01929ec5-3a4f-7000-bcde-f0123456789a",
"alert": {
"id": "01929ec5-2b3c-7000-aaaa-b00000000000",
"name": "Appartements Paris 11e"
},
"listing": {
"id": "01929ec5-1a2b-7000-cccc-d00000000000"
},
"data": {
"id": "019dedb1-fdf4-714c-8392-5b9cfd63cde3",
"property": { "propertyType": "FLAT", "transaction": { "type": "SELL" } /* … vue canonique complète */ },
"publishers": [ /* PublisherSection[] dédoublonnés */ ],
"listings": [ /* ListingSection[] */ ]
}
}Le payload embarque la représentation complète du bien sous data — exactement le corps renvoyé par GET /properties/{id} (id, property, publishers, listings) — pour que vous puissiez ingérer le match sans appel API supplémentaire (facturé). data reflète l'état indexé le plus frais à la livraison, pas un instantané figé de l'événement. listing.id désigne le listing (au sein de data.listings) dont la mise à jour a déclenché l'événement, et eventId est votre clé d'idempotence.
Vérification de la signature
Chaque livraison est signée avec votre signingKey en HMAC-SHA256. Ce qui est signé n'est pas le corps seul : c'est la chaîne {X-Webhook-Timestamp}.{corps_brut} — le timestamp, un point littéral, puis les octets exacts du corps de la requête.
Pour vérifier une livraison, dans n'importe quel langage :
- Lisez
X-Webhook-Timestampet rejetez la requête si l'écart avec votre propre horloge dépasse 5 minutes — dans un sens comme dans l'autre. - Construisez le payload signé : le timestamp,
., puis le corps brut de la requête. - Calculez
HMAC-SHA256(payload_signé, signingKey)et encodez-le en hexadécimal. La valeur du header est ce condensé préfixé desha256=. - Comparez avec
X-Webhook-Signatureen temps constant — jamais avec==, qui divulgue la signature attendue par son temps d'exécution.
Pourquoi le timestamp fait partie de la signature
Une signature seule prouve que la livraison vient bien de nous et n'a pas été altérée. Elle ne prouve pas qu'elle est récente : une requête interceptée reste valide indéfiniment et peut être rejouée à tout moment.
Le timestamp comble cette faille, mais uniquement parce que les deux moitiés sont en place. Il se trouve à l'intérieur du payload signé, donc un attaquant ne peut pas le modifier sans invalider la signature ; et votre fenêtre de ±5 minutes empêche de réutiliser un ancien timestamp valide. Sans le contrôle d'écart, le timestamp ne protège plus rien.
Les 5 minutes sont une tolérance de dérive d'horloge entre nos serveurs et les vôtres — pas un délai de livraison. Gardez l'horloge de votre serveur synchronisée en NTP. X-Webhook-Timestamp est un timestamp Unix en secondes : comparez-le à votre propre epoch (time(), Date.now() / 1000), il n'y a aucun fuseau à convertir.
Les retries ne font pas échouer le contrôle d'écart. Nous générons un nouveau timestamp et une nouvelle signature à chaque tentative de livraison. Un événement retenté deux heures plus tard, ou redélivré plusieurs jours après la réactivation d'une destination suspendue, arrive horodaté à l'instant de son envoi — jamais à l'instant où l'événement s'est produit.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyStreamEstateWebhook(rawBody, headers, signingKey) {
const timestamp = headers["x-webhook-timestamp"];
const signature = headers["x-webhook-signature"];
if (!timestamp || !signature) return false;
// Replay protection
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const expected =
"sha256=" +
crypto
.createHmac("sha256", signingKey)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected),
);
}import hmac, hashlib, time
TOLERANCE_SECONDS = 300
def verify_stream_estate_webhook(raw_body: bytes, headers: dict, signing_key: str) -> bool:
timestamp = headers.get("X-Webhook-Timestamp")
signature = headers.get("X-Webhook-Signature")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = "sha256=" + hmac.new(
signing_key.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)const TOLERANCE_SECONDS = 300;
function verify_stream_estate_webhook(
string $raw_body,
array $headers,
string $signing_key
): bool {
$timestamp = $headers["X-Webhook-Timestamp"] ?? null;
$signature = $headers["X-Webhook-Signature"] ?? null;
if (!$timestamp || !$signature) return false;
if (abs(time() - intval($timestamp)) > TOLERANCE_SECONDS) return false;
$expected = "sha256=" . hash_hmac(
"sha256",
"{$timestamp}.{$raw_body}",
$signing_key
);
return hash_equals($signature, $expected);
}require "openssl"
require "rack"
TOLERANCE_SECONDS = 300
def verify_stream_estate_webhook(raw_body, headers, signing_key)
timestamp = headers["X-Webhook-Timestamp"]
signature = headers["X-Webhook-Signature"]
return false unless timestamp && signature
return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS
expected = "sha256=" + OpenSSL::HMAC.hexdigest(
"sha256", signing_key, "#{timestamp}.#{raw_body}"
)
Rack::Utils.secure_compare(signature, expected)
endSignez le corps brut de la requête, jamais une version re-sérialisée. Tout reformatage — espaces, ré-ordonnancement des clés JSON, ré-encodage — casse la signature.
Le coupable habituel est le body parser de votre framework, qui consomme le flux brut avant votre handler. Express nécessite express.raw(), FastAPI await request.body(), Rails request.raw_post.
Attention en particulier aux caractères non-ASCII : nous émettons de l'UTF-8 non échappé, donc une annonce en Île-de-France circule sous forme de ces octets, et non de Île-de-France. Un code qui ré-encode le payload passera vos tests et échouera sur les vraies annonces françaises.
Identifier votre clé active
Votre clé de signature est créée avec votre première destination webhook et renvoyée une seule fois. Elle n'est jamais réaffichée. Lorsque vous devez vérifier quelle clé Stream Estate utilise actuellement pour signer — après un renouvellement, ou si votre coffre en contient plusieurs — demandez son empreinte :
GET /account/webhook-signing-key{
"id": "signing-key",
"hint": "b75b1473"
}L'empreinte correspond aux 8 premiers caractères hexadécimaux du SHA-256 de la clé. Recalculez-la sur la clé que vous détenez et comparez les deux :
import crypto from "node:crypto";
const hint = crypto
.createHash("sha256")
.update(signingKey)
.digest("hex")
.slice(0, 8);
// hint === "b75b1473" → c'est bien la clé actuellement utiliséeimport hashlib
hint = hashlib.sha256(signing_key.encode()).hexdigest()[:8]$hint = substr(hash("sha256", $signing_key), 0, 8);require "openssl"
hint = OpenSSL::Digest::SHA256.hexdigest(signing_key)[0, 8]L'empreinte est un condensé à sens unique : elle ne divulgue aucune portion de la clé et peut donc être journalisée ou affichée dans un tableau de bord. Elle ne ressemble en rien à la clé whsec_… elle-même, et c'est volontaire : une empreinte qui ressemblerait à la clé serait une fuite.
L'endpoint renvoie 404 tant que vous n'avez pas de clé de signature — créez d'abord une destination webhook.
Idempotence
Le même eventId peut arriver plusieurs fois — un retry après un timeout côté Stream Estate ne signifie pas que votre traitement précédent a échoué. Stockez les eventId déjà traités et ignorez les doublons :
La table doit avoir id en clé primaire ou en index unique — c'est cette contrainte qui rend le contrôle atomique.
async function handleWebhook(payload) {
const { rowCount } = await db.query(
"INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
[payload.eventId],
);
if (rowCount === 0) return; // déjà traité, ack 200
await processMatch(payload);
}async def handle_webhook(payload: dict) -> None:
inserted = await db.execute(
"INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
payload["eventId"],
)
if inserted.rowcount == 0:
return # déjà traité, ack 200
await process_match(payload)function handle_webhook(array $payload, PDO $db): void
{
$statement = $db->prepare(
"INSERT INTO processed_events (id) VALUES (:id) ON CONFLICT DO NOTHING"
);
$statement->execute(["id" => $payload["eventId"]]);
if ($statement->rowCount() === 0) {
return; // déjà traité, ack 200
}
process_match($payload);
}def handle_webhook(payload)
inserted = Event.insert_all([{ id: payload["eventId"] }], unique_by: :id)
return if inserted.empty? # déjà traité, ack 200
process_match(payload)
endRetries et circuit breaker
Une réponse non-2xx, un timeout (> 30s), ou une erreur de connexion déclenche un retry :
| Tentative | Délai après la précédente |
|---|---|
| 1 (initiale) | immédiate |
| 2 | ~1 minute |
| 3 | ~5 minutes |
| 4 | ~30 minutes |
| 5 | ~2 heures |
Après 5 échecs consécutifs, la destination passe en suspended (circuit breaker). Une fois votre endpoint réparé, réactivez-la depuis la console : console.stream.estate/webhooks.
Renouvellement de la clé
Pour renouveler la clé sans interruption de service :
POST /account/webhook-signing-key/rotate— l'API renvoie la nouvelle clé une seule fois, accompagnée de sonhint- Acceptez les deux clés (ancienne et nouvelle) côté serveur pendant la fenêtre de propagation
- Confirmez la bascule avec
GET /account/webhook-signing-key: lehintrenvoyé doit correspondre à l'empreinte de la nouvelle clé - Une fois certain que tous les événements en vol sont délivrés (< 2h après le circuit breaker), retirez l'ancienne
Le renouvellement invalide l'ancienne clé immédiatement côté Stream Estate, mais des événements en cours de retry peuvent encore arriver signés avec l'ancienne pendant la fenêtre de backoff.
Résoudre un échec de signature
| Symptôme | Cause probable | Correction |
|---|---|---|
| Toutes les livraisons échouent à la vérification | Mauvaise clé | GET /account/webhook-signing-key et comparez le hint avec l'empreinte de la clé que vous détenez |
| La vérification marchait, puis a cassé après un renouvellement | Ancienne clé encore déployée | Même contrôle — le hint indique la clé avec laquelle nous signons actuellement |
| Ça marche en local, ça échoue en production | Votre framework a reparsé le corps | Signez le flux brut, pas l'objet désérialisé |
| Ça marche sur la plupart des événements, échoue sur certains | Caractères non-ASCII ré-encodés | Comparez les octets reçus avec ceux que vous hachez — les noms de lieux accentués sont le déclencheur habituel |
| Tout est rejeté comme trop ancien ou trop dans le futur | Dérive de l'horloge serveur | Synchronisez en NTP ; comparez des secondes epoch à des secondes epoch |
| La signature correspond mais vous rejetez quand même | Comparaison du condensé sans le préfixe sha256=, ou l'inverse | La valeur du header est le préfixe plus le condensé hexadécimal |