Warum eine Bridge — und nicht direkt anbinden?
Propstack hat eine starke native Anbindung an die großen DACH-Portale (ImmoScout24, Immowelt, eBay Kleinanzeigen). Wer aber zusätzlich auf internationale Boutique-Portale setzt — Savills Global, Knight Frank, Idealista, Rightmove, James Edition — bekommt entweder gar keine native Lösung oder muss mit Datenexporten arbeiten, die einmal pro Nacht laufen. Für ein Premium-Maklerbüro mit zeitkritischem Inventar ist das zu wenig.
Die Alternative: eine Webhook-Bridge, die jede Property-Änderung in Propstack innerhalb von Sekunden an die ausgewählten Portale verteilt — und zurückmeldet, ob die Veröffentlichung dort erfolgreich war.
Typische Anforderungen, die in unseren Projekten immer wieder auftauchen:
- Latenz unter einer Minute — eine Preis-Reduktion soll sofort sichtbar werden.
- Audit-Trail — wer hat wann was wohin geschickt, war die Antwort erfolgreich?
- Selektives Targeting — nicht jedes Inserat geht auf jedes Portal (Luxus-Listings nur auf Savills, Standard-Bestand nur auf IS24).
- Recovery — wenn ein Portal ausfällt, darf die Bridge nicht das ganze Listing-Verteilen blockieren.
Welche Propstack-Events sind verfügbar?
Propstack ruft konfigurierte Webhook-URLs bei mehreren Events auf. Die für eine Portal-Bridge wichtigsten:
property.created— neues Objekt im CRM erfasst.property.updated— Felder geändert (Preis, Beschreibung, Adresse).property.deleted— Objekt gelöscht oder archiviert.property.published/property.unpublished— explizite Status-Wechsel.media.added/media.removed— Foto- oder Dokument-Updates.
Die Payloads sind JSON und enthalten die Propstack-Property-ID, einen Snapshot der relevanten Felder und einen changed-Block mit den genauen Diffs. Wer auf media.added reagiert, sollte die Property neu komplett pushen — sonst geht die Reihenfolge der Bilder verloren.
Architektur: Receiver, Queue, Adapter
Die Bridge hat drei klare Schichten:
- Receiver — empfängt Propstack-Webhooks, verifiziert die Signatur, persistiert das Event in einer Datenbank, antwortet so schnell wie möglich mit
200 OK. - Queue / Worker — verarbeitet die persistierten Events asynchron, ruft die passenden Portal-Adapter auf, schreibt Audit-Logs.
- Portal-Adapter — kapselt die Eigenheiten jedes Portals (Auth, Datenformat-Mapping, Rate-Limits). Pro Portal eine eigene Klasse.
Warum so getrennt? Wenn Idealista mal langsam antwortet, soll die Propstack-Webhook-Antwort nicht in den Timeout laufen. Propstack retried bei 5xx-Fehlern aggressiv — Doppel-Pushes wären die Folge. Receiver immer schnell ack-en, Verarbeitung asynchron.
Webhook-Receiver mit Signatur-Prüfung
Propstack signiert jeden Webhook-Request mit einem HMAC-SHA256 über den Raw-Body, der Schlüssel kommt aus der Webhook-Konfiguration. Wichtig: gegen den Raw-Body verifizieren, nicht gegen den geparsten JSON-String. JSON.stringify reordnet Properties, das zerstört die Signatur.
// receiver.js — Express + Postgres
import express from 'express';
import crypto from 'node:crypto';
import { Pool } from 'pg';
const app = express();
const db = new Pool({ connectionString: process.env.DATABASE_URL });
// Raw-Body als Buffer erhalten, NICHT als JSON parsen lassen
app.post('/webhooks/propstack', express.raw({ type: 'application/json', limit: '2mb' }), async (req, res) => {
const signature = req.header('X-Propstack-Signature');
const expected = crypto.createHmac('sha256', process.env.PROPSTACK_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!signature || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
const eventId = req.header('X-Propstack-Event-Id') ?? crypto.randomUUID();
// Idempotenz: gleicher Event-Id darf nicht doppelt verarbeitet werden
await db.query(
`INSERT INTO webhook_events (event_id, type, payload, status, received_at)
VALUES ($1, $2, $3, 'queued', NOW())
ON CONFLICT (event_id) DO NOTHING`,
[eventId, payload.event, payload]
);
res.status(200).send('ok');
});
app.listen(process.env.PORT ?? 3000);
Drei Dinge, die kritisch sind:
- Timing-Safe-Compare — Standard-String-Vergleich leakt über Side-Channels die Signatur.
- ON CONFLICT DO NOTHING — wenn Propstack retried (z. B. weil unsere Antwort nicht ankam), bleibt der Event genau einmal in der Queue.
- Schnelle Antwort — kein Portal-Call im Receiver. Antwortzeit unter 200 ms ist das Ziel.
Portal-Adapter am Beispiel Idealista
Idealista verlangt einen ganz eigenen XML-Feed-Standard (Idealista Pro Standard) und einen Push-via-FTP/SFTP-Mechanismus oder, neuerdings, eine REST-API mit OAuth 2.0. Wir nehmen die REST-Variante:
// adapters/idealista.js
import { mapPropstackToIdealista } from './mapping.js';
const IDEALISTA_BASE = 'https://api.idealista.com/3.5';
async function getToken() {
const res = await fetch('https://api.idealista.com/oauth/token', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.IDEALISTA_API_KEY}:${process.env.IDEALISTA_API_SECRET}`
).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'grant_type=client_credentials&scope=publish',
});
if (!res.ok) throw new Error(`Idealista token error: ${res.status}`);
return (await res.json()).access_token;
}
export async function pushToIdealista(propstackProperty) {
const token = await getToken();
const idealistaPayload = mapPropstackToIdealista(propstackProperty);
const res = await fetch(`${IDEALISTA_BASE}/properties/${propstackProperty.id}`, {
method: 'PUT', // PUT = upsert mit externer Referenz-ID
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(idealistaPayload),
});
if (!res.ok) {
const body = await res.text();
const isRetryable = res.status >= 500 || res.status === 429;
const error = new Error(`Idealista ${res.status}: ${body}`);
error.retryable = isRetryable;
error.statusCode = res.status;
throw error;
}
return res.json();
}
Das Feld-Mapping mapPropstackToIdealista ist der eigentliche Aufwand — Propstack-Felder wie property_type, energy_certificate_value, address.city auf das Idealista-Schema umzubiegen, inklusive Enum-Übersetzungen (z. B. Heizungsart, Bauphase). Diese Mapping-Schicht baust du pro Portal einmal sauber auf und kapselst dort auch die Tarif-, Region- und Branding-Spezifika.
Idempotenz und Retry-Strategie
Im Worker, der die Queue abarbeitet, brauchst du drei Mechanismen:
- Event-ID-Lock — gleicher Event darf nicht parallel verarbeitet werden. Wir nutzen
SELECT ... FOR UPDATE SKIP LOCKEDin Postgres. - Exponentielles Backoff — bei 5xx oder 429 versuchen wir es mit Wait-Times von 30 s, 2 min, 10 min, 1 h. Nach fünf Versuchen markieren wir das Event als
failedund alerten. - Outcome-Logging — pro Portal-Push speichern wir Request-/Response-Snapshot, damit Support-Cases nachvollziehbar sind.
// worker.js — vereinfachte Worker-Loop
import { pushToIdealista } from './adapters/idealista.js';
import { pushToImmoScout } from './adapters/immoscout.js';
const PORTAL_HANDLERS = {
idealista: pushToIdealista,
immoscout: pushToImmoScout,
};
async function processNextEvent() {
const event = await db.one(`
SELECT * FROM webhook_events
WHERE status = 'queued'
AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())
ORDER BY received_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
`);
if (!event) return false;
const portals = decideTargetPortals(event.payload);
for (const portal of portals) {
try {
const result = await PORTAL_HANDLERS[portal](event.payload);
await db.query(
`INSERT INTO portal_pushes (event_id, portal, status, response) VALUES ($1, $2, 'success', $3)`,
[event.event_id, portal, result]
);
} catch (err) {
const attempt = (event.attempts ?? 0) + 1;
const nextAttempt = err.retryable && attempt < 5
? new Date(Date.now() + Math.pow(4, attempt) * 1000)
: null;
await db.query(
`UPDATE webhook_events SET attempts = $2, next_attempt_at = $3,
status = CASE WHEN $3 IS NULL THEN 'failed' ELSE 'queued' END
WHERE event_id = $1`,
[event.event_id, attempt, nextAttempt]
);
// Notify Sentry, Slack, etc.
}
}
return true;
}
setInterval(() => { processNextEvent().catch(console.error); }, 1000);
Der Worker läuft als eigenständiger Prozess (z. B. auf Fly.io oder Railway), nicht als Netlify Function — Background-Tasks sind in Serverless schwer effizient zu betreiben, und der Worker muss kontinuierlich pollen.
Drift-Pattern: Wenn Portale heimlich abweichen
In unseren Propstack-Integrationen sehen wir immer wieder dasselbe Problem: Ein Portal akzeptiert einen Push mit 200 OK, aber das veröffentlichte Inserat sieht hinterher anders aus als geschickt — eine Etage wird ignoriert, eine Innenausstattungs-Kategorie wurde stillschweigend auf einen anderen Wert gemappt, ein Bild fehlt.
Drei Gegenmittel, die wir produktiv einsetzen:
- Tägliche Reconciliation — Worker zieht den aktuellen Stand der externen Portale (per API oder Scraping), vergleicht ihn mit dem zuletzt gepushten Snapshot und alarmiert bei Abweichungen.
- Field-level Audit — pro Property speichern wir den exakten Wert, der bei jedem Push übertragen wurde. Driftet das Portal später, ist der Verursacher klar.
- Manueller Re-Sync-Knopf — Makler hat ein UI-Element im CRM, das eine sofortige Re-Veröffentlichung erzwingt, falls eine Drift entdeckt wurde.
Deployment-Optionen: Netlify Function vs. eigener Service
Für den Receiver reicht eine Serverless Function (Netlify, Vercel, Cloudflare Workers): kurz, schnell, kein State. Für den Worker brauchst du einen dauerhaften Prozess mit Datenbankverbindung — eine kleine VM bei Hetzner, ein Fly.io-Machine oder ein Railway-Service reichen. Für Postgres nutzen wir gerne Supabase oder Neon (Free Tier reicht für die meisten Maklerbüros).
Wenn du eine produktive Bridge brauchst und nicht alles selbst bauen willst: Wir haben für mehrere Premium-Maklerbüros in DACH und Südeuropa genau solche Bridges aufgesetzt — von einzelnen Portalen bis zu 14-fach-Veröffentlichungen aus einem Propstack-Mandant. Mehr dazu unter Propstack-Entwicklung oder direkt anfragen über Projekt starten.
Weitere Themen: Propstack-Entwicklung · ImmoScout24-API anbinden · n8n-Automatisierung · Web Security: Webhook-Signaturen & Co.