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
- Create a destination — either straight from
POST /alertswithnotificationDestination: { "webhookUrl": "..." }, or up front viaPOST /account/event-destinationswith your endpoint URL - Save the
signingKeyreturned in the response — it's shown once only, when the first webhook destination of your account is created, and it signs all your webhooks - Receive events at your URL. Every time an alert matches, the API posts signed JSON
- Reply 2xx within 30 seconds — any other response triggers up to 4 retries with exponential back-off
- Rotate the key when needed from the console: console.stream.estate/webhooks (the previous key is invalidated immediately)
Request headers
| Header | Example | Description |
|---|---|---|
X-Webhook-Timestamp | 1716304200 | Unix epoch seconds — an absolute instant, no timezone conversion needed. Part of the signed payload, to prevent replays. |
X-Webhook-Signature | sha256=5d41402a... | sha256={hex_hmac} — HMAC-SHA256 of {timestamp}.{raw_body} using your signingKey. |
User-Agent | StreamEstate-Webhook/1.0 | Always exactly this value. |
Event types
eventType | Trigger |
|---|---|
NEW_MATCH | A new property matches the alert's criteria |
PRICE_CHANGE | The price of a matched property changed |
OTHER_CHANGE | Another attribute (area, status…) changed |
EXPIRED | A matched property left the market |
REACTIVATED | A matched property returned to the market |
Sample payload
{
"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:
- Read
X-Webhook-Timestampand reject the request if it drifts more than 5 minutes from your own clock — in either direction. - Build the signed payload: the timestamp,
., then the raw request body. - Compute
HMAC-SHA256(signed_payload, signingKey)and hex-encode it. The header value is that digest prefixed withsha256=. - Compare with
X-Webhook-Signatureusing 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:
| Attempt | Delay 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Works locally, fails in production | Your framework re-parsed the body | Sign the raw stream, not the deserialized object |
| Works on most events, fails on some | Non-ASCII characters re-encoded | Compare the received bytes with what you hash — accented place names are the usual trigger |
| Everything rejected as too old or too far in the future | Server clock drift | Sync with NTP; compare epoch seconds against epoch seconds |
| Signature matches but you still reject | Comparing the digest without the sha256= prefix, or the other way round | The header value is the prefix plus the hex digest |