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.
Generally available. The listings write surface (Plan 58) is live and documented in the public reference — no feature flag gates it. It is secret-key only; a publishable key is rejected with
403 INSUFFICIENT_SCOPE. The write operations are not part of the marketplace launch gate, so they are available before the public launch.
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.
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": "[email protected]"
}'- 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 | Listing not found or belongs to another org | Confirm the listing ID and 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.