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é à tout moment via POST /account/webhook-signing-key/rotate (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
ADDITIONAL_LISTINGUn listing supplémentaire est apparu sur un bien déjà matché
PRICE_CHANGEDLe prix d'un bien matché a changé
ANY_ATTRIBUTE_CHANGEDUn autre attribut (surface, statut…) a changé
LISTING_EXPIREDUn listing matché a été retiré ou expiré
LISTING_REACTIVATEDUn listing matché précédemment expiré est réactivé

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

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.

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ée
import 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)
end

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). 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 :

  1. POST /account/webhook-signing-key/rotate — l'API renvoie la nouvelle clé une seule fois, accompagnée de son hint
  2. Acceptez les deux clés (ancienne et nouvelle) côté serveur pendant la fenêtre de propagation
  3. Confirmez la bascule avec GET /account/webhook-signing-key : le hint renvoyé doit correspondre à l'empreinte de la nouvelle clé
  4. 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ômeCause probableCorrection
Toutes les livraisons échouent à la vérificationMauvaise 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 renouvellementAncienne clé encore déployéeMême contrôle — le hint indique la clé avec laquelle nous signons actuellement
Ç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.1.116Dernière mise à jour

Sur cette page