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 return404. Ask your WOHNO contact to enable it for your organization. The write operations arex-internaland not yet shown in the public reference.
Prerequisites
- A secret key (
sk_live_…) — these endpoints are always sk-only. Publishable keys fail with403 INSUFFICIENT_SCOPE. - Scope
listings:write(which implieslistings:read);listings:deleteif you also remove listings. LISTINGS_WRITE_API_ENABLEDenabled 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 Createdwith{ "data": ListingPublicDto }on a fresh create.200 OKwith{ "data": ListingPublicDto }when theexternal_refalready 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,adminormember). - 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_emailis honored only on create. On anexternal_refupsert 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_emailper 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
| Code | HTTP | What happened | Fix |
|---|---|---|---|
INSUFFICIENT_SCOPE | 403 | Using a pk_ key or missing listings:write | Use a secret key with the write scope. |
IDEMPOTENCY_KEY_REUSED | 409 | Same key, different body | Use a fresh key per distinct payload. |
VALIDATION_ERROR | 400 | Missing/invalid fields (see details.fields) | Fix the body; the response lists the bad fields. |
FORBIDDEN | 403 | Plan/quota limit (e.g. Free listing cap) | Upgrade the plan or reduce volume. |
FORBIDDEN | 403 | owner_email is not a member of your org | Use the email of an existing org member (owner/admin/member). |
NOT_FOUND | 404 | Flag off, or listing belongs to another org | Confirm the flag and the listing ownership. |
See the conventions reference for idempotency and error details.
Best practices
- One
external_refper 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-Afteron429. - Upload images after the listing exists, then flip
statustoactive.
Next steps
- React to events with webhooks — get pushed updates instead of polling.
- Embed listings on your website — surface the synced listings publicly.
- API Reference — listing write input fields.