Sync listings from your website
Keep Gene's properties identical to your website, portal or CRM, automatically.
On this page
If your listings already live somewhere else, you do not have to type them into Gene App twice. Your website, portal or CRM can send them, and Gene App keeps Properties identical to the source: new listings appear, price and status changes arrive, and sold or removed listings disappear.
This page is for the person who will connect it, usually a developer. Everything below uses a plain HTTPS API and a key.
Which way is right for you#
- Gene App is where you keep your listings. Nothing to do. Add them in Properties.
- Your website can host a file. Use a pull feed. Gene App reads your file every so often, so you never call us. Press Sync on Properties to connect a website or JSON file, or see XML listing feeds for XML.
- Your website or CRM can call an API. Use push, which is this page. Changes reach Gene App within seconds of your system sending them.
Push is the better choice when a price or status must change the moment you change it, or when your system cannot serve a public file.
Switching from the feed to the API: your first push takes over the feed's listings; disconnect the feed afterwards, or it adds them again.
Create a key#
- Open Settings > API and press Create key.
- Name it after the system that will use it, for example "Website sync".
- Tick Create and update properties. That tick also lets the key read properties.
- Copy the key straight away. It is shown once.
Every request carries the key:
Authorization: Bearer gene_live_...
The base address is https://crm.gene.so. See API for how keys work.
ImportantEach key owns the listings it sent. A key can list, change and remove only its own listings, never listings added by hand or by another key. A new key takes over the listings a revoked or expired key sent, and external ids are unique across all keys in a workspace.
The listing object#
Every call uses the same fields. Only external_id and name are required.
Every write replaces the whole listing. Send every field every time; a field you leave out is cleared, except published, which keeps its current value.
| Field | Type | Notes |
|---|---|---|
external_id | string or number | Your own stable id for the listing. Required. Max 200 characters. Gene App matches on it, so a listing keeps its record when its price changes. |
name | string | Required. Max 300 characters. |
location | string | Max 500 characters. |
type | string | For example "Villa" or "Apartment". Max 120. |
bedrooms | whole number | 0 to 100. |
price | number | 0 or more. |
currency | string | For example EUR. Max 8 characters. Send the listing's own currency. |
status | string | For example "Available" or "Sold". Max 120. |
description | string | Max 8,000 characters. |
image_urls | list of strings | Public URLs, at most 50. |
url | string | The listing's page on your site. Must start with https://. |
updated_at | date and time | ISO 8601 with a timezone, for example 2026-10-22T09:30:00Z. |
published | true or false | Send false to keep a listing in Gene App but off your portal, and true to put it back. Leave it out and a new listing follows your portal's default, while an existing listing keeps whatever it has now. |
kind | project, masterplan or unit | Defaults to project. |
custom | object | Any extra facts you want Gene App to know, such as floor, view or payment plan. Kept as sent. |
Fields Gene App does not know are refused with a 400 rather than ignored. That includes id and tenant_id.
file_urls (brochures and floor plans, up to 20) is also accepted in the same body as on the pull feed.
Send one listing#
PUT /api/v1/listings/{external_id} creates the listing or updates it. The id in the path wins over any external_id in the body. A new listing returns 201, an update returns 200, both with the saved listing.
curl -X PUT https://crm.gene.so/api/v1/listings/VILLA-204 \
-H "Authorization: Bearer gene_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: villa-204-2026-10-22T09:30" \
-d '{
"name": "Villa Serena",
"location": "Canggu, Bali",
"type": "Villa",
"bedrooms": 3,
"price": 420000,
"currency": "USD",
"status": "Available",
"description": "Three bedroom villa, 600 m from the beach.",
"image_urls": ["https://example.com/serena-1.jpg"],
"url": "https://example.com/villas/serena",
"updated_at": "2026-10-22T09:30:00Z",
"published": true,
"kind": "unit",
"custom": { "handover": "Q4 2027" }
}'
To take one listing down, delete it. A successful delete returns 204 with no body. An id this key never sent returns 404.
curl -X DELETE https://crm.gene.so/api/v1/listings/VILLA-204 \
-H "Authorization: Bearer gene_live_..."
GET /api/v1/listings returns the listings this key has sent, newest change first, and GET /api/v1/listings/{external_id} returns one. Reading needs the key to be able to read properties, which the change tick includes.
Send many at once#
POST /api/v1/listings/batch takes up to 500 listings to add or update and up to 500 ids to remove, in one call. Either list can be left out.
curl -X POST https://crm.gene.so/api/v1/listings/batch \
-H "Authorization: Bearer gene_live_..." \
-H "Content-Type: application/json" \
-d '{
"upsert": [
{
"external_id": "VILLA-204",
"name": "Villa Serena",
"location": "Canggu, Bali",
"type": "Villa",
"bedrooms": 3,
"price": 410000,
"currency": "USD",
"status": "Available",
"description": "Three bedroom villa, 600 m from the beach.",
"image_urls": ["https://example.com/serena-1.jpg"],
"url": "https://example.com/villas/serena",
"updated_at": "2026-10-22T09:30:00Z",
"published": true,
"kind": "unit",
"custom": { "handover": "Q4 2027" }
},
{
"external_id": "VILLA-205",
"name": "Villa Palma",
"location": "Canggu, Bali",
"type": "Villa",
"bedrooms": 2,
"price": 380000,
"currency": "USD",
"status": "Available",
"description": "Two bedroom villa with a pool.",
"image_urls": ["https://example.com/palma-1.jpg"],
"url": "https://example.com/villas/palma",
"updated_at": "2026-10-22T09:30:00Z",
"published": true,
"kind": "unit",
"custom": { "handover": "Q1 2028" }
}
],
"delete": ["VILLA-101"]
}'
The whole request is checked first. If any one listing is invalid, the call returns 400 with details naming the field, and nothing in that request is saved. Once the request is valid, one listing that fails to save does not stop the rest. The call returns 200 and reports each item:
{ "data": {
"results": [
{ "external_id": "VILLA-204", "action": "updated", "id": "..." },
{ "external_id": "VILLA-205", "action": "created", "id": "..." },
{ "external_id": "VILLA-101", "action": "deleted", "id": "..." }
],
"summary": { "created": 1, "updated": 1, "deleted": 1, "skipped": 0, "errors": 0 } } }
An action of error carries an error message for that item, and covers save failures only, never invalid fields. A listing id repeated inside one batch is reported as an error for the second copy. A delete for an id the key never sent comes back as skipped. Check summary.errors rather than only the status code.
Fix drift every night#
Events get lost. Once a night, send everything you have, and Gene App adds what is missing, updates what changed and removes what you no longer list.
curl -X PUT https://crm.gene.so/api/v1/listings \
-H "Authorization: Bearer gene_live_..." \
-H "Content-Type: application/json" \
-d '{ "listings": [
{
"external_id": "VILLA-204",
"name": "Villa Serena",
"location": "Canggu, Bali",
"type": "Villa",
"bedrooms": 3,
"price": 420000,
"currency": "USD",
"status": "Available",
"description": "Three bedroom villa, 600 m from the beach.",
"image_urls": ["https://example.com/serena-1.jpg"],
"url": "https://example.com/villas/serena",
"updated_at": "2026-10-22T09:30:00Z",
"published": true,
"kind": "unit",
"custom": { "handover": "Q4 2027" }
}
] }'
Like a batch, a snapshot is checked whole: one invalid listing returns 400 and nothing is saved, and a repeated external_id returns 422. The reply counts what happened:
{ "data": { "synced": 0, "updated": 1, "removed": 0, "total": 1 } }
A snapshot can delete a catalogue by accident, so it has a guard. When Gene App already holds 10 or more listings from the key and a snapshot would remove more than 25% of them, it adds and updates as normal but removes nothing, and the reply carries removalsRefused with the reason. The attempt is recorded as failed, with the reason. Send the full set, or remove listings one by one.
The guard does not apply below 10 listings, so never send an empty snapshot on a small catalogue unless you mean to clear it.
Retries and limits#
If a request times out, you do not know whether it landed. Send an Idempotency-Key header (any string up to 255 characters) on single and batch writes. Repeating the same key with the same body returns the first answer, with the header Idempotency-Replayed: true, and changes nothing. Reusing a key with a different body returns 409 with code idempotency_key_reused. A snapshot is safe to repeat as it is, so it does not use the header. A retried DELETE that already landed returns 404; treat 404 on DELETE as done.
| Limit | Value |
|---|---|
| Writes per key | 30 a minute |
| Snapshots per key | 10 an hour |
| Listings per batch | 500 to add or update, 500 to remove |
| Listings per snapshot | 5,000 |
| Request body | 256 KB (one listing), 1 MB (a batch), 4 MB (a snapshot) |
| Photos per listing | 50 |
| All requests per key | 120 a minute |
A body over its limit returns 413. At about 1 KB per listing a snapshot fits roughly 4,000 listings. Above that, send batches with explicit deletes; there is no nightly self-heal, so keep a list of what you have retired.
Errors#
Every failure has the same shape:
{ "error": { "type": "authorization_error", "message": "This API key is missing the \"properties:write\" scope.", "code": "missing_scope" } }
| Status | Code or type | Meaning |
|---|---|---|
| 400 | validation_error | The body is not valid JSON, has a field Gene App does not know, or a value breaks the table above, in any listing of the request. details names the fields. Nothing in the request is saved. |
| 401 | authentication_error | No key, a wrong key, an expired key or a revoked key. |
| 403 | missing_scope | The key was not given Create and update properties. Create a new key, as ticks cannot be changed. |
| 404 | not_found | No listing with that id from this key. |
| 409 | idempotency_key_reused | The same Idempotency-Key was sent with a different body. |
| 409 | conflict | The external_id belongs to another active key in this workspace. In a batch this is that item's result. |
| 413 | validation_error | The body is over its limit (256 KB for one listing, 1 MB for a batch, 4 MB for a snapshot). |
| 422 | validation_error | One listing could not be saved, or a snapshot repeats an external_id. The message says why. |
| 429 | rate_limited | Too many writes or snapshots. The message says how many seconds to wait; a snapshot's also comes as a Retry-After header. |
See whether it is working#
Open Properties and press Sync. The Sources list shows each connection with a coloured dot, its listing count and when it last synced. Each source is in one of four states:
- healthy (green dot): something succeeded in the last 24 hours (30 hours for a feed).
- stale (amber dot): the last success is older than that. Run your nightly snapshot and it clears.
- failing (red dot): 5 attempts in a row have failed since the last success. The error is shown under the source.
- never (grey dot): nothing has ever succeeded.
Stale and failing are recomputed every hour at :15, so a source can show an old state for up to an hour.
Your system can read the same thing:
curl https://crm.gene.so/api/v1/sync/status \
-H "Authorization: Bearer gene_live_..."
Each source returns kind, label, state, items_total, failures_24h, last_attempt_at, last_success_at and last_error.
What stays editable in Gene App#
Open a pushed listing in Properties and the editor says Synced through the API. Edit the greyed-out fields at the source. The fields your system sends (name, location, type, bedrooms, price, status, description, photos and the custom fields it sends) are greyed out, because the next push would overwrite a hand edit. Everything else stays editable: area, city, country and map pin, baths, size, contacts, the selling agent, documents, units, the portal settings and marketplaces.
To edit one by hand anyway, press Detach. The listing becomes an ordinary one and your system stops updating it. If your system sends the same external_id again, Gene App adds it as a new listing.
Let Claude Code write it for you#
Paste this into Claude Code, from the folder of the project that holds your listings. Replace the part in brackets.
Read https://crm.gene.so/docs/properties/listings-sync. If you cannot fetch it, use the facts below. My project is [stack, for example "a Next.js site with listings in Postgres"].
Facts about the Gene App listings API (base https://crm.gene.so, header Authorization: Bearer $GENE_API_KEY):
- PUT /api/v1/listings/{external_id} creates or updates one listing. DELETE the same path removes it. A repeated DELETE returns 404, which means it is already gone.
- POST /api/v1/listings/batch takes {"upsert": [listing, ...], "delete": [external_id, ...]}, up to 500 of each.
- PUT /api/v1/listings takes {"listings": [listing, ...]}, up to 5,000, and makes Gene App match it exactly (it removes what is not listed).
- A listing needs external_id (max 200 characters) and name. Other fields: location, type, bedrooms, price, currency, status, description, image_urls, url, updated_at, published, kind, custom. Unknown fields are refused with 400, and one invalid listing fails the whole request.
- Every write replaces the whole listing. Send every field every time; a field left out is cleared.
- Limits: 30 writes a minute, 10 snapshots an hour, 120 requests a minute. Body at most 256 KB for one listing, 1 MB for a batch, 4 MB for a snapshot. Every write replaces the whole listing; a field left out is cleared, except published.
- Retry on 429 (wait the seconds the message says) and 5xx with a backoff. Send an Idempotency-Key header on single and batch writes.
Build a sync that pushes my listings to Gene App:
1. Map each of my listings to the listing object. Use my stable listing id as external_id and the listing's own currency.
2. Call PUT /api/v1/listings/{external_id} whenever a listing is created or changed, and DELETE when it is removed.
3. Add a nightly job that sends the full set to PUT /api/v1/listings.
4. Read the key from the environment variable GENE_API_KEY, never from the code.
5. Log the response of every call and stop to tell me if GET /api/v1/sync/status reports failing.