Vesta API
Las integraciones de Vesta que puedes conectar sin riesgo: envía propiedades y contactos a una cuenta, y consume los anuncios publicados como feed JSON o XML. Vesta te facilita el token y el accountId.
Enviar propiedades
Send a property to Vesta from your system, and (optionally) publish it to your website, Fotocasa and Idealista — all in one HTTPS call.
- Base URL:
https://vesta-crm.com - Content type:
application/json - Auth: per-account Bearer token
- Endpoints:
GET /api/listings/external/validate— check your token (no data created)POST /api/listings/external/ingest— send/update a property (and, optionally, its owner contact)
You send one flat object describing the property. You don't need to know anything about how Vesta stores it internally — just send the fields you have. The only field you must keep stable across calls is
reference.
Publishing to Fotocasa and Idealista is documented separately. Those calls put ads live, take them down and spend the agency's portal slots, so they are not part of this page. If your integration needs them, ask Vesta for the portal publishing reference.
1. Authentication#
Every request needs:
- The account in the URL:
?account=<accountId>. - Your secret token, as a header (preferred) or query param:
Authorization: Bearer <token>- or
?token=<token>
POST /api/listings/external/ingest?account=<ACCOUNT_ID>
Authorization: Bearer <token>
Content-Type: application/json
Vesta gives you the token and the account id. Until the token is set, the
account returns 401.
Validate your token#
Before sending anything, you can confirm an agency's token is valid — useful for a "connect your CRM" step. It creates nothing.
GET /api/listings/external/validate?account=<ACCOUNT_ID>
Authorization: Bearer <token>
200 { "valid": true }— token is good.401 { "valid": false }— missing/wrong token, or the API isn't enabled for this account.
Who is pushing: the acting user (optional)#
The token identifies the agency, not the person. If your system has its own
logged-in users, send who they are: Vesta assigns the property to that agent
instead of the account's default one, and any owner contact it creates becomes
theirs too.
X-Partner-User-Email: maria@agencia.com ← what we match on
X-Partner-User-Id: 4821 ← your own id, for our logs
(X-Betterplace-User-Email / X-Betterplace-User-Id are accepted as aliases.)
- The address doesn't have to match exactly. We compare it against the
active users of that same account and take the closest one, so
maria.garcia@tusistema.comstill findsmaria@agencia.com. Capitals, dots and+tagsare ignored, and the agent's name counts too (mgarcia@finds "María García"). - Never a guess between colleagues. If two agents are equally close, nobody is picked — better the default agent than the wrong one.
- A miss is never an error. The property still comes in, assigned to the
account's default agent, and the response carries
"agentMatched": falseso you can spot users you haven't mapped yet. The field only appears when you sent the header. - An explicit
agentIdin the body wins over the header. - New listings only. Re-sending an existing
referencenever moves the property to a different agent — the agency may have reassigned it on purpose.
2. POST /api/listings/external/ingest#
Creates a property, or updates it if you reuse a reference you've sent
before (see Idempotency). Send only the fields you have — anything you omit
keeps its previous value.
Required fields#
| Field | Type | Notes |
|---|---|---|
reference |
string (1–100) | Your unique id for this property. The update key. |
operation |
enum | Sale | Rent | RentWithOption | RoomSharing | Transfer. |
price |
number | In euros. |
Everything else (all optional)#
Status & text#
| Field | Type | Notes |
|---|---|---|
status |
enum | Draft | En Venta | En Alquiler | Vendido | Alquilado | Descartado | Pendiente. Defaults to Draft. Use an active status (e.g. En Venta) if you plan to publish. |
title |
string | Headline. |
description |
string | Full description (also sent to portals). |
shortDescription |
string | Short summary. |
sourceUrl |
string (URL) | Original/public URL of the ad you sourced this from. Stored for traceability. |
source |
string (slug) | Who is sending — your integration's lowercase slug (agreed with Vesta), [a-z0-9_-] only. Stamped on the property and the contact for attribution. Some sources get channel-specific behavior (see Owner contact). |
Type#
| Field | Type | Notes |
|---|---|---|
propertyType |
enum | piso | casa | local | solar | garaje | edificio | oficina | industrial | trastero. Defaults to piso. |
propertySubtype |
string | e.g. Ático, Dúplex, Chalet, Local Comercial. |
Size & rooms#
| Field | Type | Notes |
|---|---|---|
squareMeter |
number | Useful/interior area (m²). |
builtSurfaceArea |
number | Constructed area (m²). |
bedrooms |
integer | |
bathrooms |
number | Halves allowed (e.g. 2.5). |
yearBuilt |
integer | 4-digit year. |
Address & location#
| Field | Type | Notes |
|---|---|---|
street |
string | Street name + number. |
addressDetails |
string | Extra address line. |
floor |
integer | 0=ground, negative=basement, 1+=upper. |
door |
string | e.g. A, Izq. |
postalCode |
string | |
latitude / longitude |
number | Decimal degrees. |
cadastralReference |
string | Referencia catastral. |
city / province / municipality |
string | Used to place the property in the right zone. |
district |
string | null | Distrito — the grouping above the barrio (e.g. Salamanca over Recoletos). When sent, the barrio rolls up to it. Send null/omit when the source has no distrito. Max 100 chars. |
neighborhood |
string | null | Barrio; send null if none. |
Condition#
| Field | Type | Notes |
|---|---|---|
conservationStatus |
integer | 1=Bueno, 2=Muy bueno, 3=Como nuevo, 4=A reformar, 6=Reformado. 5 no existe — se rechaza con 400. |
orientation |
enum | Lowercase, e.g. sur, norte — see Allowed values. |
exterior |
boolean | true=exterior (windows face outside), false=interior. |
Amenities#
| Field | Type | Field | Type |
|---|---|---|---|
hasElevator |
boolean | terrace |
boolean |
hasGarage |
boolean | balconyCount |
integer |
garageSpaces |
integer | garden |
boolean |
hasStorageRoom |
boolean | pool |
boolean |
builtInWardrobes |
boolean | communityPool |
boolean |
airConditioning |
boolean | privatePool |
boolean |
petsAllowed |
boolean | fireplace |
boolean |
heatingType |
enum (see Allowed values) | disabledAccessible |
boolean |
disabledAccessible = adapted for reduced mobility; feeds Idealista's adapted-access and adapted-use flags.
Community & building amenities (Fotocasa)#
All boolean unless noted. Map to Fotocasa community/luxury features.
| Field | Type | Field | Type |
|---|---|---|---|
gym |
boolean | videoIntercom |
boolean |
sportsArea |
boolean | homeAutomation |
boolean |
childrenArea |
boolean | suiteBathroom |
boolean |
communityArea |
boolean | laundryRoom |
boolean |
tennisCourt |
boolean | patio |
boolean |
sauna |
boolean | hasHeating |
boolean |
jacuzzi |
boolean | terraceSize |
integer (m²) |
superficieFinca |
number (m²) | mainFloorType |
string (e.g. parquet) |
wetFloorType |
string (e.g. gres) |
conciergeService |
boolean (portero/conserje físico) |
hasHeating is whether a heating system exists; heatingType is the fuel/method. terraceSize complements the terrace boolean. mainFloorType/wetFloorType drive Fotocasa parquet/stoneware feature flags.
Appliances & rental extras (Fotocasa)#
| Field | Type | Notes |
|---|---|---|
internet |
boolean | Internet available. |
appliancesIncluded |
boolean | Appliances included (rental). Gates the individual flags below. |
oven |
boolean | |
microwave |
boolean | |
washingMachine |
boolean | |
secadora |
boolean | Dryer. |
fridge |
boolean | |
tv |
boolean | |
stoneware |
boolean | Stoneware/gres flooring flag. |
publishableTitle |
string | Portal-specific title override (falls back to the property title). Max 255. |
useAgentPhone |
boolean | Show the agent's phone on portals instead of the account phone. |
Energy certificate#
| Field | Type | Notes |
|---|---|---|
energyScale |
string | Consumption rating letter A–G. |
energyValue |
number | Consumption (kWh/m²·yr). |
emissionsScale |
string | Emissions rating letter A–G. |
emissionsValue |
number | Emissions (kg CO₂/m²·yr). |
energyCertificateStatus |
enum | en_tramite | exento | uploaded — see Allowed values. |
Rental-specific#
| Field | Type | Notes |
|---|---|---|
rentalType |
enum | residential | seasonal | short_term. |
shortTermLicense |
string | Required when rentalType is short_term. |
isFurnished |
boolean | |
furnishedKitchen |
boolean | |
securityDeposit |
number | Deposit (€). |
communityFees |
number | Community/HOA fees (€/month). |
priceReferenceIndex |
number | Índice de referencia de precios (0.01–10000). Mandatory for Catalonia rentals. |
Sale-specific#
| Field | Type | Notes |
|---|---|---|
occupationStatus |
enum | free | tenanted | bare_ownership | illegally_occupied. |
Commercial transfer (traspaso — local only)#
| Field | Type | Notes |
|---|---|---|
isATransfer |
boolean | Marks the premises as a transfer/traspaso. |
priceTransfer |
number | Transfer price (€), used when isATransfer is true. |
Garage (propertyType: "garaje")#
| Field | Type | Notes |
|---|---|---|
garageType |
string | cerrado (covered) | abierto (uncovered). |
securityDoor |
boolean | Automatic garage door. |
alarm |
boolean | Security alarm. |
securityGuard |
boolean | On-site security personnel. |
Land / plot (propertyType: "solar")#
| Field | Type | Notes |
|---|---|---|
allowedUse |
integer | Permitted-use code → Idealista land classification. |
buildingFloors |
integer | Buildable floors. |
streetType |
string | Access type: urban | road | track | highway. |
electricityType |
string | disponible marks electricity available. |
plumbingType |
string | disponible marks water available. |
hasRoadAccess |
boolean | |
hasSewerage |
boolean | |
hasSidewalk |
boolean | |
hasStreetLighting |
boolean | |
nearestLocationKm |
number | Distance to nearest town (km). |
Commercial premises (propertyType: "local")#
| Field | Type | Notes |
|---|---|---|
ubication |
string | Premises situation/location type. |
locatedAtCorner |
boolean | On a corner. |
facadeArea |
integer | Metres of facade/storefront. |
windowsNumber |
integer | Number of shop windows (escaparates). |
localFloors |
integer | Number of floors of the premises. |
smokeExtraction |
boolean | Smoke-extraction system. |
loadingArea |
boolean | Loading dock. |
bridgeCrane |
boolean | Bridge crane (puente grúa). |
Owner contact (optional)#
Send the property's owner (propietario) in the same call and Vesta creates or matches the contact and links it to the property as its owner — no second request needed.
contact: object { "name": "Ana Pérez", "phone": "+34600111222", "email": "ana@example.com" }
| Field | Type | Notes |
|---|---|---|
name |
string (1–200) | Full name. Split into first/last on the first space. Required when contact is present. |
phone |
string | E.164 preferred (+34…); other common formats are accepted. |
email |
string |
At least one of phone or email is required. Matching is per-account: if a
contact with the same email or phone already exists, Vesta reuses it instead of
creating a duplicate, and the owner link is never duplicated. The linked
contact's id is returned as contactId.
Certain partner source values are additionally configured (by Vesta) to drop
the owner into the agency's captación pipeline (column Pendiente on the
captaciones board) — used by sourcing platforms whose sends are mandate
opportunities rather than portfolio syncs. Re-sending never resets a card the
agency has already moved forward. Ask Vesta if your integration should enable
this.
Photos#
images: array (max 50) of { "url": "https://…", "tag": "salon", "order": 0 }.
Only url is required. Sending images replaces the current photo set, so
always send the full set you want live. tag (optional) must be one of the
values in Allowed values → images[].tag — use plano to mark a floor plan.
Publication control#
| Field | Type | Notes |
|---|---|---|
publishToWebsite |
boolean | Show / hide the property on your agency website. |
isFeatured |
boolean | Mark the listing as destacado (featured). |
optionalGarage |
boolean | Garage offered as a paid add-on (Idealista operationPriceParking). Set optionalGaragePrice too. |
optionalGaragePrice |
number | Price of the optional garage add-on (€). |
idPropertyVisibility |
enum | Idealista visibility: "idealista" (published, default) | "microsite" | "private". |
visibility |
integer | Location precision: 1=exact, 2=street, 3=zone (Fotocasa + Idealista). |
hidePrice |
boolean | Hide the price on Fotocasa. |
idCoordinatesPrecision |
enum | exact | moved (Idealista). |
idealista |
boolean | State mirror only. Writes Vesta's idealista flag WITHOUT pushing to (or removing from) the portal. Use when your system already manages the portal and you just want Vesta's flag to reflect that. |
fotocasa |
boolean | State mirror only. Same as idealista above, for Fotocasa. Does not push to the portal. |
agentId |
string | Vesta user that owns the property. Defaults to the account's main user. |
The fields above only set how a listing would appear — none of them contacts a portal.
ingestalso acceptspublishTo/publishMode, which do push the ad live; they are documented in the portal publishing reference along with the/publish,/syncand/unpublishendpoints.
Allowed values for coded fields#
These fields accept only the values listed below (case-sensitive, exactly as written). Any other value is stored but ignored by the portals.
| Field | Allowed values |
|---|---|
operation |
Sale · Rent · RentWithOption · RoomSharing · Transfer |
status |
Draft · En Venta · En Alquiler · Vendido · Alquilado · Descartado · Pendiente |
propertyType |
piso · casa · local · solar · garaje · edificio · oficina · industrial · trastero |
orientation |
norte · sur · este · oeste · noreste · noroeste · sureste · suroeste |
heatingType |
Gas natural · Eléctrico · Gasóleo · Butano · Propano · Solar · Bomba de calor · Suelo radiante · Pellets · Central · Central con contador |
conservationStatus |
1=Bueno · 2=Muy bueno · 3=Como nuevo · 4=A reformar · 6=Reformado |
energyScale, emissionsScale |
A · B · C · D · E · F · G |
energyCertificateStatus |
en_tramite · exento · uploaded |
rentalType |
residential · seasonal · short_term |
occupationStatus |
free · tenanted · bare_ownership · illegally_occupied |
idCoordinatesPrecision |
exact · moved |
visibility |
1=exact location · 2=street · 3=zone |
images[].tag |
fachada · salon · cocina · dormitorio · baño · terraza · jardin · piscina · patio · vistas · exterior · interior · escaparate · comedor · garaje · plano |
propertySubtype depends on propertyType:
propertyType |
Allowed propertySubtype |
|---|---|
piso |
Tríplex · Dúplex · Ático · Estudio · Loft · Piso · Apartamento · Bajo |
casa |
Casa · Casa adosada · Casa pareada · Chalet · Casa rústica · Finca rústica · Bungalow |
local |
Local Comercial · Nave industrial |
solar |
Suelo residencial · Suelo industrial · Suelo urbanizable · Suelo rústico |
garaje |
motorcycle · car_compact · car_sedan · car_and_motorcycle · two_cars_and_more |
edificio |
Residencial · Otros · Mixto residencial · Oficinas · Hotel |
Garage (propertyType: "garaje"):
| Field | Allowed values |
|---|---|
garageType |
abierto · cerrado |
Land / plot (propertyType: "solar"):
| Field | Allowed values |
|---|---|
allowedUse |
1=Agrícola · 2=Comercial · 3=Servicios · 4=Industrial · 8=Residencial plurifamiliar · 9=Residencial unifamiliar |
streetType |
Access type: urban · road · track · highway |
electricityType |
Send disponible to mark electricity available |
plumbingType |
Send disponible to mark water available |
Commercial premises (propertyType: "local"):
| Field | Allowed values |
|---|---|
ubication |
street · mezzanine · shopping · on_top_floor · belowGround · other · unknown |
streetType |
Foot traffic: muy_transitada · transitada · moderada · poco_transitada |
Response#
201 Created for a new property, 200 OK for an update.
{
"created": true,
"reference": "PARTNER-123",
"listingId": 84213,
"propertyId": 90217,
"vestaReference": "V-0001234",
"contactId": 55012,
"publish": {
"fotocasa": {
"success": true,
"portalUrls": { "fotocasa": "https://www.fotocasa.es/..." }
},
"idealista": { "success": true, "exportTriggered": true }
}
}
contactId is present only if you sent contact. listingId / propertyId /
vestaReference are Vesta's internal ids — informational; you keep using your
own reference. A publish block appears only for integrations that also do
portal publishing (separate reference).
Idempotency#
(account, reference) identifies a property. Re-sending the same reference
updates it (no duplicate). Send only changed fields; omitted fields are
preserved (except images, which is full-set replace). Safe to retry and to run
periodic full syncs from your system.
2b. Captación mode#
The same endpoint can behave differently for sourcing integrations: the
payload is treated as a captación opportunity (a portal ad the agency may
want to win the mandate for) and saved to the Captaciones board
(/operaciones/captaciones) instead of creating a property + listing. It adds
the originPortal, publicationDate and agencyName fields and is never
published to portals.
This mode is opt-in per integration: Vesta enables it for your source slug and
hands you the field-by-field transform. Ask us if your integration sends mandate
opportunities rather than an existing portfolio.
5. Examples#
A. Send a flat for sale, with the full payload#
curl -X POST 'https://vesta-crm.com/api/listings/external/ingest?account=<ACCOUNT_ID>' \
-H 'Authorization: Bearer <TOKEN>' -H 'Content-Type: application/json' \
-d '{
"reference": "PARTNER-123",
"operation": "Sale",
"price": 185000,
"status": "En Venta",
"title": "Piso reformado de 3 dormitorios en el centro",
"description": "Totalmente reformado, listo para entrar a vivir.",
"propertyType": "piso",
"propertySubtype": "Piso",
"squareMeter": 95,
"builtSurfaceArea": 105,
"bedrooms": 3,
"bathrooms": 2,
"yearBuilt": 2008,
"street": "Calle Mayor 12",
"floor": 3,
"door": "B",
"postalCode": "24001",
"latitude": 42.5987,
"longitude": -5.5671,
"city": "León", "province": "León", "municipality": "León", "neighborhood": "Centro",
"conservationStatus": 6,
"orientation": "sur",
"exterior": true,
"hasElevator": true,
"hasStorageRoom": true,
"terrace": true,
"builtInWardrobes": true,
"airConditioning": true,
"heatingType": "Gas natural",
"energyScale": "D",
"energyValue": 120,
"emissionsScale": "E",
"communityFees": 45,
"images": [
{ "url": "https://cdn.partner.com/123/1.jpg", "order": 0, "tag": "fachada" },
{ "url": "https://cdn.partner.com/123/2.jpg", "order": 1, "tag": "salon" }
],
"publishToWebsite": true,
"visibility": 2
}'
B. Minimal rental#
curl -X POST 'https://vesta-crm.com/api/listings/external/ingest?account=<ACCOUNT_ID>' \
-H 'Authorization: Bearer <TOKEN>' -H 'Content-Type: application/json' \
-d '{ "reference": "PARTNER-999", "operation": "Rent", "price": 750, "status": "En Alquiler", "rentalType": "residential", "securityDeposit": 1500 }'
C. Show an existing property on the website (or hide it)#
curl -X POST 'https://vesta-crm.com/api/listings/external/ingest?account=<ACCOUNT_ID>' \
-H 'Authorization: Bearer <TOKEN>' -H 'Content-Type: application/json' \
-d '{ "reference": "PARTNER-999", "operation": "Rent", "price": 750, "publishToWebsite": true }'
6. Errors#
| Status | When |
|---|---|
200 |
Token validation succeeded; ingest updated a property; live publishing succeeded; or portal validation completed (inspect per-portal ready) |
201 |
New property created |
400 |
Missing/invalid account, malformed JSON, validation error (see details), or a bad agentId. contact with neither phone nor email lands here (details.contact). |
401 |
Missing/wrong token, or API not enabled for the account. validate returns { valid: false }. |
403 |
ingest: the account has reached its plan property limit |
404 |
publish / unpublish: no property found for reference |
429 |
Rate limited — back off and retry |
502 |
Live publish / unpublish: at least one portal failed (see per-portal error). Never used for mode: "validate". |
500 |
Unexpected server error |
Validation errors include a details object (field → messages) so you can show
exactly what was wrong.
7. Mapping a partner schema → this API#
If your system models properties with its own field names/codes (as some CRMs do, mirroring an Inmovilla-style integration), here's how to translate. Unlike Inmovilla, you never need Vesta's internal numeric codes — send the enums and location names below directly.
Operation#
| Partner | operation |
|---|---|
sale |
Sale |
rent |
Rent |
Property type#
Partner property_type |
propertyType |
propertySubtype |
|---|---|---|
flat |
piso |
— |
studio_apartment |
piso |
Estudio |
penthouse |
piso |
Ático |
duplex |
piso |
Dúplex |
house |
casa |
— |
estate |
casa |
Finca rústica |
premise |
local |
Local Comercial |
office |
oficina |
— |
industrial |
industrial |
— |
garage |
garaje |
— |
building |
edificio |
— |
urban |
solar |
Suelo residencial |
developable |
solar |
Suelo urbanizable |
Condition#
Partner condition |
conservationStatus |
|---|---|
good_condition |
1 |
new_build |
3 (closest — "Como nuevo"; there is no separate obra-nueva code) |
to_reform |
4 |
Other mappings#
- Location: send
province+municipality(andcity/neighborhood) as names, pluslatitude/longitude. No internal location codes needed. - Photos:
photos[].position→images[].order;photos[].url→images[].url. - Central heating: send
heatingType: "Central"(or"Central con contador") — there is no separate boolean. - Contact / owner: send the
contactobject oningest(see Owner contact) — no separate contact call is required. - Source URL:
source_url→sourceUrl.
Photos must be publicly downloadable URLs (no auth, common
image/*formats, reasonable redirects). Vesta stores the URL you send.
Worked example (nested partner payload → Vesta)#
A nested partner payload:
{
"external_reference": "bp_88213",
"title": "Piso en venta en Madrid",
"operation": "sale",
"property_type": "flat",
"condition": "good_condition",
"price": 250000,
"built_area": 95,
"rooms": 3,
"bathrooms": 2,
"description": "Luminoso y reformado.",
"source_url": "https://partner.example.com/offer/88213",
"location": {
"latitude": 40.4168,
"longitude": -3.7038,
"province_id": "Madrid",
"municipality_id": "Madrid"
},
"features": {
"lift": true,
"air_conditioning": true,
"central_heating": true,
"garage": true,
"storeroom": true,
"balcony": true,
"furnished": false
},
"energy": { "energy_certificate": "D", "emissions_certificate": "E" },
"photos": [{ "url": "https://cdn.bp.com/88213/1.jpg", "position": 1 }],
"contact": { "name": "Ana Pérez", "phone": "+34600111222" }
}
The exact Vesta ingest body it becomes:
{
"reference": "bp_88213",
"source": "partner_slug",
"title": "Piso en venta en Madrid",
"operation": "Sale",
"propertyType": "piso",
"conservationStatus": 1,
"price": 250000,
"builtSurfaceArea": 95,
"bedrooms": 3,
"bathrooms": 2,
"description": "Luminoso y reformado.",
"sourceUrl": "https://partner.example.com/offer/88213",
"latitude": 40.4168,
"longitude": -3.7038,
"province": "Madrid",
"municipality": "Madrid",
"hasElevator": true,
"airConditioning": true,
"heatingType": "Central",
"hasGarage": true,
"hasStorageRoom": true,
"balconyCount": 1,
"isFurnished": false,
"energyScale": "D",
"emissionsScale": "E",
"images": [{ "url": "https://cdn.bp.com/88213/1.jpg", "order": 0 }],
"contact": { "name": "Ana Pérez", "phone": "+34600111222" }
}
For a partner that needs a step-by-step parsing guide (with drop-in transform code) for a nested/coded schema like this, Vesta can provide a tailored companion doc on request.
Contactos y captaciones
Push contacts into a Vesta account from your own system — the contact-side companion to the Vesta Listing API.
- Base URL:
https://vesta-crm.com - Content type:
application/json - Auth: per-account Bearer token — the same
externalApiTokenas the Listing API. - Endpoints:
POST /api/listings/external/contacts— create or update a contact (name, surname, notes, pipeline stage, consent, …), and move one you created along the board (contactado→gestionado→ …)GET /api/listings/external/contacts/status— read back the pipeline state of the contacts you created (stages only, no personal data)
This lives under
/api/listings/external/*because it shares the Listing API's token and infrastructure — the path prefix is historical, the payload is pure contact data.
🔒 No endpoint here returns personal data. You can write a contact and you can read back where it ended up in the pipeline, but never the stored record — no name, email, phone, NIF, address or notes come back out. Contacts leave the database through the agency's own Vesta account.
GETon/contactsitself answers405; the state endpoint is/contacts/status.
1. Authentication#
Every request needs:
- The account in the URL:
?account=<accountId>. - Your secret token, as a header (preferred) or query param:
Authorization: Bearer <token>- or
?token=<token>
POST /api/listings/external/contacts?account=<ACCOUNT_ID>
Authorization: Bearer <token>
Content-Type: application/json
Vesta gives you the token and the account id — the same pair used for the
Listing API. Until the token is set, the account returns 401. You can reuse
GET /api/listings/external/validate?account=<ACCOUNT_ID> to check a token.
Who is pushing: the acting user (optional)#
The token identifies the agency, not the person. If your system has its own logged-in users, send who they are and the contact you create gets that agent as its gestor instead of landing unassigned:
X-Partner-User-Email: maria@agencia.com ← what we match on
X-Partner-User-Id: 4821 ← your own id, for our logs
(X-Betterplace-User-Email / X-Betterplace-User-Id are accepted as aliases.)
- The address doesn't have to match exactly. We compare it against the
active users of that same account and take the closest one, so
maria.garcia@tusistema.comstill findsmaria@agencia.com. Capitals, dots and+tagsare ignored, and the agent's name counts too (mgarcia@finds "María García"). - Never a guess between colleagues. If two agents are equally close, nobody is picked — better unassigned than the wrong gestor.
- A miss is never an error. The lead still comes in, and the response carries
"agentMatched": falseso you can spot users you haven't mapped yet. The field only appears when you sent the header. - On create only. Updating a contact never changes its gestor — the agency may have reassigned it on purpose.
2. POST /api/listings/external/contacts — create or update#
Send one flat object. Send firstName (to create or match a person) or
contactId (to target one you already created). Anything you omit keeps its
previous value on an update, and is left blank on a create.
The same endpoint does both jobs: it creates the contact, and later it moves it along the pipeline — see §4.
Targeting an existing contact: contactId#
The contactId we return on POST (and on the status read) can be sent back to
target that exact contact. Dedup is skipped, and only the fields you send are
written:
# "this lead is now managed" — no name, no email, nothing else touched
curl -X POST 'https://vesta-crm.com/api/listings/external/contacts?account=<ACCOUNT_ID>' \
-H 'Authorization: Bearer <TOKEN>' -H 'Content-Type: application/json' \
-d '{ "contactId": 84021, "ingestChannel": "tu-slug", "leadStatus": "gestionado" }'
ingestChannelis required alongsidecontactId, and it must be your own slug. You can only move contacts your integration created; a contact the agency's agent typed into Vesta is not addressable, and an id that isn't yours comes back as400 Unknown contactId.- Prefer it over matching by name for a status change. Name matching can
land on a namesake —
contactIdcannot.
Idempotency / dedup#
When you don't send contactId:
Vesta runs its own duplicate detection (the same logic the app uses when an agent adds a contact) against the account's existing contacts, in this order:
- NIF/DNI/NIE exact match
- Email exact match
- Phone exact match
- Email + name, or phone + name
- A match is found → that contact is updated with the fields you sent
(partial merge) and the response is
200 { "created": false }. - No match → a new contact is created,
201 { "created": true }.
This makes repeat calls safe: send the same person twice and you update, you
don't duplicate. Sending a stable email or nif is the most reliable way to
land updates on the right record.
Body fields#
| Field | Type | Notes |
|---|---|---|
contactId |
number | Target an existing contact exactly — skips dedup. Requires ingestChannel; only your own contacts are addressable. See above |
firstName |
string | required, unless you sent contactId |
lastName |
string | |
nif |
string | Spanish NIF/DNI/NIE — normalized on save |
email |
string | |
phone |
string | Prefix auto-extracted ("+34 600 000 000" → prefix +34, number 600000000) |
secondaryPhone |
string | Same prefix handling |
address |
string | |
notes |
string | Free-text — stored on the contact (additional_info.notes) |
questions |
array | Your form's qualification Q&A. [{ "question": "¿En qué zona?", "answer": "Chamberí" }]. Max 60 entries; answer may be a string, number, boolean (rendered Sí/No), or array of strings (joined with , ). Unanswered entries are skipped. See below. |
source |
string | Origin portal / lead-origin — where the lead came from. Drives the captación-card logo. Recognized marks: google, facebook, instagram, idealista, fotocasa, pisos, plus manual options (Website, Llamada, …). |
ingestChannel |
slug | Delivering partner/integration — who pushed the contact in. Send your own slug (e.g. tu-slug). Lowercase; drives the captación card's channel logo and attribution. |
label |
string | User tag (VIP, Inversor, …) |
entityType |
enum | individual | company |
occupation |
string | |
nationality |
string | |
dateOfBirth |
string | YYYY-MM-DD |
civilStatus |
string | |
gender |
string | |
idDocumentType |
string | nif | cif | nie | passport |
captacionStatus |
enum | Captaciones board stage (owner acquisition) — see §4 |
leadStatus |
enum | Leads board stage (buyer/renter demand) — see §4. A different board, different lanes |
gdprConsentStatus |
enum | Legal basis for holding this person — see §4b. Send it |
Sending the questions from your form#
Anything your form asked that has no dedicated field above goes in questions.
Each entry lands two places:
- Raw, in
additional_info.externalQuestions(the full array, verbatim). - As a note on the contact, one
• Pregunta: respuestaline per answered question under a📋 Cuestionario — <ingestChannel>header. That note is what the agent actually reads: it surfaces as the captación card's note preview and on the contact timeline. This mirrors how GOEX folds its sheet columns in.
The note is idempotent by content — re-POSTing the same lead with the same answers does not stack duplicates. Send it changed (you enriched the lead later) and a second note is written, because that is new information.
"questions": [
{ "question": "¿Tiene propiedad en venta?", "answer": true },
{ "question": "¿En qué zona?", "answer": "Chamberí, Madrid" },
{ "question": "Precio esperado", "answer": 350000 },
{ "question": "¿Cuándo quiere vender?", "answer": ["3 meses", "6 meses"] }
]
renders on the contact as:
📋 Cuestionario — Tu Integración
• Tiene propiedad en venta: Sí
• En qué zona: Chamberí, Madrid
• Precio esperado: 350000
• Cuándo quiere vender: 3 meses, 6 meses
Example#
curl -X POST 'https://vesta-crm.com/api/listings/external/contacts?account=<ACCOUNT_ID>' \
-H 'Authorization: Bearer <TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"firstName": "María",
"lastName": "García López",
"email": "maria.garcia@example.com",
"phone": "+34 600 000 000",
"notes": "Propietaria interesada en vender. Llamar por las tardes.",
"source": "idealista",
"ingestChannel": "tu-slug",
"captacionStatus": "contactado",
"gdprConsentStatus": "accepted",
"questions": [
{ "question": "¿Tiene propiedad en venta?", "answer": true },
{ "question": "¿En qué zona?", "answer": "Chamberí, Madrid" }
]
}'
Moving that same person on later — send only the key and the new stage, the dedup finds them:
curl -X POST 'https://vesta-crm.com/api/listings/external/contacts?account=<ACCOUNT_ID>' \
-H 'Authorization: Bearer <TOKEN>' -H 'Content-Type: application/json' \
-d '{ "firstName": "María", "email": "maria.garcia@example.com", "captacionStatus": "valoracion" }'
Response#
A write receipt — ids and the pipeline stage, never the stored record:
{
"created": true, // false when an existing contact was updated
"contactId": 84021,
"contact": {
"id": 84021,
"captacionStatus": "contactado",
"leadStatus": null,
"createdAt": "2026-07-21T10:15:00.000Z",
"updatedAt": "2026-07-21T10:15:00.000Z"
}
}
created tells you which of the two happened, and contactId is stable —
store it. It is what you send back to move the contact later (§4) and to
query its state (§3).
3. GET /api/listings/external/contacts/status — where did my leads end up?#
Read back the pipeline state of the contacts your integration created: which column the agency's agent moved them to, and when.
curl 'https://vesta-crm.com/api/listings/external/contacts/status?account=<ACCOUNT_ID>&ingestChannel=tu-slug' \
-H 'Authorization: Bearer <TOKEN>'
Query params#
| Param | Default | Notes |
|---|---|---|
account |
— | required |
ingestChannel |
— | required — your own slug. This is what scopes the read; see below |
ids |
— | comma-separated contactIds (the ones we returned on POST). Max 200 |
since |
— | ISO 8601 — only contacts touched since then. Use it to poll incrementally |
limit |
200 |
max 200 |
Response#
{
"contacts": [
{
"id": 84021,
"captacionStatus": "valoracion",
"captacionStatusUpdatedAt": "2026-08-16T09:12:04.000Z",
"leadStatus": "contactado",
"leadStatusUpdatedAt": "2026-08-15T18:40:11.000Z",
"gdprConsentStatus": "accepted",
"ingestChannel": "tu-slug",
"isActive": true,
"updatedAt": "2026-08-16T09:12:04.000Z"
}
],
"count": 1
}
🔒 What this endpoint will never return#
No personal data. Ever. No name, email, phone, NIF, address, date of birth or notes — only the stages above and their timestamps. Contacts as records leave the database through the agency's own Vesta account, never through a partner integration.
ingestChannel is required because it is the whole security model. Only
contacts carrying your channel come back. A contact an agent typed into Vesta by
hand has no channel at all, so the agency's own book is unreachable here no
matter what you ask for — in a live account with thousands of contacts, this
endpoint returns only the handful your integration sent.
Practical notes:
- Poll with
since, not by refetching everything: pass the largestupdatedAtyou have seen. - Repeat sends are safe — the upsert dedups (§2), so re-sending is still the way to change a contact. This endpoint only reads.
isActive: falsemeans the agency deleted the contact. Stop sending it.
4. Moving a contact through a pipeline#
🚨 There are two boards and they do not share lane ids. Pick the one that
matches what the person wants; sending a lane id to the wrong board is a 400.
| Board | Field | Who belongs on it |
|---|---|---|
Captaciones (/operaciones/captaciones) |
captacionStatus |
an owner you might win a mandate from |
Leads (/contactos/borradores) |
leadStatus |
a buyer or renter with demand |
captacionStatus — Captaciones#
| id | label |
|---|---|
pendiente |
Pendiente |
contactado |
Contactado |
valoracion |
Valoración |
captado |
Captado |
descartado |
Descartado |
Every created contact lands on the Captaciones board. If you omit
captacionStatus on a create, Vesta defaults it to the initial column
(pendiente) — the board only shows contacts whose captacionStatus is set, so
this guarantees imports appear there. Send an explicit stage to place them
elsewhere.
leadStatus — Leads#
| id | label |
|---|---|
nuevo |
Pendiente Acción |
contactado |
Contactado |
gestionado |
Gestionado |
inactivo |
Inactivo |
Unlike the captación board, leadStatus is only written when you send it —
an owner lead has no business appearing as a buyer demand. Send it on the first
call and the contact appears on the Leads board too.
An account may insert its own lanes between Contactado and Gestionado. Those are as valid as the built-ins; a
400response lists every id the account accepts in anallowedarray, so you never have to guess.
Both boards#
A move is a POST with the id and the new stage — nothing else:
curl -X POST 'https://vesta-crm.com/api/listings/external/contacts?account=<ACCOUNT_ID>' \
-H 'Authorization: Bearer <TOKEN>' -H 'Content-Type: application/json' \
-d '{ "contactId": 84021, "ingestChannel": "tu-slug", "leadStatus": "gestionado" }'
contactado → gestionado → back to contactado all work, in either
direction, on either board. Notes:
- Sending an unknown value returns
400with the allowed ids. - Setting or changing a stage stamps its own clock (
captacionStatusUpdatedAt/leadStatusUpdatedAt, the SLA clocks). Re-sending the value it already has is a no-op for that timestamp. - On an update, an omitted stage leaves the contact where it is — no
accidental resurrection of a
descartadoorinactivocontact. - Moving a lead back out of
gestionadore-flags it as open, so it reappears on the board instead of being written into a column nobody can see. - There is no webhook. If the agency's agent drags the card themselves, your
system is not notified — poll
/contacts/statuswithsince(§3) to pick the change up. Both sides can move a card; last write wins.
4b. Consent (gdprConsentStatus)#
You collected the lead, so you are the only one who knows on what basis. Send it with the contact:
| id | Meaning |
|---|---|
pending |
asked, no answer yet (the default for a new contact) |
accepted |
consented to commercial communications |
rejected |
explicitly refused |
withdrawn |
consented once, later withdrew |
restricted |
processing restricted (GDPR art. 18) |
Setting it stamps gdprConsentDate with the moment we received it and records
the method as portal. A status with no date proves nothing, which is why the
two always travel together.
Send it even when it's
rejected. A lead that arrives with no consent information is indistinguishable from one nobody ever asked, and the agency ends up guessing whether it can legally call the person.
5. Errors#
| Status | Meaning |
|---|---|
400 |
Missing/invalid account, invalid JSON, validation failure (details included), or an unknown captacionStatus / leadStatus / gdprConsentStatus (the response lists the allowed ids) |
400 |
Unknown contactId <id> for ingestChannel '<slug>' — the id doesn't exist, belongs to another account, or was not created by your integration |
400 |
contactId sent without ingestChannel, or neither contactId nor firstName sent |
401 |
Missing/wrong token, or the API isn't enabled for this account |
405 |
GET /contacts — there is no endpoint that returns contact records. The state endpoint is /contacts/status (§3) |
429 |
Rate limit — retry after a short back-off |
500 |
Unexpected server error |
Propiedades para tu web
Feed JSON de inmuebles de tu agencia servido desde Vesta CRM. Sustituye a los antiguos feeds XML (Apinmo / Inmovilla y similares). Mismo modelo (consultas por HTTP a una URL), pero en JSON y protegido por token.
Esta documentación es genérica: sirve para cualquier cuenta. Donde aparece
{account}sustituye por el ID de tu cuenta, y donde apareceTU_TOKEN_AQUIpon el token que te facilita Vesta. Ambos son específicos de tu cuenta.
1. Resumen rápido#
| Base URL | https://vesta-crm.com/api/listings/inmovilla |
| Formato | JSON (UTF-8) |
| Método | GET |
| Autenticación | Token Bearer (cabecera Authorization) + parámetro account |
| Cuenta | account={account} — el ID numérico de tu cuenta |
| Endpoints | Listado (/inmovilla) y Detalle por referencia (/inmovilla/{ref}) |
Solo se exponen los inmuebles publicados en la web y activos. El resto del catálogo nunca se devuelve.
2. Autenticación#
Todas las peticiones requieren dos cosas:
- El parámetro
account={account}(identifica la agencia). - El token secreto de la cuenta, en la cabecera HTTP:
Authorization: Bearer TU_TOKEN_AQUI
🔑 El token te lo facilita Vesta por separado (no se publica en esta documentación). Es secreto y específico de tu cuenta — trátalo como una contraseña. El token de una cuenta no sirve para ninguna otra cuenta.
Para pruebas rápidas desde el navegador puedes usar ?account={account}&token=TU_TOKEN_AQUI, pero ⚠️ en producción usa siempre la cabecera Authorization: Bearer (los tokens en la URL acaban en logs/historiales).
| Situación | Código |
|---|---|
| Token correcto | 200 OK |
| Token ausente / incorrecto / feed no habilitado | 401 Unauthorized |
3. Endpoint: Listado#
Devuelve todas las propiedades publicadas (tarjetas para el listado / grid).
GET https://vesta-crm.com/api/listings/inmovilla?account={account}
GET https://vesta-crm.com/api/listings/inmovilla?account={account}&localidad=nombre-de-la-localidad
Parámetros de query#
| Parámetro | Tipo | Obligatorio | Por defecto | Descripción |
|---|---|---|---|---|
account |
int | Sí | — | El ID numérico de tu cuenta. |
token |
string | Sí* | — | Token (si no se envía por cabecera). |
offset |
int | No | 0 |
Desplazamiento para paginar. |
limit |
int | No | 1000 (máx. 5000) |
Nº máximo de resultados. Cabe todo el catálogo en una llamada. |
localidad |
string | No | — | Filtra por localidad. Envía la clave (key_filtro) tal y como aparece en listaLocalidades (p. ej. nombre-de-la-localidad). Clave desconocida ⇒ list vacío. |
* Por cabecera o por query. Orden: destacados primero, luego por fecha de actualización.
Respuesta 200#
{
"offset": 0,
"pagination": 401,
"totalItems": 401,
"listaLocalidades": {
"localidad-uno": "Localidad Uno",
"localidad-dos": "Localidad Dos",
"localidad-tres": "Localidad Tres"
},
"list": [
{
"id": 1000245715,
"ref": "VESTA2026167226",
"title": "Casa en Calle Mirador",
"tipo": "casa",
"tipoOperacion": "venta",
"habitaciones": 7,
"banyos": 7,
"superficieConstruida": 718,
"precioinmo": 4600000,
"ciudad": "Ciudad Ejemplo",
"zona": "Zona Ejemplo",
"destacado": 1,
"images": "https://….amazonaws.com/accounts/{account}/VESTA2026167226/imagen_01.jpg"
}
]
}
Campos del listado#
| Campo | Tipo | Descripción |
|---|---|---|
offset |
int | Eco del offset solicitado. |
pagination |
int | Nº de elementos devueltos en esta respuesta. |
totalItems |
int | Total de inmuebles que cumplen el filtro. |
listaLocalidades |
object | Todas las localidades del catálogo publicado, como { key_filtro: "Valor a mostrar" }. La clave es URL-safe (minúsculas, sin acentos, espacios → -) y es la que se envía en el parámetro localidad. Se devuelve siempre completa, aunque la petición venga filtrada por localidad (así el desplegable del filtro no se vacía). Ordenada alfabéticamente. |
list[].id |
int | Identificador interno. Para detalle usa ref. |
list[].ref |
string | Referencia — clave estable, úsala para el detalle. |
list[].title |
string | Título publicable. |
list[].tipo |
string | Tipo de inmueble (ver §6). |
list[].tipoOperacion |
string | Operación: venta / alquiler / … (ver §6). |
list[].habitaciones |
int | Habitaciones (0 si no aplica). |
list[].banyos |
int | Nº de baños. |
list[].superficieConstruida |
int | null | Superficie construida (m²). |
list[].precioinmo |
int | Precio en €. 0 cuando precioOculto es true. |
list[].precioOculto |
bool | Si true, el agente ha ocultado el precio: muestra "Consultar precio" e ignora precioinmo. |
list[].ciudad |
string | Municipio/ciudad. |
list[].zona |
string | Barrio/zona. |
list[].destacado |
int | 1 destacado, 0 normal. |
list[].images |
string | URL de una sola imagen de portada ("" si no hay). |
4. Endpoint: Detalle (por referencia)#
GET https://vesta-crm.com/api/listings/inmovilla/{ref}?account={account}
Ejemplo: …/api/listings/inmovilla/VESTA2026167226?account={account}
Respuesta 200#
{
"ref": "VESTA2026167226",
"title": "Casa en Calle Mirador",
"tipo": "casa",
"subtipo": "Casa adosada",
"tipoOperacion": "venta",
"estado": "En Venta",
"habitaciones": 7,
"habdobles": 0,
"banyos": "7",
"planta": 0,
"superficieConstruida": 718,
"superficieUtil": 690,
"superficieParcela": 1200,
"m_parcela": "",
"m_uties": "718.00",
"anoConstruccion": 2025,
"estadoConservacion": "Como nuevo",
"energiaEscala": "A",
"energiaValor": 32,
"emisionesEscala": "A",
"emisionesValor": 5,
"direccion": "Calle Mirador",
"puerta": "B",
"cp": "08392",
"ciudad": "Ciudad Ejemplo",
"zona": "Zona Ejemplo",
"latitud": "41.571279",
"altitud": "2.485045",
"precioinmo": 4600000,
"descripcionPequena": "Espectacular casa…",
"descriptiones": ["Descripción completa…"],
"imagenes": ["https://….jpg", "https://….jpg"],
"planos": [],
"videos": [],
"virtualTours": [],
"caracteristicas": ["Superficie útil: 690 m²", "Antigüedad: 2025", "Ascensor", "Piscina privada"],
"estadoOcupacion": "free",
"esObraNueva": false
}
Campos del detalle#
Identidad y categoría
| Campo | Tipo | Descripción |
|---|---|---|
ref |
string | Referencia. |
title |
string | Título publicable. |
tipo |
string | Tipo de inmueble (ver §6). |
subtipo |
string | Subtipo (Ático, Casa adosada, Nave…); "" si no consta. |
tipoOperacion |
string | venta / alquiler / traspaso / … (ver §6). |
estado |
string | Estado (En Venta, En Alquiler, …). |
Habitaciones y superficies
| Campo | Tipo | Descripción |
|---|---|---|
habitaciones |
int | Dormitorios individuales. |
habdobles |
int | Dormitorios dobles. |
banyos |
string | Nº de baños ("2", "1.5"). |
planta |
int | null | Planta (0=bajo, -1=sótano). |
superficieConstruida |
int | null | Superficie construida (m²). |
superficieUtil |
int | null | Superficie útil (m²). |
superficieParcela |
int | null | Superficie de parcela/finca (m²). |
m_parcela |
string | (Legacy) Parcela en m² — solo solares. |
m_uties |
string | (Legacy) Superficie útil/construida en m². |
Estado y energía
| Campo | Tipo | Descripción |
|---|---|---|
anoConstruccion |
int | null | Año de construcción. |
estadoConservacion |
string | Conservación (Buen estado, Como nuevo, A reformar…). |
energiaEscala |
string | Eficiencia energética A–G ("" si no consta). |
energiaValor |
number | null | Consumo (kWh/m²·año). |
emisionesEscala |
string | Emisiones A–G. |
emisionesValor |
number | null | Emisiones (kg CO₂/m²·año). |
Ubicación (respeta la privacidad de cada inmueble — ver §7)
| Campo | Tipo | Descripción |
|---|---|---|
direccion |
string | Calle ("" si oculta por privacidad). |
puerta |
string | Puerta ("" si oculta o no consta). |
cp |
string | Código postal. |
ciudad |
string | Municipio/ciudad. |
zona |
string | Barrio/zona. |
latitud |
string | Latitud ("" si oculta por privacidad). |
altitud |
string | ⚠️ Longitud (nombre heredado de Inmovilla). "" si oculta. |
Precio y contenido
| Campo | Tipo | Descripción |
|---|---|---|
precioinmo |
int | Precio en €. 0 cuando precioOculto es true. |
precioOculto |
bool | Si true, el agente ha ocultado el precio: muestra "Consultar precio" e ignora precioinmo (y precioTraspaso, que se omite). |
descripcionPequena |
string | Descripción corta. |
descriptiones |
string[] | Descripción(es) larga(s). [] si no hay. |
imagenes |
string[] | Fotos (con marca de agua), en orden. Sin planos. |
planos |
string[] | Planos de planta. |
videos |
string[] | Vídeos. |
virtualTours |
string[] | Tours virtuales (Matterport, etc.). |
caracteristicas |
string[] | Lista legible adaptada al tipo (ver §5). |
Específicos de operación (presentes solo cuando aplican)
| Campo | Tipo | Aplica a | Descripción |
|---|---|---|---|
tipoAlquiler |
string | alquiler | residential / seasonal / short_term. |
amueblado |
bool | alquiler | Amueblado. |
deposito |
number | null | alquiler | Fianza (€). |
mascotas |
bool | alquiler | Admite mascotas. |
gastosGestion |
number | null | alquiler | Gastos de gestión (€/mes). |
licenciaTuristica |
string | alquiler vacacional | Nº de licencia turística. |
estadoOcupacion |
string | venta | free / tenanted / bare_ownership / illegally_occupied. |
esObraNueva |
bool | venta | Obra nueva. |
actividadPrincipal |
string | local | Actividad comercial principal. |
esTraspaso |
bool | local | Es un traspaso. |
precioTraspaso |
number | null | local | Precio del traspaso (€). |
⚠️
altitudcontiene la longitud, no la altitud (heredado del feed original de Inmovilla). Para un mapa:lat = latitud,lng = altitud.
5. El campo caracteristicas#
Array plano de cadenas listas para mostrar, con dos tipos de entrada:
- Con valor —
"Etiqueta: valor", p. ej."Superficie útil: 90 m²","IBI: 320 €","Gastos comunidad: 45 €/mes","Antigüedad: 2008". - Solo etiqueta (amenidades activas) —
"Ascensor","Terraza","Calefacción","Jardín".
El conjunto depende del tipo de inmueble (un garaje no muestra habitaciones; un solar no muestra ascensor; un local muestra escaparate/fachada/salida de humos). ✅ Trátalo como contenido de presentación (renderiza cada cadena como un chip/fila); no lo parsees — para filtrar usa los campos estructurados (tipo, superficieUtil, anoConstruccion, energiaEscala, etc.).
6. Valores de los enumerados#
tipo (clave estable para filtrar):
piso · casa · local · solar · garaje · edificio · oficina · industrial · trastero
tipoOperacion:
venta · alquiler · traspaso · alquiler-opcion-compra · compartir-habitacion
estado (en el feed, casi siempre disponible): En Venta · En Alquiler (también Vendido / Alquilado si se publicaran).
subtipo es texto libre del catálogo de Vesta (p. ej. Ático, Dúplex, Casa adosada, Chalet, Nave industrial, Suelo residencial).
7. Privacidad de la ubicación (importante)#
Cada inmueble tiene un nivel de visibilidad configurado por el agente. El feed lo respeta:
| Visibilidad del inmueble | direccion |
puerta |
latitud / altitud |
|---|---|---|---|
| Exacta | calle | puerta | coordenadas exactas |
| Calle | calle | "" |
"" |
| Zona | "" |
"" |
"" |
cp, ciudad y zona se devuelven siempre. Si un inmueble no muestra coordenadas, geocodifica ciudad + zona para una ubicación aproximada en el mapa.
8. Códigos de error#
| Código | Significado |
|---|---|
200 |
OK. |
400 |
Falta account o no es válido. |
401 |
Token ausente / incorrecto / feed no habilitado. |
404 |
Referencia inexistente o no publicada. |
500 |
Error interno. |
9. Caché#
| Endpoint | Caché (Cache-Control) |
|---|---|
| Listado | public, max-age=1800 (30 min) |
| Detalle | public, max-age=3600 (60 min) |
10. Ejemplos de código#
En los ejemplos, sustituye
{account}por el ID de tu cuenta yTU_TOKEN_AQUIpor tu token.
cURL#
curl -H "Authorization: Bearer TU_TOKEN_AQUI" \
"https://vesta-crm.com/api/listings/inmovilla?account={account}"
# Filtrado por localidad (la clave sale de listaLocalidades)
curl -H "Authorization: Bearer TU_TOKEN_AQUI" \
"https://vesta-crm.com/api/listings/inmovilla?account={account}&localidad=nombre-de-la-localidad"
curl -H "Authorization: Bearer TU_TOKEN_AQUI" \
"https://vesta-crm.com/api/listings/inmovilla/VESTA2026167226?account={account}"
JavaScript (fetch)#
const BASE = "https://vesta-crm.com/api/listings/inmovilla";
const ACCOUNT = 0; // ← el ID numérico de tu cuenta
const TOKEN = process.env.VESTA_FEED_TOKEN; // nunca en el front-end
async function getListado({ offset = 0, limit = 1000, localidad } = {}) {
const params = new URLSearchParams({ account: ACCOUNT, offset, limit });
if (localidad) params.set("localidad", localidad); // key_filtro de listaLocalidades
const res = await fetch(`${BASE}?${params}`, {
headers: { Authorization: `Bearer ${TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Desplegable del filtro: const { listaLocalidades } = await getListado();
// Object.entries(listaLocalidades) → [["nombre-de-la-localidad", "Nombre de la localidad"], …]
async function getDetalle(ref) {
const res = await fetch(`${BASE}/${encodeURIComponent(ref)}?account=${ACCOUNT}`, {
headers: { Authorization: `Bearer ${TOKEN}` },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
PHP#
<?php
$BASE = "https://vesta-crm.com/api/listings/inmovilla";
$ACCOUNT = 0; // ← el ID numérico de tu cuenta
$TOKEN = getenv("VESTA_FEED_TOKEN");
function vesta_feed_get($url, $token) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $code === 200 ? json_decode($body, true) : null;
}
$listado = vesta_feed_get("$BASE?account=$ACCOUNT", $TOKEN);
$detalle = vesta_feed_get("$BASE/" . rawurlencode("VESTA2026167226") . "?account=$ACCOUNT", $TOKEN);
// Desplegable del filtro de localidad:
// foreach ($listado["listaLocalidades"] as $key => $nombre) { … }
// Filtrar por localidad (enviar la clave, no el nombre):
$filtrado = vesta_feed_get("$BASE?account=$ACCOUNT&localidad=nombre-de-la-localidad", $TOKEN);
11. Migración desde un XML antiguo (Apinmo / Inmovilla)#
Si vienes de un feed XML (Apinmo, Inmovilla o similar), estas tablas mapean los campos antiguos a la Nueva API.
Listado (antes ↔ ahora)#
| XML antiguo | Nueva API |
|---|---|
id |
id |
titulo1 |
title |
tipo_ofer |
tipo (+ subtipo, tipoOperacion en detalle) |
accion (Vender/Alquilar) |
tipoOperacion |
banyos |
banyos |
ref |
ref |
habitaciones |
habitaciones |
m_cons |
superficieConstruida |
precioinmo |
precioinmo |
ciudad / zona / destacado |
ciudad / zona / destacado |
foto1 |
images (portada) |
Detalle (antes ↔ ahora)#
| XML antiguo | Nueva API |
|---|---|
ref / titulo1 |
ref / title |
tipo_ofer |
tipo + subtipo |
accion |
tipoOperacion |
habitaciones / habdobles / banyos |
igual |
m_uties / m_cons / m_parcela |
superficieUtil / superficieConstruida / superficieParcela (+ legacy m_uties, m_parcela) |
planta |
planta |
antiguedad |
anoConstruccion |
conservacion |
estadoConservacion |
energialetra / energiavalor / emisionesletra / emisionesvalor |
energiaEscala / energiaValor / emisionesEscala / emisionesValor |
cp / ciudad / zona |
igual |
latitud / altitud (longitud) |
igual |
descrip1 (resumen) / descrip1…N |
descripcionPequena / descriptiones[] |
foto1…N / vídeos / tour |
imagenes[] / videos[] / virtualTours[] (+ planos[]) |
ascensor, calefaccion, terraza, … (amenidades por tag) |
resueltas en caracteristicas[] |
Antes leías cada amenidad de un tag distinto del XML. Ahora vienen resueltas y formateadas en
caracteristicas[], y los datos clave para filtrar (tipo, operación, superficies, año, energía) están como campos estructurados.
12. Notas#
- Solo se publican inmuebles marcados como publicar en web y activos.
- Filtro por localidad: monta el desplegable con
listaLocalidades(clave → texto a mostrar) y envía la clave en?localidad=. No envíes el nombre con espacios/acentos — la clave ya viene normalizada. - Las imágenes incluyen la marca de agua de la agencia.
- El precio (
precioinmo) es el de la operación principal. - Para el mapa:
lat = latitud,lng = altitud(cuando estén disponibles — ver §7).
Contacto / soporte: a través de Vesta CRM.
Feed Kyero v3 para portales
Pull an account's listings as a portal-ready Kyero-v3 XML feed. This is the outbound XML surface — a partner/portal reads it on a schedule. It is the same data as the JSON read feed, in XML.
- Base URL:
https://vesta-crm.com - Auth: per-account Bearer token (the same single token as every other Vesta API — see README)
- Endpoint:
GET /api/listings/feed/<portal>— stream the account's stored XML snapshot
catch-all — no separate route.
1. Endpoint & portals#
GET https://vesta-crm.com/api/listings/feed/<portal>
Authorization: Bearer <token> (or ?token=<token>)
<portal> is one of:
| portal | feed |
|---|---|
kyero |
Kyero-v3 |
luxuryestate |
LuxuryEstate (Kyero-v3 shape) |
vitrinemedia |
VitrineMedia (Kyero-v3 shape) |
An unknown portal returns 404.
2. Authentication#
The account is resolved from the token — the URL never carries the numeric
account id, so feeds aren't enumerable. Send the token as a header (preferred) or
?token=:
Authorization: Bearer <token>
This is the same token used for the JSON read feed and the write API; there is only one secret per account. Accounts activate / view / rotate it in Ajustes → Portales → "API / Feeds".
3. Response#
200 OK streams the stored snapshot:
Content-Type: application/xml; charset=utf-8
Cache-Control: public, max-age=1800, s-maxage=1800
The snapshot is regenerated on Vesta's side whenever a listing changes, so polling more often than ~30 min gains nothing.
4. Errors#
| Status | Meaning |
|---|---|
404 |
Unknown portal — use kyero, luxuryestate, or vitrinemedia. |
401 |
Missing/wrong token (response carries WWW-Authenticate: Bearer). |
404 |
"Feed not available" — this account hasn't published to that portal yet, so no snapshot exists. Publish listings to the portal first. |
500 |
Unexpected server error. |
5. Example#
curl 'https://vesta-crm.com/api/listings/feed/kyero' \
-H 'Authorization: Bearer <TOKEN>' \
-o vesta-feed.xml