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.

Base URL https://vesta-crm.comAuth Authorization: Bearer <token>Formato JSON · feed portales XML
Nada de esta página borra ni publica nada. Todo lo que hay aquí o bien lee tus propios anuncios publicados, o bien mete datos en tu cuenta de Vesta. No retira anuncios de los portales, no gasta huecos de Idealista y no permite extraer contactos de la base de datos. Publicar en portales se documenta aparte — pídenoslo si tu integración lo necesita.
Listing API

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:

  1. The account in the URL: ?account=<accountId>.
  2. 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.com still finds maria@agencia.com. Capitals, dots and +tags are 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": false so you can spot users you haven't mapped yet. The field only appears when you sent the header.
  • An explicit agentId in the body wins over the header.
  • New listings only. Re-sending an existing reference never 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 AG.
energyValue number Consumption (kWh/m²·yr).
emissionsScale string Emissions rating letter AG.
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. ingest also accepts publishTo / publishMode, which do push the ad live; they are documented in the portal publishing reference along with the /publish, /sync and /unpublish endpoints.

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 (and city/neighborhood) as names, plus latitude/longitude. No internal location codes needed.
  • Photos: photos[].positionimages[].order; photos[].urlimages[].url.
  • Central heating: send heatingType: "Central" (or "Central con contador") — there is no separate boolean.
  • Contact / owner: send the contact object on ingest (see Owner contact) — no separate contact call is required.
  • Source URL: source_urlsourceUrl.

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.


Contact API

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 externalApiToken as 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 (contactadogestionado → …)
    • 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. GET on /contacts itself answers 405; the state endpoint is /contacts/status.


1. Authentication#

Every request needs:

  1. The account in the URL: ?account=<accountId>.
  2. 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.com still finds maria@agencia.com. Capitals, dots and +tags are 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": false so 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" }'
  • ingestChannel is required alongside contactId, 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 as 400 Unknown contactId.
  • Prefer it over matching by name for a status change. Name matching can land on a namesake — contactId cannot.

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:

  1. NIF/DNI/NIE exact match
  2. Email exact match
  3. Phone exact match
  4. 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 /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: respuesta line 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 largest updatedAt you 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: false means 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 400 response lists every id the account accepts in an allowed array, 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" }'

contactadogestionado → back to contactado all work, in either direction, on either board. Notes:

  • Sending an unknown value returns 400 with 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 descartado or inactivo contact.
  • Moving a lead back out of gestionado re-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/status with since (§3) to pick the change up. Both sides can move a card; last write wins.

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
Read Feed · JSON

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 aparece TU_TOKEN_AQUI pon 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:

  1. El parámetro account={account} (identifica la agencia).
  2. 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 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 AG ("" si no consta).
energiaValor number | null Consumo (kWh/m²·año).
emisionesEscala string Emisiones AG.
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 (€).

⚠️ altitud contiene 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 y TU_TOKEN_AQUI por 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.

Portal Feed · XML

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