Skip to content

Open Beta – help us test! All listings are examples only.

Sync listings from your CRM

Push and upsert listings into WOHNO from your own CRM/ERP using external_ref, idempotency keys and signed image uploads.

This guide shows how to keep WOHNO in sync with the listings you already manage in your own CRM or ERP — creating, updating and de-duplicating listings from a server, uploading images, and reacting to changes via webhooks.

The problem this solves: your source of truth is your CRM. You want every listing change there to flow into WOHNO automatically, idempotently, without creating duplicates on retries.

Beta / dark-shipped. The listings write surface (Plan 58) is gated behind the feature flag LISTINGS_WRITE_API_ENABLED. While the flag is off, these endpoints return 404. Ask your WOHNO contact to enable it for your organization. The write operations are x-internal and not yet shown in the public reference.

Prerequisites

  • A secret key (sk_live_…) — these endpoints are always sk-only. Publishable keys fail with 403 INSUFFICIENT_SCOPE.
  • Scope listings:write (which implies listings:read); listings:delete if you also remove listings.
  • LISTINGS_WRITE_API_ENABLED enabled for your organization.

Keep your secret key on the server. Never ship it in a browser bundle.

Step 1 — Upsert a listing keyed by your own ID

Send your CRM's stable identifier as external_ref. The first POST creates the listing; subsequent POSTs with the same external_ref update the existing one (org-scoped upsert), so you never create duplicates.

curl -X POST https://wohno.de/api/v1/listings \
  -H "X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7c9e6a3d-1f2b-4c8a-9d10-abc123def456" \
  -d '{
    "external_ref": "CRM-48217",
    "title": "Bright 2-room apartment",
    "street": "Beispielstraße",
    "house_number": "12",
    "zip_code": "10115",
    "city": "Berlin",
    "property_type": "apartment",
    "living_area": 58,
    "rooms": 2,
    "rent_cold": 980,
    "status": "draft"
  }'

Responses:

  • 201 Created with { "data": ListingPublicDto } on a fresh create.
  • 200 OK with { "data": ListingPublicDto } when the external_ref already exists (update/upsert).

status is draft (default) or active; only draft → active publishes the listing. The same moderation and geocoding pipeline as the dashboard wizard runs on every write.

Optional — assign the listing to a specific owner

By default a listing created via the API is owned by the user who created the API key. To assign it to a specific team member instead — for example the agent who manages it in your CRM — add their email as owner_email:

curl -X POST https://wohno.de/api/v1/listings \
  -H "X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "external_ref": "CRM-48217",
    "title": "Bright 2-room apartment",
    "street": "Beispielstraße",
    "zip_code": "10115",
    "city": "Berlin",
    "property_type": "apartment",
    "living_area": 58,
    "rooms": 2,
    "rent_cold": 980,
    "owner_email": "agent@example.com"
  }'
  • The email must belong to a member of the same organization (role owner, admin or member).
  • An unknown address or a non-member is rejected with 403 FORBIDDEN — deliberately with the same message, so the endpoint can't be used to probe which emails have a WOHNO account.
  • owner_email is honored only on create. On an external_ref upsert to an existing listing it is ignored — the owner is never re-homed.
  • Omit it to fall back to the API key's creator.

Tip: Point your secret key at a dedicated technical user. The listing owner is a real account; if a personal owner account is later removed, ownership does not transfer automatically. Setting owner_email per record keeps each listing attached to the right agent regardless of which key pushed it.

Step 2 — Make retries safe with Idempotency-Key

Pass a client-generated UUID v4 in the Idempotency-Key header (as above) and reuse it unchanged on network retries.

  • The key is valid for 24 hours. A retried request with the same key returns the same response without re-executing the action.
  • Sending the same key with a different body returns 409 IDEMPOTENCY_KEY_REUSED — one key is bound to exactly one request body.

Generate one key per logical CRM sync operation.

Step 3 — Update or transition the lifecycle

Use PATCH for partial updates. external_ref cannot be changed via PATCH. The lifecycle states rented and archived are reachable here:

curl -X PATCH https://wohno.de/api/v1/listings/9f1c2a3b-1111-2222-3333-444455556666 \
  -H "X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "rent_cold": 950, "status": "active" }'

Re-geocoding only runs when you change the address. A draft → active transition emits listing.published.

Step 4 — Upload images via a signed URL

Image uploads use a two-step signed-URL flow — no large binary travels through the API. First request an upload URL:

curl -X POST https://wohno.de/api/v1/listings/9f1c2a3b-1111-2222-3333-444455556666/images \
  -H "X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "filename": "living-room.jpg", "content_type": "image/jpeg", "size": 842133, "sort_order": 0 }'

Response (201):

{
  "data": {
    "image_id": "img_5f4e3d2c",
    "upload_url": "https://storage.wohno.de/...signed...",
    "upload_token": "...",
    "expires_at": "2026-06-04T12:10:00.000Z"
  }
}

Then PUT the binary directly to upload_url before expires_at. Allowed types are image/jpeg, image/png, image/webp; max size 5 MB. Remove an image with DELETE /api/v1/listings/{id}/images?image_id=img_5f4e3d2c.

Step 5 — Stay in sync via webhooks

Subscribe to listing.* events so your CRM learns about moderation outcomes and publish state changes pushed from WOHNO. See React to events with webhooks for the full setup; the relevant event types for this flow are listing.created, listing.published, listing.updated, listing.deleted, listing.moderated and listing.image.uploaded.

Error handling

CodeHTTPWhat happenedFix
INSUFFICIENT_SCOPE403Using a pk_ key or missing listings:writeUse a secret key with the write scope.
IDEMPOTENCY_KEY_REUSED409Same key, different bodyUse a fresh key per distinct payload.
VALIDATION_ERROR400Missing/invalid fields (see details.fields)Fix the body; the response lists the bad fields.
FORBIDDEN403Plan/quota limit (e.g. Free listing cap)Upgrade the plan or reduce volume.
FORBIDDEN403owner_email is not a member of your orgUse the email of an existing org member (owner/admin/member).
NOT_FOUND404Flag off, or listing belongs to another orgConfirm the flag and the listing ownership.

See the conventions reference for idempotency and error details.

Best practices

  • One external_ref per CRM record. This is what makes the sync idempotent across full re-imports.
  • Batch carefully. The rate limit is 1000 requests/hour per key; respect Retry-After on 429.
  • Upload images after the listing exists, then flip status to active.

Next steps