← Zurück zum Blog

11.05.2026 · TUTORIAL · IMMOBILIEN-TECH · 14 MIN

Propstack-Webhooks: Listings automatisch zu Idealista, Rightmove und ImmoScout24 pushen

Propstack-Kunden, die mit mehreren Portalen arbeiten, kennen das Problem: Eine Eigenschaftsänderung im CRM landet manchmal nicht zuverlässig auf jedem Portal. Die Standard-Integrationen decken Hauptkanäle ab, aber sobald exotische Portale wie Idealista (Spanien/Portugal), Rightmove (UK) oder Boutique-Plattformen wie Savills im Spiel sind, brauchst du eine eigene Bridge. Dieses Tutorial zeigt, wie wir eine produktionsreife Webhook-Bridge mit Node.js bauen — inklusive HMAC-Signatur-Verifikation, Retry-Queue und Idempotenz-Schlüsseln.

Inhalt
  1. Warum eine Bridge — und nicht direkt anbinden?
  2. Welche Propstack-Events sind verfügbar?
  3. Architektur: Receiver, Queue, Adapter
  4. Webhook-Receiver mit Signatur-Prüfung
  5. Portal-Adapter am Beispiel Idealista
  6. Idempotenz und Retry-Strategie
  7. Drift-Pattern: Wenn Portale heimlich abweichen
  8. Deployment-Optionen: Netlify Function vs. eigener Service

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:

  1. Receiver — empfängt Propstack-Webhooks, verifiziert die Signatur, persistiert das Event in einer Datenbank, antwortet so schnell wie möglich mit 200 OK.
  2. Queue / Worker — verarbeitet die persistierten Events asynchron, ruft die passenden Portal-Adapter auf, schreibt Audit-Logs.
  3. 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:

  1. Event-ID-Lock — gleicher Event darf nicht parallel verarbeitet werden. Wir nutzen SELECT ... FOR UPDATE SKIP LOCKED in Postgres.
  2. 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 failed und alerten.
  3. 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.