Stream EstateStream Estate
Guides

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

  1. Créez une destination — soit directement via POST /alerts avec notificationDestination: { "webhookUrl": "..." }, soit à l'avance via POST /account/event-destinations avec l'URL de votre endpoint
  2. Stockez la signingKey renvoyé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
  3. Recevez les événements sur votre URL. À chaque alerte qui match, l'API envoie un POST JSON signé
  4. Répondez 2xx sous 30 secondes — toute autre réponse déclenche jusqu'à 4 retries en backoff exponentiel
  5. Renouvelez la clé si besoin depuis la console : console.stream.estate/webhooks (l'ancienne est invalidée immédiatement)

Headers de la requête

HeaderExempleDescription
X-Webhook-Timestamp1716304200Timestamp 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-Signaturesha256=5d41402a...sha256={hex_hmac} — HMAC-SHA256 de {timestamp}.{raw_body} avec votre signingKey.
User-AgentStreamEstate-Webhook/1.0Toujours cette valeur exacte.

Types d'événement

eventTypeDéclencheur
NEW_MATCHUn nouveau bien correspond aux critères de l'alerte
PRICE_CHANGELe prix d'un bien matché a changé
OTHER_CHANGEUn autre attribut (surface, statut…) a changé
EXPIREDUn bien matché a quitté le marché
REACTIVATEDUn bien matché est revenu sur le marché

Exemple de payload

POST https://your-app.com/webhooks/stream-estate
{
  "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 :

  1. Lisez X-Webhook-Timestamp et rejetez la requête si l'écart avec votre propre horloge dépasse 5 minutes — dans un sens comme dans l'autre.
  2. Construisez le payload signé : le timestamp, ., puis le corps brut de la requête.
  3. Calculez HMAC-SHA256(payload_signé, signingKey) et encodez-le en hexadécimal. La valeur du header est ce condensé préfixé de sha256=.
  4. Comparez avec X-Webhook-Signature en 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),
  );
}

Signez 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.

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);
}

Retries et circuit breaker

Une réponse non-2xx, un timeout (> 30s), ou une erreur de connexion déclenche un retry :

TentativeDé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). Elle est ensuite sondée automatiquement (toutes les 5 minutes, puis à intervalle croissant jusqu'à 6 h) et se réactive d'elle-même dès que votre endpoint répond, avec rejeu des notifications suspendues. Pour ne pas attendre la prochaine sonde, réactivez-la depuis la console : console.stream.estate/webhooks. Détails et plafond de conservation : Notifications — circuit breaker.

Renouvellement de la clé

Le renouvellement se fait depuis la console, sur console.stream.estate/webhooks. La nouvelle clé n'est affichée qu'une seule fois ; l'ancienne est invalidée immédiatement côté Stream Estate.

Des événements en cours de retry peuvent encore arriver signés avec l'ancienne clé pendant la fenêtre de backoff. Acceptez les deux clés côté serveur le temps de la propagation (< 2h), puis retirez l'ancienne.

Résoudre un échec de signature

SymptômeCause probableCorrection
Ça marche en local, ça échoue en productionVotre framework a reparsé le corpsSignez le flux brut, pas l'objet désérialisé
Ça marche sur la plupart des événements, échoue sur certainsCaractères non-ASCII ré-encodésComparez 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 futurDérive de l'horloge serveurSynchronisez en NTP ; comparez des secondes epoch à des secondes epoch
La signature correspond mais vous rejetez quand mêmeComparaison du condensé sans le préfixe sha256=, ou l'inverseLa valeur du header est le préfixe plus le condensé hexadécimal
Version 0.14.289Dernière mise à jour

Sur cette page