Stream EstateStream Estate
Guides

Webhooks

Receive alert events in real time over an HMAC-signed webhook

Stream Estate alerts can deliver each match to an HTTP endpoint you host. The request is HMAC-SHA256 signed, retry-aware, and ships an eventId you can use as an idempotency key.

Lifecycle

  1. Create a destination — either straight from POST /alerts with notificationDestination: { "webhookUrl": "..." }, or up front via POST /account/event-destinations with your endpoint URL
  2. Save the signingKey returned in the response — it's shown once only, when the first webhook destination of your account is created, and it signs all your webhooks
  3. Receive events at your URL. Every time an alert matches, the API posts signed JSON
  4. Reply 2xx within 30 seconds — any other response triggers up to 4 retries with exponential back-off
  5. Rotate the key when needed from the console: console.stream.estate/webhooks (the previous key is invalidated immediately)

Request headers

HeaderExampleDescription
X-Webhook-Timestamp1716304200Unix epoch seconds — an absolute instant, no timezone conversion needed. Part of the signed payload, to prevent replays.
X-Webhook-Signaturesha256=5d41402a...sha256={hex_hmac} — HMAC-SHA256 of {timestamp}.{raw_body} using your signingKey.
User-AgentStreamEstate-Webhook/1.0Always exactly this value.

Event types

eventTypeTrigger
NEW_MATCHA new property matches the alert's criteria
PRICE_CHANGEThe price of a matched property changed
OTHER_CHANGEAnother attribute (area, status…) changed
EXPIREDA matched property left the market
REACTIVATEDA matched property returned to the market

Sample 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": "Paris 11e flats"
  },
  "listing": {
    "id": "01929ec5-1a2b-7000-cccc-d00000000000"
  },
  "data": {
    "id": "019dedb1-fdf4-714c-8392-5b9cfd63cde3",
    "property":   { "propertyType": "FLAT", "transaction": { "type": "SELL" } /* … full canonical view */ },
    "publishers": [ /* deduplicated PublisherSection[] */ ],
    "listings":   [ /* ListingSection[] */ ]
  }
}

The payload embeds the full property representation under data — the exact body returned by GET /properties/{id} (id, property, publishers, listings) — so you can ingest the match without an extra (billed) API call. data reflects the freshest indexed state at delivery time, not a point-in-time snapshot of the event. listing.id identifies the listing (within data.listings) whose update triggered the event, and eventId is your idempotency key.

Verifying the signature

Every delivery is signed with your signingKey using HMAC-SHA256. What is signed is not the body alone: it is the string {X-Webhook-Timestamp}.{raw_body} — the timestamp, a literal dot, then the exact bytes of the request body.

To verify a delivery, in any language:

  1. Read X-Webhook-Timestamp and reject the request if it drifts more than 5 minutes from your own clock — in either direction.
  2. Build the signed payload: the timestamp, ., then the raw request body.
  3. Compute HMAC-SHA256(signed_payload, signingKey) and hex-encode it. The header value is that digest prefixed with sha256=.
  4. Compare with X-Webhook-Signature using a constant-time comparison — never ==, which leaks the expected signature through its timing.

Why the timestamp is part of the signature

A signature alone proves the delivery came from us and was not altered. It does not prove it is recent: an intercepted request stays valid forever, and can be replayed at any time.

The timestamp closes that hole, but only because both halves are in place. It sits inside the signed payload, so an attacker cannot change it without invalidating the signature; and your ±5 minute window means an old timestamp cannot be reused. Skip the drift check and the timestamp stops protecting anything.

The 5 minutes are a tolerance for clock drift between our servers and yours — not a delivery deadline. Keep your server clock synced with NTP. X-Webhook-Timestamp is Unix epoch seconds, so compare it against your own epoch (time(), Date.now() / 1000); there is no timezone to convert.

Retries do not fail the drift check. We generate a fresh timestamp and a fresh signature on every delivery attempt. An event retried two hours later, or redelivered days later after a suspended destination is reactivated, arrives stamped with the moment it was sent — never the moment the event occurred.

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

Sign the request's raw body, never a re-serialized version. Any reformatting — whitespace, JSON key reorder, re-encoding — breaks the signature.

The usual culprit is your framework's body parser, which consumes the raw stream before your handler runs. Express needs express.raw(), FastAPI await request.body(), Rails request.raw_post.

Watch out for non-ASCII characters in particular: we emit UTF-8 unescaped, so a listing in Île-de-France travels as those bytes, not as Île-de-France. Code that re-encodes the payload will verify fine in testing and fail on real French listings.

Idempotency

The same eventId may arrive more than once — a retry after a Stream Estate-side timeout does not mean your previous handler failed. Store processed eventIds and skip duplicates:

The table needs id as a primary key or unique index — that constraint is what makes the check atomic.

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; // already handled, ack 200

  await processMatch(payload);
}

Retries and circuit breaker

A non-2xx response, a timeout (> 30s), or a connection error triggers a retry:

AttemptDelay from previous
1 (initial)immediate
2~1 minute
3~5 minutes
4~30 minutes
5~2 hours

After 5 consecutive failures, the destination flips to SUSPENDED (circuit breaker). It is then probed automatically (every 5 minutes, then at a growing interval up to 6 h) and reactivates on its own as soon as your endpoint answers, replaying the suspended notifications. To skip the wait for the next probe, reactivate it from the console: console.stream.estate/webhooks. Details and retention cap: Notifications — circuit breaker.

Rotating the signing key

Rotation is done from the console, at console.stream.estate/webhooks. The new key is shown once only; the old one is invalidated immediately on Stream Estate's side.

Events currently in retry may still arrive signed with the old key during the back-off window. Accept both keys on your endpoint while the change propagates (< 2h), then drop the old one.

Troubleshooting signature failures

SymptomLikely causeFix
Works locally, fails in productionYour framework re-parsed the bodySign the raw stream, not the deserialized object
Works on most events, fails on someNon-ASCII characters re-encodedCompare the received bytes with what you hash — accented place names are the usual trigger
Everything rejected as too old or too far in the futureServer clock driftSync with NTP; compare epoch seconds against epoch seconds
Signature matches but you still rejectComparing the digest without the sha256= prefix, or the other way roundThe header value is the prefix plus the hex digest
Version 0.14.289Last updated

On this page