# Fondaro API: full documentation > Every page of the Fondaro API documentation followed by the OpenAPI 3 document for the property, media, agent and property-source API (Fondaro MLS). Written for AI assistants and AI site builders. Authenticate with an API key in the `X-API-Key` header; keep it in the `FONDARO_API_KEY` environment variable and never in client code. Docs index: https://www.fondaro.com/llms.txt Human-readable docs: https://www.fondaro.com/docs/api/overview Building a website with an AI builder: https://www.fondaro.com/docs/api/ai-builders OpenAPI document: https://www.fondaro.com/openapi/properties.json --- # Agents Source: https://www.fondaro.com/docs/api/agents/overview > List, update, and delete real estate agents. Manage avatars and list public agent profiles. Manage real estate agent profiles for your organization. ## The Identity Model An agent is a **per-organization roster row**. Every member of a Clerk organization has exactly one agent row in that organization, created automatically the moment the membership exists. A user who belongs to three organizations has three independent rows, one per organization, and each carries that organization's own role fields. **Clerk is canonical for the person.** `firstName`, `lastName`, and the avatar come from the member's Fondaro (Clerk) account. When the account profile changes, every one of that user's agent rows is updated. When you change one of those fields through `PUT /agents/:id` on a Clerk-linked row, the API writes the change through to the Clerk account first and then persists the canonical result, so the change is visible in every organization that person belongs to. Those write-through actions are restricted to organization admins and to the member themselves. **The organization is canonical for the role.** `title`, `displayEmail`, `phoneNumber`, `whatsappNumber`, `description`, and `isActive` are owned by the organization, stored per row, and never synced anywhere. Membership changes flow automatically: joining creates the row (or reactivates an existing one), leaving deactivates it while keeping the data, and rejoining reactivates it with the organization's fields intact. **Standalone agents** (`POST /agents/standalone`) are the escape hatch for a public-facing face who has no Fondaro account, for example an external collaborator. They live in the same table, are owned entirely by the organization, and are never linked to a Clerk user. **Every agent reference is a UUID.** `agents.id` is the only identifier any endpoint accepts or returns for an agent, including `property_listings.agent_id` and brochure agent fields. Clerk user IDs (`user_...`) are never stored in an agent reference column. ### Removed Endpoints These endpoints no longer exist. The behaviour they provided is now automatic. | Removed | Replacement | |---------|-------------| | `POST /agents` (create a Clerk-linked agent) | Rows are created automatically from Clerk membership. Use `POST /agents/standalone` for a non-member face | | `POST /agents/sync` | The membership webhook keeps the roster converged; operators can re-run the reconciler if needed | | `GET /agents/:id/account-profile` | There is nothing to reconcile: Clerk values are written to the row unconditionally | | `POST /agents/:id/adopt-account-field` | Same as above | --- ## Create a Standalone Agent ``` POST /agents/standalone ``` **Authentication:** Required (JWT) Creates an agent profile that is not linked to a Clerk user account. Use this for external agents or collaborators who don't have a Fondaro login. ### Request Body | Field | Type | Required | Description | Constraints | |-------|------|----------|-------------|-------------| | `firstName` | string | Yes | First name | Max 100 chars, non-empty | | `lastName` | string | No | Last name | Max 100 chars | | `displayEmail` | string | No | Public email | Valid email format | | `phoneNumber` | string | No | Phone number | — | | `whatsappNumber` | string | No | WhatsApp number | — | | `description` | string | No | Agent bio | — | | `title` | string | No | Job title | — | | `isActive` | boolean | No | Active status | Default: `true` | | `additionalInfo` | object | No | Custom data | — | ### Example ```bash curl -X POST https://api.fondaro.com/agents/standalone \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "firstName": "Miguel", "lastName": "Fernández", "displayEmail": "miguel@example.com", "phoneNumber": "+34698765432", "title": "Independent Agent" }' ``` --- ## List Agents ``` GET /agents ``` **Authentication:** Required (JWT) Returns all agents for your organization. ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `isActive` | boolean | — | Filter by active status | | `search` | string | — | Search by name | ### Example ```bash curl "https://api.fondaro.com/agents?isActive=true&search=sarah" \ -H "Authorization: Bearer " ``` --- ## Get an Agent ``` GET /agents/:id ``` **Authentication:** Required (JWT) The `:id` is the agent's UUID. Agents are scoped to your organization: an ID belonging to another organization returns `404`. ### Example ```bash curl https://api.fondaro.com/agents/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d \ -H "Authorization: Bearer " ``` ### Errors | Status | Condition | |--------|-----------| | `404` | Agent not found | --- ## Update an Agent ``` PUT /agents/:id ``` **Authentication:** Required (JWT) All fields are optional: include only what you want to change. On a Clerk-linked agent, `firstName`, `lastName`, and `avatarUrl` are written through to the member's Fondaro account and therefore change everywhere that person appears. Those fields may only be changed by an organization admin or by the member themselves. The remaining fields are owned by your organization and stay local to this row. ### Request Body | Field | Type | Description | Constraints | |-------|------|-------------|-------------| | `firstName` | string | First name | Max 100 chars | | `lastName` | string | Last name | Max 100 chars | | `avatarUrl` | string | Avatar URL | Max 500 chars | | `avatarThumbnailUrl` | string | Thumbnail URL | Max 500 chars | | `displayEmail` | string | Public email | Valid email | | `phoneNumber` | string | Phone number | — | | `whatsappNumber` | string | WhatsApp number | — | | `description` | string | Bio / description | — | | `title` | string | Job title | — | | `isActive` | boolean | Active status | — | | `additionalInfo` | object | Custom data | — | ### Example ```bash curl -X PUT https://api.fondaro.com/agents/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "title": "Director of Sales", "description": "Specializing in luxury beachfront properties on the Costa del Sol." }' ``` --- ## Delete an Agent ``` DELETE /agents/:id ``` **Authentication:** Required (JWT) ### Example ```bash curl -X DELETE https://api.fondaro.com/agents/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d \ -H "Authorization: Bearer " ``` ### Response ```json { "success": true } ``` --- ## Upload Agent Avatar ``` POST /agents/:id/avatar ``` **Authentication:** Required (JWT) Upload a profile photo for an agent. On a Clerk-linked agent the photo becomes the member's account photo and appears in every organization they belong to (organization admins and the member themselves only). On a standalone agent the image is resized, stored, and written to the row. ### Request Send the file as `multipart/form-data` with the field name `avatar`. | Constraint | Value | |-----------|-------| | Max file size | 5 MB | | Accepted formats | JPEG, JPG, PNG, WebP, GIF | ### Example ```bash curl -X POST https://api.fondaro.com/agents/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d/avatar \ -H "Authorization: Bearer " \ -F "avatar=@photo.jpg" ``` ### Response Returns the updated agent object with new `avatarUrl` and `avatarThumbnailUrl` fields. ### Errors | Status | Condition | |--------|-----------| | `404` | Agent not found | | `422` | File too large or invalid format | --- ## Delete Agent Avatar ``` DELETE /agents/:id/avatar ``` **Authentication:** Required (JWT) Removes the agent's avatar photo. ### Example ```bash curl -X DELETE https://api.fondaro.com/agents/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d/avatar \ -H "Authorization: Bearer " ``` --- ## Public: Active Agents with Listings ``` GET /properties/agents?organizationId= ``` **Authentication:** None required (public endpoint) Returns active agents that have at least one active property listing. This is intended for public-facing websites to display agent directories. ### Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `organizationId` | UUID | Yes | Organization ID to list agents for | ### Example ```bash curl "https://api.fondaro.com/properties/agents?organizationId=a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" ``` ### Response Returns an array of public agent profiles (no internal IDs or timestamps): ```json [ { "id": "f1e2d3c4-...", "firstName": "Sarah", "lastName": "Johnson", "avatarUrl": "https://cdn.fondaro.com/avatars/...", "avatarThumbnailUrl": "https://cdn.fondaro.com/avatars/...", "displayEmail": "sarah@luxuryestates.com", "phoneNumber": "+34612345678", "whatsappNumber": "+34612345678", "description": "Specializing in luxury beachfront properties.", "title": "Senior Property Consultant" } ] ``` ## Response Format Note Agent responses include both `snake_case` and `camelCase` field names for backwards compatibility: - `first_name` / `firstName` - `last_name` / `lastName` - `image_url` / `avatarUrl` Prefer the `camelCase` versions in new integrations. The public endpoint only returns `camelCase` fields. --- # Build your website with AI Source: https://www.fondaro.com/docs/api/ai-builders > Instructions for AI site builders and AI assistants (v0, Lovable, Bolt, Claude Code, Cursor) that turn the Fondaro API into a listings website, with the key kept in FONDARO_API_KEY. The Fondaro agent network is the shared listing network of the agencies on Fondaro. With one API key, your website can show **your agency's listings** or **every agency's published listings**, and an AI site builder can build that website for you from the instructions on this page. ## The short version 1. In the dashboard, open **Settings → Integrations → API keys** and create a key with the **Read listings** (`properties:read`) scope. If the site calls the API from the browser, list your website under **Allowed websites**. 2. In your builder's environment or secrets panel, save the key as **`FONDARO_API_KEY`**. Never paste the key into the chat or the code. 3. On the same page, **Connect your website with AI** gives you these instructions tailored to your agency, with real examples from your own listings. Copy or download them (`fondaro-mls-integration.md`) and paste them into the builder. The generic instructions below work too, without signing in. ## For AI assistants - This whole API documentation plus the OpenAPI document, in one file: [/llms-full.txt](/llms-full.txt) - An index of every docs page as raw Markdown: [/llms.txt](/llms.txt) - The OpenAPI 3 document: [/openapi/properties.json](/openapi/properties.json), also browsable in the [API reference](/docs/api-reference) ## Instructions to paste into your builder Everything below this heading is written for the builder. The key is always the placeholder `FONDARO_API_KEY`. ### Connection - Base URL: `https://api.fondaro.com` - Authentication: send the header `X-API-Key: ` on every request. - The key is the environment variable **`FONDARO_API_KEY`**. Read it with `process.env.FONDARO_API_KEY` in server code. Never write the key into the code or a client bundle. - Full API description (OpenAPI 3): `https://api.fondaro.com/openapi/properties.json` - Scopes: a listings website needs `properties:read` only. A request without the scope it needs gets `403` with `code: "API_KEY_SCOPE_MISSING"`. ### 1. Search, first page ```bash curl -X POST "https://api.fondaro.com/properties/search?includeBranding=true" \ -H "X-API-Key: $FONDARO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scope": "own", "cursor": "*", "limit": 24, "query": "sea views", "listingType": ["sale"], "propertyType": ["house_detached", "penthouse"], "city": ["Marbella"], "bedrooms": { "min": 3 }, "price": { "min": 500000, "max": 3000000 }, "sort": [{ "field": "relevance" }] }' ``` - `scope`: `"own"` for your agency's listings, `"network"` (the default) for every agency's published listings. Other agencies' listings arrive without private fields. - `cursor: "*"` starts keyset paging. The response is `{ "results": [...], "total": 142, "totalIsLowerBound": false, "nextCursor": "..." }`; `total` is on the first page only. - `sort`: `relevance` (best match for `query`, newest when there is no query), or `price`, `publishedAt`, `bedrooms`, `livingArea` with `"order": "asc" | "desc"`. - `query` is free text: words, "quoted phrases", `-exclusion`, `OR`. Numbers are not read as prices or bedrooms: use the filters. - Other filters: `propertyCategory`, `countryCode`, `region`, `bathrooms`, `livingArea`, `plotArea`, `features` (for example `["pool", "sea_view"]`), `boundingBox` for a map view. ### 2. Next page Send the same body with `"cursor": ""`. Stop when the response has no `nextCursor`. When a filter changes, start again with `"cursor": "*"`. ### 3. One listing ```bash curl "https://api.fondaro.com/properties/?includeBranding=true" \ -H "X-API-Key: $FONDARO_API_KEY" ``` `` is a result's `id`. Show `referenceNumber` (`FDR-...`) as the reference buyers quote. ### 4. Location menu and filter options ```bash curl "https://api.fondaro.com/properties/stats/aggregations?filterByOrganization=true" \ -H "X-API-Key: $FONDARO_API_KEY" curl "https://api.fondaro.com/properties/stats/aggregations?q=sierra" \ -H "X-API-Key: $FONDARO_API_KEY" ``` Counts per town (`byCity`), region, country and property type, plus `availableCities` and `availablePropertyTypes` for dropdowns. `filterByOrganization=true` counts your listings only; without it, the whole network. `q` narrows towns and regions to names containing it and adds matching `communities`, for a type-ahead place picker. ### Images - `images[]`: full-size JPEG photos (up to 1920x1080) with `url`, `order` (0 first), `isFeatured` (the cover), `width` and `height`. Use them on the detail page and in galleries. - `thumbnails[]`: WebP versions (up to 400x225); `originalImageIndex` points at the photo. Use them in grids and cards. - `floorPlans[]`: floor plan images, same shape as photos. - Sort by `order`, lazy-load below the fold, and set `width` and `height` to avoid layout shift. Load the URLs as they are (Fondaro hosts them); do not proxy or re-host. ### Fields The [OpenAPI document](/docs/api-reference) describes every field and enum value. The ones a listings site shows most: `title`, `description`, `price` and `currency`, `listingType` (`sale`, `rent`, `sale_or_rent`, `fraction`), `propertyType`, `city`, `region`, `community`, `latitude` and `longitude`, `bedrooms`, `bathrooms`, `livingArea` and `plotArea` (in `areaUnit`), `features` (amenity slugs), `energyRating`, `agent` and `organizationBranding` (with `includeBranding=true`). ### Do and don't - Do cache API responses for 60 seconds (for example Next.js `revalidate: 60`) and render listing pages on the server. - Do show the listing agency (`organizationBranding`) and agent on another agency's listing. - Do handle `429` by waiting the `Retry-After` seconds; handle `403` (missing scope or origin) and `404` (listing no longer published). - Don't scrape the Fondaro dashboard or bulk-copy listings into another database; read them through this API. - Don't put the key in client-side code unless it is a read-only key with allowed websites set to your site. - Don't show a listing after it disappears from search: it was sold, withdrawn or expired. ### Contact form Fondaro has no public enquiry endpoint a browser may call directly. Make the contact form post to your own site's server code (a server action or serverless function) that emails your agency, including the listing `referenceNumber` in the message. If your agency uses the Fondaro Integrations API, that server code can instead create a lead with `POST https://api.fondaro.com/integrations/v1/leads` using a separate integration key with the `leads:write` scope, kept on the server (see [Integrations API](/docs/api/integrations)). ### Ask your builder > Build a property listings website that reads the Fondaro agent network through the API described above, with the key in the `FONDARO_API_KEY` environment variable used only in server code. Make: a listings grid with photo, title, price, town, bedrooms and bathrooms; filters for price, bedrooms, property type and location (fill the location menu from `/properties/stats/aggregations`); cursor pagination with a "Load more" button; a detail page per listing with a photo gallery, the description, key facts, amenities, a map from `latitude`/`longitude`, the agent card and a contact form that posts to our own server route and emails us with the listing reference. Cache API calls for 60 seconds. API contract v1. --- # Authentication Source: https://www.fondaro.com/docs/api/authentication > Create, scope, rotate and revoke Fondaro API keys, restrict them to your website's origin, and read their rate limits. The Fondaro property API uses API keys. A key belongs to your organization and can do only what its **scopes** allow. Only an organization admin can create, rotate or revoke keys; every member can see them. ## API key format A key is `fondaro_pk_` followed by 32 letters and digits: ``` fondaro_pk_Q7r2Lm9XcV4tB8nK3pW6yZ1aD5sF0gHj ``` The full key is shown once, when it is created or rotated. Fondaro stores only a hash of it and its first 12 characters (`prefixHint`, for example `fondaro_pk_Q`), which the dashboard shows so you can tell keys apart. ## Using a key Send the key in the `X-API-Key` header: ```bash curl https://api.fondaro.com/properties/stats/counts \ -H "X-API-Key: fondaro_pk_Q7r2Lm9XcV4tB8nK3pW6yZ1aD5sF0gHj" ``` `Authorization: ApiKey fondaro_pk_…` works too. Do not send a key as `Authorization: Bearer …`: Bearer is reserved for dashboard sessions, and a key sent that way returns `401`. ## Scopes Each key has one or more scopes. A request that needs a scope the key does not have returns `403`: ```json { "statusCode": 403, "code": "API_KEY_SCOPE_MISSING", "message": "This API key is missing the properties:write scope.", "missingScopes": ["properties:write"], "requiredScopes": ["properties:write"] } ``` | Scope | Allows | |-------|--------| | `properties:read` | Search and read Fondaro network listings, counts, aggregations and themes (`/properties/*` reads). | | `properties:write` | Create, update, renew and delete your own listings, set their portal publications, and generate listing descriptions (`POST /properties/description/generate`). | | `media:write` | Upload listing photos with `POST /properties/media/upload`. | | `agents:read` | Reserved for reading your agent roster. `/agents` accepts dashboard sessions only today, so the scope does not open a route yet. | | `sources:read` | Browse your connected property sources through [`/property-sources`](/docs/api/properties/sources). | Every operation in the [OpenAPI document](https://api.fondaro.com/openapi/properties.json) names the scope it needs in `x-required-scopes`. Keys created before scopes existed hold all five scopes, so existing integrations keep working. Dashboard sessions are not scope-checked. ## Creating a key Create keys from the dashboard, or through the API with an admin's session token: ```bash curl -X POST https://api.fondaro.com/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Agency website", "scopes": ["properties:read"], "allowedOrigins": ["https://www.youragency.com"], "expiresInDays": 365 }' ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | A label of 1 to 255 characters. | | `scopes` | string[] | No | Scopes from the table above, at least one, no repeats. Omit to grant all five. | | `allowedOrigins` | string[] | No | Up to 20 browser origins that may use the key (see below). Omit for a server-only key. | | `expiresInDays` | integer | No | Days until the key expires, from 1 to 3650. Omit for a key that does not expire. | **Response** (the only time you see `key`): ```json { "id": "a1b2c3d4-5678-4abc-9def-123456789abc", "key": "fondaro_pk_Q7r2Lm9XcV4tB8nK3pW6yZ1aD5sF0gHj", "prefixHint": "fondaro_pk_Q", "scopes": ["properties:read"] } ``` A member who is not an organization admin gets `403` with `code: "ORG_ADMIN_REQUIRED"`. The API records the admin who created the key from the session; the body cannot choose it. ## Allowed origins (calling the API from a browser) A key with `allowedOrigins` can be used straight from your website's JavaScript. An origin is a scheme, host and optional port, such as `https://www.youragency.com` (no path, no wildcard; plain `http` only for `localhost` while you develop). - A browser request carries an `Origin` header. If it is not one of the key's allowed origins, the API answers `403` with `code: "API_KEY_ORIGIN_NOT_ALLOWED"`. - The API's CORS rules let listed origins through, so the browser's preflight succeeds. - Requests without an `Origin` (your server, a build step, curl) are not origin-checked. Anyone can read a key that ships in a web page, so only give a browser key `properties:read`. Keys without allowed origins belong on a server. ## Listing keys ```bash curl https://api.fondaro.com/api-keys \ -H "Authorization: Bearer " ``` ```json [ { "id": "a1b2c3d4-5678-4abc-9def-123456789abc", "name": "Agency website", "status": "active", "prefixHint": "fondaro_pk_Q", "legacy": false, "scopes": ["properties:read"], "allowedOrigins": ["https://www.youragency.com"], "rateLimitPerMinute": null, "createdById": "user_2abcDEF", "createdAt": "2026-09-01T12:00:00.000Z", "lastUsedAt": "2026-09-24T09:15:00.000Z", "lastUsedIp": "203.0.113.7", "lastUsedUserAgent": "Mozilla/5.0 …", "usageCount": 247, "expiresAt": "2027-09-01T12:00:00.000Z", "rotatedFromId": null, "graceUntil": null } ] ``` `lastUsedAt`, `usageCount`, `lastUsedIp` and `lastUsedUserAgent` are written in batches, so they can be up to 30 seconds behind. They count verified requests, including ones a route or rate limit later rejects. A key with `legacy: true` was created before key hints existed and has no `prefixHint`. ## Rotating a key Rotation gives you a new secret with the same name, scopes, allowed origins, rate limit and expiry. The old key keeps working for 24 hours so you can swap it without downtime; after that it returns `401`. ```bash curl -X POST https://api.fondaro.com/api-keys/a1b2c3d4-5678-4abc-9def-123456789abc/rotate \ -H "Authorization: Bearer " ``` ```json { "id": "f0e1d2c3-b4a5-4968-8776-655443322110", "key": "fondaro_pk_Zk3mN8pQ2rT6vX9yB1cE4gH7jL0oS5uW", "prefixHint": "fondaro_pk_Z", "rotatedFromId": "a1b2c3d4-5678-4abc-9def-123456789abc", "previousKeyValidUntil": "2026-09-25T12:00:00.000Z" } ``` A key can be rotated once; rotate its replacement next time. Rotating a revoked, expired or already rotated key returns `409`. Admins only. ## Expiry A key with `expiresInDays` stops working at `expiresAt` and returns `401`. Seven days before, Fondaro emails your organization's notification address so an admin can rotate it. ## Revoking a key ```bash curl -X DELETE https://api.fondaro.com/api-keys/a1b2c3d4-5678-4abc-9def-123456789abc \ -H "Authorization: Bearer " ``` ```json { "success": true } ``` A revoked key stops working at once. This cannot be undone. Admins only. ## Rate limits Each key has its own allowance per endpoint: 120 requests a minute, 600 per 10 minutes and 3,000 an hour. Fondaro can raise a key's per-minute limit for a busy site; the 10-minute and hourly windows scale with it. See [API Overview](/docs/api/overview#rate-limits). ## Changes made with a key Listing changes made through a key are recorded in the listing history as `apikey:`, not as the person who created the key. ## Connect your website with AI `GET /api-keys/integration-guide?keyId=` returns a Markdown document for your organization that an AI site builder (v0, Lovable, Bolt, Claude Code, Cursor) turns into a listings website: the base URL, the header, the exact search, detail and aggregation requests, image rules, the field reference and real examples from your published listings. It never contains your key; it uses the `FONDARO_API_KEY` placeholder, which you set in the builder's environment panel. Any member can fetch it. ```json { "markdown": "# Fondaro listings for Your Agency …", "fileName": "fondaro-mls-integration.md", "contractVersion": "v1", "generatedAt": "2026-09-24T12:00:00.000Z", "exampleCount": 2 } ``` ## Security best practices - Store keys in environment variables or a secrets manager; never commit them. - Give each integration its own key with only the scopes it needs. - Use allowed origins and `properties:read` for any key that runs in a browser. - Set an expiry for time-limited integrations and rotate keys before they expire. - Watch `lastUsedAt` and `lastUsedIp` for activity you do not expect, and revoke a key you no longer use. ## API key vs. dashboard session Key management (`/api-keys`) needs a dashboard session (JWT); a key cannot create, rotate or revoke keys. Video Studio (`/properties/studio/*`, including video runs) is dashboard-only too: it spends Studio credit for the signed-in person, and an API key gets `401` there. Property and property-source routes accept either a key or a session. A property API key does not authenticate routes that require a session, including property activity and key management. --- # Calendar Source: https://www.fondaro.com/docs/api/calendar > REST endpoints for the calendar: one window of events, viewings, open houses and expected closes, what is not done, busy time, and creating, moving, ticking and cancelling events. ## Overview The calendar is composed on the server for the person asking: their meetings, the viewings they host, the open houses they host or are going to, the day each of their deals is expected to close and, once they connect their Google or Microsoft account, their own appointments from that calendar. The endpoints on this page use the dashboard's Clerk bearer token and resolve the organization from the request. Reading is open to every member. Writing needs an active plan, exactly as on the CRM endpoints, and only the person whose calendar an event is on can change it. Invitations are never sent by Fondaro. When an event has attendees, it is created in the owner's own Google or Outlook calendar and their provider invites people, from their address, with them as the organiser. Without a connected calendar an event is the owner's own record, and a request that invites anyone is refused. There is one kind of Fondaro entry: an **event**. A task was a lead, a title, a due date and a tick; since 2026-09-26 each one is a calendar event with the same id, and the tick is on the event (`done`). An event whose lead is invited by nobody outside the team is **owed** until ticked. The CRM task endpoints (`/crm/tasks`, integrations and MCP task tools) keep their routes and `Task` shape, served from these same events. Since 2026-09-29 a create or an edit can say which of the two the person meant with `kind` (see [Task or Event](#task-or-event)): a **task** is something you owe a lead; an **event** is everything else, and a lead on it that is not invited never makes it owed. ## Endpoints | Method & path | Who | Purpose | |---|---|---| | `GET /calendar` | Any member | Everything in a window of dates | | `GET /calendar/summary` | Any member | How many owed events are overdue | | `GET /calendar/overdue` | Any member | The overdue events themselves, oldest first | | `GET /calendar/busy` | Any member | Your booked time, for conflict warnings | | `GET /calendar/lead/:leadId/next` | Anyone who can open the lead | The lead's overdue events and the next five | | `GET /calendar/events/:id` | Owner, or see below | One event in full | | `POST /calendar/events` | Any member | Create a meeting, or a viewing of your listing | | `PATCH /calendar/events/:id` | Owner | Move or edit an event | | `DELETE /calendar/events/:id` | Owner | Cancel an event | | `POST /calendar/events/:id/complete` | Owner, a teammate on it, or an admin | Tick it done | | `DELETE /calendar/events/:id/complete` | Owner, a teammate on it, or an admin | Untick it | | `POST /calendar/events/:id/retry-sync` | Owner | Try the calendar write again | ## Task or Event `kind` is a request field on create and edit; nothing is stored under that name. The event itself says which it is: `taskLike` on [Read one event](#read-one-event) is `true` for a task. - `"task"`: the first lead in `leads` is the event's lead and is never invited (an `invite: true` on it is ignored). A task without a lead is `400 CALENDAR_TASK_NEEDS_LEAD`. It fires `task.created`. - `"event"`: the first lead becomes the event's lead only when it is invited or `propertyListingId` makes the event a viewing. Otherwise every lead rides on the event in `attendees` (`leadId`, `invited: false`): it shows on the lead's reads, but the event has no `leadId`, is never owed and fires `meeting.created`. - Absent (phones, MCP, integrations): as before, the first lead is the event's lead. On an edit, an absent `kind` keeps the event's own: a task keeps the old rules and an event applies the event rule to any change of people. A `kind` that differs from the event's own re-applies its rule to the people already on it, even when no people field is sent. ## Time zones and windows `from` and `to` on `GET /calendar` are **local dates** (`YYYY-MM-DD`) in the zone `tz`, an IANA name such as `Europe/Madrid`. `to` is exclusive, and a window is at most 42 days, so a six-week month view fits in one call. - Timed events, viewings and open houses are placed by their instant in `tz`. - An all-day event comes back with `allDay: true` and `date` set, and sits on that date whatever the zone. - An overdue event stays on its own day. Nothing is moved onto today; read `GET /calendar/overdue` for the list. ## Who sees what - **Expected closes** are scoped as on `GET /crm/tasks`: a member sees their own; an admin sees everyone's, or the people and teams named in `assigneeIds` and `teamIds`. - **Events** are listed by whose calendar they are on: the owner's, and a teammate's who is on the event (`attendees[].userId`). A lead's reads (`leadId=N`, `GET /calendar/lead/:leadId/next`) take every event the lead is on: as its lead (`leadId`), as another lead or collaborator on it (`attendees[].leadId`), or as the collaborator of its viewing. An admin asking for a teammate with `assigneeIds` gets that person's Fondaro meetings and viewings, never their own Google or Outlook appointments (`kind: "external"`). - `GET /calendar/events/:id` returns your own events, any event of a viewing, and, for an admin, a teammate's Fondaro events. Anything else is `404`. - **Lead names** follow the lead's own rule: an admin can open every lead, a member the leads assigned to them and the shared collaborator leads. An event on a lead you cannot open still shows, with its `leadId`, but `leadName` is `null` (and `lead.name` is `""` on the event in full). ## The item object `GET /calendar` returns one flat list, `items`, of a union by `kind`: `meeting`, `viewing`, `open_house` (you host it), `open_house_attending` (you are going), `deal_close`, `external` (your own Google or Outlook appointment) and `post` (a listing post you scheduled or published to your own Instagram or LinkedIn). Every item carries: | Field | Type | Notes | |---|---|---| | `key` | string | `kind:id`, stable across requests | | `kind` | string | See above | | `startsAt` | string or null | ISO-8601 instant | | `endsAt` | string or null | Exclusive; `null` for a point in time (a deal close, a post) | | `allDay` | boolean | | | `date` | string or null | `YYYY-MM-DD` for an all-day item, never shifted by the zone | | `title` | string | | | `eventId`, `leadId`, `listingRef`, `openHouseId`, `dealId` | | What the item opens; `null` when it has none | | `leadName` | string or null | `null` when the item has no lead, or its lead is one you cannot open | | `sync` | string | Your events only: `none`, `pending`, `synced` or `failed` | | `ownerUserId` | string or null | Clerk user id of whose calendar it is | | `readOnly` | boolean | Other people's items, your own appointments, repeating and cancelled events | Per kind: a `meeting` (every event made in Fondaro) adds `status`, `location`, `attendeeCount` (everyone on it but the owner: teammates, leads and guests), `invitedCount` (how many invited people the provider tells about a move), `leadResponse` (`accepted`, `declined`, `tentative`, `needs_action` or `null`), `done` (ticked), `owed` (has a lead, invites nobody outside the team, not cancelled, not done) and `overdue` (owed, and its day or its start has passed); a `viewing` adds `status` and `viewingId`; an open house adds `status` and `address`; a `deal_close` adds `status` and `stage`; an `external` item adds `status`, `location` and `recurring`; a `post` adds `status` (`scheduled`, `publishing` or `published`), `postId`, `channel` (`instagram` or `linkedin`), `postKind` (`feed` or `story`) and `providerPostUrl`. A post is a point in time (`endsAt` is `null`), always `readOnly`, and opens with `postId` (see [Listing Posts](/docs/api/listing-posts)); drafts are not on the calendar. The list is lean on purpose: no descriptions and no attendee lists. Read one event for those. ## Read a window ```bash curl 'https://api.fondaro.com/calendar?from=2026-09-21&to=2026-09-28&tz=Europe/Madrid' \ -H 'Authorization: Bearer ' ``` ```json { "window": { "from": "2026-09-21", "to": "2026-09-28", "tz": "Europe/Madrid" }, "items": [ { "key": "meeting:6b1f0c2e-7d3a-4e5f-9a8b-1c2d3e4f5a6b", "kind": "meeting", "startsAt": "2026-09-24T09:00:00.000Z", "endsAt": "2026-09-24T09:30:00.000Z", "allDay": false, "date": null, "title": "Meeting with a buyer", "eventId": "6b1f0c2e-7d3a-4e5f-9a8b-1c2d3e4f5a6b", "leadId": 4182, "leadName": "A. Buyer", "listingRef": null, "openHouseId": null, "dealId": null, "sync": "synced", "ownerUserId": "user_2a", "readOnly": false, "status": "confirmed", "location": "Office", "attendeeCount": 1, "invitedCount": 1, "leadResponse": "accepted", "done": false, "owed": false, "overdue": false } ], "truncated": false } ``` An admin reading a teammate's week adds `&assigneeIds=user_2b` (repeat the parameter for several people) or `&teamIds=`. `&leadId=4182` reads one lead's window instead: every Fondaro event the lead is on and the lead's viewings, whoever's calendar they are on, for anyone who can open the lead (see [Who sees what](#who-sees-what)). `assigneeIds` and `teamIds` are then ignored, and your own Google or Outlook appointments are left out. A lead you cannot open is `404`. `truncated` is `true` only if one source hit its safety bound; a person's calendar never does. ## Count what is not done ```bash curl 'https://api.fondaro.com/calendar/summary?tz=Europe/Madrid' \ -H 'Authorization: Bearer ' ``` ```json { "overdueCount": 3 } ``` How many owed events are overdue, counted in the database, so it is right however many there are. `assigneeIds` and `teamIds` scope it as on `GET /calendar`. ## List what is not done ```bash curl 'https://api.fondaro.com/calendar/overdue?tz=Europe/Madrid' \ -H 'Authorization: Bearer ' ``` ```json { "items": [ { "eventId": "0f9c3a1e-2b4d-4c6e-8f10-2a3b4c5d6e7f", "title": "Call about the offer", "startsAt": "2026-09-21T00:00:00.000Z", "endsAt": "2026-09-22T00:00:00.000Z", "allDay": true, "date": "2026-09-21", "leadId": 4182, "leadName": "A. Buyer", "ownerUserId": "user_2a" } ], "truncated": false } ``` Oldest first, at most 200; `truncated` is `true` when more were overdue. `leadId=N` reads one lead's overdue events instead, for anyone who can open the lead (`assigneeIds` and `teamIds` are then ignored; a lead you cannot open is `404`). ## Read your busy time `from` and `to` are ISO-8601 instants, at most 42 days apart. The answer holds the events you own or are a teammate on that are neither done nor cancelled, including your own Google or Outlook appointments and viewings you host. An all-day event is one block over its whole day (`allDay: true`). Pass the event you are editing as `excludeEventId` so it does not clash with itself. ```bash curl 'https://api.fondaro.com/calendar/busy?from=2026-09-24T09:00:00Z&to=2026-09-24T10:00:00Z' \ -H 'Authorization: Bearer ' ``` ```json { "busy": [ { "key": "external:8c7d6e5f-4a3b-4c2d-9e1f-0a9b8c7d6e5f", "kind": "external", "startsAt": "2026-09-24T09:15:00.000Z", "endsAt": "2026-09-24T10:00:00.000Z", "allDay": false, "title": "Dentist", "eventId": "8c7d6e5f-4a3b-4c2d-9e1f-0a9b8c7d6e5f" } ] } ``` Busy time is read from what Fondaro holds of your calendar; no request goes to Google or Microsoft. ## Read a lead's next events The lead page's Upcoming card: no window, so nothing far ahead is missed. `tz` is required and judges "overdue" and "today". ```bash curl 'https://api.fondaro.com/calendar/lead/4182/next?tz=Europe/Madrid' \ -H 'Authorization: Bearer ' ``` ```json { "overdue": [ { "key": "event:0f9c3a1e-2b4d-4c6e-8f10-2a3b4c5d6e7f", "eventId": "0f9c3a1e-2b4d-4c6e-8f10-2a3b4c5d6e7f", "kind": "meeting", "title": "Call about the offer", "startsAt": "2026-09-21T08:00:00.000Z", "endsAt": "2026-09-21T08:15:00.000Z", "allDay": false, "date": null, "status": "confirmed", "ownerUserId": "user_2a", "leadId": 4182, "leadName": "A. Buyer", "viewingId": null, "attendeeCount": 1, "done": false, "owed": true, "overdue": true, "canComplete": true } ], "next": [], "hasMore": false } ``` `overdue` lists the lead's overdue events, oldest first, at most 200. `next` is the next five that are neither done nor cancelled, at any distance ahead (an event under way counts); `hasMore` is `true` when more follow. The lead may be on an event as its lead, as another lead or collaborator on it, or as the collaborator of its viewing; `leadId` is the event's own lead. `canComplete` is whether you may tick it (the owner, a teammate on it, or an admin). A lead you cannot open is `404`. ## Read one event `:id` is an event id, or `viewing:` for a viewing booked before the calendar existed. Such a viewing has no event yet and is shown from the viewing itself. `tz` (optional) is your IANA zone. With it, `overdue` on an all-day event is judged by your day, as `GET /calendar` does; without it, by the event's own zone. A zone that is not IANA is `400 CALENDAR_TZ_INVALID`. ```bash curl 'https://api.fondaro.com/calendar/events/6b1f0c2e-7d3a-4e5f-9a8b-1c2d3e4f5a6b?tz=Europe/Madrid' \ -H 'Authorization: Bearer ' ``` ```json { "id": "6b1f0c2e-7d3a-4e5f-9a8b-1c2d3e4f5a6b", "eventId": "6b1f0c2e-7d3a-4e5f-9a8b-1c2d3e4f5a6b", "kind": "meeting", "origin": "fondaro", "title": "Meeting with a buyer", "description": "Bring the floor plans.", "location": "Office", "startsAt": "2026-09-24T09:00:00.000Z", "endsAt": "2026-09-24T09:30:00.000Z", "allDay": false, "timezone": "Europe/Madrid", "status": "confirmed", "recurring": false, "ownerUserId": "user_2a", "attendees": [ { "email": "buyer@example.com", "name": "A. Buyer", "leadId": 4182, "invited": true, "response": "accepted" }, { "userId": "user_2b", "email": "ana@agency.example", "name": "Ana Ruiz", "response": "accepted" } ], "people": [ { "kind": "lead", "leadId": 4182, "name": "A. Buyer", "email": "buyer@example.com", "invited": true, "response": "accepted" }, { "kind": "teammate", "userId": "user_2b", "name": "Ana Ruiz", "email": "ana@agency.example", "invited": false, "response": "accepted" } ], "attendeeCount": 2, "sync": "synced", "syncError": null, "provider": "google", "lead": { "id": 4182, "name": "A. Buyer" }, "listingRef": null, "viewing": null, "openHouseId": null, "history": null, "canEdit": true, "completedAt": null, "owed": true, "overdue": false, "canComplete": true, "taskLike": false, "videoMissing": false } ``` - `sync` is `pending` until your calendar holds the event, and `failed` when the last write did not land (`syncError` says why). A viewing or open house you deleted in Google or Outlook stays booked in Fondaro and reads `failed` with `syncError: "removed"` until you add it back with [retry](#try-the-calendar-write-again). - `history` is set when an edit lost to a newer one made on the other side: `{ "at", "side": "fondaro" | "external", "summary": "moved" | "edited" }`. See [When both sides change an event](/docs/dashboard/calendar#when-you-change-things-in-google-or-outlook). - `provider` names where the event lives. Fondaro holds no direct link to the event there; the dashboard opens that day in Google Calendar or Outlook. - `lead` is `{ "id", "name" }`; for a lead you cannot open, `name` is `""`. Send the same `leadId` back on an edit and the event keeps its lead. - `people` is everyone on the event but the owner, the event's lead first: `{ "kind": "teammate" | "lead" | "guest", "userId"?, "leadId"?, "name", "email"?, "invited", "response" }`. A lead you cannot open has `name: ""` and no email. `attendeeCount` is `people.length`. - `taskLike` is `true` for a task: a lead, nobody invited outside the team, not cancelled, done or not. Clients use it to show a tick and to open the event as a task. - `videoMissing` is `true`, for the owner only, when the event asked for a video call and the provider sent back no link. It clears once `location` is set, for example to a pasted link. - In `attendees`, `invited` says whether the person was sent an invite from your calendar. A lead can be on an event without one (`invited: false`). Entries written before 2026-09-26 have no `invited`: an email and no `userId` means invited. ## Create an event ```bash curl -X POST 'https://api.fondaro.com/calendar/events' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "title": "Meeting with a buyer", "startsAt": "2026-09-24T09:00:00.000Z", "endsAt": "2026-09-24T09:30:00.000Z", "timezone": "Europe/Madrid", "leads": [{ "leadId": 4182, "invite": true }, { "leadId": 4190 }], "teammateIds": ["user_2b"], "attendees": [{ "email": "colleague@example.com" }], "location": "Office", "videoCall": false, "kind": "event", "connectedAccountId": "9d2c4b1a-3e5f-4a6b-8c7d-0e1f2a3b4c5d", "calendarId": "viewings@group.calendar.google.com" }' ``` A task needs only its lead, a title and a moment: ```bash curl -X POST 'https://api.fondaro.com/calendar/events' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "kind": "task", "title": "Call A. Buyer", "startsAt": "2026-09-25T10:00:00.000Z", "endsAt": "2026-09-25T10:15:00.000Z", "timezone": "Europe/Madrid", "leads": [{ "leadId": 4182 }] }' ``` | Field | Required | Notes | |---|---|---| | `title` | Yes | 1 to 500 characters | | `startsAt`, `endsAt` | Yes | ISO-8601 instants | | `timezone` | Yes | IANA zone the event is scheduled in | | `allDay` | No | | | `leads` | No | Up to 20 `{ leadId, invite? }`, leads or collaborators you can see. The first is the event's lead. `invite: true` puts that lead's email on the invite (it needs an email); without it the lead is on the event but not invited | | `leadId` | No | Shorthand for one lead, `leads: [{ leadId }]`; ignored when `leads` is sent | | `inviteLead` | No | With `leadId`: invite that lead | | `teammateIds` | No | Up to 50 Clerk user ids of people in your organization. The event is on their calendars too and they can tick it; they are never sent an invite. Each gets access to every lead on it | | `ownerUserId` | No | Whose calendar it goes on: you by default. An admin may name one of `teammateIds` | | `attendees` | No | Up to 50 `{ email, name? }`, always invited | | `location`, `description` | No | | | `propertyListingId` | No | One of your organization's listings: the event becomes a **viewing** of it, created through the viewing rules | | `videoCall` | No | Ask the owner's own calendar for a video link: Google Meet on a Google account, Microsoft Teams on a Microsoft one; there is no other video provider. Off unless `true`; ignored for a viewing. When `location` is empty, the link is put there. When the provider sends back no link, the event reads `videoMissing: true` | | `kind` | No | `"task"` or `"event"`; see [Task or Event](#task-or-event) | | `connectedAccountId` | No | Which of the owner's calendar accounts it goes to: their mailbox, or a shared mailbox they connected, live and with calendar. Absent: the default account, the owner's oldest connected mailbox with calendar. The accounts and their calendars are on [`GET /connected-accounts`](/docs/api/connected-accounts#calendars) | | `calendarId` | No | A calendar of that account (`calendars[].id`) that is not `readOnly`. Absent: the account's primary calendar. Sent without `connectedAccountId`, it is looked for on the default account | The answer is the event, as [Read one event](#read-one-event) returns it. With a connected calendar the event is written there straight away and the provider sends the invitations; `sync` shows how that went. A target that is not one of the owner's writable calendars is `400 CALENDAR_TARGET_INVALID`. A viewing (`propertyListingId`) ignores the target and goes to the default calendar. An edit never moves an event to another calendar. ## Move or edit an event Send only what changes. Moving keeps the length unless you send `endsAt`. Attendees who stay keep their answers. `kind` turns a task into an event or back (see [Task or Event](#task-or-event)). `leads`, `teammateIds` and `attendees` each replace their own group only when sent; `leadId` and `inviteLead` change the event's lead only and keep the other leads. A change made in Google or Outlook keeps every lead and teammate on the event: a lead whose invite was removed there stays on the event, not invited. ```bash curl -X PATCH 'https://api.fondaro.com/calendar/events/6b1f0c2e-7d3a-4e5f-9a8b-1c2d3e4f5a6b' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "startsAt": "2026-09-24T11:00:00.000Z" }' ``` - A viewing's event moves the **viewing** itself, through the same rules as the viewing endpoints. - `:id` may be `viewing:` for a viewing that has no event yet: its event is created first, then the change applies. The answer carries the new event id. - An event with no calendar joins yours once you have connected one. - Open houses change where they live: `400 CALENDAR_EVENT_OPEN_HOUSE`. ## Cancel an event ```bash curl -X DELETE 'https://api.fondaro.com/calendar/events/6b1f0c2e-7d3a-4e5f-9a8b-1c2d3e4f5a6b' \ -H 'Authorization: Bearer ' ``` A meeting becomes `cancelled` and is removed from your calendar, whose provider tells the attendees. A viewing is cancelled as a viewing (`viewing:` works here too). An open house is cancelled from the open house. ## Tick an event done ```bash curl -X POST 'https://api.fondaro.com/calendar/events/0f9c3a1e-2b4d-4c6e-8f10-2a3b4c5d6e7f/complete' \ -H 'Authorization: Bearer ' ``` `DELETE` on the same path unticks it. Both are idempotent and answer the event, as [Read one event](#read-one-event) returns it, with `completedAt` set or cleared. The owner, a teammate on the event, or an admin may tick it (`canComplete`). A done event stays on its day. Ticking is a Fondaro mark: it changes nothing in Google or Outlook. Your own appointments cannot be ticked (`400 CALENDAR_EVENT_READ_ONLY`), nor can a viewing (`400 CALENDAR_EVENT_VIEWING`, mark it on the viewing), an open house (`400 CALENDAR_EVENT_OPEN_HOUSE`) or a cancelled event (`409 CALENDAR_EVENT_CANCELLED`). A tick fires the `task.completed` webhook when the event was owed. The CRM task endpoints and the MCP `update_task` / `delete_task` tools follow the same rule: only the owner, a teammate on the event, or an admin can tick, untick or delete a task (`403` otherwise). Access to the lead alone lets you read and edit the task, not tick or delete it. ## Webhooks Every event made in Fondaro fires one "created" webhook, never two: - An **owed** event (a lead, nobody invited outside the team) fires `task.created`, whether it is made on the calendar or through the task endpoints. - Any other event (one that invites someone, a viewing, an event with no lead) fires `meeting.created`, whose `leadIds` lists every lead on it, the event's lead first. An edit can make one new: `kind: "task"` on an event with a lead that invites nobody outside the team makes it owed and fires `task.created`. Inviting someone onto an owed event fires `meeting.created`. An edit without `kind` keeps the event's own kind, so removing the last invitee from an event, or adding a lead to it without inviting them, leaves it an event and fires nothing. Other edits fire neither. Tasks created in bulk (`POST /crm/leads/bulk/tasks` and the `bulk_create_tasks` tool) fire no webhooks. See [Integrations](/docs/api/integrations) for the payloads. ## Try the calendar write again ```bash curl -X POST 'https://api.fondaro.com/calendar/events/6b1f0c2e-7d3a-4e5f-9a8b-1c2d3e4f5a6b/retry-sync' \ -H 'Authorization: Bearer ' ``` Writes the event to your calendar again (or removes it there, if cancelled). It also adds back a viewing or open house you deleted in Google or Outlook. Fondaro retries failed writes on its own every 15 minutes as well. Without a connected calendar the answer is `409 CALENDAR_NOT_CONNECTED`. ## Errors | Status | `code` | When | |---|---|---| | 400 | `CALENDAR_TZ_REQUIRED` / `CALENDAR_TZ_INVALID` | No zone, or not an IANA zone | | 400 | `CALENDAR_WINDOW_INVALID` / `CALENDAR_WINDOW_TOO_WIDE` | Bad dates, or more than 42 days | | 400 | `CALENDAR_LEAD_NO_EMAIL` | Inviting a lead with no email | | 400 | `CALENDAR_TASK_NEEDS_LEAD` | `kind: "task"` with no lead | | 400 | `CALENDAR_TARGET_INVALID` | `connectedAccountId` or `calendarId` is not one of the owner's writable calendars | | 400 | `CALENDAR_TEAMMATE_UNKNOWN` | A `teammateIds` entry who is not an active member of your organization | | 400 | `CALENDAR_OWNER_NOT_ON_EVENT` | `ownerUserId` is not one of `teammateIds` | | 403 | `CALENDAR_OWNER_NOT_ALLOWED` | A member naming someone else in `ownerUserId` | | 400 | `CALENDAR_EVENT_READ_ONLY` | Your own Google or Outlook appointment, or a repeating event | | 400 | `CALENDAR_EVENT_OPEN_HOUSE` | Change or cancel an open house from the open house | | 400 | `CALENDAR_EVENT_VIEWING` | Mark a viewing done on the viewing itself | | 403 | `CALENDAR_EVENT_NOT_YOURS` | Someone else's event | | 404 | `CALENDAR_EVENT_NOT_FOUND` | No such event, or not one you may see | | 409 | `CALENDAR_NOT_CONNECTED` | Attendees, or a retry, without a connected calendar | | 409 | `CALENDAR_EVENT_CANCELLED` | The event is cancelled | | 409 | `CALENDAR_VIEWING_NOT_MIRRORED` | The viewing was saved but its event was not; try again | --- # Calling Source: https://www.fondaro.com/docs/api/calling > REST endpoints for your own phone number, prices for a Fondaro number, and what a country needs before Fondaro can give you a number there. Changed by the 2026-09 onboarding program. ## Overview Calling is on when a person has a number to call from: their own mobile, verified once, or a Fondaro number. There is no separate switch-on step for the customer. The endpoints keep their historical `/twilio/...` paths. The endpoints on this page use the dashboard's Clerk bearer token and resolve the organization from the request. Every member verifies and reads their own number. The country requirements flow (`/twilio/regulatory/...`) is limited to organization admins. ## Endpoints | Method & path | Who | Purpose | |---|---|---| | `GET /twilio/phone-numbers/my-caller-id` | Any member | Your own verified number, or `{ "verified": false }` | | `POST /twilio/phone-numbers/verify-caller-id` | Any member | Start verifying your own number: Fondaro calls it | | `GET /twilio/phone-numbers/caller-id-verifications/current` | Any member | Your latest verification attempt | | `GET /twilio/phone-numbers/caller-id-verifications/:attemptId` | Any member | One attempt, for polling | | `DELETE /twilio/phone-numbers/caller-id-verifications/:attemptId` | Any member | Cancel a pending attempt | | `GET /twilio/phone-numbers` | Any member | The numbers you may call from | | `GET /twilio/pricing` | Any member | The price of a Fondaro number and per-minute calls | | `POST /twilio/regulatory/bundles/:id/submit` | Admin | Send a country's details and documents for review | ## Verify your own number ```bash curl -X POST https://api.fondaro.com/twilio/phone-numbers/verify-caller-id \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+34612345678" }' ``` ```json { "id": "5b0c...", "phoneNumber": "+34612345678", "status": "pending", "validationCode": "482913", "expiresAt": "2026-09-26T10:24:03.000Z", "createdAt": "2026-09-26T10:14:03.000Z", "updatedAt": "2026-09-26T10:14:03.000Z" } ``` Fondaro calls the number; the person types `validationCode` on their phone. Poll `GET .../caller-id-verifications/:attemptId` until `status` is `succeeded`, `failed`, `expired` or `cancelled`. `failureReason` is `verification_failed`, `provider_unavailable` or `conflict`. What changed in 2026-09: - **The phone account is created on demand.** A member verifying before their organization's phone account exists no longer waits for an admin; the account is created for an entitled organization. A sandbox organization is still refused. - **Verifying sets calling up.** When the first own number in an organization succeeds and the organization owns no purchased number, Fondaro runs the setup that used to be an admin wizard: it buys the routing number the calls travel through and switches calling on. Nothing is bought when a purchased number already exists, so an organization that switched calling off is never switched back on. A failed setup leaves the verification in place and is retried on the next `GET /twilio/token` or `GET /twilio/phone-numbers` (at most once a minute). - **A country Fondaro cannot call** is refused with `422` and `code: "CALLER_ID_COUNTRY_NOT_SUPPORTED"` ("We can't call numbers in this country yet."). Other refusals: `409` the number is already used in the organization, `403` calling is not available for the organization (for example a sandbox), `503` try again. ## Why a person cannot call `GET /twilio/token` answers `400` with a stable `code` when a person cannot call; `GET /twilio/phone-numbers` carries the same value as `callingBlockedCode`. The codes did not change; the `message` text did, and shipped apps show it as is: | `code` | `message` | |---|---| | `CALLING_DISABLED` | Calling is switched off for your agency. | | `CALLING_PAUSED_BY_FONDARO` | Calling is paused while we check unusual activity. It comes back on its own. | | `NO_ROUTING_NUMBER` | Calling isn't ready yet. | | `NO_CALLER_ID` | Add your phone number to start calling. | ## Prices ```bash curl "https://api.fondaro.com/twilio/pricing?numberType=local" \ -H "Authorization: Bearer " ``` ```json { "numberRentalBasePrice": 1.0, "numberRentalCustomerPrice": 1.5, "numberRentalMarkupPct": 50, "perMinuteBasePrice": 0.013, "perMinuteCustomerPrice": 0.0195, "perMinuteMarkupPct": 50, "country": "ES", "currency": "EUR" } ``` `country` (ISO 3166-1 alpha-2) and `numberType` (`local` by default) are optional. **Changed:** with no `country`, the price is for the organization's own country; the US is used only when the organization has none. Prices are in the organization's billing currency; `numberRentalBasePrice` and `numberRentalCustomerPrice` are `null` where numbers cannot be rented. ## Send a country's details for review ```bash curl -X POST https://api.fondaro.com/twilio/regulatory/bundles//submit \ -H "Authorization: Bearer " ``` **Changed:** - A request that was **rejected** can be fixed and sent again; before, only a draft could be sent. Anything else answers `400` "This has already been sent." - When the details do not pass the check before sending, the `400` names each field so a form can mark it inline: ```json { "statusCode": 400, "code": "DOCUMENTS_NOT_COMPLIANT", "message": "Some details need a fix: The business address is missing", "fields": [ { "field": "business_address", "requirement": "Business address", "reason": "The business address is missing" } ] } ``` `message` stays readable on its own for older clients. Each entry in `fields` has `field`, `requirement` and `reason`, any of which may be `null`. Once a request is approved, Fondaro buys the number with the approved details and emails the admin; nobody has to come back to press anything (the `calling:sync-regulatory-bundles` job, every 30 minutes). --- # Connected inboxes Source: https://www.fondaro.com/docs/api/connected-accounts > REST endpoints to list, connect, reconnect and disconnect the inboxes your organization's members connect to Fondaro, choose which of an inbox's calendars show, read the price of the next one, and ask an admin for a paid one. ## Overview A connected inbox is one account a member of your organization has connected to Fondaro: their Google or Microsoft email with its calendar, or another mailbox. The API calls it a **connected account**. Each member connects at most one inbox of each kind. The endpoints on this page use the dashboard's Clerk bearer token and resolve the organization from the request. Every member can list, connect, reconnect and disconnect their own inboxes. Listing the whole organization's inboxes, and disconnecting another member's, is limited to organization admins; a member receives `403`. Connecting is a browser round trip: you ask Fondaro for a link, send the person to it, and they come back to the dashboard once they have signed in and allowed access. The API never receives a password. ## Endpoints | Method & path | Who | Purpose | |---|---|---| | `GET /connected-accounts` | Any member | Your own connected inboxes | | `GET /connected-accounts/organization` | Admin | Every member's connected inboxes | | `GET /connected-accounts/price` | Any member | What the next inbox costs your organization, and whether you may add a paid one | | `POST /connected-accounts/connect` | Any member | Start connecting an inbox; returns a link | | `POST /connected-accounts/requests` | Member | Ask your admins for an inbox your plan does not include | | `GET /connected-accounts/requests` | Admin | The members' open inbox requests | | `POST /connected-accounts/requests/:id/allow` | Admin | Allow a member one paid inbox of that kind | | `POST /connected-accounts/requests/:id/decline` | Admin | Decline a request | | `POST /connected-accounts/:id/reconnect` | Owner | Start reconnecting an inbox; returns a link | | `DELETE /connected-accounts/:id` | Owner or admin | Disconnect an inbox | | `PUT /connected-accounts/:id/calendars/:calendarId` | Owner | Show or hide one of the inbox's calendars | | `POST /connected-accounts/:id/calendars/refresh` | Owner | List the inbox's calendars again | | `GET /crm/email-sender` | Any member | Which address your CRM email goes out from | | `POST /crm/leads/:id/emails` | Admin or lead assignee | Send an email to a lead from your inbox | | `POST /crm/leads/:id/find-past-emails` | Admin or lead assignee | Search your inbox for a lead's older emails and add them to the lead | | `GET /crm/leads/:id/channels` | Admin or lead assignee | Which message channels you can use for this lead | | `POST /crm/leads/:id/messages` | Admin or lead assignee | Send a WhatsApp, Instagram, LinkedIn or Telegram message to a lead | | `GET /crm/leads/:id/messages/:messageId/attachments/:index` | Admin or lead assignee | A short-lived link to a file on a message | | `POST /crm/leads/:id/lookup-profile` | Admin or lead assignee | Read a lead's linked LinkedIn or Instagram profile once | ## The connected account object | Field | Type | Notes | |---|---|---| | `id` | string | UUID | | `kind` | string | `mailbox` today; `shared_mailbox`, `whatsapp`, `linkedin`, `instagram` and `telegram` are reserved for later channels | | `provider` | string | `google`, `microsoft` or `imap` (Other mailbox) | | `address` | string or null | The connected email address, once known | | `status` | string | `connecting`, `ok`, `needs_reconnect`, `error` or `disconnected` | | `statusReason` | string or null | Why the status last changed, when there is a reason | | `statusChangedAt` | string | ISO-8601 | | `capabilities` | string[] | What currently works through this inbox: `email`, `calendar`, `messaging` | | `connectedAt` | string or null | ISO-8601 | | `disconnectedAt` | string or null | ISO-8601; always null in list responses | | `ownerUserId` | string | Clerk user id of the member who connected it | | `calendars` | object[] | Only on an inbox with the `calendar` capability: its calendars, the primary first, then by name. Empty until the first sync lists them. See [Calendars](#calendars) | `needs_reconnect` means the provider stopped accepting the connection (a changed password, or access removed in the Google or Microsoft account settings). [Reconnect](#reconnect-an-inbox) restores it without changing the `id`. ## List your inboxes `GET /connected-accounts` returns your live inboxes, the ones not disconnected. `available` is `false` when connecting inboxes is not available on this Fondaro environment yet; `accounts` is then empty. ```bash curl 'https://api.fondaro.com/connected-accounts' \ -H 'Authorization: Bearer ' ``` ```json { "available": true, "accounts": [ { "id": "3c9e2f41-7b1d-4c8a-9e53-0a6d2b7f1e84", "kind": "mailbox", "provider": "google", "address": "agent@example.com", "status": "ok", "statusReason": null, "statusChangedAt": "2026-09-25T09:14:03.000Z", "capabilities": ["email", "calendar"], "connectedAt": "2026-09-25T09:14:03.000Z", "disconnectedAt": null, "ownerUserId": "user_2a", "calendars": [ { "id": "agent@example.com", "name": "agent@example.com", "primary": true, "readOnly": false, "selected": true }, { "id": "viewings@group.calendar.google.com", "name": "Viewings", "primary": false, "readOnly": false, "selected": true }, { "id": "es.spain#holiday@group.v.calendar.google.com", "name": "Holidays in Spain", "primary": false, "readOnly": true, "selected": false } ] } ] } ``` ## List the organization's inboxes `GET /connected-accounts/organization` (admin only) returns every member's live inboxes as an array. Each item is a connected account object with an `owner` added: ```bash curl 'https://api.fondaro.com/connected-accounts/organization' \ -H 'Authorization: Bearer ' ``` ```json [ { "id": "3c9e2f41-7b1d-4c8a-9e53-0a6d2b7f1e84", "kind": "mailbox", "provider": "microsoft", "address": "agent@example.com", "status": "needs_reconnect", "statusReason": "credentials", "statusChangedAt": "2026-09-25T11:02:40.000Z", "capabilities": ["email", "calendar"], "connectedAt": "2026-09-20T08:30:00.000Z", "disconnectedAt": null, "ownerUserId": "user_2b", "owner": { "name": "Agent Name", "email": "agent@example.com", "imageUrl": null } } ] ``` ## Read the price `GET /connected-accounts/price` returns what the next inbox would cost your organization. `included` is `true` while your plan's included inboxes are not all in use (one per person on your plan). `amountCents` is the monthly price of each further inbox in your billing currency. `canAddPaid` is `true` for an admin: only an admin adds an inbox your plan does not include. A member asks instead (see [Ask for an inbox](#ask-for-an-inbox)), and `requests` lists their own open requests, at most one per kind: `pending` while it waits for an admin, `allowed` once an admin allowed it and until that inbox connects. ```bash curl 'https://api.fondaro.com/connected-accounts/price' \ -H 'Authorization: Bearer ' ``` ```json { "included": false, "amountCents": 1000, "currency": "EUR", "canAddPaid": false, "requests": [ { "id": "6f0c1d2e-...", "kind": "whatsapp", "provider": "whatsapp", "status": "pending", "askedAt": "2026-09-30T09:12:00.000Z", "answeredAt": null } ] } ``` See [Connected inboxes: Price](/docs/dashboard/integrations/connected-accounts#price) for when an inbox is charged. ## Connect an inbox `POST /connected-accounts/connect` starts a connection and returns a `url`. Open it in the person's browser. After they sign in and allow access they return to the matching page under **Organization > Integrations**, with `?connected=` on success or `?connect_error=` on failure. | Body field | Type | Notes | |---|---|---| | `provider` | string | `google`, `microsoft` or `imap` | | `kind` | string, optional | Defaults to `mailbox` | ```bash curl -X POST 'https://api.fondaro.com/connected-accounts/connect' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "provider": "google" }' ``` ```json { "url": "https://..." } ``` The inbox appears in `GET /connected-accounts` once the provider confirms it, usually a few seconds after the person returns; poll until its `status` is `ok`. If the inbox is not your organization's included one, its first month is charged when it connects. | Status | Code | Meaning | |---|---|---| | `409` | `already_connected` | You already have a live inbox of this kind | | `403` | `sandbox` | Sandbox organizations cannot connect inboxes | | `403` | `admin_approval_required` | You are a member, your plan's included inboxes are in use, and no admin allowed you one of this kind. [Ask for one](#ask-for-an-inbox) | | `402` | `not_entitled` | Your organization has no active plan or trial | | `503` | `not_available` | Connecting inboxes is not available on this environment yet | Reconnecting an inbox you already have is never refused this way. ## Ask for an inbox Once your plan's included inboxes are in use, only an admin adds another one, because it is billed monthly. A member asks with `POST /connected-accounts/requests`, with the same body as connect. Your organization's admins see the request in their Inbox and on **Inboxes**, and get a notification. Asking again for the same kind returns the open request unchanged. ```bash curl -X POST 'https://api.fondaro.com/connected-accounts/requests' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "provider": "whatsapp", "kind": "whatsapp" }' ``` ```json { "id": "6f0c1d2e-...", "kind": "whatsapp", "provider": "whatsapp", "status": "pending", "askedAt": "2026-09-30T09:12:00.000Z", "answeredAt": null } ``` | Status | Code | Meaning | |---|---|---| | `409` | `not_needed` | You are an admin, or your plan still includes your next inbox: connect it directly | | `409` | `already_connected` | You already have a live inbox of this kind | | `403` | `ORG_ADMIN_REQUIRED` | The shared mailbox is connected by an admin | | `403` | `sandbox` | Sandbox organizations cannot connect inboxes | | `402` | `not_entitled` | Your organization has no active plan or trial | An admin lists open requests with `GET /connected-accounts/requests`. Each row adds `userId`, `requester` (`name`, `email`, `imageUrl`) and the monthly price (`amountCents`, `currency`). `POST /connected-accounts/requests/:id/allow` lets that member connect one inbox of that kind: their `POST /connected-accounts/connect` then succeeds, the inbox is billed like any extra inbox, and the member gets a notification. The allowance is used once that inbox connects. `POST /connected-accounts/requests/:id/decline` closes the request; the member can ask again. Answering a request that is no longer open returns `409 request_closed` (allowing an allowed request again returns it unchanged). ## Reconnect an inbox `POST /connected-accounts/:id/reconnect` returns a `url` the same way. Only the member who connected the inbox can reconnect it. The inbox keeps its `id`, and reconnecting is never charged again. ```bash curl -X POST 'https://api.fondaro.com/connected-accounts/3c9e2f41-7b1d-4c8a-9e53-0a6d2b7f1e84/reconnect' \ -H 'Authorization: Bearer ' ``` ```json { "url": "https://..." } ``` ## Disconnect an inbox `DELETE /connected-accounts/:id` disconnects the inbox straight away and answers `204` with no body. The member who connected it can disconnect it, and so can any organization admin. Conversations already saved to leads stay. There is no refund for the current month. ```bash curl -X DELETE 'https://api.fondaro.com/connected-accounts/3c9e2f41-7b1d-4c8a-9e53-0a6d2b7f1e84' \ -H 'Authorization: Bearer ' ``` ## Calendars A Google or Microsoft inbox with the `calendar` capability carries its calendars in `calendars`. Each entry: | Field | Type | Notes | |---|---|---| | `id` | string | The calendar's id as the provider lists it. URL-encode it in a path: it may contain `@` or `#` | | `name` | string or null | As named in Google or Outlook | | `primary` | boolean | The account's main calendar. It always shows and cannot be hidden | | `readOnly` | boolean | Shared with the person for reading only. It can show, but new events never go to it | | `selected` | boolean | Its events show on the person's [calendar](/docs/api/calendar). A newly listed calendar starts hidden, except the primary | These routes act on your own inbox only; any other inbox, or one without calendar, is `404`. Both answer `{ "calendars": [...] }`, the inbox's calendars after the change, in the same order as on the inbox. To send a new event to one of these calendars, pass `connectedAccountId` and `calendarId` to [`POST /calendar/events`](/docs/api/calendar#create-an-event). ### Show or hide a calendar `PUT /connected-accounts/:id/calendars/:calendarId` with `{ "selected": true }` shows the calendar, `false` hides it. Fondaro reads the inbox's calendars straight after: a calendar you show brings its events in, and one you hide takes them off the calendar. ```bash curl -X PUT 'https://api.fondaro.com/connected-accounts/3c9e2f41-7b1d-4c8a-9e53-0a6d2b7f1e84/calendars/viewings%40group.calendar.google.com' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "selected": false }' ``` ```json { "calendars": [ { "id": "agent@example.com", "name": "agent@example.com", "primary": true, "readOnly": false, "selected": true }, { "id": "viewings@group.calendar.google.com", "name": "Viewings", "primary": false, "readOnly": false, "selected": false } ] } ``` ### List the calendars again `POST /connected-accounts/:id/calendars/refresh` asks the provider for the inbox's calendars again, for one made in Google or Outlook after connecting. It takes no body. ```bash curl -X POST 'https://api.fondaro.com/connected-accounts/3c9e2f41-7b1d-4c8a-9e53-0a6d2b7f1e84/calendars/refresh' \ -H 'Authorization: Bearer ' ``` | Status | Code | Meaning | |---|---|---| | `400` | | `selected` is missing or not a boolean | | `404` | `CONNECTED_ACCOUNT_NOT_FOUND` | Not your inbox, disconnected, or without calendar | | `404` | `CALENDAR_NOT_FOUND` | The inbox does not list that calendar (try [List the calendars again](#list-the-calendars-again)) | | `409` | `CALENDAR_PRIMARY_LOCKED` | Hiding the primary calendar | | `409` | `CONNECTED_ACCOUNT_NEEDS_RECONNECT` | Refresh on an inbox that needs [reconnecting](#reconnect-an-inbox) | ## Which address your email goes out from `GET /crm/email-sender` tells you how an email you send to a lead right now would leave, so a composer can show the From address or the Connect step before anyone writes. ```bash curl 'https://api.fondaro.com/crm/email-sender' \ -H 'Authorization: Bearer ' ``` ```json { "via": "mailbox", "address": "sofia@example.com", "status": "ok", "accountId": "3c9e2f41-7b1d-4c8a-9e53-0a6d2b7f1e84", "options": [ { "accountId": "3c9e2f41-7b1d-4c8a-9e53-0a6d2b7f1e84", "address": "sofia@example.com", "via": "mailbox" }, { "accountId": "8d2a61f0-5c3e-4b7a-a1d9-6e4f0b2c7a15", "address": "info@example.com", "via": "shared_mailbox" } ] } ``` | Field | Type | Meaning | |---|---|---| | `via` | `"mailbox"` \| `"none"` | `mailbox`: your connected inbox sends. `none`: there is nothing to send from | | `address` | string \| null | The From address: your inbox's address, or `null` without an inbox | | `status` | string \| null | Your inbox's status (`connecting`, `ok`, `needs_reconnect`, `error`), or `null` without an inbox | | `accountId` | string \| null | The connected account `id`, or `null` without an inbox | | `options` | object[] | Every address you can pick as From, each `{ accountId, address, via }`: your own inbox (`via: "mailbox"`) and your agency's shared inbox when you are the admin who connected it (`via: "shared_mailbox"`). Only inboxes whose status is `ok` are listed; empty when nothing can send | An inbox whose `status` is `needs_reconnect` or `error` answers `via: "mailbox"` with that `status`: sending waits for a reconnect. ## Send an email to a lead `POST /crm/leads/:id/emails` sends from your connected inbox. The From address is your inbox unless you pick another of your `options` with `fromAccountId`; `senderName` and `senderEmail` are still accepted for older clients and ignored. The message lands in your inbox's Sent folder, and the lead's reply comes back to your inbox. | Field | Type | Required | Notes | |---|---|---|---| | `subject` | string | Yes | | | `bodyText` | string | Yes | The plain version. Always stored, and sent as paragraphs when there is no `bodyHtml` | | `bodyHtml` | string | No | The formatted body, at most 100,000 characters. Sent instead of `bodyText`'s paragraphs after it is cleaned to `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `s`, `ul`, `ol`, `li`, `blockquote` and `a` (http, https or mailto links only; no other attributes) | | `fromAccountId` | string | No | The `accountId` of one of your `options` from `GET /crm/email-sender`. Without it the email goes out from your default | | `cc` | string[] | No | Up to 10 email addresses. An address already in To is dropped, and each address is kept once | | `bcc` | string[] | No | Up to 10 email addresses. An address already in To or Cc is dropped | | `signatureHtml` | string | No | Your signature. Cleaned before sending: layout tags, links and https images stay; `style` and other attributes go | | `replyToEmailId` | string | No | The `id` of an email on this lead's timeline you are answering. The message is sent as a reply to it, so it threads in the lead's inbox | | `documentIds` | string[] | No | See [Documents](/docs/api/documents#sending-a-document-in-a-lead-email) | ```bash curl -X POST 'https://api.fondaro.com/crm/leads/4821/emails' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "subject": "Re: The villa in Nueva Andalucía", "bodyText": "Saturday at 11 works for me.", "replyToEmailId": "b7e1c5d2-4a3f-4e8b-9c61-2d0f7a9e5b13" }' ``` With formatting, a copy and your agency's shared inbox as From: ```bash curl -X POST 'https://api.fondaro.com/crm/leads/4821/emails' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "subject": "Three homes for Saturday", "bodyText": "Here are the three homes:\n- Villa Azul\n- Casa Sol\n- Finca Olivo", "bodyHtml": "

Here are the three homes:

  • Villa Azul
  • Casa Sol
  • Finca Olivo
", "fromAccountId": "8d2a61f0-5c3e-4b7a-a1d9-6e4f0b2c7a15", "cc": ["partner@example.com"], "bcc": ["office@example.com"] }' ``` The email on the lead's timeline carries `toAddresses`, `ccAddresses` and `bccAddresses`. | Status | Code | Meaning | |---|---|---| | `400` | `sender_not_allowed` | `fromAccountId` is not one of your `options` | | `403` | `mailbox_not_connected` | You have no connected inbox. Connect an inbox first | | `403` | `mailbox_needs_reconnect` | Your inbox needs reconnecting. Use `POST /connected-accounts/:id/reconnect` | | `403` | `SANDBOX_OUTBOUND_DISABLED` | Nothing is sent from a sandbox organization | ## Find past emails for a lead `POST /crm/leads/:id/find-past-emails` searches your own connected inboxes for the lead's email address over the last 12 months and queues what it finds. The emails then go through the same checks as new mail and appear on the lead's timeline within a minute or two; an email that involves none of your leads is never stored. Use it for a lead created after you connected your inbox: connecting brings in the last 90 days once, and a lead added later does not pull older emails on its own. Collaborator leads are open to every member. It answers `202` with a summary. `found` counts emails involving one of your leads, `enqueued` those not already on Fondaro. Calling it again is safe: emails already saved are not added twice. ```bash curl -X POST 'https://api.fondaro.com/crm/leads/4821/find-past-emails' \ -H 'Authorization: Bearer ' ``` ```json { "mailboxes": 1, "found": 6, "enqueued": 4 } ``` | Status | Code | Meaning | |---|---|---| | `400` | `lead_has_no_email` | The lead has no email address to search for | | `403` | | You are not an admin or an assignee of this lead | | `404` | | No such lead in your organization | | `409` | `mailbox_not_connected` | You have no connected inbox. Connect one first | | `409` | `mailbox_needs_reconnect` | Your inbox needs reconnecting. Use `POST /connected-accounts/:id/reconnect` | ## Message channels for a lead `GET /crm/leads/:id/channels` lists the channels you can message this lead on. A channel is listed when you have a live WhatsApp, Instagram, LinkedIn or Telegram connection and the lead is reachable there: for WhatsApp, the lead has written to you or their phone number is on WhatsApp; for the others, the lead has written to you or a member linked their profile to the lead. An empty list means no message channel for this lead. ```bash curl 'https://api.fondaro.com/crm/leads/4821/channels' \ -H 'Authorization: Bearer ' ``` ```json { "channels": [ { "channel": "whatsapp", "canSend": false, "block": "new_chats_closed", "opensAt": "2026-09-26T09:14:00.000Z", "integration": "whatsapp", "accountAddress": "+34600000001", "counterpart": "+34600000002", "newChat": true } ] } ``` `block` says why `canSend` is false: `needs_reconnect` (reconnect the account), `restricted` (the provider restricted the account; nothing more is sent from it), or `new_chats_closed` (a first message to someone new waits until `opensAt`; replies are never held). ## Send a message to a lead `POST /crm/leads/:id/messages` sends from your own connection of that channel. A person sends every message: there is no scheduled, bulk or automated sending. | Field | Type | Required | Notes | |---|---|---|---| | `channel` | string | Yes | `whatsapp`, `instagram`, `linkedin` or `telegram` | | `body` | string | Yes | Up to 4,000 characters. May be empty when a file goes alone | | `attachments` | string[] | No | Up to 3 Documents PDF ids, 10 MB together | ```bash curl -X POST 'https://api.fondaro.com/crm/leads/4821/messages' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "channel": "whatsapp", "body": "Saturday at 11 works for me." }' ``` It answers with the message (`status` `sent`). To protect your account, messages from one account go at least 15 seconds apart (Instagram 10 seconds, and at most 10 an hour). A message sent inside that gap waits for its turn, for up to 30 seconds, and shows on the lead as pending meanwhile. A first message to someone new is held for 24 hours after you connect or reconnect, then limited to 3 a day in the first week and 20 a day after. | Status | Code | Meaning | |---|---|---| | `403` | `message_not_connected` | You have no connection of that channel. `integration` names the setup page | | `403` | `message_needs_reconnect` | Your connection needs reconnecting | | `403` | `message_account_restricted` | The provider restricted the account; nothing more is sent from it | | `403` | `message_new_chats_closed` | New chats open at `opensAt` | | `403` | `SANDBOX_OUTBOUND_DISABLED` | Nothing is sent from a sandbox organization | | `404` | | No such lead, or you are not an admin or an assignee of it | | `422` | `message_not_on_channel` | The lead is not on WhatsApp, or has no linked profile on that channel | | `429` | `message_paced` / `message_rate_limited` | Try again after `retryAfterSeconds` | | `502` | `message_send_failed` | The message could not be sent. Try again | ## Download a file on a message `GET /crm/leads/:id/messages/:messageId/attachments/:index` returns a short-lived download link for the file at position `index` in the message's `attachments`. A file that was not kept (`stored: false`) answers `404`, and so does a Documents file you sent, which you open from Documents. ```bash curl 'https://api.fondaro.com/crm/leads/4821/messages/6a1f0c3e-2b7d-4e59-8c14-9d3a5e7b2f60/attachments/0' \ -H 'Authorization: Bearer ' ``` ```json { "url": "https://…", "filename": "terrace.jpg", "contentType": "image/jpeg" } ``` ## Look up a lead's profile `POST /crm/leads/:id/lookup-profile` reads the lead's linked LinkedIn or Instagram profile once, through your own connection, without the lead seeing a profile visit. It changes nothing on the lead; add the details you want yourself. LinkedIn allows about 80 look ups a day per account, Instagram 10 an hour. ```bash curl -X POST 'https://api.fondaro.com/crm/leads/4821/lookup-profile' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "channel": "linkedin" }' ``` ```json { "channel": "linkedin", "identity": "sample-buyer", "name": "Sample Buyer", "headline": "Relocating to Marbella", "company": "Example Ltd", "location": "London", "category": null, "emails": [], "phones": [], "profileUrl": "https://www.linkedin.com/in/sample-buyer" } ``` | Status | Code | Meaning | |---|---|---| | `422` | `lookup_not_linked` | Link the lead on that channel first | | `404` | `lookup_not_found` | The profile could not be found | | `429` | `lookup_limited` | Try again after `retryAfterSeconds` | --- # CRM Reports Source: https://www.fondaro.com/docs/api/crm-reports > Endpoints for building, running, and freezing CRM reports: report CRUD, the batch metric run contract, the widget catalog, and assignee gating. ## Overview The CRM Reports API persists reports (saved widget layouts), runs their widgets against live data, and freezes point-in-time snapshots. All endpoints are under `/crm-reports`, require a Clerk bearer token, and resolve the active organization from the request. Every request is scoped to the caller's organization and re-gated by role: - **Members** only ever see rows they own or are assigned to. Any `assigneeIds` they pass is ignored. - **Admins** may pass `assigneeIds` to aggregate across the selected members, or omit it to see only their own data. This gating is recomputed on the server for every metric run, so a member opening an admin's shared report still sees only their own data. ## Endpoints | Method & path | Purpose | |---|---| | `GET /crm-reports` | List reports (own + organization-visible) with snapshot counts | | `POST /crm-reports` | Create a report | | `GET /crm-reports/:id` | Get a report definition | | `PATCH /crm-reports/:id` | Update title, description, layout, visibility, or default timeframe | | `DELETE /crm-reports/:id` | Delete a report (cascades its snapshots) | | `POST /crm-reports/metrics/run` | Batch-run widgets and return series (live) | | `POST /crm-reports/:id/snapshots` | Freeze a snapshot | | `GET /crm-reports/:id/snapshots` | List a report's snapshots | | `GET /crm-reports/snapshots/:snapshotId` | Get a frozen snapshot | | `POST /crm-reports/snapshots/:snapshotId/renew` | Extend a snapshot's expiry (+180 days) | | `DELETE /crm-reports/snapshots/:snapshotId` | Delete a snapshot | ## The metric run contract `POST /crm-reports/metrics/run` is the hot path the builder calls on every config change. It runs all widgets with bounded concurrency and returns one series per widget, keyed by `instanceId`. Request body: | Field | Type | Notes | |---|---|---| | `widgets` | array | Each: `{ instanceId, metricId, chart, groupBy?, timeframe?, filters? }` | | `assigneeIds` | string[] | Optional admin scope. Ignored for non-admins | | `timeframe` | object | Report default: `{ preset, from?, to?, bucket }` | ### Widget filters A widget may carry a `filters` object that narrows the population before it is aggregated. This is distinct from `groupBy`, which splits the result into series. | Field | Type | Notes | |---|---|---| | `filters` | `Record` | Optional. Per-widget filter map | Recognised keys (lead-scoped in v1): | Key | Value type | Meaning | |---|---|---| | `tag` | `uuid[]` | Keep only leads carrying any of these tag ids | | `source` | lead source enum[] | Keep only leads from these acquisition sources | | `status` | CRM status enum[] | Keep only leads in these CRM statuses | Filters apply to the lead metrics that declare them (every `leads.*` metric except `leads.avg_time_in_status`). They are ignored by deal, task, call, email, and team metrics. A single value may be sent as a string or as a one-element array; the runner coerces it. The runner parses `filters` defensively: it validates tag ids as UUIDs and ignores any unrecognised key. Report persistence does **not** validate `filters`, so the saved value round-trips verbatim and only the runner interprets it. ```bash curl -X POST https://api.fondaro.com/crm-reports/metrics/run \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "timeframe": { "preset": "last_30d", "bucket": "day" }, "assigneeIds": [], "widgets": [ { "instanceId": "w1", "metricId": "leads.created_over_time", "chart": "area" }, { "instanceId": "w2", "metricId": "deals.win_rate", "chart": "radial" } ] }' ``` Each result is a `MetricSeries`: ```json { "instanceId": "w1", "shape": "timeseries", "series": [{ "key": "value", "label": "New leads" }], "points": [{ "bucket": "2026-05-01", "value": 12 }], "stat": null } ``` `shape` is one of `timeseries`, `category`, `stat`, or `leaderboard`. Timeseries points carry a `bucket` field; category points carry a `category` field; stat results carry a `stat` object with `value`, `previous`, and `deltaPct`. ## The widget catalog Reports can only reference metrics from a fixed catalog (the single source of truth shared by the builder and the AI designer). The backend validates every `metricId` and chart type on create, so arbitrary queries are never possible. Metrics span six categories: leads, deals, tasks, calls, emails, and team (team metrics are admin-only). Each metric declares its shape, default chart, the chart types it supports, any group-by dimensions, any filters it accepts, and an `adminOnly` flag. ### Tag and commission metrics | `metricId` | Shape | Supported charts | Notes | |---|---|---|---| | `leads.by_tag` | `category` | bar, pie, donut, radar | Distribution of leads across tags. Counts are non-exclusive: a lead counts toward every tag it carries, so the points can sum to more than the lead total. Accepts the lead filters. Supports timeframe. Not admin-only | | `deals.commission_breakdown` | `category` | bar | Where commission on won deals goes, in the dominant currency. Emits five ordered points: total commission, collaborator payout, agency commission, internal agent payout, agency net. Supports timeframe. `adminOnly: true` | | `deals.commission_by_agent` | `leaderboard` | leaderboard | Commission earned per internal agent on won deals, in the dominant currency. Supports timeframe. `adminOnly: true` | Both commission metrics are `adminOnly`. As with every admin-only metric, a non-admin run returns an empty series rather than an error, and the metric is hidden from the catalog the builder shows to non-admins. ## Snapshots A snapshot stores a frozen copy of the layout plus the computed series. It is computed under the caller's scope at freeze time, expires after 180 days (renewable), and is hard-deleted by a daily cleanup job 30 days after expiry. --- # Documents Source: https://www.fondaro.com/docs/api/documents > REST endpoints for the organization document drive: CRUD, file upload, folders, the bin, presigned downloads, share links, lead and listing attachments, and the keyless public resolver. ## Overview The Documents API stores an organization's drive: markdown documents, uploaded PDFs, any other uploaded file (`kind: "file"`), folders and a bin. All authenticated endpoints are under `/documents`, require a Clerk bearer token, and resolve the organization from the request. The organization is never a path or body parameter, so a caller only ever reaches their own library. Two rules govern every response: - **Read is visibility.** A member sees `organization` and `public` documents plus their own `private` ones. An organization admin sees every document in the organization. A document the caller cannot see answers `404`, not `403`. - **Write is ownership.** Changing, sharing, revoking, or deleting a document is limited to its creator and organization admins. Attaching to a lead or a listing is deliberately looser: it needs read on the document plus access to the lead or listing, so one member can author a guide the whole organization sends out. Every endpoint on this page, reads included, requires an active subscription. An organization without one receives `403` with `code: "SUBSCRIPTION_REQUIRED"` on every route, and its public share links stop resolving (`404`) until it subscribes again. Nothing is deleted; subscribing restores the library and every link as it was. Markdown bodies are stored in Postgres. Uploaded files are stored private in object storage and are only ever reachable through a short-lived presigned URL minted behind authorization. The storage key never appears in a response body. ## Endpoints | Method & path | Purpose | |---|---| | `GET /documents` | List the library, filtered and paginated | | `POST /documents` | Create a markdown document | | `POST /documents/upload` | Upload a file (multipart) | | `GET /documents/:id` | One document, including its markdown body | | `PATCH /documents/:id` | Update title, body, assignees, visibility, pin or folder | | `DELETE /documents/:id` | Move the document to the bin | | `GET /documents/:id/download` | `302` to a presigned attachment download | | `GET /documents/:id/view` | `302` to a presigned inline view | | `POST /documents/:id/share` | Publish behind a public link | | `POST /documents/:id/revoke` | Take the public link down | | `POST /documents/:id/leads` | Attach the document to leads | | `DELETE /documents/:id/leads/:leadId` | Detach one lead | | `POST /documents/:id/property-listings` | Attach the document to listings | | `DELETE /documents/:id/property-listings/:listingId` | Detach one listing | | `GET /documents/by-listing/:listingId` | Documents attached to one listing | | `POST /documents/by-listing/counts` | Visible document counts for a page of listings | | `GET /crm/leads/:id/documents` | Documents attached to one lead | | `GET /documents/folders` | Every live folder, with visible counts | | `POST /documents/folders` | Create a folder | | `PATCH /documents/folders/:folderId` | Rename, move or pin a folder | | `DELETE /documents/folders/:folderId` | Move a folder and everything in it to the bin | | `POST /documents/move` | Move documents and folders into a folder | | `POST /documents/bin` | Move documents and folders to the bin | | `GET /documents/bin` | What the caller may restore | | `POST /documents/bin/restore` | Restore binned items | | `POST /documents/bin/purge` | Delete binned items for good | | `POST /documents/bin/empty` | Delete everything the caller could restore, for good | | `GET /documents/storage` | The organization's used bytes and quota | | `GET /public/documents/:slug` | Keyless share resolver | | `GET /public/documents/:slug/download` | Keyless presigned PDF download | ## The document object `GET /documents` returns the summary shape. Everything that returns a single document returns the detail shape, which is the summary plus the last six fields. | Field | Type | Notes | |---|---|---| | `id` | string | UUID | | `title` | string | Up to 200 characters | | `kind` | string | `markdown`, `pdf` or `file`. Clients should tolerate an unknown value | | `visibility` | string | `private`, `organization`, or `public` | | `sizeBytes` | number or null | Uploaded kinds only | | `createdBy` | string | Clerk user id | | `assigneeIds` | string[] | Clerk user ids, up to 50 | | `shareUrl` | string or null | Non-null only while the link is live | | `attachedLeadCount` | number | | | `attachedListingCount` | number | | | `pinnedAt` | string or null | Org-wide pin to the sidebar | | `folderId` | string or null | The folder it lives in; `null` is the top level | | `contentType` | string or null | Uploaded kinds only. `application/pdf`, a sniffed `image/*`, or `application/octet-stream` | | `fileExtension` | string or null | Lower-case extension of an uploaded file | | `deletedAt` | string or null | Set only on a row read from the bin | | `createdAt` / `updatedAt` | string | ISO-8601 | | `body` | string or null | Detail only. GFM markdown source, markdown kind only | | `shareRevokedAt` | string or null | Detail only | | `shareExpiresAt` | string or null | Detail only. `null` means the link never expires | | `attachedLeadIds` | number[] | Detail only | | `attachedListingIds` | string[] | Detail only | ## List documents `GET /documents` | Query | Type | Notes | |---|---|---| | `kind` | string | `markdown`, `pdf` or `file` | | `folderId` | string | A folder id, or `root` for the top level. Absent lists every live document wherever it lives | | `scope` | string | `shared` (someone else's documents shared with the team or pinned to you) or `pinned` | | `visibility` | string | `private`, `organization`, or `public`. Narrows what the caller can already see; it never widens it | | `assigneeId` | string | Clerk user id pinned to the document | | `q` | string | Case-insensitive match on the title, up to 128 characters | | `offset` | number | Defaults to `0` | | `limit` | number | 1 to 50, defaults to 20 | ```bash curl -G https://api.fondaro.com/documents \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "kind=pdf" \ --data-urlencode "q=guide" \ --data-urlencode "limit=20" ``` ```json { "items": [ { "id": "3f1c0d0e-0000-4000-8000-000000000000", "title": "2026 Property Guide", "kind": "pdf", "visibility": "public", "sizeBytes": 2214592, "createdBy": "user_2abc", "assigneeIds": [], "shareUrl": "https://www.fondaro.com/d/9pQ2rB7wKx1vT0sN4mH6cA", "attachedLeadCount": 3, "attachedListingCount": 0, "createdAt": "2026-08-01T09:12:00.000Z", "updatedAt": "2026-08-20T14:03:00.000Z" } ], "total": 1, "offset": 0, "limit": 20 } ``` Results are ordered by `updatedAt` descending. ## Create a markdown document `POST /documents` | Field | Type | Required | Notes | |---|---|---|---| | `title` | string | Yes | 1 to 200 characters | | `body` | string | No | GFM markdown source, up to 1,000,000 UTF-8 bytes | | `assigneeIds` | string[] | No | Up to 50 unique Clerk user ids | ```bash curl -X POST https://api.fondaro.com/documents \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "2026 Property Guide", "body": "# 2026 Property Guide\n\nWhat buyers ask us most." }' ``` Returns the detail shape. A new document starts at `private` visibility. ## Upload a file `POST /documents/upload`, `multipart/form-data`. | Part | Type | Required | Notes | |---|---|---|---| | `file` | file | Yes | Any type. A PDF up to 25 MB, anything else up to 50 MB | | `title` | string | No | Falls back to the uploaded filename | | `folderId` | string | No | A live folder of the organization; absent is the top level | | `assigneeIds` | string[] | No | Repeat the field, or send a JSON array | ```bash curl -X POST https://api.fondaro.com/documents/upload \ -H "Authorization: Bearer $TOKEN" \ -F "file=@price-list.xlsx" \ -F "folderId=6f1c0b8e-2a1d-4f7e-9a51-0c9d3c2b7e11" ``` The declared MIME type is caller-controlled and proves nothing, so the bytes decide. A file starting with `%PDF-` becomes a `pdf` document; anything else is a `file`, stored under its sniffed image type when the bytes are a JPEG, PNG, WebP or GIF and as `application/octet-stream` otherwise, so only a PDF or an image is ever served inline. An upload that would take the organization past its storage quota (10 GB, bin included) answers `400` with `code: "DOCUMENT_STORAGE_FULL"` and nothing is stored. There is no presigned-PUT path: uploads always go through the API so validation stays server-side. ## Read, update, and delete `GET /documents/:id` returns the detail shape. `PATCH /documents/:id` accepts any subset of: | Field | Type | Notes | |---|---|---| | `title` | string | 1 to 200 characters | | `body` | string | Markdown documents only. Replaces the whole body. `400` on a PDF | | `assigneeIds` | string[] | Up to 50 unique Clerk user ids. Replaces the list | | `teamIds` | string[] | Up to 20 team ids. Their current members join `assigneeIds`, at most 50 people in total. See [Teams](/docs/api/teams) | | `visibility` | string | `private` or `organization` only | | `pinned` | boolean | Org-wide pin to the sidebar | | `folderId` | string or null | Move to a folder; `null` moves it to the top level | `public` is deliberately not patchable. It is reachable only through `POST /documents/:id/share`, so a `public` row always has a live link behind it. Patching visibility on a document that is currently `public` is refused: revoke the link first. `DELETE /documents/:id` returns `{ "deleted": true }` and moves the document to the bin: it leaves every read (the library, lead and listing attachments, the timeline) and its share link answers `404` until it is restored. Its file and attachment records stay until the bin is emptied, the item is purged, or 30 days pass. ## Folders `GET /documents/folders` returns `{ "items": [...] }`, every live folder of the organization. Folders have no visibility of their own: every member sees every folder, and the counts on each are what the caller can see inside it. | Field | Type | Notes | |---|---|---| | `id` | string | UUID | | `name` | string | Up to 200 characters | | `parentId` | string or null | `null` is a top-level folder | | `createdBy` | string | Clerk user id | | `pinnedAt` | string or null | Org-wide pin to the sidebar | | `itemCount` | number | Direct child folders plus visible documents | | `sizeBytes` | number | Bytes of visible uploaded documents, recursively | | `deletedAt` | string or null | Set only on a row read from the bin | | `createdAt` / `updatedAt` | string | ISO-8601 | `POST /documents/folders` takes `{ "name", "parentId"? }`; any member may create one. `PATCH /documents/folders/:folderId` takes any of `name`, `parentId` (`null` = top level) and `pinned`, and is creator-or-admin; a folder cannot move into itself or anything under it (`400`). ```bash curl -X POST https://api.fondaro.com/documents/folders \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "Marbella listings"}' ``` ## Moving and the bin `POST /documents/move` takes `{ "documentIds"?, "folderIds"?, "targetFolderId" }` (`targetFolderId: null` is the top level) and answers `{ "moved": n }`. Every named item must be one the caller may change, or the whole call is refused. `POST /documents/bin` takes `{ "documentIds"?, "folderIds"? }` and moves them to the bin. A folder takes everything inside it, colleagues' files included, under one timestamp, and restoring the folder brings back exactly that batch. `GET /documents/bin` answers `{ "folders", "documents", "retentionDays" }`: the top of every binned batch the caller may restore (an admin sees the whole organization's bin, a member their own items). `POST /documents/bin/restore` and `POST /documents/bin/purge` take the same `{ "documentIds"?, "folderIds"? }` body; `POST /documents/bin/empty` takes none. Purging deletes the rows, their attachment records and their stored files for good. Anything still in the bin after 30 days is purged by a daily job. ```bash curl -X POST https://api.fondaro.com/documents/bin/restore \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"documentIds": ["2b9c1c55-6d7e-4f3a-8e21-9a0b1c2d3e4f"]}' ``` `GET /documents/storage` answers `{ "usedBytes", "quotaBytes" }` for the organization, bin included. ## Downloads `GET /documents/:id/download` and `GET /documents/:id/view` both answer `302` with a five-minute presigned URL and `Cache-Control: no-store`. Download forces an attachment disposition with a filename derived from the title; view is the inline twin used by the dashboard preview. The redirect is minted only after the document row is joined to the caller's organization and passes the visibility check. ```bash # -L follows the redirect; the signed URL is short-lived and caller-specific. curl -L -o guide.pdf https://api.fondaro.com/documents/$DOC_ID/download \ -H "Authorization: Bearer $TOKEN" ``` Both routes are PDF-only. A markdown document has no stored file and answers `400`. ## Share links `POST /documents/:id/share` sets `visibility` to `public`, mints an unguessable slug, and returns the detail shape with `shareUrl` populated. | Field | Type | Required | Notes | |---|---|---|---| | `expiresAt` | string | No | ISO-8601 instant in the future. Omit for a link that never expires | The call is idempotent while the current link is live: sharing twice returns the same URL, so a dialog that shares on open never invalidates an address somebody already sent. An `expiresAt` in the past is rejected with `400` rather than minting a link that is dead on arrival. ```bash curl -X POST https://api.fondaro.com/documents/$DOC_ID/share \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` `POST /documents/:id/revoke` stamps the revocation and drops `visibility` back to `organization`, so the team keeps the document while the public link dies. It is idempotent: a second revoke keeps the original timestamp. **Sharing again after a revoke mints a different slug.** Revoke is a real kill switch, so addresses already sent stay dead. The same applies after an expiry lapses. Clients that cached a `shareUrl` must re-read the document after a revoke and re-share. ## Attachments `POST /documents/:id/leads` takes `{ "leadIds": number[] }`, up to 50 unique ids. Every id must clear the caller's own lead visibility, so a document is never a side door onto a lead the caller cannot open. An unreachable id answers `404` and nothing is written. Inserts are idempotent and keep the first attachment record. `DELETE /documents/:id/leads/:leadId` detaches one lead and is a clean no-op when the row is already gone. `POST /documents/:id/property-listings` takes `{ "listingIds": string[] }`, up to 50 unique UUIDs. Listings live in a separate database, so the column carries no foreign key and every id is proven organization-owned before a single row is written. A partial attach never happens. Detach deliberately skips that check, so a listing deleted upstream can still be detached. All four endpoints return the document detail shape. ```bash curl -X POST https://api.fondaro.com/documents/$DOC_ID/leads \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"leadIds": [4821, 4822]}' ``` ## Reverse lookups `GET /crm/leads/:id/documents` and `GET /documents/by-listing/:listingId` both return `{ "documents": [...] }` using the attachment shape: `id`, `title`, `kind`, `visibility`, `sizeBytes`, `createdBy`, `attachedBy`, `attachedAt`, and `updatedAt`. They are ordered most recently attached first. An attachment is not a grant. Both lists re-apply the document visibility clause, so a private document another member attached stays absent for everyone but its creator and the organization admins. ```json { "documents": [ { "id": "3f1c0d0e-0000-4000-8000-000000000000", "title": "2026 Property Guide", "kind": "pdf", "visibility": "public", "sizeBytes": 2214592, "createdBy": "user_2abc", "attachedBy": "user_2def", "attachedAt": "2026-08-20T14:05:00.000Z", "updatedAt": "2026-08-20T14:03:00.000Z" } ] } ``` Attaching a document to a lead also appears in that lead's timeline as a derived `document-attached` entry. No separate event row is written. ### Counts for a page of listings `POST /documents/by-listing/counts` answers how many documents the caller can see on each listing of a page, in one request. It is a read: it creates nothing, and it is a `POST` only because a page of UUIDs does not belong in a query string. It answers `200`. | Field | Type | Required | Notes | |---|---|---|---| | `listingIds` | string[] | Yes | 1 to 100 listing UUIDs. Duplicates are collapsed | The response is an object keyed by listing id, and **every requested id is present**, `0` included, so a missing key never has to mean "no documents". - The count applies the same visibility clause as `GET /documents/by-listing/:listingId`, so it counts exactly the documents that list shows the same caller (the list itself stops at 50 rows; the count does not): a private document another member attached is not counted for anyone but its creator and the organization admins. - Only documents of the caller's organization are counted. An id the caller's organization does not own counts `0`, the same answer as an unknown UUID, and the call does not `404`. Unlike the single-listing list, there is nothing to confirm: a zero reveals nothing about whether the listing exists. - An empty `listingIds`, more than 100 ids, or a value that is not a UUID answers `400`. ```bash curl -X POST https://api.fondaro.com/documents/by-listing/counts \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"listingIds": ["7b0e6c1a-1d2e-4f3a-9b4c-5d6e7f8a9b0c", "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f"]}' ``` ```json { "7b0e6c1a-1d2e-4f3a-9b4c-5d6e7f8a9b0c": 3, "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f": 0 } ``` ## Sending a document in a lead email `POST /crm/leads/:id/emails` accepts an optional `documentIds` array of up to three unique document UUIDs. Each one becomes a real email attachment. | Field | Type | Required | Notes | |---|---|---|---| | `documentIds` | string[] | No | Up to 3 unique UUIDs. PDF documents only | Every id is re-checked against the caller's document visibility first, so an invisible or foreign document answers `404`, exactly as a missing one does. Two refusals then carry a machine-readable `code` in a `422` body, both naming the `documentId` the sender would blame: | `code` | When | |---|---| | `CRM_EMAIL_DOCUMENT_NOT_ATTACHABLE` | The id is a markdown document, or a PDF with no stored file. Link to it instead by putting its `shareUrl` in the body | | `CRM_EMAIL_ATTACHMENTS_TOO_LARGE` | The set goes over the 10 MB combined cap | Both run before the message reaches the mail provider. Declared sizes are checked before anything is downloaded, then the real bytes are re-totalled after fetching. The stored email records which documents went out under `contentReferences.documentAttachments`, so the lead timeline can show them on the sent message. ```bash curl -X POST https://api.fondaro.com/crm/leads/4821/emails \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "senderName": "Sofia Andersson", "senderEmail": "sofia@example.com", "subject": "The guide I mentioned", "bodyText": "

Here it is.

", "documentIds": ["3f1c0d0e-0000-4000-8000-000000000000"] }' ``` ## Public resolver (keyless) `GET /public/documents/:slug` needs no credential. The slug is the entire authorization. The route is rate limited to 60 requests per minute per IP. ```bash curl https://api.fondaro.com/public/documents/9pQ2rB7wKx1vT0sN4mH6cA ``` ```json { "slug": "9pQ2rB7wKx1vT0sN4mH6cA", "title": "2026 Property Guide", "kind": "pdf", "body": null, "viewUrl": "https://…presigned…", "sizeBytes": 2214592, "updatedAt": "2026-08-20T14:03:00.000Z" } ``` `body` carries the GFM markdown source for a markdown document. `viewUrl` is a five-minute presigned inline URL for a PDF, meant to be consumed immediately by the page that just loaded. It must never be emailed or stored: the durable address is always `https://www.fondaro.com/d/{slug}`. The response deliberately says nothing about the organization, the author, visibility, or attachments. A share link reveals the document, never the organization around it. | Status | Meaning | |---|---| | `404` | Unknown slug, or the owning organization is disabled or gone | | `410` | The link existed and no longer resolves | A `410` body carries a machine-readable `code` of `revoked` or `expired`, so a viewer can say which happened: ```json { "statusCode": 410, "message": "This document link has been revoked by the agent.", "code": "revoked" } ``` Public document existence is private tenant state, which is why a disabled organization collapses to the same `404` as an unknown slug. `GET /public/documents/:slug/download` answers `302` to a five-minute presigned attachment download for a shared PDF, and walks the same resolution ladder on every hit. A link revoked between page load and click gets a `410`, not a file. A markdown document answers `400`: it has no file. ## MCP and assistant access The same library is reachable from [Fondaro MCP](/docs/api/mcp) under the `documents:read` and `documents:write` scopes, and from Ask Fondaro, where every document write is a proposal a person approves. PDF upload and document deletion are exposed on neither surface: an MCP request has no file transport, and deletion stays a dashboard action. --- # Idealista Source: https://www.fondaro.com/docs/api/idealista > Map-first Idealista property search: search by polygon, radius, or place; fetch listing detail; and look up the listing agency. # Idealista API The Idealista endpoints power the map-first source on the Properties surface. They proxy the RealtyAPI Idealista gateway behind one Fondaro-owned key, so there are no per-organization credentials. The source is **free** and **ungated**: when the key is configured every organization can use it, within a daily allowance per organization (see [Notes](#notes)). All endpoints require the standard dashboard auth: a Clerk bearer token plus the `x-organization-id` header. Base URL: `https://api.fondaro.com` ## Deprecated routes The search, autocomplete, sublocations and detail routes are deprecated compatibility routes. They stay available with the same request and response shapes for existing web and mobile app versions, and will be removed once those versions are retired. Every response carries a `Deprecation: true` header and a `Link` header to its successor under [`/property-sources/idealista/...`](/docs/api/properties/sources), for example `Link: ; rel="successor-version"`. New integrations should use the [Property Sources](/docs/api/properties/sources) API. The same applies to every other portal mounted at `/{portal}/...`, with that portal's source id in the successor link. ## Availability ```http GET /idealista/status ``` ```json { "connected": true } ``` `connected` is `true` when the RealtyAPI key is configured server-side. When `false`, the source is hidden in the UI. ## Search ```http POST /idealista/search ``` Provide **exactly one** of `polygon`, `circle`, or `locationId` / `place`: | Field | Type | Notes | |---|---|---| | `polygon` | `[lon, lat][]` | A GeoJSON ring → `/search/bypolygon`. | | `circle` | `{ lat, lon, radiusKm }` | Centre + radius → `/search/bycoordinates`. | | `locationId` | `string` | Resolved place id → `/search/bylocation` (full filters). | | `place` | `string` | Free-text place (used with or instead of `locationId`). | | `searchType` | `"For_Sale" \| "For_Rent"` | Operation. Default `For_Sale`. | | `propertyType` | `string` | CSV of Idealista types, e.g. `flat,duplex`. | | `minPrice` / `maxPrice` | `number` | EUR. | | `minSize` / `maxSize` | `number` | m². | | `bedrooms` | `number[]` | Idealista codes: `0`=studio, `1`,`2`,`3`,`4`(=4+). | | `amenities` | `string[]` | Server-side amenity flags, honoured on the place path only. | | `page` | `number` | 1-based; 50 results per page. | ```bash curl -X POST 'https://api.fondaro.com/idealista/search' \ -H 'Authorization: Bearer ' \ -H 'x-organization-id: ' \ -H 'Content-Type: application/json' \ -d '{"circle":{"lat":40.4168,"lon":-3.7038,"radiusKm":2},"maxPrice":800000,"bedrooms":[3]}' ``` Response: ```json { "properties": [ { "id": "104588621", "source": "idealista", "price": 1750000, "currency": "EUR", "latitude": 40.43, "longitude": -3.69, "externalAgency": { "name": "...", "phone": "...", "micrositeShortName": "..." }, "deeplinkUrl": "https://www.idealista.com/inmueble/104588621/" } ], "total": 3165, "page": 1, "totalPages": 1055, "pageSize": 50 } ``` Each `properties[]` item is a normalized `UnifiedProperty` carrying coordinates, amenity booleans, the listing deeplink, and the `externalAgency` block. ## Place autocomplete ```http GET /idealista/autocomplete?input=Marbella&country=es ``` ```json { "locations": [ { "name": "Marbella, Málaga", "locationId": "0-EU-ES-29-...", "subTypeText": "Municipio", "total": 12345, "divisible": true } ] } ``` ## Sublocations ```http GET /idealista/sublocations?locationId=0-EU-ES-28&country=es ``` Drills the geography tree one level down (province → municipalities → districts), with per-area counts. ## Listing detail ```http GET /idealista/detail/:propertyCode?country=es&locale=en ``` Returns the full `UnifiedProperty` (all photos, energy rating, full description, community costs, deeplink, agency). `country` (`es` default, `it`, `pt`) selects the Idealista country backend the property code lives on. ```json { "property": { "id": "106387165", "source": "idealista", "bedrooms": 4, "bathrooms": 5, "livingArea": 263, "energyRating": "b", "images": ["..."], "deeplinkUrl": "..." } } ``` ## Resolve a pasted link or id ```http POST /idealista/resolve Content-Type: application/json { "input": "https://www.idealista.com/inmueble/106387165/", "locale": "en" } ``` Resolves a pasted Idealista listing URL (idealista.com/.es/.it/.pt, including `obra-nueva` new-development links) or a bare 6–12 digit property code to the full listing detail. The country is detected from the URL host; bare codes default to `es`. ```json { "property": { "id": "106387165", "source": "idealista", "...": "..." }, "propertyCode": "106387165", "country": "es" } ``` Returns `400` when the input is not an Idealista listing link or property code, and `404` when the listing does not exist or has been withdrawn. ```bash curl -s -X POST 'https://api.fondaro.com/idealista/resolve' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "input": "https://www.idealista.com/inmueble/106387165/" }' ``` ## Agency profile ```http GET /idealista/agent/:micrositeShortName?country=es GET /idealista/agent/:micrositeShortName/locations?country=es ``` `/agent/:micrositeShortName` returns the agency profile (name, phone, email, website, languages, active-since year, total listings). The `/locations` variant returns the provinces, municipalities, and districts where the agency has active listings, with per-area counts. ## Notes - Polygon and radius searches accept only the common filter subset (price, size, beds, type, operation). Amenity filtering on those paths happens client-side over the fetched pins; on the place path amenities are passed through to the server. - Results and detail are cached for a few minutes to keep the shared credit budget low. There is no billing for this source, but search, autocomplete, sublocations and detail count one unit against your organization's daily RealtyAPI allowance for each request that reaches the gateway (a cached answer counts nothing). When the allowance is used up these routes return `429` until it resets at midnight UTC. --- # Inbox Source: https://www.fondaro.com/docs/api/inbox > REST endpoints for the Inbox: the people waiting for an answer, your team chats and what arrived for you, in one ordered list with Waiting, New and All tabs, the rail's count, marking a conversation as needing no reply, dismissing a row, and replying to someone new. ## Overview The Inbox is composed on the server for the person asking. Every row comes from a record that already exists (a message, a lead, a meeting answer, a brochure view, a request from another agency, a chat you are in); nothing is stored for the Inbox itself except two marks: that a conversation needs no reply, and that someone dismissed a row. A row leaves because something happened on its record: you replied on any channel, called, booked, linked, marked it handled or dismissed it. Only two rows expire on their own: a new conversation after 14 days, and an opened brochure after 48 hours. The endpoints on this page use the dashboard's Clerk bearer token and resolve the organization from the request. Reading is open to every member. Marking a row handled needs an active plan, exactly as on the CRM endpoints. ## Endpoints | Method & path | Who | Purpose | |---|---|---| | `GET /inbox` | Any member | One page of the Inbox, in order | | `GET /inbox/count` | Any member | How many people are waiting for you | | `POST /inbox/items/:ref/handled` | Anyone who can open the lead, or who has the row | Mark a conversation as needing no reply, or dismiss a row | | `DELETE /inbox/items/:ref/handled` | The same | Undo that mark or dismissal | | `GET /inbox/faces/:kind/:id` | Anyone who has the row | The person's profile picture, the path in `faceUrl` | | `POST /new-conversations/:key/reply` | The owner of the inbox it came in on | Reply to someone who is not a lead yet | ## What is in it | `kind` | A row when | It leaves when | |---|---|---| | `awaiting_reply` | The newest message on a lead, on any channel, is theirs | Anyone replies on any channel, a call connects, or the row is marked handled | | `new_conversation` | A first message from someone who is not a lead yet reached one of your connected inboxes | Add as lead, link to a lead, Not a lead, or 14 days | | `new_lead` | A lead assigned to you nobody has called, emailed or messaged | Any call, sent email or outbound message | | `unassigned_lead` | A new lead with nobody on it (admins, scope `agency`) | Someone is assigned | | `invite_declined` | A lead declined, or answered maybe to, an upcoming meeting you organised | The meeting is moved (everyone is asked again), cancelled or accepted | | `brochure_opened` | A brochure you made for one lead was opened by someone outside your agency | Any contact with the lead after the view, or 48 hours | | `reconnect` | One of your connected inboxes needs reconnecting | It is connected again | | `co_listing_invitation` | Another agency invited you (or your agency, for admins) to co-list a home | Accepted or declined | | `partnership_request` | Another agency asked to partner (admins) | Accepted or declined | | `open_house_tomorrow` | Your open house is tomorrow and agents said they are going | The day passes | | `agent_request` | An agent at another agency asked to message you | Accepted or declined | | `request_reply` | Someone answered your open buyer request in a chat and you have not replied | You reply in that thread, or the request closes | | `post_draft` | A listing post on your own Instagram or LinkedIn waits for you: a draft (from a new listing, a price change, the Post action or Ask), a post that failed, or one whose account needs reconnecting. `reason` is `{ code: "post_draft", postId, channel, postKind, status }`, `ref` the listing, `automatic` true when a trigger drafted it | It is published, scheduled or discarded | With a `view` (see below), your own chats join the list: | `kind` | A row when | It leaves when | |---|---|---| | `team_dm` | A direct message with a colleague or an agent at another agency whose newest message is theirs and unread by you | You read it or reply | | `team_mention` | An unread message in a channel or group mentions you | You read it | | `team_channel` | A channel or group that does not wait on you (the `all` view only) | Never waits | | `lead_thread` | A lead's conversation that waits on nobody (the `all` view only) | Never waits | A chat is only ever yours: you see a direct message, a channel or a group only as a member of it, at every scope. An admin's `members` or `agency` scope changes which lead conversations show, never whose chats. Group chatter never waits; only a direct message to you or a mention of you does. Your own last message never waits. Overdue tasks are not rows: they live on the calendar. ## Order Rows come back in one total order: 1. `reconnect`, when a broken inbox blocks a reply on this page; 2. the people waiting (`awaiting_reply`, `new_conversation`, `new_lead`, `unassigned_lead`) together, longest waiting first; 3. the team (`group: "team"`): `agent_request` first, then `team_dm`, `team_mention` and `request_reply`, longest waiting first. A lead waiting always comes before your team; 4. `invite_declined` (and a `reconnect` that blocks nothing); 5. `brochure_opened`; 6. the network rows; 7. `post_draft`. Inside a group, Ask may lift a row (someone asking to be left alone first, then a specific request such as a viewing) or sink it (an automatic reply). Ask never removes a row: with Ask's reading missing or not yet available in the message's language, every message still appears with the reason `wrote`. A lead appears once, with its strongest reason. `additionalCount` says how many other reasons it has. ## GET /inbox | Query | Type | Notes | |---|---|---| | `scope` | `me` \| `members` \| `agency` | Default `me`. Members always get `me`. | | `assigneeIds` | string, repeatable | User ids for `scope=members` (admins). | | `channel` | `email` \| `whatsapp` \| `linkedin` \| `instagram` \| `telegram` | Only rows on this channel. | | `q` | string | Only rows whose person or agency name contains it. | | `cursor` | string | `nextCursor` from the previous page. | | `limit` | 1 to 50 | Default 30. | | `timeZone` | IANA name | For "tomorrow" and the note's "overnight". Default `UTC`. | | `view` | `waiting` \| `new` \| `all` | The view (the dashboard's filter). Without it you get the list as it was before the tabs, with no chats, no `views` and no `note`. | | `via` | a connected inbox id \| `team` \| `agencies` | Only rows from that inbox, your colleagues' chats, or other agencies (their chats and their requests). | ```bash curl 'https://api.fondaro.com/inbox?view=waiting&scope=me&limit=30&timeZone=Europe/Madrid' \ -H 'Authorization: Bearer ' ``` ```json { "asOf": "2026-09-25T10:00:00.000Z", "scope": "me", "items": [ { "id": "message:6b0c9d0e-5d7a-4e0a-9d59-0f3c1e2a7b11", "kind": "awaiting_reply", "group": "people", "ref": { "kind": "lead", "id": "1042" }, "conversationKey": null, "messageRef": { "kind": "message", "id": "6b0c9d0e-5d7a-4e0a-9d59-0f3c1e2a7b11" }, "connectedAccountId": "3f0c6f8e-1c1a-4a7e-9e7b-2d0c7f6a1b11", "occurredAt": "2026-09-25T08:58:12.000Z", "lead": { "id": 1042, "firstName": "Lead", "lastName": "Example", "language": "en", "assigneeIds": ["user_2abc"] }, "title": "Lead Example", "channel": "whatsapp", "reason": { "code": "wrote", "channel": "whatsapp" }, "replyOwed": true, "automatic": false, "fromAnAgency": false, "handleable": true, "additionalCount": 0, "offers": [], "preview": "Hi! Is Saturday 11 still ok?", "chat": null, "faceUrl": "/inbox/faces/message/6b0c9d0e-5d7a-4e0a-9d59-0f3c1e2a7b11" }, { "id": "chat:0d1e2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6", "kind": "team_dm", "group": "team", "ref": { "kind": "conversation", "id": "0d1e2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6" }, "conversationKey": null, "messageRef": null, "connectedAccountId": null, "occurredAt": "2026-09-25T09:40:00.000Z", "lead": null, "title": "Ana Soler", "channel": null, "reason": { "code": "team", "waits": "dm" }, "replyOwed": true, "automatic": false, "fromAnAgency": false, "handleable": false, "additionalCount": 0, "offers": [], "preview": "Can you take the viewing on Saturday?", "chat": { "conversationId": "0d1e2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6", "type": "dm", "scope": "internal", "status": "active", "waits": "dm", "unreadCount": 1, "mentionCount": 0, "person": { "userId": "user_2ana", "avatarUrl": null }, "last": { "fromViewer": false, "senderFirstName": "Ana", "attachment": null }, "mentionedBy": null }, "faceUrl": null } ], "nextCursor": null, "groups": { "reconnect": 0, "people": 1, "team": 1, "invites": 0, "brochures": 0, "network": 0, "posts": 0 }, "counts": { "awaiting_reply": 1, "team_dm": 1, "new_conversation": 0, "...": 0 }, "waitingCount": 2, "hasConnectedAccount": true, "view": "waiting", "views": { "waiting": 2, "new": 0 }, "note": { "sentences": [ { "shape": "wrote", "people": [{ "rowId": "message:6b0c9d0e-5d7a-4e0a-9d59-0f3c1e2a7b11", "ref": { "kind": "lead", "id": "1042" }, "name": "Lead Example" }], "others": 0, "questionId": null }, { "shape": "team", "person": { "rowId": "chat:0d1e2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6", "ref": { "kind": "conversation", "id": "0d1e2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6" }, "name": "Ana Soler" }, "mention": false, "channelName": null } ] } } ``` - `ref` is what the row opens: the lead, a `calendar_event`, a `brochure`, a `listing`, an `organization`, an `open_house`, or a chat `conversation` (`agent_request`, `request_reply` and the team rows). It is `null` for `new_conversation` (open it by `conversationKey` with the new conversation endpoints) and for `reconnect` (use `connectedAccountId`). - `reason` is a code with parameters, for the client to put into words: `ask` (`questionId`, Ask's reading), `wrote` (`channel`), `new_conversation` (with `askReason`, why they wrote, when Ask read it), `enquiry` (why a new lead enquired, when Ask read it), `origin` (where a new lead came from), `invite_declined`, `brochure_opened`, `reconnect`, `co_listing_invitation`, `partnership_request`, `open_house_tomorrow`, `agent_request`, `request_reply`, `team` (a chat; `waits` is `dm`, `mention` or `null`) and `thread` (a lead's conversation in `all`: the channel of the newest message and whether it was yours). - `preview` is the person's own words, at most about 140 characters: a message's first line, an email's subject, a new conversation's newest message, a chat's last message. It is `null` when there are no words (a new lead, a voice note: `chat.last.attachment` then says `voice`, `gif` or `card`). - `chat` carries a team row's conversation: direct message or channel, `internal` (colleagues) or `external` (another agency), why it waits, the unread and mention counts, the other person of a direct message, and who sent the last message. A person is always a name, never an email. - `faceUrl` is the path of the person's own profile picture on WhatsApp, Instagram, LinkedIn or Telegram, for a lead's or a new conversation's row on those channels, and `null` otherwise. Fetch it with your bearer token: it answers the image (cached privately for a day, with an `ETag`), or 404 when they have none or you cannot see the row. - `connectedAccountId` is the broken inbox on `reconnect`, and the inbox a message arrived on for a message row (what `via` filters on). - `offers` are Ask's suggestions for the message, the same ones the lead page shows. Accepting one always opens a proposal for you to approve. - `groups`, `counts`, `waitingCount` and `views` cover the whole Inbox, not the page and not the `q`, `channel` or `via` filter. - Paging is by position in the order, so a row that clears between two pages never shifts the next one. ### Tabs | `view` | What it holds | |---|---| | `waiting` | Every row above that waits, your waiting chats included, in the order above | | `new` | `new_lead`, `new_conversation`, `unassigned_lead` and `agent_request`, in the same order | | `all` | Every conversation, newest activity first: one row per lead with a message or email, per new conversation, and per chat you are in. Requests to you are pinned above the first page. A lead or chat that waits keeps its waiting row. | `views` counts the rows of `waiting` and `new` (`all` has no count). With a `view`, `q` also matches the last words (`preview`), not only the name. Each view has its own cursor; a cursor from another view starts from the top. ### The note `note` is on the first page of every view (`null` on later pages, and when there is nothing to say): one or two sentences chosen by the server, as parts your client puts into words, so every client says the same thing. The first shape that fits wins, in this order: `wrote` (people who wrote), `new_people` (new leads from one source, with `overnight` before noon for leads since 18:00 the day before in `timeZone`), `invite`, `brochure`, then `team` (a direct message or a mention, never ahead of a lead sentence). A sentence names at most two people; `others` counts only the people beyond them. It never carries a total. Each name's `rowId` is the row it focuses. The dashboard no longer draws the note (2026-09-27); it stays on the wire for other clients. ## GET /inbox/count The rail's number: people waiting for you (`awaiting_reply`, `new_conversation`, `new_lead`), one per lead, plus the chats that owe you an answer (`team_dm`, `team_mention`) and requests to you (`agent_request`), always in your own scope. ```bash curl 'https://api.fondaro.com/inbox/count' \ -H 'Authorization: Bearer ' ``` ```json { "waitingCount": 3 } ``` ## Handled "No reply needed" is kept on the message it clears, so it holds on the web and the phone alike. The next message from the lead is a new row, so the lead comes back by itself. `:ref` is the row's `messageRef` as a key (`email:` or `message:`, URL-encoded), or `lead:` for the lead's newest message, which must be theirs. Only a message on a lead you can open can be marked. A row with no message is dismissed instead (below). New conversations are decided with the new conversation endpoints, and chats clear when you read them. ```bash curl -X POST 'https://api.fondaro.com/inbox/items/message%3A6b0c9d0e-5d7a-4e0a-9d59-0f3c1e2a7b11/handled' \ -H 'Authorization: Bearer ' ``` ```json { "messageRef": "message:6b0c9d0e-5d7a-4e0a-9d59-0f3c1e2a7b11", "handled": true, "handledAt": "2026-09-25T10:02:41.000Z" } ``` Marking a message that is already marked keeps the first mark. `DELETE` on the same path removes it and returns `"handled": false`. | Status | When | |---|---| | `400` | `:ref` is not an email, a message, a lead or a row that can be dismissed | | `403` | Your organization has no active plan | | `404` | No inbound message on a lead you can open, or no such row in your Inbox | ### Dismiss A row with no message of its own (`new_lead`, `unassigned_lead`, `invite_declined`, `brochure_opened`, `reconnect`, `co_listing_invitation`, `partnership_request`, `open_house_tomorrow`, `agent_request`, `request_reply`, `post_draft`) is dismissed on the same path, with the row's `id` as `:ref`. With a `view`, these rows come back with `handleable: true`; without one, `handleable` still means a message to mark. The dismissal holds for everyone in your organization, on the web and the phone, until something newer happens on the row: another view of the brochure, a new answer to the invite, a newer reply to your buyer request, the inbox breaking again. Then the row comes back and can be dismissed again. You can dismiss only a row your own Inbox shows you right now. `timeZone` (an IANA name, default `UTC`) finds an open house "tomorrow" where you are. ```bash curl -X POST 'https://api.fondaro.com/inbox/items/new_lead%3Alead%3A1042/handled?timeZone=Europe/Madrid' \ -H 'Authorization: Bearer ' ``` ```json { "messageRef": "new_lead:lead:1042", "handled": true, "handledAt": "2026-09-25T10:02:41.000Z" } ``` `DELETE` on the same path is Undo: the row is back on the next read. ## Read Every row carries `unread`, the dot: something new since you last opened this conversation. It is yours alone, on the web and the phone; a colleague opening the same lead does not clear it for you. It says nothing about owing an answer: a row you have opened still waits in Waiting and still counts in `waitingCount` until you answer or mark it handled. | Row | `unread` is true while | |---|---| | A lead (`awaiting_reply`, `lead_thread`, `new_lead`, `unassigned_lead`) | Their newest message, or the enquiry of a new lead, is newer than your last open. A conversation whose newest message is yours is read. | | `new_conversation` | Their newest message is newer than your last open, until you reply | | A chat (`team_dm`, `team_mention`, `team_channel`) | It has messages you have not read. Chats are read by reading them, not here. | | Anything else | You have not opened it since it happened (another view of the brochure, a new answer to the invite) | `POST /inbox/read` marks a conversation opened now. `key` names it: `lead:` for any row of a lead, `conversation:` for a new conversation, the row's `id` for anything else. You can read only what your own Inbox could show you: a lead you can open, a new conversation in one of your inboxes, a row your Inbox holds right now. `timeZone` (an IANA name, default `UTC`) finds an open house "tomorrow" where you are. Reading needs no active plan. ```bash curl -X POST 'https://api.fondaro.com/inbox/read' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "key": "lead:1042" }' ``` ```json { "key": "lead:1042", "readAt": "2026-09-25T10:02:41.000Z" } ``` Reading again moves `readAt`. Afterwards `inbox:changed` goes to your own sockets only, so your other tabs and devices refetch. | Status | When | |---|---| | `400` | `key` is not a lead, a new conversation or a row that reads by its `id` (a chat, or a lead's row by its own `id`) | | `404` | No such lead you can open, conversation in your inboxes, or row in your Inbox | ## Reply to someone new `POST /new-conversations/:key/reply` answers a `new_conversation` row from the inbox they wrote to, before you decide whether they are a lead. `:key` is the row's `conversationKey`, URL-encoded. Only the owner of that inbox can reply, exactly as only they can open the conversation. | Body | Type | Notes | |---|---|---| | `body` | string, 1 to 20,000 characters | Plain text. A WhatsApp, Instagram, LinkedIn or Telegram message is at most 4,000. | | `subject` | string | Email only. Default: `Re:` and their newest subject. | | `cc` | string[] | Email only. Up to 10 addresses; one already in To is dropped, and each is kept once. | | `bcc` | string[] | Email only. Up to 10 addresses; one already in To or Cc is dropped. | | `bodyHtml` | string, at most 100,000 characters | Email only. The formatted body, sent instead of `body`'s paragraphs after it is cleaned to `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `s`, `ul`, `ol`, `li`, `blockquote` and `a` (http, https or mailto links only). `body` stays the plain version. | A chat reply ignores `cc`, `bcc` and `bodyHtml`. The From is always the inbox they wrote to, so the reply stays in their thread. An email goes out from that mailbox as a reply to their newest message, so it lands in the same thread for them. A chat message goes into the chat they opened, a few seconds after your last message from that account, like every message you send. The reply stays with the conversation: it moves to the lead when you add them as a lead or link them, and it goes with the conversation on Not a lead or after 14 days. The row stays in your Inbox until you decide, without its dot (`replyOwed: false`), and the conversation's newest message is yours (`answered: true`). ```bash curl -X POST 'https://api.fondaro.com/new-conversations/email.3f0c6f8e-1c1a-4a7e-9e7b-2d0c7f6a1b11.YnV5ZXJAZXhhbXBsZS5jb20/reply' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "body": "Hello, the villa is free to view on Saturday at 11." }' ``` With formatting and a copy: ```bash curl -X POST 'https://api.fondaro.com/new-conversations/email.3f0c6f8e-1c1a-4a7e-9e7b-2d0c7f6a1b11.YnV5ZXJAZXhhbXBsZS5jb20/reply' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "body": "Hello, the villa is free to view on Saturday at 11.", "bodyHtml": "

Hello, the villa is free to view on Saturday at 11.

", "cc": ["partner@example.com"] }' ``` ```json { "message": { "id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d", "kind": "email", "direction": "outbound", "subject": "Re: Villa in Mijas", "body": "Hello, the villa is free to view on Saturday at 11.", "messageAt": "2026-09-25T10:04:00.000Z", "attachments": [] } } ``` The conversation's messages (`GET /new-conversations/:key`) now carry `direction`, `inbound` or `outbound`. | Status | When | |---|---| | `400` | An empty body, or a chat message over 4,000 characters | | `403` | A sandbox organization (nothing is sent from one), or no active plan | | `404` | No such conversation in your inboxes | | `409` | The inbox cannot send: `code` is `mailbox_needs_reconnect` or `message_needs_reconnect` (reconnect it), `message_account_restricted`, or `mailbox_not_connected` / `message_not_connected` (connect it again). `integration` names the connection page | | `429` | Sending is paused: `message_paced`, `message_rate_limited` or `mailbox_rate_limited` | | `502` | The provider did not take it; try again | ## Live updates After a message reaches a lead, a new conversation arrives, a reply goes to one, or a row is marked, dismissed or brought back, the dashboard's chat socket sends `inbox:changed` with an empty payload to your organization. Refetch `GET /inbox`. A client should also refetch every 60 seconds, which covers a dropped socket. ## MCP `list_inbox` on the [MCP server](/docs/api/mcp) (scope `crm:read`) returns the list as it was before the tabs, without offers, with `preview` on lead rows. It never returns a chat: team rows and chat words stay out of the assistant and MCP. There is no tool to mark a row handled. --- # Inmobalia Source: https://www.fondaro.com/docs/api/inmobalia > Connect an Inmobalia personal access token and import contacts and activity history into the Fondaro CRM with a reversible, admin-triggered import. The Inmobalia endpoints drive a reversible import of your Inmobalia CRM into Fondaro. Fondaro reads from the [Inmobalia API](https://api-crm.inmobalia.com) directly using a per-organization personal access token (PAT). The import is read-only against Inmobalia and runs in two passes: - **Contacts → leads** (with activity logs as notes, and tasks, pending or completed, as tasks). A contact's lead status maps to a Fondaro CRM status. - **Sales → deals** (the second pass): each Inmobalia Sale becomes a Fondaro Deal attached to the buyer's (or seller's) imported lead, carrying multi-party commission (collaborators and internal agents). The sale's stage maps to a Fondaro deal stage. Run this only after the contacts pass has committed. Inmobalia has no endpoint that lists contacts by owner, so both passes choose their records from a **snapshot**: a background scan copies every contact (with its owners from the contact detail) and every sale (with its responsible user) into Fondaro, and a `selection` over that snapshot decides what the preview and commit read. Only the selected records are then fetched in full. See [Snapshot](#snapshot). Every endpoint requires a valid Clerk session, an active organization, and the **organization admin** role. The PAT itself is managed through the standard [integration credentials](/docs/dashboard/integrations#inmobalia) endpoints under the `inmobalia` provider (`api_token` key). ## Connection status ``` GET /inmobalia/import/status ``` Reports whether a token is connected, whether it has expired, and a summary of the most recent import batch. Performs one live call to Inmobalia to confirm the token still works. ### Response ```json { "connected": true, "expiresAt": "2026-12-31T00:00:00.000Z", "expired": false, "lastBatch": { "id": "9f1c…", "status": "committed", "counts": { "contacts": 120, "leadsCreated": 118, "leadsSkipped": 2, "notes": 540, "tasks": 33, "tasksUndated": 4, "tasksSkipped": 0, "errors": 0 } } } ``` Imported tasks are calendar events. `tasksUndated` counts the ones with no date in Inmobalia, placed all day on the day they were created there (else on the import day); `tasksSkipped` counts the ones left out because no active teammate could own them. A task goes to its mapped owner (who gains access to the lead), else the lead's first active assignee, else the earliest active admin. Both counts are absent on batches imported before 2026-09-26. ### Source status ``` GET /inmobalia/status ``` **Authentication:** Required (Clerk JWT + organization, any member) The property-source status used by Property Search and existing app versions. `connected` is `true` only when the saved token passed its latest live check with Inmobalia; a saved token that fails it returns `connected: false` with `connection.status` `attention`, a `reason` and a display-safe `message`. The fields match the [Resales Online status route](/docs/api/resales-online#connection-status). The result is cached (6 hours after a pass, 5 minutes after a failure, re-checked immediately when the token changes). ```json { "connected": true, "connection": { "status": "connected", "checkedAt": "2026-09-24T10:15:00.000Z" } } ``` Saving the token (`PUT /integrations/credentials/inmobalia/api_token`) runs the same check and returns its result as `connection` beside `success`, and `POST /integrations/credentials/inmobalia/test` always runs a fresh check and adds `connection` to its `valid` / `detail` / `error` / `reason` fields. ## User mapping ``` GET /inmobalia/import/user-mapping ``` Lists Inmobalia users and Fondaro org members, with an auto-match (`autoMatch`) keyed by Inmobalia username, matched case-insensitively by email. `null` means no match was found. ### Response ```json { "inmobaliaUsers": [{ "username": "jdoe", "email": "j@agency.com", "name": "Jane Doe" }], "fondaroMembers": [{ "userId": "user_123", "email": "j@agency.com", "name": "Jane Doe" }], "autoMatch": { "jdoe": "user_123" }, "sandbox": false } ``` `sandbox` is `true` when the organization is a sandbox. There, only sandbox members can own imported records: the commit drops any `userMapping` value that is not a member. A contact whose Inmobalia owners are all unmapped and a deal with no mapped user are assigned to the admin who started the import. A task whose owner is unmapped goes to the lead's first assignee (the admin who started the import when no owner is mapped). A lead with any unmapped owner gets an extra note `Inmobalia owner: ` (`Inmobalia owners: a, b` for several); no tag is created for it. ## Snapshot The snapshot is keyed to the organization, or to the parent organization when called from a sandbox, so a sandbox and its parent share one snapshot. Rows expire 14 days after the last completed scan; the `inmobalia-snapshot-expire` K8s CronJob (`cli inmobalia:snapshot-expire`, daily 04:36 UTC) deletes expired rows. A scan is recorded as an import batch with `params.kind = "snapshot"`: it never appears in [Batch status](#batch-status) lists and cannot be rolled back. ### Status ``` GET /inmobalia/import/snapshot ``` ```json { "sandbox": false, "scan": { "id": "3b0e…", "status": "committing", "mode": "full", "phase": "contacts_detail", "startedAt": "2026-09-17T09:00:00.000Z", "updatedAt": "2026-09-17T09:04:12.000Z", "committedAt": null, "stalled": false, "counts": { "contactsListed": 4210, "contactsTotal": 4210, "contactsDetailed": 1830, "contactsRemoved": 0, "salesListed": 0, "salesTotal": null, "detailErrors": 2 }, "error": null, "errorCode": null }, "snapshot": { "contacts": 4210, "contactsWithDetail": 1830, "sales": 0, "scannedAt": null, "expiresAt": null } } ``` `scan` is the latest scan (`null` before the first). It walks `contacts_list` → `contacts_detail` → `sales_list` → `done`. `stalled` is `true` while a scan is still `committing` but its cursor has not moved for 5 minutes. `snapshot.scannedAt` is when the newest completed scan finished; selection is only meaningful once it is set. A contact counts toward a user only once its detail was read (`contactsWithDetail`). ### Start, refresh or resume a scan ``` POST /inmobalia/import/snapshot/scan ``` ```json { "mode": "full" } ``` Returns `202` with `{ "batchId": "3b0e…", "resumed": false }`. The scan runs in the background and saves its cursor after every page, so a restart or failure loses at most one page; Inmobalia rate limits (`429`) are retried with backoff. Lists are walked in Inmobalia id order. Only when Inmobalia refuses that sort on the first page of a list (`400` or `422`) does the scan walk that list in Inmobalia's default order instead, from the start. Any other error on a list page, including a refused sort on a later page or a `408`, `409` or `429` that outlasts the retries, fails the scan so you can resume it in the same order: switching order halfway would skip records that a full scan then removes from the snapshot. - `full` (default) lists everything. When it completes, contacts and sales it no longer saw are deleted from the snapshot. - `refresh` lists only records modified since the previous completed scan started (minus an hour of slack) and re-reads contact details that changed or failed before. It deletes nothing. - `resume` continues a failed or stalled scan from its cursor (`resumed: true`). `400` when there is no scan to resume. If a scan is already running and not stalled, its id is returned instead of starting a second one. A stalled scan is taken over by any mode. Blocked with `400` if the token is expired. ### Scan progress ``` GET /inmobalia/import/snapshot/scans/:id ``` Returns one scan in the `scan` shape above. `404` for an unknown id or a batch that is not a snapshot scan. ### Selection counts ``` POST /inmobalia/import/snapshot/selection ``` The body is a `selection`, the same object the preview and commit endpoints accept: ```json { "users": ["jdoe", "__unassigned__"], "from": "2026-01-01T00:00:00.000Z", "to": "2026-07-01T00:00:00.000Z", "contactTypes": ["buyer", "owner"], "includeArchived": false, "sinceLastImport": true, "activities": true, "tasks": false, "limit": 50 } ``` Every field is optional; an empty object selects the whole snapshot. | Field | Meaning | |-------|---------| | `users` | Inmobalia usernames, plus `__unassigned__` for records with no owner. A contact matches when any of its owners is listed; a sale matches on its responsible user. Empty or absent means everyone, including contacts whose detail has not been read yet. At most 500 entries. | | `from`, `to` | ISO instants on the Inmobalia `dateCreated`. `from` is inclusive, `to` exclusive. Applies to contacts and sales. | | `contactTypes` | Contact-type flags (`buyer`, `owner`, `tenantLong`, `tenantShort`, `collaborator`, `developer`, `lawyer`, `serviceco`). A contact matches when it has any of them. Contacts only. | | `includeArchived` | Include archived contacts (default `false`). | | `sinceLastImport` | Only contacts created after their owner's last committed contacts import in this organization. An import that selected users counts for those users; one without a user selection counts for everyone. An import that covered only part of its users' contacts counts for nobody: one with a `limit`, a `from` or `to` date, or `contactTypes` (and, before snapshot selections, a top-level `limit`, `fromDateCreated` or `contactTypes`, or the single-agent test mode). | | `activities`, `tasks` | Import activity logs as notes and tasks (both default `true`). Commit only. | | `events`, `enquiries`, `files` | Import calendar events and enquiries as notes, and contact and sale files as Documents (all default `false`). Not snapshotted, so they are fetched and counted during the commit; see [Calendar events, enquiries and files](#calendar-events-enquiries-and-files). Commit only. | | `fileVisibility` | Who can open documents made from files: `private` (default: organization admins and the importing admin) or `organization` (every member). Recorded on the batch as `params.fileVisibility` and returned on [Batch status](#batch-status). | | `limit` | Import at most this many records (1 to 100000), ordered by Inmobalia id. | ```json { "users": [{ "username": "jdoe", "contacts": 812, "sales": 41, "lastImportedAt": "2026-09-10T08:00:00.000Z" }], "unassigned": { "contacts": 96, "sales": 3, "lastImportedAt": null }, "selected": { "contacts": 50, "sales": 41, "contactsAlreadyImported": 12, "salesAlreadyImported": 0 }, "contactsPendingDetail": 0 } ``` Per-user counts apply every filter except `users`, so the list shows what ticking each user would add. `selected.contacts` and `selected.sales` apply the whole selection, capped by `limit`; `*AlreadyImported` counts the matching records (before the limit) that already exist in Fondaro. `contactsPendingDetail` counts snapshot contacts whose owners are not known yet. ## Lead statuses ``` GET /inmobalia/import/lead-statuses ``` Returns your account's configured **contact lead statuses**. These drive the mandatory lead-status → CRM-status mapping (`statusMapping`) used by the contacts commit. Per-account, so the wizard builds the mapping table from these rather than any fixed list. ### Response ```json { "statuses": ["HOT", "WARM", "COLD"] } ``` ## Sale stages ``` GET /inmobalia/import/sale-stages ``` Returns your account's configured **sale pipeline stages**. These drive the mandatory sale-stage → deal-stage mapping (`stageMapping`) used by the sales commit. ### Response ```json { "stages": ["LEAD_CAPTURING", "OFFERING", "CLOSED"] } ``` ## Contact sources ``` GET /inmobalia/import/sources ``` Returns your account's configured **contact sources** (the source catalogue Inmobalia stamps on contacts). These drive the optional source → lead-source/tag mapping (`sourceMapping`) used by the contacts commit. Per-account, so the wizard builds the mapping table from these rather than any fixed list. ### Response ```json { "sources": ["PORTAL_IDEALISTA", "WALK_IN", "REFERRAL"] } ``` ## Activity types ``` GET /inmobalia/import/activity-types ``` Returns your account's **activity types** (the `type` of activity logs and tasks, from Inmobalia's `/activities/types`), trimmed, de-duplicated and sorted. The wizard lets you mark which of them are viewings; see `viewingActivityTypes` under [Commit](#viewings). ### Response ```json { "types": ["CALL", "EMAIL", "VISIT"] } ``` ## Contact tags ``` GET /inmobalia/import/contact-tags ``` Returns your account's **contact tag catalogue**, normalized the way the commit names Fondaro tags (whitespace collapsed, cut to 64 characters, deduplicated case-insensitively). It backs the preview of the optional `importContactTags` commit flag; the commit itself reads each contact's own tags. ### Response ```json { "tags": ["VIP", "Golf club", "Russian speaker"] } ``` ## Preview (dry run) ``` POST /inmobalia/import/preview ``` Classifies the contacts that would be imported without writing anything. Does not fetch contact detail or activities, so it is cheap. ### Request ```json { "selection": { "users": ["jdoe"], "from": "2026-01-01T00:00:00.000Z", "contactTypes": ["buyer", "owner"], "limit": 25 } } ``` `selection` is the object described under [Selection counts](#selection-counts); the preview classifies exactly the snapshot contacts it selects. Without a `selection`, the preview lists contacts from Inmobalia directly with the account-wide filters `fromDateCreated`, `includeArchived`, `contactTypes` and `limit`, which are ignored when a `selection` is present. The single-agent test mode fields `assignedToUser` (preview and commit) and `contactIds` (commit) were removed on 2026-09-17: select one user with a `limit` instead. ### Response ```json { "total": 120, "toCreate": 110, "existing": 8, "invalidCount": 2, "invalid": [{ "contactId": 42, "reasons": ["Missing phone number"] }], "sample": [{ "contactId": 1, "firstName": "Ada", "lastName": "Lovelace", "email": "ada@x.com", "phoneNumber": "+34…", "existing": false, "willImport": true, "missingFields": [] }], "typeBreakdown": { "buyer": 90, "owner": 30, "tenantLong": 0, "tenantShort": 0, "collaborator": 0, "developer": 0, "lawyer": 0, "serviceco": 0 }, "assignmentSummary": { "inmobaliaUsers": 4, "autoMatched": 3, "unmatched": 1 } } ``` ## Commit (background import) ``` POST /inmobalia/import/commit ``` Starts the import and returns immediately with a batch id. The work runs in the background; poll the batch endpoint for progress. Returns `202 Accepted`. Blocked with `400` if the token is expired. One import runs at a time per organization. While another contacts or sales batch of the organization is still importing or being undone, the commit is refused with `409` before anything is written (snapshot scans do not count): ```json { "statusCode": 409, "code": "inmobalia_commit_in_progress", "message": "Another import is still running in this organization. Wait for it to finish, then try again.", "runningBatch": { "id": "9f1c…", "kind": "sales", "status": "committing", "startedAt": "2026-09-17T09:00:00.000Z" } } ``` Wait for that batch to finish (poll [Batch status](#batch-status)) and send the commit again. A batch whose run stopped without finishing (its record unchanged for 30 minutes) no longer blocks. A commit is also refused with `409` and `code: "inmobalia_crm_wipe_in_progress"` while the organization is deleting all of its CRM data, checked before the running-import check. Everything the import wrote would be deleted with the rest, so wait for the deletion to finish and send the commit again: ```json { "statusCode": 409, "code": "inmobalia_crm_wipe_in_progress", "message": "This organization is deleting all of its CRM data right now. Wait for that to finish, then try again.", "crmWipe": { "id": "4b02…", "status": "deleting", "startedAt": "2026-09-17T08:00:00.000Z" } } ``` ### Request ```json { "selection": { "users": ["jdoe"], "from": "2026-01-01T00:00:00.000Z", "tasks": false, "limit": 25 }, "userMapping": { "jdoe": "user_123", "asmith": null }, "missingFieldStrategy": "skip", "statusMapping": { "HOT": "client", "WARM": "potential", "DEFAULT": "lead" }, "sourceMapping": { "Facebook": { "leadSource": "meta", "tagName": "Facebook" }, "Idealista": { "leadSource": "inmobalia", "tagName": "Idealista" }, "__default__": { "leadSource": "inmobalia" } }, "typeMapping": { "buyer": "buyer", "owner": "seller", "tenantLong": "tenant", "tenantShort": "tenant", "collaborator": "collaborator", "developer": "collaborator", "lawyer": "collaborator", "serviceco": "collaborator" }, "importContactTags": true } ``` `userMapping` maps each Inmobalia username to a Fondaro `userId` (or `null` to import that user's contacts unassigned). `missingFieldStrategy` is `skip` (drop contacts missing a required field) or `placeholder` (import them with placeholder values). `statusMapping` is **mandatory**: it maps each Inmobalia contact lead status (from [Lead statuses](#lead-statuses)) plus a `DEFAULT` catch-all to a Fondaro CRM status. The commit re-validates it and rejects (`400`) an incomplete or invalid mapping before writing anything. `selection` works as in the preview: the commit imports exactly the snapshot contacts it selects, minus `skipContactIds`, and fetches contact detail, activities and tasks only for those. `selection.activities: false` or `selection.tasks: false` skips that fetch entirely. The selection is recorded on the batch (`params.selection`, with `params.sandbox`) and is what `sinceLastImport` reads on later imports. Each created lead's `companyName` is the contact's Inmobalia `company`, trimmed and normalized the same way as a company name typed in the CRM; an empty company leaves it `null`. `sourceMapping` is optional. It maps an Inmobalia contact source name (from [Contact sources](#contact-sources)) to a customer-claimable Fondaro `leadSource`: `meta`, `google`, `client_web`, `manual`, `csv_import`, `zapier`, `james_edition`, `inmobalia`, or `n8n`. It can also include a `tagName` (a plain string; the tag is found-or-created by name, so you don't pre-create it). The special `__default__` key covers contacts with no source. Any source you don't map defaults to a `Lead.source` of `INMOBALIA`; mapping a source overrides that with your chosen `leadSource`, and the mapped tag (if any) is attached to every lead that source brings in. Only the tags you reference are applied; no tags are created from Inmobalia's own source details or free-form tags. Regardless of mapping, each contact's original source, source details, and tags are preserved in the lead's metadata. `typeMapping` is optional and maps each Inmobalia contact-type flag (`buyer`, `owner`, `tenantLong`, `tenantShort`, `collaborator`, `developer`, `lawyer`, `serviceco`) to a Fondaro lead type (`buyer`, `seller`, `tenant` or `collaborator`). Flags you leave out take the defaults shown above; an unknown flag or lead type is rejected with `400` before anything is written. A contact with several flags gets the strongest mapped type: `collaborator` over `seller` over `buyer` over `tenant`, and a contact with no flag is a `buyer`. The effective mapping (defaults included) is saved on the batch and becomes the organization's saved mapping for [Lead-type backfill](#lead-type-backfill). `importContactTags` is optional and defaults to `false`. When `true`, each contact's Inmobalia tags become Fondaro tags attached to its lead; tags that don't exist yet are created (found by name, case-insensitively, archived tags included) and their ids are recorded on the batch so an undo can remove the ones nothing else uses. Each created lead's `purchasedAt` is the contact's Inmobalia `dateCreated` (falling back to the import time when it is missing, unreadable or in the future), so the lead's timeline and reports start when the contact entered Inmobalia. The raw `dateCreated` and `dateModified` are kept in the lead's `sourceMetadata` alongside the other Inmobalia fields. In a sandbox, owners that are not sandbox members are handled as described under [User mapping](#user-mapping). `fondaro` is reserved provenance for leads supplied by Fondaro. It may appear in lead reads and CRM/report source filters, but it is not accepted in `sourceMapping`; a stale or hand-written mapping that supplies it is ignored and falls back to `inmobalia`. Use `meta` when the imported contact genuinely came from the customer's own Meta activity. ### Response ```json { "batchId": "9f1c…" } ``` The import is **idempotent**: every created lead carries `externalId = "inmobalia:"`, so re-running skips contacts that already exist. ### Viewings `viewingActivityTypes` is optional (default none, at most 50): the Inmobalia activity types (from [Activity types](#activity-types)) that are viewings. Among the activity logs and tasks the import brings along (`selection.activities`, `selection.tasks`), one of these types becomes a [property viewing](/docs/api/properties/activity) instead of a note or task when: - its `properties[].propertyReference` values name exactly one of the organization's own listings (case-insensitive `referenceNumber` match; no match, or references naming different listings, keeps it a note or task), and - it has a readable date, and one of its contacts is an imported non-collaborator lead. The viewing's `leadId` is the first imported non-collaborator contact among the activity's `contactMain`, `contacts` and the contact being imported; `collaboratorLeadId` is the first imported collaborator lead among them. `agentUserId` is the activity's `responsible` user through `userMapping` (in a sandbox, an unmapped user falls back to the importing admin), else its first mapped `users` entry. A log is `completed` at its `dateCompleted` (else `dateCreated`); a task is dated at its calendar date and is `scheduled` while it is pending and still ahead, otherwise `completed`. `kind` is `in_person`, `source` is `import`, `notes` hold the activity header and details, and `createdBy` is the importing admin. The effective list is saved on the batch (`params.viewingActivityTypes`). ### Import ledger and catch-up Viewings and documents carry no import column, and event and enquiry notes must not repeat, so each batch keeps a ledger in its params: `importedViewings` (`viewing:log:` or `viewing:task:` → viewing id), `importedDocuments` (`file:contact::` or `file:sale::` → document id) and `importedNotes` (`event::contact:` or `enquiry:` → note id). Sale files have no id, so when several files of one sale share a name and creation date, the first keeps that key and the next ones add their position among them (`…::2`, `…::3`); a file Inmobalia lists twice (same file path and size) is imported once. The commit reads the organization's ledger keys once (every batch that is not rolled back) and never creates a second row for a key it already holds. A key only counts while its row still stands: the note still exists, the viewing still exists with a client lead, the document still exists and is attached to a lead. So after a forced undo of an earlier import removed a lead and what hung off it, importing that contact again recreates its viewings, notes and documents. An activity whose viewing already exists (it was imported through another of its contacts) adds nothing, not even a note. An event linked to two imported contacts is a note on each of their leads, once. The ledger is also what lets a run **catch up**: for a selected contact whose lead an earlier import created, the commit adds the viewings, event and enquiry notes and files that lead does not have yet (the notes join this batch's `created_note_ids`); for a sale whose deal already exists, its files. It never re-adds activity notes or tasks. Rolling back a batch deletes exactly the viewings and documents in its ledger and every note it created, catch-up notes on earlier leads included (their search index entries are removed after the undo commits). ### Calendar events, enquiries and files With `selection.events`, `selection.enquiries` or `selection.files` set, the commit reads these per selected contact, for the leads it creates and, through the [ledger](#import-ledger-and-catch-up), for leads an earlier import created: - **Calendar events** (`GET /v1/events/by-contact/{id}`, each read in full for its notes and users) become one note per event on the lead: `[Calendar event: ]`, the start and end (or the all-day date), time zone, users and the event notes, dated at the event start. Inmobalia events carry no type and no property reference, so viewings come from activity logs and tasks instead (see [Viewings](#viewings)). Needs the `events:read` scope. - **Enquiries** (`GET /v1/enquiries/by-contact/{id}`, each read in full) become one note per enquiry: subject, status, transaction, condition, price range, bedrooms, bathrooms, property references, expiry and the enquiry notes. Area and type ids are left out because the payload carries no names for them. Needs the `enquiries:read` scope. - **Files** (`GET /v1/contacts/{id}/files`) become [Documents](/docs/api/documents) through the Documents upload path, so its rules apply: PDF only (the file must start with `%PDF-`, whatever its name), 25 MB at most, the lead's assignees pinned on the document, and the document attached to the lead. Documents are uploaded `private`; with `selection.fileVisibility: "organization"` each is then opened to the organization through the Documents update (if that fails, it stays private). Files are listed right before download because Inmobalia's file links expire after 5 minutes, downloaded two at a time through the SSRF-safe fetch (HTTPS only, the token never sent to the file host), and skipped entirely for an organization without an active subscription. Each created document is recorded in the batch ledger (`params.importedDocuments`) before it is attached. Event and enquiry notes count toward `counts.notes` and are also counted as `counts.events` and `counts.enquiries`; files add `counts.documents` and `counts.filesSkipped`, and viewings `counts.viewings`. Each of these keys is present only when its object was selected (or, for viewings, when `viewingActivityTypes` is not empty). A token without `events:read` or `enquiries:read` does not fail the import: that object is switched off for the rest of the run and reported in [`skippedObjects`](#batch-status). ## Sales preview (dry run) ``` POST /inmobalia/import/sales/preview ``` Classifies the Sales that would import as Deals, list-only (no per-sale calls). A Sale attaches to the buyer's (fallback seller's) imported lead, so run this after the contacts pass has committed. ### Request ```json { "status": "WON", "selection": { "users": ["jdoe"], "from": "2026-01-01T00:00:00.000Z", "limit": 25 } } ``` All fields optional (`status` is one of `IN_PROGRESS`, `WON`, `LOST`). With a `selection`, the preview reads the snapshot sales it selects: `users` matches the sale's responsible user (`__unassigned__` for none), `from`/`to` the sale's `dateCreated`, and `limit` caps the count; the contact-only fields are ignored. Without one, sales are listed from Inmobalia with `fromDateCreated` and `limit`. `forContactIds` was removed on 2026-09-17. ### Response ```json { "totalSales": 60, "toCreate": 52, "existing": 3, "skippedNoLead": 5, "unlinkedParties": 7, "byStage": { "CLOSED": 40, "OFFERING": 12 }, "byStatus": { "WON": 40, "IN_PROGRESS": 20 }, "sample": [{ "saleId": 81, "code": 81, "title": "CL Postigo de Arance 14", "stage": "CLOSED", "status": "WON", "amount": 655000, "primaryLeadLinked": true }] } ``` `skippedNoLead` counts sales whose buyer and seller are both un-imported (no deal can attach). `unlinkedParties` counts buyer/seller references that won't resolve to a lead among the importable sales. ## Sales commit (background import) ``` POST /inmobalia/import/sales/commit ``` Starts the Sales → Deals pass and returns a batch id (`202`). Poll the batch endpoint for progress. Refused with `409` and `code: "inmobalia_commit_in_progress"` while another import of the organization is running or being undone, exactly as the [contacts commit](#commit-background-import). ### Request ```json { "stageMapping": { "CLOSED": "under_contract", "OFFERING": "offer", "DEFAULT": "qualified" }, "userMapping": { "jdoe": "user_123" }, "status": "WON", "selection": { "users": ["jdoe"], "from": "2026-01-01T00:00:00.000Z", "limit": 25 } } ``` `stageMapping` is **mandatory**: it maps each Inmobalia sale stage (from [Sale stages](#sale-stages)) plus a `DEFAULT` catch-all to a Fondaro deal stage (`qualified`, `viewing`, `offer`, `reserved`, `under_contract`). `userMapping` is reused from the contacts pass. `status` and `selection` are optional and match the sales preview, so the commit imports the same set the preview showed. Each created deal carries `externalId = "inmobalia:sale:<id>"` (idempotent re-runs) and connects buyer, seller, lawyers, collaborators, and internal agents as deal participants with their commission shares. Each participant's `side` is set only where the sale says it: the buyer's lawyer is `buyer`, the seller's lawyer is `seller`, and an external collaborator is `buyer` or `seller` when the snapshot shows that collaborator as the contact who sent the buyer or the seller (Inmobalia `sentBy`). Everyone else, including a collaborator who sent both or neither, has `side: null`. ### Response ```json { "batchId": "a2d7…" } ``` Each deal also gets: - `propertyListingId` when the sale's `propertyReference` matches the `referenceNumber` of exactly one of the organization's own listings (case-insensitive). No match or several matches leave it `null`. - `stageChangedAt` from the sale: `dateClosed` for a won or lost sale, otherwise `date`, then `dateCreated`. Future or unreadable dates are ignored and the column keeps its default. - A **Sent by** link: when the sale lists exactly one distinct external collaborator whose contact was imported, the primary lead's `referredByLeadId` is set to that collaborator's lead, only if it was empty. The batch records every link it set so an undo clears exactly those. Inmobalia's sale schema exposes no invoice number or collection dates, so the deal finance fields (`estimatedCollectionAt`, `invoiceNumber`, `collectedAt`) are not imported. With `selection.files`, each deal the run creates, and each deal an earlier run created, also brings its sale files (`GET /v1/sales/{id}/files`) as Documents, under the same rules as contact files. There is no document-to-deal link, so a sale file is attached to the deal's primary lead, and to the deal's own listing when `propertyListingId` is set. Sale files have no id in Inmobalia. A sales batch's `counts` are `{ totalSales, dealsCreated, dealsSkipped, participants, notes, errors, partiesDropped, listingsLinked, referrersSet }`. `partiesDropped` counts sale parties that got no participant row because their contact was not imported (`contact_not_imported`) or their Inmobalia user is not mapped to a member (`user_not_mapped`); the batch lists them by name (see [Batch status](#batch-status)). The last three counts are absent on batches imported before 2026-09-17. ## Batch status ``` GET /inmobalia/import/batches/:id GET /inmobalia/import/batches ``` Poll a single batch (or list the 20 most recent). `status` moves through `committing → committed` (or `failed`), and `rolling_back → rolled_back` on undo. ### Response ```json { "id": "9f1c…", "kind": "sales", "status": "committed", "counts": { "totalSales": 60, "dealsCreated": 52, "dealsSkipped": 8, "participants": 190, "notes": 12, "errors": 0, "partiesDropped": 2, "listingsLinked": 31, "referrersSet": 9 }, "committedAt": "2026-09-17T10:05:00.000Z", "rolledBackAt": null, "error": null, "droppedParties": [ { "saleId": 81, "saleCode": 81, "role": "lawyer", "ref": "4410", "reason": "contact_not_imported" }, { "saleId": 94, "saleCode": 94, "role": "internal_agent", "ref": "mlopez", "reason": "user_not_mapped" } ] } ``` `kind` is `contacts` or `sales`; snapshot scans are not listed here (see [Snapshot](#snapshot)). `droppedParties` appears on sales batches only, capped at 500 entries (`counts.partiesDropped` is the full total); `ref` is the Inmobalia contact id for contacts and the username for agents. `fileVisibility` (`private` or `organization`) appears when files were selected. Two optional keys report what [calendar events, enquiries and files](#calendar-events-enquiries-and-files) left out: `skippedObjects` (for example `{ "events": "insufficient_scope" }`) names an object the token has no scope for, and `filesSkippedByReason` counts files that did not become documents by reason: `not_pdf`, `too_large`, `no_url`, `download_failed`, `upload_failed` or `not_entitled`. ## Rollback (undo) ``` POST /inmobalia/import/batches/:id/rollback ``` Undoes a batch in one transaction, and writes assignment-audit reversals. Generic over both passes: a **contacts** batch hard-deletes the leads, notes, and tasks it created, plus any tag it created (recorded since 2026-09-17) that no lead, routing rule or smart view uses any more; a **sales** batch hard-deletes only the deals it created (which cascades their participants and stage history) plus any folded notes, clears the `referredByLeadId` links it set where they still hold its value, and never touches contacts-batch leads. Either pass also deletes the viewings and documents in its ledger (`params.importedViewings`, `params.importedDocuments`), documents with their lead and listing links, in the same transaction, and removes the stored PDFs after it commits. The search index entries of deleted notes go with them. Refused with `400` if the batch is already rolled back, still running, or a snapshot scan (a scan imports nothing, so there is nothing to undo). ### Request ```json { "force": false } ``` The body is optional. Two guards run before anything is deleted: 1. **Another import, or a CRM data deletion.** While another contacts or sales batch of the organization is importing or being undone, the undo is refused with `409` and `code: "inmobalia_commit_in_progress"`; while the organization is deleting all of its CRM data, with `code: "inmobalia_crm_wipe_in_progress"`. `force` does not override either: an undo would delete rows that run is still writing to, or that the deletion is already counting. 2. **Dependent deals import** (contacts batches). While a later sales batch that is not rolled back still has deals, parties or notes on this batch's leads, the undo is refused with `409` and `code: "inmobalia_rollback_dependent_batch"`. `force` does not override this: undo that sales batch first. 3. **Worked-on rows.** If anything the batch did not write now hangs off its leads (calls, emails, notes, tasks, status or assignment changes, deals, viewings, documents, brochures) or its own rows were edited after it finished (leads, notes, tasks; for a sales batch, deal edits, stage changes, reassignments, viewings and edited folded notes), the undo is refused with `409` and `code: "inmobalia_rollback_touched"`. A viewing, document or note the batch itself created counts only when it was edited after the import (a document also when it was attached to another lead or a listing after the import), wherever it sits: that includes the catch-up rows a batch adds to leads and deals an earlier import created, which the undo deletes too. One a later batch added to these leads (a catch-up) counts like any other. Send `"force": true` to delete anyway. ```json { "statusCode": 409, "code": "inmobalia_rollback_touched", "message": "2 leads from this import were worked on after it finished. Review them, or delete anyway.", "touchedTotal": 2, "touched": [ { "leadId": 5012, "name": "Ada Lovelace", "touches": ["call", "status_change"] }, { "leadId": 5019, "name": "Alan Turing", "touches": ["note"] } ] } ``` `touched` lists at most 200 rows, one per lead the touches sit on (a lead from an earlier import included); `touchedTotal` is the full count. For a sales batch each row on one of its deals also carries `dealId` and `dealTitle`; a touch on a lead that holds none of its deals (a sale file caught up onto an earlier deal) has neither. `leadId` is `null` only for an edited viewing or document that no lead holds any more. Touch kinds: `call`, `email`, `note`, `note_edited`, `task`, `task_edited`, `status_change`, `assignment_change`, `deal`, `viewing`, `document`, `brochure`, `lead_edited`, `deal_stage_change`, `deal_edited`. ### Response ```json { "status": "rolled_back", "deleted": { "leads": 118, "notes": 540, "tasks": 33, "deals": 0, "participants": 0, "tags": 4, "documents": 12, "viewings": 7 }, "forced": false } ``` A sales-batch undo zero-fills `leads`/`tasks`/`tags` and reports `deals` and `participants` instead. ## Lead-type backfill ``` POST /inmobalia/import/lead-type-backfill ``` Applies the organization's saved contact-type mapping (the newest contacts batch that recorded a `typeMapping`, else the defaults) to Inmobalia leads imported before lead types were set. Reads each lead's stored Inmobalia type flags and changes only leads still at `buyer`, so a type set by hand is kept; `sourceMetadata` is never rewritten. A dry run unless `apply` is `true`. The same operation is available to operators as `cli inmobalia:backfill-lead-type [--organization-id=<uuid>] [--apply]`. ### Request ```json { "apply": false } ``` ### Response ```json { "applied": false, "organizations": 1, "scanned": 812, "toChange": 64, "changed": 0, "byType": { "seller": 41, "collaborator": 23, "tenant": 0 } } ``` ## Sandbox migration profile ``` GET /inmobalia/import/sandbox-profile ``` Called from the **parent** organization: returns the mapping of the latest committed import in that organization's [sandbox](/docs/dashboard/migrations#sandbox), so the real run can reuse it. There is no profile table. Every committed batch already records its mapping on `params`, and the profile is read from the sandbox's newest committed `contacts` batch and newest committed `sales` batch (by `committedAt`). Read-only; nothing is imported or copied. The sandbox is resolved server-side from the caller's organization, never from the request, and only batches that belong to that sandbox are read. The response is `available: false` when the organization has no live sandbox, when the sandbox has no committed import, and when called from inside a sandbox (sandboxes do not nest). ```bash curl https://api.fondaro.com/inmobalia/import/sandbox-profile \ -H "Authorization: Bearer $TOKEN" ``` ### Response ```json { "available": true, "sandboxName": "Your Agency (Sandbox)", "contacts": { "batchId": "3b7e…", "committedAt": "2026-09-17T09:05:00.000Z", "selection": { "users": ["jdoe", "mlopez"], "from": "2025-01-01T00:00:00.000Z", "contactTypes": ["buyer"], "includeArchived": false, "activities": true, "tasks": false }, "userMapping": { "jdoe": "user_123" }, "usersToMap": ["mlopez"], "statusMapping": { "HOT": "client", "WARM": "potential", "DEFAULT": "lead" }, "sourceMapping": { "Facebook": { "leadSource": "meta", "tagName": "Facebook" } }, "typeMapping": { "buyer": "buyer", "owner": "seller" }, "importContactTags": true, "missingFieldStrategy": "placeholder" }, "sales": { "batchId": "8c21…", "committedAt": "2026-09-17T10:02:00.000Z", "selection": { "users": ["jdoe"] }, "stageMapping": { "CLOSED": "under_contract", "OFFERING": "offer", "DEFAULT": "viewing" }, "status": "WON" } } ``` With nothing to offer: ```json { "available": false, "sandboxName": "Your Agency (Sandbox)", "contacts": null, "sales": null } ``` `sandboxName` is `null` when there is no sandbox. `contacts` or `sales` is `null` when the sandbox has no committed batch of that kind; `available` is `true` when at least one is present. How the profile differs from the stored batch: - **`selection`** keeps `users`, `from`, `to`, `contactTypes`, `includeArchived`, `activities` and `tasks`. `limit` (a trial size) and `sinceLastImport` (relative to the sandbox's own import history) are dropped. - **`userMapping`** is translated to the parent. A sandbox value that is the Clerk user id of a parent member carries over unchanged. Any other mapped value goes to `usersToMap` (sorted), for the admin to map. Unmapped (`null`) sandbox entries are not carried. - **`statusMapping`**, **`sourceMapping`**, **`typeMapping`**, **`importContactTags`**, **`missingFieldStrategy`**, **`stageMapping`** and the sales **`status`** filter are returned as recorded (`null` where the batch recorded none). The snapshot is shared with the sandbox, so the statuses, sources and stages they are keyed by are the parent's own. Pass these values to [Commit](#commit-background-import) and [Sales commit](#sales-commit-background-import) as usual; the commits validate them exactly as they validate a hand-built mapping. --- # Integration reporting and search Source: https://www.fondaro.com/docs/api/integrations > Read call reports and viewings, discover leads from indexed CRM history, and receive message and meeting webhooks with scoped integration keys. These routes use the **Fondaro API** integration credential from Organization → Integrations → n8n. Send `Authorization: Bearer <integration-key>` to `https://api.fondaro.com`. Integration keys have organization-wide CRM authority bounded by their stored scopes, expiry and revocation. MCP keys and OAuth tokens use their separate authentication plane. Disabled organizations cannot use these reads. ## Calls and activities `GET /integrations/v1/leads/:id/calls` requires `leads:read`, with integer `limit` 1–100 (20 default) and nonnegative integer `offset` (0 default). Response: `{calls,total,hasMore,nextOffset}`. Each summary contains the recorded `ownerId: string | null`, call identity, direction/outcome/state, duration, timestamps and recording availability. It omits recording URLs, provider credentials and heavy bodies. Call-only totals are exact. ```bash curl 'https://api.fondaro.com/integrations/v1/leads/10132/calls?limit=20&offset=0' \ -H 'Authorization: Bearer <integration-key>' ``` `GET /integrations/v1/leads/:id/activities?types=call` remains supported under the same scope and uses the same eligible call selection. Supported comma-separated types are `call`, `email`, `note`, `task-created`, `task-completed`, `status-change`, `deal-stage-change`, `deal-won`, `deal-lost`, `assignee-change`, `viewing`, `document-attached`, and `lead-created`. Filtering runs before paging. Response: `{entries,total,totalIsExact,hasMore,nextOffset}`. General totals can be lower bounds; never use timeline totals for KPI counts. A `viewing` activity carries `id`, `propertyListingId`, `agentUserId`, `viewingAt`, `kind`, `status`, `collaboratorLeadId` and `dealId`. It appears on the client lead's activities and, since 2026-09-17, also on the activities of the collaborator lead that sent the client. On a collaborator's feed the entry has `collaboratorLeadId` equal to the `:id` you requested; the client stays behind the viewing reads below. A lead that is both client and collaborator on one viewing lists it once. Continue with `nextOffset` until `hasMore` is false. Ordering uses effective activity time, event type and source ID; the origin event appears once at the oldest position. Offsets traverse unchanged history deterministically, but concurrent inserts can shift them. No multi-request snapshot is guaranteed. Outbound owner IDs identify the recorded calling rep; inbound IDs identify an internal owner only when recorded. Empty/system ownership is null. Lead reassignment and shared numbers are never fallback calling identities. Resolve labels through `GET /integrations/v1/users` or `/users/:id` under `users:read`; retain historical owner IDs if team lookup no longer resolves them. `call.logged` and `call.analyzed` carry the same ownership field. **Call ownership versus lead assignment:** `ownerId` identifies the recorded internal user for that call. A lead's `assigneeIds` identifies its current assignment set; the singular `assigneeId` in lead-list filters selects leads assigned to that user. Those fields describe different relationships. If Alice calls a lead and it is later reassigned to Bob, the call retains Alice's `ownerId`, while the lead's `assigneeIds` reflects Bob. Label `ownerId` as **Recorded calling user** in reports. ## Lead company name Every public lead payload (Create, Find, Get, Search, List, the polling trigger and the assignee responses) carries `companyName: string | null`, the agency or firm the lead belongs to. It is mostly used on collaborator leads but is accepted on every lead type. Both writes require `leads:write`. `POST /integrations/v1/leads` accepts an optional `companyName`. `PATCH /integrations/v1/leads/:id/contact` accepts `companyName` as a string to set it, or `null` (or an empty string) to clear it; omit the field to keep the current value. Values are trimmed and must be at most 255 characters, otherwise the request returns `400`. ```bash curl -X PATCH 'https://api.fondaro.com/integrations/v1/leads/10132/contact' \ -H 'Authorization: Bearer <integration-key>' \ -H 'Content-Type: application/json' \ -d '{"companyName":"Costa Partners"}' ``` Leads imported from Inmobalia before this field existed keep their original import record untouched; a one-time backfill copies the company recorded there into `companyName` where it is still empty. Deal participants, including their buyer or seller side, are not part of the integrations API. ## Assigning teams `POST /integrations/v1/leads`, `POST /integrations/v1/deals`, `POST /integrations/v1/leads/:leadId/tasks` and `POST /integrations/v1/leads/:id/assignees` accept an optional `teamIds` list next to their user ids (up to 20 team UUIDs; the lead create and assignees endpoints also accept a single id or a comma-separated string). Each team is replaced by its current members when the request runs, combined with the users you name, and duplicates are removed. On the assignees endpoint `userIds` may be omitted when `teamIds` names at least one team. One request can assign at most 50 people; when teams push it over, the request returns `400` with `code: "ASSIGNEE_LIMIT_EXCEEDED"` naming the team, and nothing is written. Someone who joins a team later does not gain earlier records. Requests without `teamIds` behave exactly as before. Team ids come from the dashboard; see [Teams](/docs/api/teams). ```bash curl -X POST 'https://api.fondaro.com/integrations/v1/leads/10132/assignees' \ -H 'Authorization: Bearer <integration-key>' \ -H 'Content-Type: application/json' \ -d '{"teamIds":["0f6d8f7e-3c1a-4a52-9d61-2b7a1c9e4d10"]}' ``` ## Viewings `GET /integrations/v1/viewings` and `GET /integrations/v1/viewings/:id` require an explicitly granted `viewings:read`. Existing keys do not acquire this permission; omitted scopes on key creation preserve legacy defaults. | Filter | Validation and meaning | | --- | --- | | `propertyListingId`, `dealId` | UUID | | `leadId`, `collaboratorLeadId` | Positive integer | | `agentUserId` | Nonempty hosting-agent ID | | `status` | `scheduled`, `completed`, `cancelled`, `no_show` | | `kind` | `in_person`, `virtual` | | `from`, `to` | ISO timestamp bounds on `viewingAt`; inclusive `from`, exclusive `to`; ordered range | | `limit`, `offset` | Integer 1–100 (20 default); nonnegative offset (0 default) | Filters intersect before counting/paging. Response: `{viewings,total,hasMore,nextOffset}`, with exact totals and `(viewingAt DESC,id DESC)` ordering. Rows include relationship IDs, `agentUserId`, `createdBy`, status/kind/source, viewing/created/updated timestamps, notes and `notesTruncated`. List notes are previews; authorized REST detail notes are complete. ```bash curl 'https://api.fondaro.com/integrations/v1/viewings?leadId=10132&status=scheduled&limit=20' \ -H 'Authorization: Bearer <integration-key>' ``` This release reads registered own-listing viewings. Records are organization-visible; relationship IDs do not expose linked contact details. The host and registering user have distinct identities. MCP/Assistant callers with explicit lead filters must pass canonical CRM visibility checks. Deleted optional links do not prevent reading the registered viewing. There are no viewing write adapters or new viewing webhooks in this release. ## Semantic discovery Both POST routes require `leads:read` and use the existing RAG facade: ```bash curl 'https://api.fondaro.com/integrations/v1/leads/semantic-search' \ -H 'Authorization: Bearer <integration-key>' \ -H 'Content-Type: application/json' \ -d '{"query":"buyers who mentioned a garden near the beach","maxResults":10}' curl 'https://api.fondaro.com/integrations/v1/leads/listing-match' \ -H 'Authorization: Bearer <integration-key>' \ -H 'Content-Type: application/json' \ -d '{"listingRef":{"source":"resales_online","id":"R123456"},"maxResults":10}' ``` Semantic search accepts trimmed nonempty `query` up to 500 characters, optional positive integer `leadId`, valid ordered ISO `occurredFrom`/`occurredTo`, and `maxResults` 1–30 (10 default). Listing matching requires exactly one UUID `propertyListingId` or `{source,id,country?}` `listingRef`. Preserve the country returned with the listing ref when present. Listing identity is resolved through the unified Property Sources service; URLs and caller-supplied org/user/admin authority are not accepted. Response: `{matches,tookMs,reranked}`. Matches retain ranked lead IDs/names, evidence snippets, source identities/counts and occurrence times. No vectors, confidence percentage or Return All pagination is exposed. Reranker degradation returns `reranked:false`; embedding errors remain errors. These operations share a 30-request/minute limit per organization and per key, using existing shared throttle storage, alongside ordinary integration limits. A 429 includes standard `Retry-After` seconds. The existing storage outage behavior remains fail-open. No new customer billing meter is introduced. The corpus is asynchronously indexed eligible notes, call transcript chunks/summaries, human-written emails, lead memory and each lead's brief over a 24-month window. No relevant indexed match does not prove a lead does not exist. Evidence counts are not ledger totals. Occurrence-date bounds refer to when the source activity happened, including inclusive upper search bounds; they do not refer to future travel dates mentioned in text. Ordinary `GET /integrations/v1/leads/search` stays unchanged. Evidence with `sourceType: "lead_brief"` is the lead brief (shown as "What they want" in dashboard search results and listing matches): a short, dated summary of what the buyer wants, written automatically from their records, at most one per lead, with the lead ID as `sourceId`. Listing match ranks people by their brief first; a matching brief can raise a person but never lower one below their best record match. Semantic search treats it as one more piece of evidence, and a search with `occurredFrom` or `occurredTo` leaves briefs out, because a brief's date is its newest input, not when each fact was said. ## Message and meeting webhooks Four trigger events cover conversations and the calendar. Subscribe to them like any other event, with a key that holds `triggers:subscribe`: ```bash curl 'https://api.fondaro.com/integrations/v1/subscriptions' \ -H 'Authorization: Bearer <integration-key>' \ -H 'Content-Type: application/json' \ -d '{"targetUrl":"https://example.com/fondaro","eventTypes":["message.received","message.sent","meeting.created","meeting.responded"]}' ``` | Event | Fires when | | --- | --- | | `message.received` | A lead's email or chat message (WhatsApp, LinkedIn, Instagram, Telegram) is stored on the lead | | `message.sent` | An email or chat message to a lead is sent from Fondaro, or sent from the agent's own connected mailbox or phone and stored on the lead | | `meeting.created` | A meeting (a viewing's included) is created on the Fondaro calendar, or an event you owe gains an invitee. An event with a lead that invites nobody outside the team is a task and fires `task.created` instead, never both | | `meeting.responded` | An invitee accepts, declines or tentatively accepts a meeting invite; one delivery per changed answer | Each delivery is the usual signed envelope, `{event, deliveryId, occurredAt, data}`, with the `X-Fondaro-Signature`, `X-Fondaro-Event` and `X-Fondaro-Timestamp` headers. The delivery is queued together with the message or meeting it describes, so a write that is rolled back sends nothing. Delivery is at least once: deduplicate on `data.messageId` or `data.eventId` plus the event name. `message.received` and `message.sent` carry: ```json { "organizationId": "5b1f0c1e-8a54-4f1d-9d7e-3f2a1c9b7e21", "leadId": 10132, "messageId": "c2a4e1f0-6b7d-4c3e-9a1f-0e2d3c4b5a69", "messageType": "message", "channel": "whatsapp", "direction": "inbound", "connectedAccountId": "8e7d6c5b-4a39-4281-9f0e-1d2c3b4a5968", "userId": null, "occurredAt": "2026-09-25T10:00:00.000Z" } ``` `messageType` is `email` (a lead email) or `message` (a chat message). `channel` is `email`, `whatsapp`, `linkedin`, `instagram` or `telegram`. `userId` is the team member who pressed Send in Fondaro, and null for inbound messages and for messages sent from the agent's own phone or mail app. `connectedAccountId` is null for email sent before connected inboxes, through your organization's former sending domain. `occurredAt` is when the message was sent or received, not when Fondaro stored it. `meeting.created` carries: ```json { "organizationId": "5b1f0c1e-8a54-4f1d-9d7e-3f2a1c9b7e21", "eventId": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", "leadId": 10132, "leadIds": [10132, 10140], "viewingId": null, "ownerId": "user_2abc", "startsAt": "2026-10-01T15:00:00.000Z", "endsAt": "2026-10-01T15:30:00.000Z", "occurredAt": "2026-09-25T10:00:00.000Z" } ``` `leadId` is the meeting's own lead; `leadIds` lists every lead and collaborator on it, that lead first (empty when it has none). `meeting.responded` carries `organizationId`, `eventId`, `leadId` (the lead who answered, else the meeting's lead, else null), `ownerId`, `response` (`accepted`, `declined` or `tentative`) and `occurredAt`. Payloads carry ids and times only, never a message body, a subject or an address. Read the lead back with `GET /integrations/v1/leads/:id` and its emails with `GET /integrations/v1/leads/:id/activities?types=email`. A message from someone who is not a lead yet fires nothing; once the agent adds the sender as a lead, later messages do. Mail imported when a mailbox is first connected (the last 90 days) fires nothing either. --- # Lead routing Source: https://www.fondaro.com/docs/api/lead-routing > REST endpoints for who gets new leads: the Smart routing switch, your own routing rules, and the assignment reason on a lead. ## Overview When a new lead arrives, Fondaro gives it an owner in this order: 1. **Your own rules** first. The most specific matching rule wins (a rule for the lead's Growth plan, language or tag beats a catch-all); among equally specific rules, the lower `priority` wins. 2. **Smart routing** when none of your rules match. It is one catch-all rule Fondaro creates when your organization first gets a plan, on by default and switched off only by you. It hands leads to the active people on your team: when some of them list the lead's language on their profile (`agent_profiles.languages`, matched on the primary subtag, so `nl-BE` reaches `nl`), whichever of those got a lead longest ago; otherwise the whole team in a counted rotation, where nobody gets a second lead before everyone has had one. Smart routing never overrides a rule you wrote, and switching it off keeps it off: Fondaro does not switch it back on. The endpoints on this page use the dashboard's Clerk bearer token and resolve the organization from the request. Reading Smart routing is open to every member; everything else under `/lead-routing` is limited to organization admins, and a member receives `403`. ## Endpoints | Method & path | Who | Purpose | |---|---|---| | `GET /lead-routing/smart` | Any member | Is Smart routing on, and who it hands leads to | | `PUT /lead-routing/smart` | Admin | Switch Smart routing on or off | | `GET /lead-routing/rules` | Admin | Your own rules (the Smart routing rule is not listed) | | `POST /lead-routing/rules` | Admin | Create a rule | | `PATCH /lead-routing/rules/:id` | Admin | Change a rule | | `DELETE /lead-routing/rules/:id` | Admin | Delete a rule | | `GET /crm/leads/:id` | Any member who can see the lead | Carries `lead.assignmentReason` | ## Smart routing ```bash curl https://api.fondaro.com/lead-routing/smart \ -H "Authorization: Bearer <clerk session token>" # { "enabled": true, "teamUserIds": ["user_2abc...", "user_2def..."] } curl -X PUT https://api.fondaro.com/lead-routing/smart \ -H "Authorization: Bearer <clerk session token>" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' # { "enabled": false } ``` `teamUserIds` is the active team in turn order: the people Smart routing hands leads to right now. `PUT` takes `{ "enabled": boolean }` and returns the new state. On creates the rule if it does not exist yet or reactivates it; off deactivates it. The call is idempotent. The rule behind the switch carries the strategy `smart`. It has no conditions and no members of its own (the rotation reads the team at the moment a lead arrives, so someone who joins is included and someone who leaves drops out), and it sorts after every rule you can write (`priority` 1000). It is only ever changed through this switch: it is not in `GET /lead-routing/rules`, and `PATCH` or `DELETE` on its id returns `404`. ## Your own rules ```bash curl -X POST https://api.fondaro.com/lead-routing/rules \ -H "Authorization: Bearer <clerk session token>" \ -H "Content-Type: application/json" \ -d '{ "language": "sv-SE", "strategy": "round_robin", "priority": 0, "members": [ { "userId": "user_2abc...", "weight": 0, "sortOrder": 0 }, { "userId": "user_2def...", "weight": 0, "sortOrder": 1 } ] }' ``` | Field | Type | Notes | |---|---|---| | `subscriptionId` | UUID, optional | Only leads from this Growth plan | | `language` | string, optional | BCP 47 tag, for example `sv-SE`. The exact tag matches first; a lead with the same base language (`sv`, `sv-FI`) still matches when no exact rule does | | `tagId` | UUID, optional | Only leads carrying this tag | | `strategy` | `round_robin` or `weighted` | `smart` is refused with `400`: it belongs to the switch above | | `priority` | integer ≥ 0 | Lower wins among equally specific rules | | `members` | array, at least one | `{ userId, weight (0-100), sortOrder }`; for `weighted` the weights must add up to 100 | | `isActive` | boolean | `PATCH` only | ## The assignment reason on a lead `GET /crm/leads/:id` returns `lead.assignmentReason` for every member who can see the lead, so the lead page can say "Assigned to {name}": ```json { "lead": { "id": 12345, "assignmentReason": { "assigneeIds": ["user_2abc..."], "at": "2026-09-26T10:14:03.000Z" } }, "origin": { } } ``` It is read from the lead's assignment history: set when the newest change to the lead's owners is Fondaro routing it on arrival (people added, nobody removed, no team), and `null` once a person has changed the owners since, or when nothing routed it. The ids are Clerk user ids of the lead's owners at that moment. --- # Listing Collaborators Source: https://www.fondaro.com/docs/api/listing-collaborators > Invite agents and agencies from other agencies to co-list or refer a home, answer invitations, and claim an external listing your agency holds. ## Overview A collaborator is another agency, or one of its agents, working a home with the agency that lists it. | Role | What it means | |---|---| | `co_lister` | Markets the home beside the listing agency. Shown on the home's network detail, listed under the co-lister's own listings as co-listed, may host an open house on it, and may be messaged about it. | | `referrer` | Brought the home to the listing agency. Visible to the two agencies only. | | `lister` | Your agency's verified claim to an **external** home (a listing read from a source with your agency's own credentials). Only then can you invite co-listers to it. | Who does what: - On a home listed on the Fondaro network, your agency's admin or the home's listing agent invites. Invitations go to another agency (by id or page address) or to one of its agents (by profile handle), as `co_lister` or `referrer`, and only on a home that is on the market. - The invited agent answers for themselves. A whole-agency invitation is answered by that agency's admin. - Either side can end a pending or active collaboration. - Blocks either way, suspended profiles, disabled agencies and people who left their agency cannot be invited, and all look the same: no one by that name. Privacy stays as it is on the network: nothing here carries the owner's private commission, and a home that is off the market is never shown to another agency (an invitation then shows `listing: null`). The `/network` routes use the dashboard's Clerk bearer token and your active organization. They do not accept a property API key. ## Endpoints | Method & path | Who | Purpose | |---|---|---| | `GET /network/collaborators?listingSource=&listingId=[&listingCountry=]` | Agents of the listing agency | The home's pending and active collaborators | | `POST /network/collaborators` | Admin or listing agent | Invite an agent or an agency | | `POST /network/collaborators/claims` | Admin | Claim `lister` on an external home | | `GET /network/collaborators/invitations` | Any agent | Pending invitations to you or your active agency | | `POST /network/collaborators/:id/accept` | The invited agent, or the invited agency's admin | Accept | | `POST /network/collaborators/:id/decline` | Same | Decline | | `POST /network/collaborators/:id/revoke` | Either side | End it | A home can have at most 20 pending and active collaborators. ## List a home's collaborators ```bash curl "https://api.fondaro.com/network/collaborators?listingSource=internal&listingId=a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" \ -H "Authorization: Bearer $TOKEN" ``` ```json { "canManage": true, "collaborators": [ { "id": "8d2f4c1a-6b3e-4f70-9a15-2e7c0b9d4f36", "ref": { "source": "internal", "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" }, "role": "co_lister", "status": "active", "agency": { "networkSlug": "costa-homes", "name": "Costa Homes", "logoLightUrl": null, "logoDarkUrl": null, "organizationId": "2c7e0b9d-4f36-4a15-8d2f-4c1a6b3e4f70" }, "person": { "clerkUserId": "user_2def…", "firstName": "Tom", "lastName": "Berg", "imageUrl": null, "title": "Sales agent" }, "invitedByOrganizationId": "9a15e7c0-b9d4-4f36-8d2f-4c1a6b3e4f70", "canRespond": false, "canRevoke": true, "createdAt": "2026-09-24T08:00:00.000Z", "respondedAt": "2026-09-24T09:30:00.000Z", "updatedAt": "2026-09-24T09:30:00.000Z" } ] } ``` Another agency asking about your home, or you asking about theirs, gets `404`. `person` is `null` when a whole agency collaborates. An `agency` object can also carry `accentColor` (`#rrggbb`) and `coverImageUrl`, the agency's brochure colour and cover photo, present only when the agency set them and never on keyless public reads; clients build the profile card's cover from them. ## Invite ```bash curl -X POST https://api.fondaro.com/network/collaborators \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "ref": { "source": "internal", "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" }, "role": "co_lister", "handle": "tom-berg" }' ``` Name the agent with `handle`. When they work at several agencies, add `organizationId` or `networkSlug` for the agency. To invite a whole agency, send `organizationId` or `networkSlug` without `handle`. Returns the new collaborator with `status: "pending"`. The invited agent, or the invited agency's admins, get a `network.colisting_request` push. ## Answer or end ```bash curl -X POST https://api.fondaro.com/network/collaborators/8d2f4c1a-6b3e-4f70-9a15-2e7c0b9d4f36/accept \ -H "Authorization: Bearer $TOKEN" ``` `decline` and `revoke` work the same way. Each returns the collaborator with its new status (`active`, `declined`, `revoked`). ## Invitations to you ```bash curl https://api.fondaro.com/network/collaborators/invitations \ -H "Authorization: Bearer $TOKEN" ``` Each invitation carries the collaborator fields above, `invitedBy` (the inviting agency) and `listing` (the home as your agency can see it, the same fields as an open house's listing, or `null` when it is off the market). `canRespond` tells you whether you may answer it. ## Claim an external home ```bash curl -X POST https://api.fondaro.com/network/collaborators/claims \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "ref": { "source": "zoddak", "id": "12345" } }' ``` The claim holds only when your agency's own access to that source returns the home as one of your agency's listings. Portals never can, a Fondaro network home already has its listing agency, and a source that cannot tell your own listings from its shared pool is refused. Claiming again returns the same claim. Ending a claim also ends the invitations you made under it. ## Effects elsewhere - **Network detail.** `GET /properties/:id` from a dashboard session carries `coListers`: one card per active co-lister (agency, and the agent when one person co-lists). Email, phone and WhatsApp appear only when that agent shares contact with the network. - **Your own listings.** A dashboard session's own-scope search (`POST /properties/search` with `scope: "own"`) also returns the other agencies' homes you co-list, on the market only, marked `relation: "co_lister"` and in the network shape. Commission filters and sorting never read them. - **Messages.** `POST /chat/dms/from-record` with `{ "ref": { "kind": "listing", "source": "internal", "id": "…" }, "agentUserId": "user_…" }` messages one of the home's co-listers instead of its listing agent. The older `POST /chat/dms/from-property/:id` (same optional `agentUserId`) still works for released mobile apps and will be removed. - **Open houses.** An active co-lister may host an open house on the home. ## Errors | Status | `code` | When | |---|---|---| | 404 | `LISTING_COLLABORATOR_NOT_FOUND` | No such collaborator, or it is not yours to see | | 403 | `LISTING_COLLABORATOR_MANAGER_ONLY` | You are not the admin or the listing agent | | 403 | `LISTING_COLLABORATOR_INVITEE_ONLY` | Only the invited agent, or the invited agency's admin, answers | | 422 | `LISTING_COLLABORATOR_LISTING_UNAVAILABLE` | Your agency does not list the home, or it is off the market | | 400 | `LISTING_COLLABORATOR_OWN_AGENCY` | You invited your own agency | | 404 | `LISTING_COLLABORATOR_INVITEE_UNAVAILABLE` | No agent or agency by that name can be invited | | 400 | `LISTING_COLLABORATOR_AGENCY_REQUIRED` | The agent works at several agencies; name one | | 409 | `LISTING_COLLABORATOR_DUPLICATE` | Already invited or active in that role | | 409 | `LISTING_COLLABORATOR_LIMIT_REACHED` | The home has 20 collaborators | | 409 | `LISTING_COLLABORATOR_CLOSED` | Already answered or ended | | 422 | `LISTING_COLLABORATOR_CLAIM_UNSUPPORTED` | That source cannot prove the claim | | 422 | `LISTING_COLLABORATOR_CLAIM_UNVERIFIED` | Your agency's own access does not return the home | | 422 | `LISTING_COLLABORATOR_CLAIM_INTERNAL` | A Fondaro network home is never claimed | --- # Listing Collections Source: https://www.fondaro.com/docs/api/listing-collections > Keep a named list of homes with colleagues and agents from other agencies, from any connected source. ## Overview A collection is a named list of homes you keep together with other agents. It can hold homes from any connected source (the Fondaro network, Resales Online, the portals and the rest), and it replaces the dashboard Shortlist. - You create a collection in your active agency. You manage it: rename it, delete it, and add or remove people. - You can add a colleague (someone at one of your agencies) or someone you have an accepted conversation with on the network. People you have blocked, or who have blocked you, cannot be added. - Everyone in a collection can open it and add or remove homes. Anyone but the creator can leave. Each person sees every home through their own agency. Another agency's home shows while it is on the market, with its street address and map position, and never with the owner's private commission. A home you can no longer open (sold, withdrawn, or from a source your agency has not connected) stays in the list with `listing: null`. To share a collection, send its link in a conversation. Only people in the collection can open it. ## Endpoints | Method & path | Who | Purpose | |---|---|---| | `GET /network/collections` | Any agent | The collections you are in | | `POST /network/collections` | Any agent | Create one in your active agency | | `GET /network/collections/:id` | Members | The collection, its people and its homes | | `PATCH /network/collections/:id` | Creator | Rename | | `DELETE /network/collections/:id` | Creator | Delete it and its homes | | `POST /network/collections/:id/items` | Members | Add a home | | `DELETE /network/collections/:id/items/:itemId` | Members | Remove a home | | `POST /network/collections/:id/members` | Creator | Add a colleague or a connection | | `DELETE /network/collections/:id/members/:userId` | Creator, or yourself to leave | Remove someone | The `/network` routes use the dashboard's Clerk bearer token and your active organization. They do not accept a property API key. A collection you are not in answers `404`, whether or not it exists. | Limit | Value | |---|---| | Name | 80 characters | | Homes in one collection | 200 | | People in one collection, you included | 50 | | Collections you can create | 100 | ## List your collections ```bash curl https://api.fondaro.com/network/collections \ -H "Authorization: Bearer $TOKEN" ``` ```json { "collections": [ { "id": "5f0c2d8e-3b1a-4c7e-9a42-6d8e1f0b7c21", "name": "Golf frontline, three bedrooms", "itemCount": 6, "memberCount": 3, "isCreator": true, "createdAt": "2026-09-24T08:00:00.000Z", "updatedAt": "2026-09-24T09:12:00.000Z" } ] } ``` Recently changed first. ## Create a collection ```bash curl -X POST https://api.fondaro.com/network/collections \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Golf frontline, three bedrooms" }' ``` Returns the new collection's summary. You are its first member. ## Get one collection ```bash curl https://api.fondaro.com/network/collections/5f0c2d8e-3b1a-4c7e-9a42-6d8e1f0b7c21 \ -H "Authorization: Bearer $TOKEN" ``` ```json { "id": "5f0c2d8e-3b1a-4c7e-9a42-6d8e1f0b7c21", "name": "Golf frontline, three bedrooms", "itemCount": 2, "memberCount": 2, "isCreator": true, "createdAt": "2026-09-24T08:00:00.000Z", "updatedAt": "2026-09-24T09:12:00.000Z", "members": [ { "clerkUserId": "user_2abc…", "firstName": "Lucia", "lastName": "Moreno", "imageUrl": null, "isCreator": true }, { "clerkUserId": "user_2def…", "firstName": "Tom", "lastName": "Berg", "imageUrl": "https://…", "isCreator": false } ], "items": [ { "id": "b7e1a2c4-0f3d-4e59-8a61-2c9d7e0f1a33", "ref": { "source": "internal", "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" }, "addedByUserId": "user_2def…", "createdAt": "2026-09-24T09:12:00.000Z", "listing": { "ref": { "source": "internal", "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" }, "source": "internal", "title": "Garden apartment", "price": 650000, "currency": "EUR", "bedrooms": 3, "city": "Marbella", "address": { "line1": "Calle Ejemplo 7", "postalCode": "29660" }, "latitude": 36.4912, "longitude": -4.9876, "sharedCommission": 3 } }, { "id": "0c4f9e2a-7d1b-4a38-b5e6-3f2a1c0d9e84", "ref": { "source": "resales_online", "id": "R4471123", "country": "ES" }, "addedByUserId": "user_2abc…", "createdAt": "2026-09-24T08:05:00.000Z", "listing": null } ] } ``` Homes are newest first. `listing` has the same fields as an open house's listing. `addedByUserId` is `null` when the person who added the home has deleted their account. ## Add or remove a home ```bash curl -X POST https://api.fondaro.com/network/collections/5f0c2d8e-3b1a-4c7e-9a42-6d8e1f0b7c21/items \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "ref": { "source": "resales_online", "id": "R4471123", "country": "ES" } }' ``` `ref` is the home's `ListingRef`, as the [property sources](/docs/api/properties/sources) return it. You can only add a home your agency can open now and that is on the market. Adding a home twice returns the same item. ```bash curl -X DELETE https://api.fondaro.com/network/collections/5f0c2d8e-3b1a-4c7e-9a42-6d8e1f0b7c21/items/0c4f9e2a-7d1b-4a38-b5e6-3f2a1c0d9e84 \ -H "Authorization: Bearer $TOKEN" ``` Answers `204`, also when the home was already gone. ## Add or remove a person ```bash curl -X POST https://api.fondaro.com/network/collections/5f0c2d8e-3b1a-4c7e-9a42-6d8e1f0b7c21/members \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clerkUserId": "user_2def…" }' ``` Take the id from `GET /network/connections`. Returns the person as the collection shows them. Adding someone twice changes nothing. ```bash curl -X DELETE https://api.fondaro.com/network/collections/5f0c2d8e-3b1a-4c7e-9a42-6d8e1f0b7c21/members/user_2def… \ -H "Authorization: Bearer $TOKEN" ``` Use your own id to leave. The creator cannot leave; delete the collection instead. ## Errors | Status | `code` | When | |---|---|---| | 404 | `LISTING_COLLECTION_NOT_FOUND` | No such collection, or you are not in it | | 403 | `LISTING_COLLECTION_CREATOR_ONLY` | Renaming, deleting or managing people when you did not create it | | 403 | `LISTING_COLLECTION_ORGANIZATION_REQUIRED` | Your active organization is not an agency you are part of | | 422 | `LISTING_COLLECTION_LISTING_UNREADABLE` | Your agency cannot open that home, or it is off the market | | 422 | `LISTING_COLLECTION_MEMBER_NOT_CONNECTED` | The person is not a colleague or a connection, is blocked, or is not on the network | | 422 | `LISTING_COLLECTION_LIMIT_REACHED` | You already created 100 collections | | 422 | `LISTING_COLLECTION_ITEMS_FULL` | The collection holds 200 homes | | 422 | `LISTING_COLLECTION_MEMBERS_FULL` | The collection has 50 people | | 400 | `LISTING_COLLECTION_CREATOR_CANNOT_LEAVE` | The creator tried to leave | --- # Listing Posts Source: https://www.fondaro.com/docs/api/listing-posts > REST endpoints to prepare a post from one of your listings, edit its caption, publish it or schedule it to your own Instagram or LinkedIn, and reply to a comment that is an enquiry. ## Overview A listing post is one post of one of your agency's listings to your own connected Instagram (a feed post or a story) or LinkedIn. Fondaro renders the slides from the listing and writes a caption from its description; you publish it or schedule it. Nothing is posted without a person's action: only `publish` and `schedule` reach Instagram or LinkedIn, and a scheduled post goes out once, within five minutes of its time. Drafts that Fondaro prepares on its own (when a listing goes live or its price changes) wait in the Inbox as `post_draft` rows. The endpoints use the dashboard's Clerk bearer token and resolve the organization from the request. Every route is your own: posts on accounts you connected, and drafts you made. Writes need an active plan, as on the CRM endpoints. A sandbox organization cannot publish. ## Endpoints | Method & path | Purpose | |---|---| | `GET /listing-posts?listingId&status` | Your posts, newest first (at most 100) | | `GET /listing-posts/:id` | One post | | `GET /listing-posts/linkedin-pages` | The LinkedIn company pages you can post as | | `GET /listing-posts/:id/engagement` | Likes, comments and impressions of a published post | | `POST /listing-posts` | Prepare a draft from a listing | | `PATCH /listing-posts/:id` | Change the caption, feed or story, or the LinkedIn page | | `POST /listing-posts/:id/publish` | Publish now | | `POST /listing-posts/:id/schedule` | Publish at a time | | `POST /listing-posts/:id/unschedule` | Back to a draft | | `DELETE /listing-posts/:id` | Discard a post that has not gone out | | `POST /listing-posts/comments/:messageId/reply` | Reply under a comment that is an enquiry | ## The post object | Field | Type | Notes | |---|---|---| | `id` | string | | | `listing` | object | `ref` (`{ source: "internal", id }`), `reference`, `location` | | `account` | object or null | `id`, `kind` (`instagram` or `linkedin`), `address`, `status`; `null` once the account was removed | | `channel` | string | `instagram` or `linkedin` | | `kind` | string | `feed` or `story` (Instagram only) | | `linkedinOrganizationId` | string or null | LinkedIn only: the company page it goes out as; `null` posts as you | | `caption` | string | At most 2,200 characters | | `media` | array | `{ url, width, height }` per rendered slide, in posting order | | `scheduledAt` | string or null | When it goes (or went) out | | `status` | string | `draft`, `scheduled`, `publishing`, `published`, `failed` or `needs_reconnect` | | `providerPostId`, `providerPostUrl` | string or null | Set once published; the link when Instagram or LinkedIn reports one | | `createdBy` | string or null | Clerk user id; `null` when Fondaro drafted it | | `createdAt`, `updatedAt` | string | ISO 8601 | An Instagram feed post goes out as the listing's cover slide for now; a LinkedIn post carries up to four slides. A post whose account stopped working becomes `needs_reconnect`; reconnect the account, then publish again. A `failed` post is never sent again unless you publish it. ## Prepare a draft ```bash curl -X POST https://api.fondaro.com/listing-posts \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "listingId": "2d3d0669-0dc5-4e80-b573-b543a59fcd29", "channel": "instagram", "kind": "feed", "language": "es" }' ``` To post on LinkedIn as a company page, add `"linkedinOrganizationId"` with an `id` from `GET /listing-posts/linkedin-pages`, which reads your pages from your LinkedIn when you ask (`{ "pages": [{ "id", "name" }] }`, empty without a working LinkedIn). A page you cannot post as is a `400`. `language` picks the caption's language (the listing's description in that language when it has one); it defaults to your organization's first language. The response is the post object with `status: "draft"`. | Status | Body `code` | When | |---|---|---| | `400` | | A story on LinkedIn; the listing has no price, town, public photo or agency name | | `404` | | Not one of your organization's listings | | `409` | `LISTING_POST_NO_ACCOUNT` | You have no connected account on that channel | ## Change the caption, feed or story, or the page ```bash curl -X PATCH https://api.fondaro.com/listing-posts/$POST_ID \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "caption": "Estepona. €1,250,000. Three terraces over the sea." }' ``` The body takes any of `caption`, `kind` (`feed` or `story`; changing it renders the slides again and keeps the caption; a story is Instagram only) and `linkedinOrganizationId` (a page id, or `null` to post as you). Allowed while the post is `draft`, `scheduled`, `failed` or `needs_reconnect`; otherwise `409` with `LISTING_POST_NOT_EDITABLE`. ## Publish now or schedule ```bash curl -X POST https://api.fondaro.com/listing-posts/$POST_ID/publish \ -H "Authorization: Bearer $TOKEN" curl -X POST https://api.fondaro.com/listing-posts/$POST_ID/schedule \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "scheduledAt": "2026-10-02T18:00:00+02:00" }' ``` Both return the post. `publish` answers once Instagram or LinkedIn has taken it (`published`), or with `needs_reconnect` or `failed`. When the network is busy the post stays `scheduled` for now and goes out within five minutes. `schedule` needs a time at least a minute ahead and at most 90 days away (`400` otherwise). Either answers `409` with `LISTING_POST_NEEDS_RECONNECT` when your account needs reconnecting, and `403` from a sandbox. `unschedule` moves a `scheduled` post back to a draft; `DELETE` discards a post that has not gone out, with its slides (`204`). ## Reply to a comment Comments on your published posts are read about once an hour. A comment that is about the home is stored as a message of kind `comment` (on the lead, when the commenter is already linked to one, otherwise as a new conversation in your Inbox); nothing is stored for any other comment. Reply under it with the message id: ```bash curl -X POST https://api.fondaro.com/listing-posts/comments/$MESSAGE_ID/reply \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "text": "Thank you, I have sent you the details by message." }' ``` Only the person whose account published the post can reply. The response is `{ "replied": true }`, and the comment no longer waits in the Inbox. ## Engagement ```bash curl https://api.fondaro.com/listing-posts/$POST_ID/engagement \ -H "Authorization: Bearer $TOKEN" ``` Returns `{ "likes", "comments", "impressions", "readAt" }` for a published post, read from Instagram or LinkedIn when you ask (kept for five minutes, never stored). A count the network does not report is `null`; impressions are LinkedIn only. The response is empty (`null`) for a post that is not out or whose account cannot be read now. ## On the calendar Scheduled and published posts appear in `GET /calendar` as items of kind `post`. See [Calendar](/docs/api/calendar). --- # MCP Server Source: https://www.fondaro.com/docs/api/mcp > Connect an MCP client with Fondaro OAuth or a scoped fdr_mcp_ API key. Fondaro runs a hosted **Model Context Protocol (MCP)** server. It accepts Fondaro OAuth or a scoped `fdr_mcp_` API key. Once connected, a client can work with CRM records, search property sources, turn selected listings into shareable interactive brochures, and write, share, and attach documents from your organization's library on your behalf. The key is bound to the user and organization that created it: members reach only the leads assigned to them, and organization admins reach the whole organization. Nothing about MCP widens what an account can already do in the dashboard. ## Server Endpoint There is one endpoint. Configure it in a supported client exactly as written, path included and with no trailing slash: ``` https://api.fondaro.com/mcp/v1 ``` The server prefers **MCP 2026-07-28 over Streamable HTTP** and negotiates earlier revisions from **2025-11-25** through **2024-10-07**. Both paths are stateless and return JSON. Only `POST` carries MCP traffic; `GET` and `DELETE` return `405`, and there is no SSE stream or session to configure. ## Authentication The public server accepts Fondaro OAuth access tokens issued for the exact MCP resource and scoped Fondaro MCP API keys. A bearer credential of any other shape, including a dashboard session token, is rejected with `401`. ChatGPT and Claude Code discover OAuth from the MCP endpoint and open Fondaro's browser approval flow. Each acts as a public CIMD client, uses authorization code with S256 PKCE, and manages token refresh itself. Users never copy an OAuth token. The authorization server publishes metadata at `https://api.fondaro.com/.well-known/oauth-authorization-server`; the protected resource metadata is available at `https://api.fondaro.com/.well-known/oauth-protected-resource/mcp/v1`. Keys use the prefix `fdr_mcp_` followed by 32 lowercase hexadecimal characters: ``` fdr_mcp_YOUR_KEY ``` Send it as a Bearer token. This authenticated discovery request shows the complete 2026-07-28 envelope and headers every direct client must send: ```bash curl -X POST https://api.fondaro.com/mcp/v1 \ -H "Authorization: Bearer fdr_mcp_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Mcp-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: server/discover" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "your-client", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {} } } }' ``` Key facts: - Keys are minted per user from the dashboard (Settings, then Integrations, then Fondaro MCP). See the [Fondaro MCP dashboard guide](/docs/dashboard/fondaro-mcp). - The plaintext key is shown **exactly once** at creation and is stored only as a SHA-256 hash. If you lose it, revoke it and mint a new one. - Each key carries a chosen subset of [tool scopes](#tool-scopes), enforced fail-closed: a tool whose scope the key lacks is hidden from `tools/list` and denied at call time. - Keys can have an optional expiry (1 to 3650 days) and can be revoked at any time. Members revoke their own keys; organization admins can revoke any key in the organization. ## Tool scopes Every tool declares the scope it needs. Seven scopes exist: | Scope | Grants | | ----------------- | ------------------------------------------------------------------ | | `crm:read` | Read leads, tasks, notes, deals, calls, emails, messages, your Inbox and calendar, and records | | `crm:write` | Create and modify leads, tasks, notes, and deals | | `properties:read` | Search property sources and read listings, open houses, and viewings | | `brochures:read` | List and read interactive brochures | | `brochures:write` | Create, edit, renew, revoke, and delete interactive brochures | | `documents:read` | List and read library documents, including their live share links | | `documents:write` | Create and edit documents, share or revoke links, attach to leads | API keys carry whatever subset you choose when minting them (omit the subset to grant all seven scopes available at creation time). A key missing a tool's scope never sees that tool. Existing keys keep their stored scope set: mint a replacement key and reconnect the client if an older key needs brochure or document access. Creating a brochure, adding or refreshing a listing, and renewing with optional refresh require both `brochures:write` and `properties:read`, because those operations may snapshot a paid portal listing. Metadata edits, reorder, remove, revoke, and delete need only `brochures:write`. Document tools need only their own scope. Attaching a document to a lead is gated on your access to that lead, not on a CRM scope: a document is never a side door onto a lead you cannot already open. ## Identity and scoping Every tool call runs as the caller, with the same access the dashboard enforces: - **Members** see and touch only leads assigned to them. Lists, searches, counts, and timelines all narrow to their own leads. Reaching a lead they are not assigned to returns an access error, never another member's data. - **Brochure members** see and change only brochures they created; organization admins can work across the organization's brochures. - **Organization admins** operate organization-wide. - **Admin-only tools** (lead assignment and organization-wide bulk status changes) are hidden from a member's `tools/list` entirely. - **Document tools** follow the library's own visibility: you see organization-wide and publicly shared documents plus your own private ones, and an organization admin sees every document. Changing or sharing a document is limited to its creator and organization admins. - **CRM write tools and every document tool, reads included** additionally require the organization's active subscription entitlement. Interactive brochure writes remain available as a free Fondaro feature. Access is bound to **live organization membership**, re-checked on every request behind a short role cache (about five minutes). Removing a user from the organization therefore severs their OAuth connections and `fdr_mcp_` keys within that window. Individual grants and keys can also be disconnected or revoked at any time from the dashboard. ## Rate limits - **Per user or key**: 120 requests per 60 seconds. Exceeding it returns a structured rate-limit error with a `Retry-After` header. - **Portal property reads and brochure snapshots**: external property portals share a daily allowance of 300 calls per organization per UTC day. The portal source itself counts one unit per real upstream call (an upstream cache miss), whichever tool or brochure operation triggered it, so a read is never charged twice; shared cache hits and repeated missing-listing results are free. A brochure operation is charged once per portal listing it fetches from the provider. Once reached, uncached portal calls return an informative error until midnight UTC; Fondaro network and Resales Online brochure work stays available. ## Preferred MCP 2026-07-28 request contract Every request is independent. Send all of these on every `POST`: - `Mcp-Protocol-Version: 2026-07-28`, matching the protocol version in `params._meta`. - `Mcp-Method`, matching the JSON-RPC `method`. - `Mcp-Name` when calling a named tool, matching `params.name`. - The `io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientInfo`, and `io.modelcontextprotocol/clientCapabilities` fields in `params._meta`. A missing or mismatched header returns a protocol error. An unsupported protocol version is rejected. `Mcp-Session-Id` and `Last-Event-ID` do not create state and should not be sent. `server/discover` describes the Fondaro server and advertises a tools-only capability set. Fondaro does not advertise prompts, resources, logging, subscriptions, tasks, MCP Apps, or extensions. Successful list and call responses carry `resultType: "complete"` plus the Fondaro server identity in result `_meta`. ## Compatibility path for earlier revisions Clients using `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, or `2024-10-07` may use the legacy `initialize` and headerless JSON-RPC shapes. The SDK negotiates the revision and echoes the selected version. The following compatibility sequence initializes with `2025-11-25` and then lists tools: ```bash # 1. Negotiate the compatibility revision. curl -X POST https://api.fondaro.com/mcp/v1 \ -H "Authorization: Bearer fdr_mcp_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "your-client", "version": "1.0.0" } } }' # 2. List tools with the same bearer credential. curl -X POST https://api.fondaro.com/mcp/v1 \ -H "Authorization: Bearer fdr_mcp_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }' ``` No server-side session is created: no `Mcp-Session-Id` is issued, and each `POST` is independent. A headerless `tools/list` or `tools/call` therefore also works without a preceding `initialize`. The compatibility path exposes the same role- and scope-filtered tool catalogue and the same JSON Schema 2020-12 inputs as the preferred path. ## Calling tools MCP traffic is JSON-RPC over the endpoint itself. List the available tools: ```bash curl -X POST https://api.fondaro.com/mcp/v1 \ -H "Authorization: Bearer fdr_mcp_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Mcp-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/list" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "your-client", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {} } } }' ``` Fondaro returns tools in ascending name order. The list result has a private five-minute cache hint because its contents depend on the current user, scopes, role, entitlement, and connected property providers. Do not share or reuse one user's cached list for another user. Call a tool by name with its arguments: ```bash curl -X POST https://api.fondaro.com/mcp/v1 \ -H "Authorization: Bearer fdr_mcp_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Mcp-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: list_leads" \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "list_leads", "arguments": { "limit": 10 }, "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "your-client", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {} } } }' ``` In practice you will rarely craft these by hand. Your MCP client renders the tools and calls them for you once connected. ## Connecting clients The dashboard has setup flows for the clients below. Use current released versions. Clients that do not yet send MCP 2026-07-28 may negotiate one of the supported earlier revisions automatically. | Client | Setup | Configuration path | | ---------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ChatGPT | OAuth | Add the Fondaro MCP endpoint as a custom app, then sign in to Fondaro and approve access in the browser. No token or API key is copied. | | Codex CLI | API key | Load `FONDARO_MCP_API_KEY` in a terminal, then use `--bearer-token-env-var` as shown below. The shared Codex configuration stores the variable name, not the key. | | Claude Code | OAuth | Add the user-scoped HTTP server without an `Authorization` header, choose Authenticate in `/mcp`, then approve access in the browser. No token or API key is copied. | | Cursor | API key | Add an environment-backed `Authorization` header to a private or global `mcp.json`; launch inheritance is not reliable across every installation. | | Visual Studio Code | API key | Add the endpoint and an environment-backed `Authorization` header to your user-level MCP configuration, then start the editor from the prepared terminal. | | Scripts and other header-capable clients | API key | Send `Authorization: Bearer fdr_mcp_…` to the endpoint. | For Codex CLI, securely load the key in the current terminal session, add the server, list the saved configuration, and start Codex from that same terminal: ```bash codex mcp add fondaro --url https://api.fondaro.com/mcp/v1 \ --bearer-token-env-var FONDARO_MCP_API_KEY codex mcp list ``` Use `/mcp` to inspect the connection. Do not put the key value in `config.toml`. For Claude Code, add the endpoint without `--header` using a current released version: ```bash claude mcp add --transport http --scope user fondaro \ https://api.fondaro.com/mcp/v1 ``` Start Claude Code, open `/mcp`, choose `fondaro`, then choose **Authenticate**. Sign in to Fondaro, select the organization, review the permissions it requests, and choose **Allow**. Claude Code owns and refreshes the resulting OAuth credentials. Cancelling an explicit re-login can leave the server at **Needs authentication**; run `/mcp` and authenticate again. For Cursor, use this only when Cursor can be launched from the terminal where `FONDARO_MCP_API_KEY` was loaded. Keep the configuration private/global rather than in a project: ```json { "mcpServers": { "fondaro": { "url": "https://api.fondaro.com/mcp/v1", "headers": { "Authorization": "Bearer ${env:FONDARO_MCP_API_KEY}" } } } } ``` For ChatGPT, use `https://api.fondaro.com/mcp/v1` as the custom app's MCP server URL. ChatGPT discovers the Fondaro authorization server, opens browser sign-in, and stores and refreshes the resulting OAuth credentials. Disconnect the grant under **Your connections** in Fondaro. The friendly [dashboard guide](/docs/dashboard/fondaro-mcp) includes the ChatGPT and Claude Code browser-sign-in walkthroughs plus hidden-input commands for macOS zsh, Linux bash, and Windows PowerShell, current-session lifetime guidance, recovery for a lost key, and dashboard-backed connection verification. Every credential attached to your account, whatever its type, is listed under **Your connections** on that page, with its owner, scopes, last use, expiry, and status. Each one is revoked independently. ## Managing keys MCP API keys are created and revoked from the dashboard, which calls the key-management REST endpoints below. These endpoints use your normal dashboard session (a Clerk JWT), **not** an `fdr_mcp_` key. | Method | Endpoint | Description | | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ | | `POST` | `/mcp-keys` | Mint a key for yourself. Body: `{ name, scopes?, expiresInDays? }`. Returns `{ id, key, keyPrefix, scopes, expiresAt }`. | | `GET` | `/mcp-keys` | List keys. A member sees their own; an admin sees every key in the organization. Never includes the key value. | | `DELETE` | `/mcp-keys/:id` | Revoke a key. Members revoke their own; admins revoke any key in the organization. | **Request body for `POST /mcp-keys`:** | Field | Type | Required | Description | | --------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | string | Yes | A label to identify the key | | `scopes` | string[] | No | Any of `crm:read`, `crm:write`, `properties:read`, `brochures:read`, `brochures:write`, `documents:read`, `documents:write`. Omit for all seven scopes current at creation time. | | `expiresInDays` | number | No | 1 to 3650. Omit for a non-expiring key. | The `key` field is returned only in the `POST` response and never again. ## Tool catalogue The server exposes 77 tools from seventeen explicitly allowlisted tool classes. CRM reads and writes carry their corresponding CRM scope, property tools carry `properties:read`, and brochure and document tools carry the scopes shown below. CRM writes and every document tool (reads included) require the organization's subscription entitlement; brochure writes do not. The three CRM admin-only tools are hidden from a member's tool list. ### Leads | Tool | Description | Scope | Admin only | | --------------------- | ----------------------------------------------- | ----------- | ---------- | | `list_leads` | List CRM leads with optional filters, paginated | `crm:read` | No | | `search_leads` | Fuzzy-search leads by name, email, or phone | `crm:read` | No | | `get_lead` | Full detail for one lead | `crm:read` | No | | `get_lead_timeline` | A lead's chronological activity timeline | `crm:read` | No | | `get_lead_counts` | Lead counts for each pipeline stage | `crm:read` | No | | `list_tags` | The organization's CRM tag catalogue | `crm:read` | No | | `list_org_members` | The organization's members, for assignment | `crm:read` | No | | `list_org_teams` | The organization's teams and their members | `crm:read` | No | | `create_lead` | Create a new CRM lead | `crm:write` | No | | `update_lead_contact` | Update a lead's name, email, phone, or company | `crm:write` | No | | `update_lead_status` | Move a lead to a different pipeline stage | `crm:write` | No | | `set_lead_tags` | Replace the full tag list on a lead | `crm:write` | No | **Chat channels.** `get_lead` also returns `channels`: the chat identities linked to the lead (WhatsApp, Instagram, LinkedIn or Telegram), each with its handle and whether you have a working connection there. It carries no token or provider id. Read the messages themselves with `get_conversation`. **Company name.** A lead can record the agency or firm it belongs to, which is how you tell collaborators apart. `list_leads`, `search_leads`, `get_lead` and the lead write tools return it as `companyName` (a string, or `null` when none is recorded). `create_lead` accepts an optional `companyName`, and `update_lead_contact` accepts `companyName` as a string to set it or `null` (or an empty string) to clear it; leave the field out to keep the current value. Values are trimmed and capped at 255 characters. The field is accepted on every lead type, though it mostly matters for collaborators. ### Lead assignment | Tool | Description | Scope | Admin only | | ------------------------- | --------------------------------------------------- | ----------- | ---------- | | `set_lead_assignees` | Replace the owners on a lead | `crm:write` | Yes | | `add_lead_assignees` | Add owners to a lead without removing existing ones | `crm:write` | Yes | | `bulk_update_lead_status` | Move many leads to one stage, organization-wide | `crm:write` | Yes | **Assigning a team.** `create_lead`, `set_lead_assignees`, `add_lead_assignees`, `create_task`, `update_task`, `create_deal` and `update_deal` accept an optional `teamIds` (one id or a list of up to 20, from `list_org_teams`) next to their user ids. Each team is replaced by its current members at the moment of the call, combined with the users you name, and duplicates are removed. One call can assign at most 50 people; a call that teams push over that limit fails with an error naming the team, and nothing is written. Someone who joins a team later does not gain records assigned before. Choosing owners through `teamIds` follows the same admin rules as the user ids next to it. See [Teams](/docs/api/teams). ### Inbox and calendar (read-only) | Tool | Description | Scope | Admin only | | -------------- | --------------------------------------------------------------------------------------------- | ---------- | ---------- | | `list_inbox` | Your Inbox: who is waiting on you, in the order the Fondaro page shows it | `crm:read` | No | | `get_calendar` | Your calendar for a window of local dates: tasks, meetings, viewings, open houses, deal closes | `crm:read` | No | | `get_free_times` | When you are free for a meeting or a viewing of a given length between two dates | `crm:read` | No | `list_inbox` returns `{ waitingCount, counts, items, nextCursor }`, one row per lead, with `otherReasons` counting the rest. It takes `view` (`waiting`, `new` or `all`), `channel`, `q` (a name), `cursor` and `limit` (1 to 50, default 30). Members see their own rows; organization admins may also pass `scope` `members` (with `assigneeIds`) or `agency`. Team chat is never included. `get_calendar` takes a `from` date and an exclusive `to` date (at most 42 days) and an IANA time zone `tz`, and returns `{ window, items, truncated }`. A task is an event on a lead, so its `eventId` is the task id. `get_free_times` takes a `from` date, an exclusive `to` date (at most 14 days), a `durationMinutes` (15 to 480), an optional IANA time zone `tz` and an optional `leadId`. It reads your own calendar and returns `{ timeZone, durationMinutes, slots }`, where each slot is a `startsAt` and `endsAt` inside working hours (08:00 to 20:00, Monday to Saturday), earliest first, on the half hour. With a `leadId` it also leaves out times that lead already has something, and you need access to the lead. ### Tasks | Tool | Description | Scope | Admin only | | ------------- | ----------------------------------------- | ----------- | ---------- | | `list_tasks` | List your tasks, or a single lead's tasks | `crm:read` | No | | `create_task` | Create a task on a lead | `crm:write` | No | | `update_task` | Update a task (reassigning is admin-only; ticking needs the owner, a teammate on it, or an admin) | `crm:write` | No | | `delete_task` | Permanently delete a task (the owner, a teammate on it, or an admin) | `crm:write` | No | ### Notes | Tool | Description | Scope | Admin only | | ------------- | ------------------------------------ | ----------- | ---------- | | `list_notes` | List the notes on a lead | `crm:read` | No | | `create_note` | Add a note to a lead | `crm:write` | No | | `update_note` | Edit a note's content | `crm:write` | No | | `delete_note` | Delete a note (author, or any admin) | `crm:write` | No | ### Deals | Tool | Description | Scope | Admin only | | ------------------- | ------------------------------------------------ | ----------- | ---------- | | `list_deals` | List deals for a lead or across the organization | `crm:read` | No | | `get_deal` | Fetch one deal by id | `crm:read` | No | | `create_deal` | Create a deal on a lead | `crm:write` | No | | `update_deal` | Update a deal's fields | `crm:write` | No | | `change_deal_stage` | Move a deal to another pipeline stage | `crm:write` | No | | `close_deal_won` | Mark a deal as won | `crm:write` | No | | `close_deal_lost` | Mark a deal as lost | `crm:write` | No | | `reopen_deal` | Reopen a won or lost deal | `crm:write` | No | **Commission and collection.** `update_deal` also takes the deal's money fields: `commissionAmount`, `commissionPercent` and `commissionMode` (`exact` or `percentage`), `invoiceNumber`, `estimatedCollectionAt` and `collectedAt`. Each accepts `null` to clear it. A deal with an invoice number counts as invoiced and a deal with a collected date counts as collected. **Deal party side.** Each entry in `get_deal`'s `participants` carries `side`: `buyer` or `seller` when the party acts for that side of the transaction (most often a collaborator agency), or `null` when it is not recorded. The deal tools do not edit participants; set the side from the dashboard. ### Calls (read-only) | Tool | Description | Scope | Admin only | | --------------------- | -------------------------------------------------------------------- | ---------- | ---------- | | `get_call_history` | A lead's call history with recording, transcript, and analysis flags | `crm:read` | No | | `get_call_transcript` | The stored transcript for a call | `crm:read` | No | | `get_call_analysis` | The stored AI analysis for a call | `crm:read` | No | ### Messages and email (read-only) | Tool | Description | Scope | Admin only | | ------------------- | ------------------------------------------------------------------------------------ | ---------- | ---------- | | `get_email_history` | A lead's email history | `crm:read` | No | | `get_conversation` | A lead's WhatsApp, Instagram, LinkedIn and Telegram messages, newest first | `crm:read` | No | `get_conversation` takes a `leadId` and an optional `channel`. It returns 20 messages by default (50 at most, `limit`) and pages back with `before`. Each message carries its channel, direction, delivery status, time, text (a voice note's transcript) and file names. Inbound text is customer-written and untrusted. Members read only leads assigned to them; organization admins read any lead. Email is not included: use `get_email_history`. ### Records (read-only) | Tool | Description | Scope | Admin only | | ----------------- | --------------------------------------------------------------------------- | ---------- | ---------- | | `resolve_records` | Summarize up to 20 records of any kind in one call, as they look to you | `crm:read` | No | `resolve_records` takes `refs`, each a `kind` and an `id` (a listing also needs its `source` and optional `country`). The seventeen kinds are `lead`, `listing`, `deal`, `task`, `brochure`, `document`, `call`, `conversation`, `agent`, `organization`, `open_house`, `buyer_request`, `collection`, `email`, `message`, `calendar_event` and `viewing`. Each answer carries a `status` (`ok`, `unavailable`, `not_visible` or `not_connected`) and, when `ok`, a title, subtitle, image, `href`, meta and the kind's facts. A record you may not see, or whose kind your key has no scope for (for example a listing needs `properties:read`), answers `not_visible`. A lead summary never carries contact details and a listing summary never carries a commission. Nothing is ever written. ### Semantic search (read-only) | Tool | Description | Scope | Admin only | | ------------------------ | ---------------------------------------------------------------------------------------------- | ---------- | ---------- | | `search_crm_semantic` | Rank the people whose CRM history matches a plain-language description, with evidence snippets | `crm:read` | No | | `match_leads_to_listing` | Rank the people whose CRM history suggests interest in one property listing | `crm:read` | No | Both tools match meaning rather than exact words, across notes, call transcripts and their stored summaries, human-written emails from the last 24 months, and the assistant's distilled memory of each lead. Automated template sends are never part of the searchable corpus. Neither tool counts or aggregates; use the CRM read tools for totals. An evidence snippet labelled **Memory** comes from the short document Ask Fondaro keeps about that person, not from something they wrote. Memory is per lead only: organization and personal notes-to-self are never searchable this way. Scoping is identical to every other CRM read. A member's search reaches assigned leads and organization-shared collaborator leads; an organization admin's reaches the whole organization. That narrowing is applied in the database, not by the tool, so no argument can widen it. **`search_crm_semantic` arguments:** | Field | Type | Required | Description | | -------------- | ------ | -------- | --------------------------------------------------------------------------------------- | | `query` | string | Yes | What to look for, in natural language. Phrase it the way the person would have said it. | | `occurredFrom` | string | No | ISO date or timestamp. Consider only history at or after this moment. | | `occurredTo` | string | No | ISO date or timestamp. Consider only history at or before this moment. | | `leadId` | number | No | Narrow the search to one person's own history. | | `maxResults` | number | No | 1 to 30. Defaults to 10. | Dates are enforced in SQL and are never inferred from the query text. Occurrence bounds refer to when source activity happened. Resolve relative time only when it describes the intended source-activity window; a future summer visit mentioned in a transcript does not set that transcript’s occurrence date. **`match_leads_to_listing` arguments:** | Field | Type | Required | Description | | ------------------- | ------ | ----------- | ---------------------------------------------------------------------------------------- | | `propertyListingId` | string | Conditional | The id of one of your own property listings. | | `source` | string | Conditional | The property source, as returned by `list_property_sources` or a property search result. | | `listingId` | string | Conditional | The listing id within that source, as returned by a property search result. | | `country` | string | No | Preserve the optional country from the canonical listing ref when using source/listing id. | | `maxResults` | number | No | 1 to 30. Defaults to 10. | Send either `propertyListingId`, or both `source` and `listingId` with the ref’s `country` when present. A raw listing URL is never accepted. The listing's own text becomes the search query at match time and is never stored in Fondaro's search index; the response repeats it as `listingQuery` so a surprising match list can be judged against what was actually searched. ```bash curl -X POST https://api.fondaro.com/mcp/v1 \ -H "Authorization: Bearer fdr_mcp_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Mcp-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: search_crm_semantic" \ -d '{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "search_crm_semantic", "arguments": { "query": "wants a sea view penthouse, visiting this summer", "occurredFrom": "2026-01-01", "maxResults": 5 }, "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "your-client", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {} } } }' ``` The tool result is a JSON text block. `matches` is ordered best first, and `rank` is the only relevance signal the server publishes: there is no score, deliberately, because a percentage invites more confidence than a semantic match earns. `bestMatchAt` is the timestamp of the strongest matching record, not the person's last touch, and each `evidence` entry's `sourceId` is the note, call, or email row it came from. ```json { "matches": [ { "leadId": 4821, "firstName": "Sofia", "lastName": "Andersson", "rank": 1, "matchCount": 4, "sourceCount": 3, "bestMatchAt": "2026-05-14T09:12:00.000Z", "evidence": [ { "sourceType": "call_transcript", "sourceId": "8f1c0d0e-0000-4000-8000-000000000000", "occurredAt": "2026-05-14T09:12:00.000Z", "snippet": "she is set on a penthouse with open sea views, and is flying in during August" }, { "sourceType": "note", "sourceId": "6c2b1a44-0000-4000-8000-000000000000", "occurredAt": "2026-03-02T16:40:00.000Z", "snippet": "top floor only, wants the terrace facing the water" } ] } ], "tookMs": 940, "reranked": true } ``` `sourceType` is one of `note`, `call_transcript`, `call_summary`, `email`, `message`, `lead_memory`, or `lead_brief`. A `lead_brief` entry is the lead brief, shown as "What they want" in dashboard search results and listing matches: a short, dated summary of what the buyer wants, written automatically from the lead's own records. There is at most one per lead; its `sourceId` is the lead ID, its `occurredAt` is the newest record it covers, and its `snippet` is the brief lines that share words with the query, each fact keeping its source kind and date. `match_leads_to_listing` ranks people by their brief first: a brief that matches the listing weighs a little more than a single record, so it can raise a person but never lower one, and a strong record match still sets the rank. In `search_crm_semantic` the brief is one more piece of evidence, and a search with `occurredFrom` or `occurredTo` leaves briefs out, because a brief's date is its newest input, not the date each fact was said. `reranked` is `false` when the reranking model was unavailable and the fused ordering was served instead: retrieval degrades rather than failing, and says so. `match_leads_to_listing` returns the same shape plus the `listingQuery` string. ### Properties (read-only) | Tool | Description | Scope | Admin only | | ------------------------------ | ------------------------------------------------------------------------------ | ----------------- | ---------- | | `list_property_sources` | List every property source the organization can search, and what each supports | `properties:read` | No | | `list_property_search_options` | List exact values for one source and one search facet | `properties:read` | No | | `search_properties` | Search one property source for listings | `properties:read` | No | | `list_viewings` | Read registered viewings with intersected filters | `properties:read` | No | | `get_viewing` | Read one organization-visible registered viewing | `properties:read` | No | | `list_open_houses` | List upcoming open houses on the network, or your agency's own | `properties:read` | No | | `get_open_house` | Read one open house: window, host, commission offered, invitation and listing | `properties:read` | No | | `get_property` | Fetch the full details of one property | `properties:read` | No | | `autocomplete_location` | Resolve a place name to a source-native location token | `properties:read` | No | The property tools span the Fondaro network, the per-organization connected sources (Resales Online, Zoddak, and Inmobalia, when the organization has each integration set up), and 19 public property portals. Use `list_property_sources` when you need to choose a source. Use the bounded `list_property_search_options` tool for one source-specific facet (`property_type`, `feature`, `stage`, `operation`, `status`, or `sort`) instead of loading every source vocabulary at once. On the Fondaro network source, `scope` picks whose stock is searched: `own` (the default, your agency's listings) or `network` (every agency's active listings). Repeat `scope` when you continue or refine a search. `partners` and `openHouseWithinDays` are available in Ask only and are refused here with a typed error. `search_properties` uses a strict source-aware contract. Location selections are the opaque `locationIds` returned by `autocomplete_location`; property types, locations, features, stages, and reference numbers are arrays where the source supports multiple values. Price, bedroom, bathroom, build-size, and plot-size bounds use `min…`/`max…` fields. `operation`, `newDevelopments`, `sort`, supported `geo`, an opaque continuation `cursor`, and a `limit` from 1–20 are also available where advertised. Older singular fields such as `locationId`, `propertyType`, `page`, `minSize`, and top-level latitude/longitude are rejected by schema validation. A successful search includes a compact receipt with the requested criteria, resolved source-native values, applied facets, redacted wire parameter names, verification status, excluded upstream mismatches, and an opaque next cursor when more results exist. Unsupported facets, invalid or ambiguous option values, invalid/stale locations, missing required locations, disconnected sources, exhausted portal allowance, upstream mismatch, and upstream failure return distinct machine-readable errors. Fondaro never returns a successful result that silently ignored an explicit criterion. Fondaro MCP searches and location discovery retain their own-organization inventory scope. Search defaults to active listings unless an explicit supported status is supplied. The separate [Property Sources REST API](/docs/api/properties/sources) supports explicit own/network scope and defaults to the active network. New search and detail results include a canonical `ref` with `source`, `id` and optional `country`. Keep that identity intact: Fondaro ids are UUIDs, and the same portal id in two countries names two listings. Own Fondaro human references remain accepted by detail tools. Option discovery defaults to twelve results and returns an opaque continuation when needed. After `PROPERTY_CURSOR_INVALID`, repeat the original search or option query without its expired or incompatible cursor. Detail tools return compact records with at most six images, 1,000 description characters and 40 features. Development ranges retain their upper bounds, so a development's starting price must not be treated as the price of every unit. ### Collections and buyer requests (read-only) | Tool | Description | Scope | Admin only | | ---------------------- | ---------------------------------------------------------------------------------------- | ----------------- | ---------- | | `list_collections` | The listing collections you are in, most recently changed first | `properties:read` | No | | `get_collection` | One collection with its homes, each as a property summary row with an `itemId` | `properties:read` | No | | `list_buyer_requests` | Buyer requests on the agent network: your agency's own, or the ones a listing fits | `properties:read` | No | These read what the agent network pages read, as you: a person with no agency, or a suspended network profile, gets the same refusal as the dashboard. `list_collections` returns `{ items: [{ id, name, homeCount, updatedAt }] }` and `get_collection` takes the collection `id`. Another agency's home shows only while it is active and never with its private commission; a home you can no longer open comes back with `unavailable: true` rather than disappearing. `list_buyer_requests` returns `{ items: [{ id, summary, createdAt }] }`; a request is what a buyer is looking for (place, type, bedrooms, price), never a buyer's name or contact. Without `listingId` it returns your agency's own requests in every status; with `listingId` (one of your own Fondaro MLS listings) it returns the open requests from other agencies that home fits. Adding to a collection and posting a request are done in Ask Fondaro or on the dashboard. ### Interactive brochures | Tool | Description | Scope | Admin only | | --------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------- | ---------- | | `list_brochures` | List visible brochures with explicit pagination, status, share URL, listing count, and view statistics | `brochures:read` | No | | `get_brochure` | Read one brochure and compact listing summaries | `brochures:read` | No | | `create_brochure` | Create a shareable brochure from 1–25 property identities, with optional public location visibility | `brochures:write` + `properties:read` | No | | `update_brochure` | Update title, recipient fields, theme, accent, or public location visibility | `brochures:write` | No | | `add_brochure_listing` | Add one source property, optionally at a position | `brochures:write` + `properties:read` | No | | `remove_brochure_listing` | Remove one brochure listing | `brochures:write` | No | | `reorder_brochure_listings` | Replace the complete brochure listing order | `brochures:write` | No | | `refresh_brochure_listing` | Refresh one source snapshot while preserving overrides | `brochures:write` + `properties:read` | No | | `set_brochure_listing_location` | Correct one snapshot's address or coordinates | `brochures:write` | No | | `update_brochure_listing_details` | Set or clear per-brochure detail overrides and hide flags | `brochures:write` | No | | `revoke_brochure` | Disable the share URL reversibly | `brochures:write` | No | | `renew_brochure` | Restore the share URL, optionally refreshing listings | `brochures:write` + `properties:read` | No | | `delete_brochure` | Permanently delete a brochure | `brochures:write` | No | Start with `search_properties` or `get_property`, then pass each returned `source` and exact source-native `id` unchanged. Brochure creation accepts `internal`, `resales_online`, `zoddak`, `inmobalia` (both need the connection on your organization), and every public portal source: `idealista`, `rightmove`, `immobiliare`, `immoscout`, `funda`, `zoopla`, `seloger`, `otodom`, `immowelt`, `realtor`, `homes`, `trulia`, `propertyfinder`, `dubizzle`, `centris`, `loopnet`, `zillow`, `redfin`, and `bayut`. `resales_viewer`, arbitrary URLs, and normalized or guessed IDs are not accepted for MCP creation. An existing dashboard brochure containing a `resales_viewer` listing remains readable and manageable. For Idealista, preserve the optional `country` value (`es`, `it`, or `pt`) alongside the bare property id. The returned `publicUrl` works immediately; background image ingest and coordinate enrichment may still be running. Listing edits use the brochure-listing UUID returned by `create_brochure` or `get_brochure`, not the source property id. Reorder always sends the complete current UUID set. `create_brochure` also accepts `leadIds`, the CRM lead ids to connect the brochure to so it shows on each lead page. Every lead must be visible to you; omit it for a brochure with no lead. `create_brochure` accepts an optional `showLocations` boolean. An explicit value overrides the organization's starting value; omitted input inherits it. `update_brochure` can change the value later, independently from a locked branding preset. When false, the public viewer omits structured locations, maps, nearby places, and location-derived copy, and the public payload redacts address and coordinate fields to `null`; authenticated brochure data remains available for editing. Brochure list, get, create, and update results report the effective `showLocations` value. This JSON-RPC example creates and shares an Andrew brochure from five Idealista results: ```bash curl -X POST https://api.fondaro.com/mcp/v1 \ -H "Authorization: Bearer fdr_mcp_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Mcp-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: create_brochure" \ -d '{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "create_brochure", "arguments": { "title": "Estepona homes for Andrew", "recipientName": "Andrew", "recipientNote": "Five homes selected for your review.", "showLocations": false, "listings": [ {"source":"idealista","id":"ID_FROM_SEARCH_1","country":"es"}, {"source":"idealista","id":"ID_FROM_SEARCH_2","country":"es"}, {"source":"idealista","id":"ID_FROM_SEARCH_3","country":"es"}, {"source":"idealista","id":"ID_FROM_SEARCH_4","country":"es"}, {"source":"idealista","id":"ID_FROM_SEARCH_5","country":"es"} ] }, "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "your-client", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {} } } }' ``` Take `publicUrl` from the successful tool result and share that HTTPS link. It stops resolving after revocation or expiry and resumes at the same URL after renewal. ### Documents | Tool | Description | Scope | Admin only | | --------------------------- | ------------------------------------------------------------------------------------------ | ----------------- | ---------- | | `list_documents` | List library documents, most recently changed first, with the live share URL on each | `documents:read` | No | | `get_document` | Read one document including its markdown body | `documents:read` | No | | `list_document_folders` | List the library's folders with how many items you can see directly inside each | `documents:read` | No | | `list_lead_documents` | List the documents attached to one lead, with who attached each one and when | `documents:read` | No | | `create_markdown_document` | Create a markdown document in the library | `documents:write` | No | | `update_document` | Replace a markdown document's title, body, or both | `documents:write` | No | | `share_document` | Publish the document behind a public link and return that URL | `documents:write` | No | | `revoke_document_share` | Take the public link down | `documents:write` | No | | `attach_document_to_lead` | Record that a document belongs with a lead | `documents:write` | No | | `detach_document_from_lead` | Remove that link between a document and a lead | `documents:write` | No | Uploading a PDF and deleting a document are deliberately not exposed. An MCP request has no file transport, and deletion stays a dashboard action. All ten document tools are hidden from an organization without an active subscription, the four reads included. Documents is gated fully to active plans, unlike the CRM, where reads stay available. `list_documents` accepts optional `kind` (`markdown` or `pdf`), `visibility` (`private`, `organization`, or `public`), a free-text `q` on the title, `folderId` (a folder id from `list_document_folders`, or `root` for the top level), `scope` (`shared` for a colleague's documents you can see, `pinned` for the sidebar pins), `mine` (only your own), `sort` (`updated`, `title` or `size`), and `limit` (1 to 50, default 20) with `offset`. A `visibility` filter only narrows what you can already see; it never widens it. Each result carries `shareUrl`, non-null only while the link is genuinely live, so a client that just listed the library can put a link in an email without a second call. `get_document` returns the same fields plus `body`, `attachedLeadIds`, and `attachedListingIds`. A body longer than 8000 characters comes back trimmed with `truncated: true` rather than failing. Only a markdown document has a body; an uploaded PDF returns `null` and is read through its share link. `update_document` replaces the whole body, so send everything that should survive. Only the document's creator or an organization admin can change, share, or revoke a document. `share_document` takes an optional `expiresAt` (an ISO-8601 instant in the future). Omit it for a link that never expires, which is the default. Re-sharing a document whose link is still live returns the same URL, so a link already sent in an email keeps working. `revoke_document_share` kills the link permanently: anyone holding the URL stops being able to open it, and sharing the document again later mints a **different** URL. The document itself stays in the library at organization visibility. ```bash curl -X POST https://api.fondaro.com/mcp/v1 \ -H "Authorization: Bearer fdr_mcp_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Mcp-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: share_document" \ -d '{ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "share_document", "arguments": { "documentId": "DOCUMENT_ID_FROM_LIST" }, "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "your-client", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {} } } }' ``` Take `shareUrl` from the successful result and send that HTTPS link. It resolves at `/d/…` until it is revoked or its optional expiry passes. `attach_document_to_lead` and `detach_document_from_lead` need access to both the document and the lead. Attaching twice is harmless and keeps the first receipt; detaching something already detached is a clean no-op. An attachment is not a grant: a private document someone else attached stays invisible to you. ## Security best practices - Treat an `fdr_mcp_` key like a password. Store it in a secrets manager, never in source control. - Never copy an OAuth access or refresh token. ChatGPT or Claude Code owns those credentials, and Fondaro exposes only the revocable connected-app grant. - Grant each key only the scopes its client needs. A read-only assistant does not need `crm:write`. - A client that only reads brochures needs `brochures:read`; a client that builds them normally needs `brochures:write` plus `properties:read`, and usually `brochures:read` for follow-up list/get calls. - Use an expiry for time-limited integrations, and revoke keys you no longer use. - Rotate a key by minting a new one, updating the client, then revoking the old one. Multiple live keys plus revoke make this zero-downtime. - Keep the key value out of `config.toml`, `mcp.json`, source control, logs, screenshots, and shell history. Prefer environment-variable references supported by the client. - Treat a document `shareUrl` like a bearer link, exactly as you would a brochure URL. Anyone with it can open the document until it is revoked or expires. Revoking is instant and permanent for that URL; re-sharing mints a new one, so links already sent stay dead. - Treat a brochure `publicUrl` like a bearer link. Anyone with it can view the brochure until it expires or is revoked. Recipient names and notes, plus the share URL returned by a tool, enter the selected MCP client's conversation history; do not send sensitive personal data to a client whose retention policy you have not accepted. ## Recorded calling user Call summaries expose `ownerId: string | null`, the recorded internal calling user. Outbound calls use the rep recorded when the call was placed; inbound calls retain an internal owner when one was recorded. Empty or system ownership is `null`. A shared phone number and the lead's current assignee do not identify who called. Historical IDs remain even if the user has left your team. **Call ownership versus lead assignment:** `ownerId` identifies the recorded internal user for that call. A lead's `assigneeIds` identifies its current assignment set; the singular `assigneeId` in lead-list filters selects leads assigned to that user. Those fields describe different relationships. If Alice calls a lead and it is later reassigned to Bob, the call retains Alice's `ownerId`, while the lead's `assigneeIds` reflects Bob. Label `ownerId` as **Recorded calling user** in reports. For per-person reporting, use Lead Get Activities with `types=call`, then join `call.ownerId` to User Get or a cached User Get Many result. The integration key needs `leads:read` for activities and `users:read` for user labels. Keep an unresolved ID when user lookup fails. Both `call.logged` and `call.analyzed` webhooks include the same `ownerId`. Existing Get Activities workflows receive this additive field after the API update. ## Reporting pages and bounded content `get_lead_timeline` keeps small nested task/deal records and body-bearing call/email records. Oversized records retain their event identity, recorded owner and state, with `_preview.complete: false` and `omittedFields` identifying shortened content. Use the public stored transcript/analysis and email detail tools for detail; an oversized detail returns an explicitly marked preview instead of claiming complete text. Recording credentials, media URLs and processing payloads are excluded from public call summaries. Timeline bodies are bounded in SQL: fields up to 1,200 UTF-8 bytes remain intact, larger text becomes a 200-character preview and larger JSON is omitted with nested `_preview` metadata. The JSON response budget is 10,000 UTF-8 bytes. Timeline pages select a contiguous prefix that fits, keeping only referenced `tasksById` and `dealsById` sidecars. `limit` is an upper bound. Continue with `nextOffset`, or add the returned entry count to your current offset; adding the requested limit can skip entries. `hasMore` includes entries deferred by the response budget. `totalIsExact: false` declares a lower-bound timeline count. Use call/activity source reads for reporting, never the general timeline total for KPI counts. `get_lead_timeline` viewing entries appear on the client's timeline and on the timeline of the collaborator who sent the client. Each carries `role` (`client` or `collaborator`), the client `leadId`, `collaboratorLeadId`, `dealId`, `createdBy`, and name labels in `collaborator` and, on collaborator entries, `client`. Under the response budget `property` keeps only `id`, `referenceNumber`, `city` and `propertyType`, and linked deals are not added to `dealsById`; use `get_viewing`, `get_property` and `get_deal` for the rest. `get_call_history({leadId})` preserves a small complete array. Supply `limit` or `offset` for `{calls,total,hasMore,nextOffset}` (20 default, 100 maximum). An oversized unpaged history returns an error with `CALL_HISTORY_PAGINATION_REQUIRED`; it does not emit an incomplete success array. Equal timestamps have deterministic source ordering. Offset traversal is stable for unchanged history; concurrent inserts can shift offsets, so this is not a multi-request snapshot. Other public collections and ranked searches fail transparently if they exceed the budget; they do not silently remove selected rows. Request fewer results or narrower filters. Details can return an explicitly marked JSON preview. ## Viewing reads `list_viewings` and `get_viewing` require `properties:read` and read registered own-listing viewings. List filters include `propertyListingId`, `leadId`, `collaboratorLeadId`, `agentUserId`, `dealId`, `status`, `kind`, `from`, `to`, `limit`, and `offset`. Time bounds refer to `viewingAt`: `from` is inclusive, `to` exclusive. Pages use `{viewings,total,hasMore,nextOffset}` with exact totals, ordered by viewing time then ID descending. List notes are previews with `notesTruncated`; unusually large detail notes are also explicitly shortened. Viewings are organization-visible. An explicit lead/collaborator filter still requires access to that linked CRM lead. These reads expose relationship identifiers without expanding contact details. `agentUserId` is the hosting agent; `createdBy` is the registering user. Resolve their labels with existing team reads. ## Open house reads `list_open_houses` and `get_open_house` require `properties:read` and are read-only. `list_open_houses` takes `scope` (`network`, the default: upcoming published open houses other agencies opened to the network, nearest first; or `mine`: your agency's own in any status), `from`, `to`, `area`, `source`, the one-listing filter `listingSource` + `listingId` (+ `listingCountry` when the listing's reference has one) and a `limit` from 1 to 25 (default 10), and returns `{items,hasMore}`. `get_open_house` takes the open house `id`. Both carry the commission offered to the introducing agency, never the listing owner's private commission. `get_open_house` includes the disclosed address and the listing's street address, as for any active network listing, and `hostUserId`, the member to message about it. See [Open houses](/docs/api/open-houses) for the HTTP routes and the public invitation page. ## Semantic search limits `search_crm_semantic` accepts trimmed nonempty queries up to 500 characters, positive integer `leadId`, ordered ISO occurrence-date bounds, and 1–30 results (10 default). `match_leads_to_listing` accepts exactly one own-listing UUID or source/listing identity pair. It resolves source-qualified listings through the unified Property Sources service, preserving the optional country identity. Both return the complete `{matches,tookMs,reranked}` envelope or a transparent size error. `reranked: false` means reranking degraded; embedding failures are errors. Members can discover assigned leads and organization-shared collaborators. This asynchronously indexed corpus includes eligible notes, call transcript chunks/summaries, human-written emails and lead memory over a 24-month ingestion window. An empty response means no relevant indexed history, not that no such lead exists. Evidence/source counts are matching indexed evidence, not total calls or viewings. Occurrence filters describe when the source activity happened. A transcript mentioning next summer does not make its source occurrence date next summer. --- # Open Houses Source: https://www.fondaro.com/docs/api/open-houses > Read open houses agencies open to the network, RSVP to them, and the keyless invitation page for agents without an account. ## Overview An open house is an agency opening one of its listings to other agents for a time window. It states what the introducing agency earns, and other agents say they are coming. Open houses are free for every organization, host and attendee. The listing behind an open house can come from any connected source (the Fondaro network, Resales Online and the rest). Fondaro reads it live through the same [property sources](/docs/api/properties/sources) as everything else, so the price and photos you see are the listing's current ones. The commission on the event is fixed when the host publishes it, so a later change to the listing cannot change a promise already made. What each reader sees: | Reader | Street address | Commission offered | Listing owner's private commission | |---|---|---|---| | A signed-in agent (`/network/open-houses`) | Yes: the event's disclosed address and the listing's own address and map position, as for any active network listing | Yes | Never | | Anyone with the link (`/public/open-houses/:slug`) | Only after they confirm their RSVP by email | Yes | Never | ## Endpoints | Method & path | Auth | Purpose | |---|---|---| | `GET /network/open-houses` | Dashboard session | Upcoming open houses on the network, or your agency's own | | `GET /network/open-houses/:id` | Dashboard session | One open house | | `GET /network/open-houses/by-slug/:slug` | Dashboard session | The same open house, by the slug in its invitation link | | `GET /network/open-houses/:id/rsvps` | Dashboard session | Host only: who is coming, by agency | | `POST /network/open-houses/:id/rsvp` | Dashboard session | Say you are going, or that you are not | | `GET /public/open-houses/:slug` | None | The invitation page data | | `POST /public/open-houses/:slug/rsvp` | None | Ask to RSVP; sends a confirmation email | | `POST /public/open-houses/:slug/rsvp/confirm` | None | Confirm the RSVP from the emailed link | Hosting (create, edit, publish, cancel, and the host's list of who is coming) uses the same `/network/open-houses` routes from the dashboard. Only your agency's admin or the listing's own agent can manage an open house. The `/network` routes use the dashboard's Clerk bearer token and your active organization. They do not accept a property API key. On the [MCP server](/docs/api/mcp), the same reads are the `list_open_houses` and `get_open_house` tools under the `properties:read` scope. ## List open houses ``` GET /network/open-houses ``` | Query parameter | Type | Default | Description | |---|---|---|---| | `scope` | `network` \| `mine` | `network` | `network`: published events other agents can see, nearest first. `mine`: your agency's events in every status | | `from` | ISO date-time | now | Events that end after this moment | | `to` | ISO date-time | none | Events that start before this moment | | `area` | string | none | Matches the listing's city, area or community. Case and accents do not matter | | `placeId` | UUID | none | A place from `GET /places`: only events whose listing is in that place or inside it (a town and its neighbourhoods, a province and its towns). Combines with `area`. An unknown id returns `400 OPEN_HOUSE_FILTER_INVALID` | | `source` | source id | none | Only events on listings from this source, for example `internal` or `resales_online` | | `listingSource` | source id | none | With `listingId`: the open houses of exactly one listing | | `listingId` | string | none | The listing's id in its source. Needs `listingSource` | | `listingCountry` | string | none | The listing's country, when its reference carries one (portal listings). A listing without one only matches events without one | | `limit` | 1 to 100 | `50` | Page size | To ask whether one listing has an open house, pass its reference exactly as a property search or detail returned it: ```bash curl "https://api.fondaro.com/network/open-houses?listingSource=internal&listingId=a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" \ -H "Authorization: Bearer $TOKEN" ``` `listingId` without `listingSource`, or a `source` that names a different source than `listingSource`, answers `400 OPEN_HOUSE_FILTER_INVALID`. ```bash curl "https://api.fondaro.com/network/open-houses?area=nueva%20andalucia&limit=10" \ -H "Authorization: Bearer $TOKEN" ``` ```json { "items": [ { "id": "0b4d6c1e-8a53-4c2e-9f61-2b1f0e7d9a10", "slug": "q3H8vZ0bRk2mXw4t", "status": "published", "visibility": "network", "listingRef": { "source": "internal", "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" }, "startsAt": "2026-09-29T09:00:00.000Z", "endsAt": "2026-09-29T12:00:00.000Z", "timezone": "Europe/Madrid", "disclosedAddress": "Calle Ejemplo 7, Los Granados Golf", "mapUrl": "https://maps.example.com/?q=36.49,-4.98", "commissionOffer": 4, "commissionNotes": "+ VAT, payable on invoice", "invitation": { "headline": "Garden apartment on the golf", "body": "Come and see it before it goes to the portals.", "points": ["South facing", "Community fees 1,800 EUR a year"] }, "contactName": "Lucia Moreno", "contactPhone": "+34 600 000 002", "host": { "organizationId": "…", "name": "Example Realty", "logoUrl": "https://…" }, "hostUserId": "user_2abc…", "listing": { "ref": { "source": "internal", "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" }, "source": "internal", "title": "Garden apartment", "price": 650000, "currency": "EUR", "bedrooms": 3, "city": "Marbella", "area": "Nueva Andalucía", "address": { "line1": "Calle Ejemplo 7", "postalCode": "29660" }, "latitude": 36.4912, "longitude": -4.9876, "agent": { "firstName": "Lucia", "lastName": "Moreno" } }, "goingCount": 4, "myRsvp": null, "isHost": false, "createdAt": "2026-09-24T08:00:00.000Z", "updatedAt": "2026-09-24T08:05:00.000Z" } ], "hasMore": false } ``` `listing` is `null` when the listing can no longer be read or is no longer on the market; the event's own fields still show. `hostUserId` is who to message: start a conversation with `POST /chat/dms` and `recipientUserId` set to it. `hostHandle` is the host agent's network handle (`/network/agents/{handle}`), or `null` when their profile is not open to you; `host.networkSlug` is the host agency's page (`/network/agencies/{slug}`), or `null`. `commissionOffer` is a percentage for the introducing agency. ## Get one open house ``` GET /network/open-houses/:id ``` ```bash curl https://api.fondaro.com/network/open-houses/0b4d6c1e-8a53-4c2e-9f61-2b1f0e7d9a10 \ -H "Authorization: Bearer $TOKEN" ``` Returns one object in the shape above. Another agency's open house is readable once it is published, including one shared by link only. A draft is visible to the host alone; anything else reads as `404`. ### By its invitation link ``` GET /network/open-houses/by-slug/:slug ``` ```bash curl https://api.fondaro.com/network/open-houses/by-slug/q3H8vZ0bRk2mXw4t \ -H "Authorization: Bearer $TOKEN" ``` The invitation link `/open-houses/:slug` carries only the slug. This answers with the same object, the same rules and the same `404`s as the read by id, so an app that opens the link while signed in can show the full event, including one shared by link only. ## Who is coming (host only) ``` GET /network/open-houses/:id/rsvps ``` ```json [ { "id": "5e0c…", "status": "going", "kind": "network", "attendeeUserId": "user_2xyz…", "attendeeOrganizationId": "…", "agencyName": "Costa Homes", "attendeeName": "Ana García", "attendeeImageUrl": "https://img.clerk.com/…", "guestName": null, "guestEmail": null, "createdAt": "2026-09-25T10:00:00.000Z", "updatedAt": "2026-09-25T10:00:00.000Z" }, { "id": "7a91…", "status": "going", "kind": "guest", "attendeeUserId": null, "attendeeOrganizationId": null, "agencyName": "Guest Agency", "attendeeName": null, "attendeeImageUrl": null, "guestName": "Pat Guest", "guestEmail": "pat@example.com", "createdAt": "2026-09-25T11:00:00.000Z", "updatedAt": "2026-09-25T11:00:00.000Z" } ] ``` An agent on Fondaro carries their name and photo as another agency sees them: never their sign-in email. A guest is named by what they typed when they confirmed. Only your agency's admin or the listing's agent can read this list. ## RSVP as a signed-in agent ``` POST /network/open-houses/:id/rsvp ``` ```bash curl -X POST https://api.fondaro.com/network/open-houses/0b4d6c1e-8a53-4c2e-9f61-2b1f0e7d9a10/rsvp \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "going" }' ``` You have one RSVP per open house. Send `{ "status": "declined" }` to take it back and `going` again to change your mind. Returns the open house with your `myRsvp`. Your own agency's events answer `400 OPEN_HOUSE_OWN_EVENT`; a cancelled or finished one answers `409 OPEN_HOUSE_RSVP_CLOSED`. ## The public invitation ``` GET /public/open-houses/:slug ``` No authentication. The slug is the link the host shares; open houses are never listed publicly and the response carries `X-Robots-Tag: noindex, nofollow`. It is cached for up to five minutes. ```bash curl https://api.fondaro.com/public/open-houses/q3H8vZ0bRk2mXw4t ``` ```json { "slug": "q3H8vZ0bRk2mXw4t", "status": "published", "startsAt": "2026-09-29T09:00:00.000Z", "endsAt": "2026-09-29T12:00:00.000Z", "timezone": "Europe/Madrid", "commissionOffer": 4, "commissionNotes": "+ VAT, payable on invoice", "invitation": { "body": "Come and see it before it goes to the portals." }, "contactName": "Lucia Moreno", "contactPhone": "+34 600 000 002", "host": { "name": "Example Realty", "logoUrl": "https://…" }, "listing": { "ref": { "source": "internal", "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" }, "title": "Garden apartment", "price": 650000, "city": "Marbella", "area": "Nueva Andalucía" }, "goingCount": 4, "rsvpOpen": true } ``` There is no street address, postcode, map link or map position here. They appear only in the confirmation response below. ### RSVP without an account ``` POST /public/open-houses/:slug/rsvp ``` ```bash curl -X POST https://api.fondaro.com/public/open-houses/q3H8vZ0bRk2mXw4t/rsvp \ -H "Content-Type: application/json" \ -d '{ "name": "Ana Example", "agency": "Example Homes", "email": "ana@example.com" }' ``` Answers `202 { "status": "pending_confirmation" }` and emails a confirmation link to the address given. Nothing is saved and the host sees nothing until the link is confirmed. The answer is the same whether or not that email has answered before. The form's hidden `website` field must stay empty. The route allows 5 requests a minute per network address. ### Confirm the RSVP ``` POST /public/open-houses/:slug/rsvp/confirm ``` The email links to the invitation page on fondaro.com, which sends the token here. It is a `POST` so an email scanner opening the link cannot confirm on someone's behalf. The link is valid for 48 hours and can be opened again. ```bash curl -X POST https://api.fondaro.com/public/open-houses/q3H8vZ0bRk2mXw4t/rsvp/confirm \ -H "Content-Type: application/json" \ -d '{ "token": "v2:…" }' ``` ```json { "status": "confirmed", "openHouse": { "slug": "q3H8vZ0bRk2mXw4t", "goingCount": 5, "rsvpOpen": true }, "disclosedAddress": "Calle Ejemplo 7, Los Granados Golf", "mapUrl": "https://maps.example.com/?q=36.49,-4.98", "listingAddress": { "line1": "Calle Ejemplo 7", "postalCode": "29660" }, "latitude": 36.4912, "longitude": -4.9876 } ``` `openHouse` is the full public object from above (shortened here). A link that is not valid answers `400 OPEN_HOUSE_RSVP_TOKEN_INVALID`. ## Errors Errors carry a `code`: | Status | Code | Meaning | |---|---|---| | `404` | `OPEN_HOUSE_NOT_FOUND` | No such open house, or it is not visible to you | | `400` | `OPEN_HOUSE_FILTER_INVALID` | The one-listing filter is incomplete or contradicts `source` | | `403` | `OPEN_HOUSE_HOST_ONLY` | Only the host agency's admin or the listing's agent can do this | | `400` | `OPEN_HOUSE_OWN_EVENT` | Your agency hosts this open house | | `409` | `OPEN_HOUSE_RSVP_CLOSED` | The open house is cancelled or has ended | | `400` | `OPEN_HOUSE_RSVP_TOKEN_INVALID` | The confirmation link is not valid or has expired | | `503` | `OPEN_HOUSE_EMAIL_FAILED` | The confirmation email could not be sent; try again | ## Notifications Signed-in agents with the mobile app get a push for the open houses they are part of. Each push carries `{ "type": "open_house.<event>", "route": "open_house", "openHouseId": "…" }`: | Type | Who gets it | |---|---| | `open_house.rsvp_created` | The member who created the event, when someone says they are coming | | `open_house.updated` | Everyone going, when the time, the address or the commission changes | | `open_house.cancelled` | Everyone going, when the event is cancelled | Each type is also a notification preference key (`PUT /notifications/preferences` with `types`); all three are on by default. --- # API Overview Source: https://www.fondaro.com/docs/api/overview > Introduction to the Fondaro API: base URL, authentication, scopes, OpenAPI, response format, errors, pagination and rate limits. The Fondaro API gives you programmatic access to property listings, agents, and related resources. Use it to integrate Fondaro data into your own applications, websites, and workflows. Use [Property Sources](/docs/api/properties/sources) for unified discovery, search, detail, locations and options across connected sources. Its canonical ids include `internal` (the Fondaro network), `resales_online`, `zoddak`, `inmobalia` and nineteen portal ids listed on that page. Existing `/properties/*` routes continue to expose the Fondaro listing and management contracts. ## Base URL All API requests are made to: ``` https://api.fondaro.com ``` ## Authentication Every request must include an API key in one of these headers: ``` X-API-Key: fondaro_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` or: ``` Authorization: ApiKey fondaro_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Do not send a property API key as a Bearer token: `Authorization: Bearer ...` is reserved for dashboard sessions and an API key sent that way returns `401`. See [Authentication](/docs/api/authentication) for creating, rotating and revoking keys and for calling the API from a browser. ## Scopes A key can do only what its scopes allow; a missing scope returns `403` with `code: "API_KEY_SCOPE_MISSING"` and the scope it needs. | Scope | Routes | |-------|--------| | `properties:read` | `/properties` reads: search, detail, similar, counts, aggregations, themes, portals | | `properties:write` | `POST`, `PUT` and `DELETE` on `/properties`, renew, portal publications and `POST /properties/description/generate` | | `media:write` | `POST /properties/media/upload` | | `agents:read` | Reserved: `/agents` accepts dashboard sessions only today | | `sources:read` | Every `/property-sources` route | ## OpenAPI The property API describes itself in OpenAPI 3: [`https://api.fondaro.com/openapi/properties.json`](https://api.fondaro.com/openapi/properties.json) (public, any origin). Every field has a description and an example, every operation names the scope it needs (`x-required-scopes`), and enum values list their meaning. Point your code generator, API client or AI assistant at it. ## Response Format All successful responses return JSON. A typical list response includes the data and a total count: ```json { "results": [ ... ], "total": 142 } ``` Single-resource responses return the object directly: ```json { "id": "a1b2c3d4-...", "referenceNumber": "FDR-A2B3C", "listingType": "sale", "propertyType": "villa", ... } ``` ## Error Format Errors return an appropriate HTTP status code with a JSON body: ```json { "statusCode": 404, "message": "Property with ID a1b2c3d4-... not found", "error": "Not Found" } ``` ### Common Status Codes | Code | Meaning | |------|---------| | `200` | Success | | `201` | Resource created | | `400` | Bad request: invalid parameters or validation failure | | `401` | Unauthorized: missing or invalid API key | | `403` | Forbidden: insufficient permissions | | `404` | Resource not found | | `429` | Rate limit exceeded | ### Validation Errors When request body validation fails, the response includes details about which fields are invalid: ```json { "statusCode": 400, "message": [ "Country code must be exactly 2 characters (ISO 3166-1 alpha-2)", "Currency must be 3 uppercase letters (e.g., EUR, USD, GBP)" ], "error": "Bad Request" } ``` ## Pagination The `/properties/search` endpoint uses page-based pagination with two parameters: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | integer | `1` | Page number (1-indexed) | | `limit` | integer | `20` | Items per page (1–100) | The response includes a `total` field so you can calculate the number of pages: ```bash curl -X POST https://api.fondaro.com/properties/search \ -H "X-API-Key: fondaro_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"page": 2, "limit": 10}' ``` ```json { "results": [ ... ], "total": 142 } ``` Total pages = `Math.ceil(total / limit)`. For large result sets, send `"cursor": "*"` instead of `page`: the response carries `nextCursor` while more rows exist (send it back with the same criteria) and a `total` on the first page, counted up to 10,000 (`totalIsLowerBound`). See [Search Properties](/docs/api/properties/search). Unified `/property-sources/:source/search` uses an opaque `nextCursor`, a page size of at most 20 and an optional total. Continue with the returned cursor; reset it whenever organization, source or search criteria change. ## Rate Limits Property API-key requests use these limits, tracked per verified key and endpoint: | Window | Max Requests | |--------|-------------| | 1 minute | 120 | | 10 minutes | 600 | | 1 hour | 3,000 | Requests using the same key share its allowance across IP addresses. Different keys have separate allowances, even when they share an IP address. Requests without a verified identity use the source-IP allowance. Fondaro can raise the per-minute limit of one key (for a busy website); its 10-minute and hourly windows scale in proportion, so a key raised to 600 a minute gets 3,000 per 10 minutes and 15,000 an hour. Ask support if a site needs it. When you exceed a limit, the API returns `429 Too Many Requests`. Wait for the number of seconds in `Retry-After` before retrying. An endpoint's explicit rate limit takes precedence over these defaults. JWT-authenticated dashboard requests are tracked per user. Ordinary authenticated `GET` requests instead use a burst limit of 120 requests per 10 seconds and do not use the 10-minute or 1-hour accumulated quotas; explicit route-specific limits still take precedence. ## Dual Authentication Property and property-source endpoints accept both dashboard sessions (JWT) and API keys, so the same endpoints power the dashboard and your integrations. Scopes apply to API keys only. For CRM automation with scoped integration credentials, see [Integration reporting and search](/docs/api/integrations). These keys use a separate authentication plane from the property API key above. To assign a whole team to leads, deals, tasks and documents, or to manage your organization's teams, see [Teams](/docs/api/teams). --- # Property Activity & Viewings Source: https://www.fondaro.com/docs/api/properties/activity > Register and manage own-listing viewings, name an agent from another agency on them, and read the merged property activity feed. The property-activity API manages viewings and returns a merged chronological feed for a Fondaro listing owned by the caller's organization. These routes require a **Clerk session JWT and active organization**. They do not accept a Fondaro property API key. ```http Authorization: Bearer CLERK_SESSION_JWT x-organization-id: ORGANIZATION_ID ``` All listing and viewing identifiers are UUIDs. A listing outside the active organization is returned as `404`, the same as a missing listing. ## Route catalogue | Method | Route | Purpose | |--------|-------|---------| | `GET` | `/properties/:id/viewings` | List registered viewings for one own listing | | `POST` | `/properties/:id/viewings` | Register a viewing | | `PATCH` | `/viewings/:viewingId` | Change a viewing | | `DELETE` | `/viewings/:viewingId` | Delete a viewing | | `GET` | `/properties/:id/activity` | Read viewings, listing changes, deal events, and brochure links as one feed | | `GET` | `/network/viewings` | Viewings another agency named you on as the network collaborator (read only) | Any member of the owning organization can read the routes and register a viewing. A non-admin can edit or delete only a viewing whose `createdBy` is their Clerk user id; an organization admin can change any viewing in the organization. ## Viewing object ```json { "id": "67444de5-bfa4-4aa4-87c3-a1b2f1a4af59", "organizationId": "69e9323f-e150-4317-9a8b-e952d0704fc4", "propertyListingId": "29a1e184-237a-40c8-a1fe-624b0bcbb2f4", "leadId": 4312, "collaboratorLeadId": 8821, "collaboratorUserId": "user_2partner", "collaboratorOrganizationId": "0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6", "agentUserId": "user_2abc123", "dealId": "5a149be6-e22f-4633-8f13-e8ddb169cd4d", "viewingAt": "2026-07-28T09:30:00.000Z", "kind": "in_person", "status": "scheduled", "notes": "Meet at reception.", "source": "user", "createdBy": "user_2abc123", "createdAt": "2026-07-23T14:12:09.000Z", "updatedAt": "2026-07-23T14:12:09.000Z" } ``` Closed vocabularies: - `kind`: `in_person` or `virtual` - `status`: `scheduled`, `completed`, `cancelled`, or `no_show` - `source`: `user`, `mcp`, `import`, `n8n`, or `system`; HTTP creation always writes `user` The client, collaborator, hosting member, and deal links are nullable. Deleting a linked lead or deal clears that link without deleting the viewing. `collaboratorUserId` and `collaboratorOrganizationId` name an agent from another agency on Fondaro who brought the client, beside the CRM `collaboratorLeadId`. They are always set together or both `null`. See [Agent from another agency](#agent-from-another-agency). ## List viewings ```http GET /properties/29a1e184-237a-40c8-a1fe-624b0bcbb2f4/viewings?limit=20&offset=0 Authorization: Bearer CLERK_SESSION_JWT x-organization-id: ORGANIZATION_ID ``` | Query | Type | Default | Constraints | |-------|------|---------|-------------| | `limit` | integer | `20` | 1–100 | | `offset` | integer | `0` | 0 or greater | The response is newest first by `viewingAt`: ```json { "viewings": [], "total": 0, "hasMore": false } ``` This flat list returns viewing rows only. Use the Activity endpoint when you need client and collaborator name labels or the listing's other event types. ## Register a viewing ```http POST /properties/29a1e184-237a-40c8-a1fe-624b0bcbb2f4/viewings Authorization: Bearer CLERK_SESSION_JWT x-organization-id: ORGANIZATION_ID Content-Type: application/json ``` ```json { "viewingAt": "2026-07-28T09:30:00.000Z", "kind": "in_person", "leadId": 4312, "collaboratorLeadId": 8821, "agentUserId": "user_2abc123", "dealId": "5a149be6-e22f-4633-8f13-e8ddb169cd4d", "notes": "Meet at reception." } ``` | Field | Type | Required | Rules | |-------|------|----------|-------| | `viewingAt` | ISO 8601 string | Yes | Date and time of the viewing | | `kind` | string | Yes | `in_person` or `virtual` | | `status` | string | No | Omit to derive it from `viewingAt` | | `leadId` | integer or `null` | No | Client CRM lead | | `collaboratorLeadId` | integer or `null` | No | Referring external-agent lead | | `collaboratorUserId` | string or `null` | No | Clerk user id of an agent from another agency on Fondaro; send with `collaboratorOrganizationId` | | `collaboratorOrganizationId` | UUID or `null` | No | That agent's agency; send with `collaboratorUserId` | | `agentUserId` | string or `null` | No | Hosting Clerk user id, maximum 255 characters; omission defaults to the caller | | `dealId` | UUID or `null` | No | Optional deal in the organization | | `notes` | string or `null` | No | Maximum 5,000 characters | When `status` is omitted, a time at or before the request time becomes `completed`; a future time becomes `scheduled`. If a lead id is supplied, it must identify a purchased CRM lead with a CRM status in the active organization. A member must already be assigned to that lead; an organization admin may attach any eligible lead in the organization. The same visibility rule is applied to `collaboratorLeadId`. Attaching a lead does not grant CRM access. The dashboard filters the collaborator picker to collaborator-type leads; callers should preserve that convention. The response is the created viewing object. ## Agent from another agency When an agent from another agency on Fondaro brought the client, name them with `collaboratorUserId` and their agency with `collaboratorOrganizationId`: ```json { "viewingAt": "2026-07-28T09:30:00.000Z", "kind": "in_person", "collaboratorUserId": "user_2partner", "collaboratorOrganizationId": "0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6" } ``` - Send both or neither. On update, `null` for both removes the agent; changing only one of them is refused. - The agency must be another agency than yours (your own people host as `agentUserId`), and the agent must be on that agency's active roster on the Fondaro network. An agent already on the viewing is not checked again, so an edit still works after their agency leaves the network. - The agent sees the viewing on [`GET /network/viewings`](#viewings-you-were-named-on) and cannot change or delete it. They never see your client, your collaborator lead, your deal, your hosting member, or your notes. ## Update a viewing `PATCH /viewings/:viewingId` accepts the same fields as create, all optional. An omitted key remains unchanged. Explicit `null` clears `leadId`, `collaboratorLeadId`, `agentUserId`, `dealId`, or `notes`, and `null` for both `collaboratorUserId` and `collaboratorOrganizationId` removes the agent from another agency. ```json { "status": "completed", "notes": "Client requested a second visit." } ``` Changing `viewingAt` does not re-derive the status on update. Send the intended `status` in the same patch when moving a viewing across past/future boundaries. ## Delete a viewing ```http DELETE /viewings/67444de5-bfa4-4aa4-87c3-a1b2f1a4af59 Authorization: Bearer CLERK_SESSION_JWT x-organization-id: ORGANIZATION_ID ``` ```json { "success": true } ``` ## Read property activity ```http GET /properties/29a1e184-237a-40c8-a1fe-624b0bcbb2f4/activity?types=viewing,deal&limit=20&offset=0 Authorization: Bearer CLERK_SESSION_JWT x-organization-id: ORGANIZATION_ID ``` | Query | Type | Default | Description | |-------|------|---------|-------------| | `types` | string or repeated string[] | all | `viewing`, `change`, `deal`, and/or `brochure`; comma-separated and repeated forms are both accepted | | `limit` | integer | `20` | 1–100 | | `offset` | integer | `0` | 0 or greater | The response merges four data sources, sorts them newest first, then applies the requested page: ```json { "entries": [ { "type": "viewing", "date": "2026-07-28T09:30:00.000Z", "viewing": { "id": "67444de5-bfa4-4aa4-87c3-a1b2f1a4af59", "propertyListingId": "29a1e184-237a-40c8-a1fe-624b0bcbb2f4", "leadId": 4312, "collaboratorLeadId": 8821, "viewingAt": "2026-07-28T09:30:00.000Z", "kind": "in_person", "status": "scheduled" }, "lead": { "id": 4312, "firstName": "Alex", "lastName": "Morgan" }, "collaborator": { "id": 8821, "firstName": "Sam", "lastName": "Lee" } } ], "total": 1, "hasMore": false } ``` Entry `type` values are finer-grained than the filter: - `viewing`, with `viewing` and nullable `lead`/`collaborator` name labels; - `change`, with a server-written `change` event and field-level `changes`; - `deal-stage-change`, `deal-won`, or `deal-lost`, each selected by the `deal` filter; and - `brochure`, with `brochureId`, `slug`, title, listing reference, and creation time. `total` is the number of merged entries loaded for the requested window and can be a lower bound when a source has more history than the per-source cap. Use `hasMore` and advance `offset` rather than relying on `total` as an exact all-time count. ## Viewings you were named on The agent from another agency reads the viewings an agency named them on. This route is yours whichever organization is active: it needs a signed-in agent on the Fondaro network, not an organization header, and it has no write counterpart. ```bash curl "https://api.fondaro.com/network/viewings?limit=20&offset=0" \ -H "Authorization: Bearer $CLERK_SESSION_JWT" ``` | Query | Type | Default | Constraints | |-------|------|---------|-------------| | `limit` | integer | `20` | 1–100 | | `offset` | integer | `0` | 0 or greater | Newest `viewingAt` first: ```json { "viewings": [ { "id": "67444de5-bfa4-4aa4-87c3-a1b2f1a4af59", "listing": { "source": "internal", "id": "29a1e184-237a-40c8-a1fe-624b0bcbb2f4" }, "hostAgency": { "networkSlug": "costa-homes", "name": "Costa Homes", "logoLightUrl": null, "logoDarkUrl": null }, "viewingAt": "2026-07-28T09:30:00.000Z", "kind": "in_person", "status": "scheduled", "createdAt": "2026-07-23T14:12:09.000Z", "updatedAt": "2026-07-23T14:12:09.000Z" } ], "total": 1, "hasMore": false, "nextOffset": 1 } ``` `listing` is the host's Fondaro network listing; open it through the [property sources](/docs/api/properties/sources) network detail like any other listing. `hostAgency` is `null` when the host agency is no longer on the network; the viewing still shows. A property API key is refused. ## Common errors | Status | Condition | |--------|-----------| | `400` | Invalid UUID, query value, enum value, date, or field constraint; half a network collaborator pair, your own agency as the collaborator's agency, or an agent who is not on that agency's roster | | `403` | A member tries to attach a lead they cannot access, or change another member's viewing | | `404` | Listing, viewing, eligible lead, or deal is unavailable in the active organization | --- # Interactive Brochures Source: https://www.fondaro.com/docs/api/properties/brochures > Create and manage shareable interactive property brochures through the authenticated REST API or Fondaro MCP. Fondaro brochures are interactive web pages containing one to 25 snapshotted property listings. A brochure can include a recipient name and note, live organization and agent branding, listing-specific corrections, optional public location presentation, maps, galleries, view statistics, and an expiry. Creating one returns a shareable HTTPS URL under `/b/:slug`; there is no separate brochure renderer or PDF payload in this API. PDF window cards are a separate dashboard feature described in the [Brochures dashboard guide](/docs/dashboard/properties/brochures#pdf-window-cards). The obsolete `POST /properties/:id/brochure` PDF route is not part of the current API contract. ## Choose an API surface - Use the REST routes on this page from the Fondaro dashboard or another client with a valid Clerk session JWT. The `/brochures` controller does not accept an `fdr_mcp_` key. - Use the hosted [Fondaro MCP server](/docs/api/mcp) for API-key automation. It exposes the existing 13-operation brochure lifecycle through scoped, compact tools and returns the same public viewer URL. Duplication is a Clerk-session REST and dashboard action and is not an MCP tool. ## Access and lifecycle rules - A member can list, read, and change only brochures they created. An organization admin can operate across the organization. - Every lookup is organization-scoped; a brochure in another organization is not exposed. - A brochure accepts at most 25 listings. Creation needs at least one. - Expiry defaults to 90 days and can be set from 1 to 365 days. - Revoking disables the public link without deleting data. Renewing clears revocation and restores the same URL. Permanent delete removes the brochure and its listing snapshots. - Non-admin branding changes still obey the organization's locked brochure preset. - `showLocations` is independent from the branding lock. Any caller allowed to edit a brochure may change it. - Create, add, refresh, and renew-with-refresh may continue image ingest and coordinate enrichment after the response. The public link itself is available immediately. ## REST route catalogue All authenticated management routes are under `https://api.fondaro.com`. | Method | Route | Purpose | |--------|-------|---------| | `GET` | `/brochures` | List visible brochures, optionally filtered by search text, creator, expired state, or revoked state | | `POST` | `/brochures` | Create a brochure and snapshot 1–25 listings | | `GET` | `/brochures/:id` | Read a brochure, listings, live branding, organization preset, and `publicUrl` | | `POST` | `/brochures/:id/duplicate` | Duplicate a visible brochure with fresh identities, lifecycle, and public link | | `PATCH` | `/brochures/:id` | Update title, recipient fields, theme, accent, or public location visibility | | `DELETE` | `/brochures/:id` | Permanently delete a brochure | | `POST` | `/brochures/:id/renew` | Restore the link, set a new expiry, and optionally refresh listings | | `POST` | `/brochures/:id/revoke` | Disable the public link reversibly | | `POST` | `/brochures/:id/listings` | Add one source listing, optionally at a zero-based position | | `DELETE` | `/brochures/:id/listings/:listingId` | Remove one brochure listing | | `POST` | `/brochures/:id/listings/order` | Replace the complete listing order | | `POST` | `/brochures/:id/listings/:listingId/refresh` | Re-snapshot one listing while preserving brochure overrides | | `PATCH` | `/brochures/:id/listings/:listingId/details` | Set or clear listing detail overrides and visibility flags | | `PATCH` | `/brochures/:id/listings/:listingId/location` | Correct address and coordinate fields | `listingId` in the mutation routes is the brochure-listing UUID returned by create/get, not the source property's id. Reorder requires the exact, complete current set of brochure-listing UUIDs. ## CRM lead connections The Clerk-authenticated REST API can connect a brochure to zero to 25 unique purchased CRM leads without changing the free-text `recipientName` shown publicly. - Send optional `leadIds: number[]` when creating a brochure. Omit it or send `[]` to create without connections. - On `PATCH /brochures/:id`, omitting `leadIds` preserves the current set, `leadIds: []` clears it, and a non-empty array atomically replaces the complete set. - Members may use only leads assigned to them. Organization admins may use any purchased CRM lead in the organization. - If any id is missing, outside the organization, no longer purchased, or unavailable to the caller, the API returns a generic unavailable-selection error and makes no brochure or connection change. - Authenticated list, detail, create, update, renew, and revoke responses include `linkedLeads`, containing only each visible lead's `id`, `firstName`, and `lastName`. - `GET /brochures?leadId=123` returns connected brochures that the caller may also open. Members receive only brochures they created; admins receive all matching organization brochures. - The `q` list filter also matches `recipientName` and the names of connected leads still visible to the caller. These fields are intentionally unavailable through managed or public MCP brochure tools. A brochure write scope does not imply CRM read access. The unauthenticated public viewer never receives `leadIds`, `linkedLeads`, or other CRM association metadata. ## Create a brochure ```http POST /brochures Authorization: Bearer CLERK_SESSION_JWT Content-Type: application/json ``` ```json { "title": "Estepona homes for Andrew", "recipientName": "Andrew", "recipientNote": "Five homes selected for your review.", "showLocations": false, "expiresInDays": 90, "agentId": "user_2abc123", "listings": [ { "source": "internal_mls", "sourcePropertyId": "0d24495c-33ac-4b24-9b6b-48dcad68ef65" }, { "source": "idealista", "sourcePropertyId": "105432100", "sourceCountry": "es" } ] } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `title` | string | No | Display title, maximum 255 characters | | `recipientName` | string | No | Recipient name shown on the cover | | `recipientNote` | string | No | Personal note, maximum 4,096 characters | | `themeName` | string | No | A theme returned by `GET /properties/themes`; preset locks still apply | | `accentColorOverride` | string | No | Hex accent colour; preset locks still apply | | `showLocations` | boolean | No | Show structured locations and maps publicly. Explicit values override the organization starting value; omitted input inherits it, with `true` as the compatibility fallback | | `agentId` | string | No | Agent UUID or live Clerk user id in the current organization | | `expiresInDays` | integer | No | 1–365; defaults to 90 | | `listings` | array | Yes | 1–25 source identities in the desired display order | REST source identities use the persisted source spelling: `internal_mls`, `resales_online`, `resales_viewer`, `zoddak`, `inmobalia`, or one of the 19 portal slugs (`idealista`, `rightmove`, `immobiliare`, `immoscout`, `funda`, `zoopla`, `seloger`, `otodom`, `immowelt`, `realtor`, `homes`, `trulia`, `propertyfinder`, `dubizzle`, `centris`, `loopnet`, `zillow`, `redfin`, `bayut`). A Property Viewer row is normally produced by the dashboard's viewer-link preview/import flow; do not invent its compound source id. The response contains the brochure, its listing snapshots, and: ```json { "id": "9d48015b-c4df-40be-a9db-388d1a1fa3f1", "slug": "unguessable-random-slug", "showLocations": false, "publicUrl": "https://www.fondaro.com/b/unguessable-random-slug", "expiresAt": "2026-10-19T10:00:00.000Z" } ``` ## Duplicate a brochure ```http POST /brochures/9d48015b-c4df-40be-a9db-388d1a1fa3f1/duplicate Authorization: Bearer CLERK_SESSION_JWT ``` The route has no request body. A member may duplicate only a brochure they created; an organization admin may duplicate any brochure in the organization. The caller becomes the owner of the new copy. Duplication copies the title, recipient fields, location visibility, ordered listing snapshots, brochure-specific corrections, and CRM lead connections still visible to the caller. It does not refresh or re-snapshot source listings. A locked organization branding preset remains authoritative for a non-admin caller. The result has a new brochure UUID, slug, public URL, brochure-listing UUIDs, timestamps, and independently owned hosted image keys. It is active for a fresh 90 days with zero views and no last-viewed time, even when the source is expired or revoked. The source is unchanged. The response uses the same complete detail shape as create and `GET /brochures/:id`, including `listings`, `linkedLeads`, live or intentionally frozen `branding`, `organizationPreset`, and `publicUrl`. ## Update brochure settings All fields are optional. An omitted field stays unchanged. `null` clears `title`, `recipientName`, `recipientNote`, or `accentColorOverride`; `themeName` must be a valid non-empty theme name. Send `showLocations: false` to hide structured locations publicly or `showLocations: true` to reveal the retained snapshot again. ```http PATCH /brochures/9d48015b-c4df-40be-a9db-388d1a1fa3f1 Authorization: Bearer CLERK_SESSION_JWT Content-Type: application/json ``` ```json { "recipientNote": "I moved our two favourites to the top.", "accentColorOverride": "#1A5F7A", "showLocations": true } ``` ## Add, reorder, refresh, and remove listings Add uses a source identity and an optional zero-based position: ```json { "source": "resales_online", "sourcePropertyId": "R5423149", "position": 1 } ``` Reorder sends every current brochure-listing UUID exactly once: ```json { "listingIds": [ "7cb69216-21f2-42ee-9b34-5bd48488e161", "44a60158-4903-4da8-87b4-127af78ed33c" ] } ``` Refresh reads the original source again but preserves every brochure-specific title, reference, description, property type, price/room override, location correction, and hide flag. ## Correct listing details Every field is optional. Omit a field to keep its current override; send `null` to clear an override and fall back to the source snapshot. ```json { "titleOverride": "Three-bedroom penthouse with sea views", "priceOverride": 850000, "priceToOverride": null, "bedroomsOverride": 3, "bedroomsToOverride": null, "priceHidden": false, "factsHidden": false } ``` Supported override fields are `titleOverride`, `referenceNumberOverride`, `descriptionOverride`, `propertyTypeOverride`, `priceOverride`, `priceToOverride`, `bedroomsOverride`, `bedroomsToOverride`, `bathroomsOverride`, and `bathroomsToOverride`. `priceHidden` renders “Price on request”; `factsHidden` suppresses bed and bathroom facts. ## Correct a listing location Patch any combination of `latitude`, `longitude`, `addressLine1`, `addressLine2`, `city`, `region`, `provinceState`, `postalCode`, `countryCode`, and `community`. Omitted fields remain unchanged; `null` clears a field. ## Revoke, renew, and delete Revoke returns the brochure with `revokedAt` set. The public data endpoint and `/b/:slug` viewer respond with `410 Gone` while a brochure is revoked or expired. Renew clears revocation and extends expiry from the time of the request: ```json { "expiresInDays": 90, "refreshListings": true } ``` The response contains `failedRefreshes` when individual source refreshes fail; the renewed brochure and unchanged public URL remain valid. Use `DELETE /brochures/:id` only when permanent removal is intended. ## Public viewer data The localized web viewer uses the unauthenticated `GET /public/brochures/:slug` endpoint. Resolving it records a view asynchronously and returns the rich public payload used by the page. `GET /public/brochures/:slug/exists` checks link availability without returning the brochure. When `brochure.showLocations` is `false`, every public listing returns `null` for `addressLine1`, `addressLine2`, `postalCode`, `city`, `provinceState`, `region`, `countryCode`, `community`, `latitude`, and `longitude`. The authenticated `GET /brochures/:id` management response keeps those fields so corrections and a later visibility change remain possible. Free-text titles, descriptions, recipient notes, and image content are not rewritten. Treat the slug and `publicUrl` as bearer-like secrets: anyone who has the link can see its recipient note and listings until expiry or revocation. Do not log the URL or place sensitive personal data in recipient fields. ## Themes `GET /properties/themes` remains the theme catalogue for interactive brochures and dashboard window cards. It returns the canonical picker order with each theme's machine name, display label, mood description, light/dark preview colours, and primary font family. Registry-only tenant themes and retired compatibility aliases are not returned. Theme selection does not generate a PDF and does not create a brochure by itself. --- # Create, Read, Update, Delete Properties Source: https://www.fondaro.com/docs/api/properties/crud > Manage property listings: create, read, update, delete, renew, and find expiring properties. Create, retrieve, update, and delete individual property listings. Also includes endpoints for renewing listings and finding properties that are about to expire. ## Create a Property ``` POST /properties ``` **Authentication:** Required (API key or JWT) Creates a new property listing for your organization. A unique reference number is assigned automatically: `FDR-` followed by eight letters and digits (for example `FDR-7KQ2MX9A`). Listings created before 2026-09 keep their shorter `FDR-XXXXX` references. ### Request Body **Required fields:** | Field | Type | Description | Constraints | |-------|------|-------------|-------------| | `listingType` | string | Listing type | `sale`, `rent`, `sale_or_rent`, `fraction` | | `propertyCategory` | string | Property category | `residential`, `commercial`, `industrial`, `land` | | `propertyType` | string | Property type | Must belong to `propertyCategory` (see [Validation](#validation)) | | `city` | string | City name | Non-empty, at most 100 characters | | `countryCode` | string | Country code | ISO 3166-1 alpha-2 (2 uppercase letters, e.g., `ES`) | | `currency` | string | Currency code | ISO 4217 (3 uppercase letters, e.g., `EUR`) | **Optional fields:** | Field | Type | Description | Constraints | |-------|------|-------------|-------------| | `description` | string | Property description | — | | `translatedDescriptions` | object | Descriptions in other languages | `{ "es": "...", "fr": "..." }` | | `addressLine1` | string | Street address | At most 255 characters | | `addressLine2` | string | Additional address | At most 255 characters | | `postalCode` | string | Postal / ZIP code | At most 20 characters | | `provinceState` | string | Province or state | At most 100 characters | | `region` | string | Region name | At most 100 characters | | `community` | string | Community or neighborhood | At most 100 characters | | `latitude` | number | Latitude | -90 to 90; inside `countryCode` | | `longitude` | number | Longitude | -180 to 180; inside `countryCode` | | `price` | number | Listing price | 0 to 9,999,999,999,999.99 | | `bedrooms` | integer | Number of bedrooms | Whole number >= 0 | | `bathrooms` | number | Number of bathrooms | 0 to 99.9 (halves allowed) | | `livingArea` | number | Living area size | 0 to 9,999,999,999.99 | | `plotArea` | number | Plot area size | 0 to 9,999,999,999.99 | | `areaUnit` | string | Unit for area fields | `sqm` or `sqft` | | `yearBuilt` | integer | Year of construction | 1801 to current year + 5 | | `isNewConstruction` | boolean | New build flag | — | | `energyRatingDetails` | string | Free-text notes on the energy certificate | — | | `title` | string | Listing headline | At most 140 characters | | `translatedTitles` | object | Headlines in other languages | `{ "es": "...", "fr": "..." }` | | `rentalPeriod` | string | What `rentalPrice` is per | `day`, `week`, `month`, `year` | | `rentalPrice` | number | Rent, for `rent` and `sale_or_rent` listings | 0 to 9,999,999,999,999.99 | | `rentalPriceLowSeason` | number | Low-season rent | 0 to 9,999,999,999,999.99 | | `rentalPriceHighSeason` | number | High-season rent | 0 to 9,999,999,999,999.99 | | `availableFrom` | string | First day the property is available | Calendar date `YYYY-MM-DD` | | `energyRating` | string | EU energy certificate letter | `A+`, `A` to `G`, `exempt`, `in_progress` | | `energyConsumptionKwh` | number | kWh per m² per year | 0 to 99,999,999.99 | | `co2Emissions` | number | kg CO₂ per m² per year | 0 to 99,999,999.99 | | `parkingSpaces` | integer | Parking spaces | Whole number >= 0 | | `parkingType` | string | Kind of parking | `garage`, `underground`, `covered`, `open`, `street`, `communal` | | `communityFeesPerYear` | number | Community fees per year, in `currency` | 0 to 9,999,999,999.99 | | `ibiPerYear` | number | IBI (property tax) per year, in `currency` | 0 to 9,999,999,999.99 | | `basuraPerYear` | number | Rubbish collection tax per year, in `currency` | 0 to 9,999,999,999.99 | | `floorPlans` | array | Floor plan images | `[{ url, alt?, order }]`; `url` must be an image you own (see [Images](#images)) | | `videoUrls` | string[] | Video links (YouTube, Vimeo, ...) | Up to 20 http(s) URLs | | `virtualTourUrls` | string[] | Virtual tour links (Matterport, ...) | Up to 20 http(s) URLs | | `floor` | integer | Floor the property is on | -20 to 300 (basements are negative) | | `totalFloors` | integer | Floors in the building | 0 to 300 | | `orientation` | string | Direction the main facade faces | `N`, `NE`, `E`, `SE`, `S`, `SW`, `W`, `NW` | | `isExterior` | boolean | Faces the street rather than an inner courtyard | — | | `condition` | string | State of the property | `new`, `excellent`, `good`, `needs_renovation`, `under_construction` | | `completionDate` | string | Completion of an off-plan property | Calendar date `YYYY-MM-DD` | | `furnished` | string | Furnishing | `no`, `partly`, `fully` (see [Amenities and furnishing](#amenities-and-furnishing)) | | `features` | string[] | Amenities | Feature slugs (see [Amenities and furnishing](#amenities-and-furnishing)) | | `sharedCommission` | number | Commission you offer a collaborating agency, %. Every agency on the network sees it | 0–100 | | `sharedCommissionNotes` | string | Terms of that offer, shown with it to every agency on the network. Write it for collaborators, not as an internal note | — | | `viewingInstructions` | string | Keys and access notes for viewings | Only your organization ever sees it | | `hasPool` | boolean | Swimming pool (see [Amenities and furnishing](#amenities-and-furnishing)) | — | | `hasSeaView` | boolean | Sea view | — | | `hasGarage` | boolean | Garage | — | | `hasTerrace` | boolean | Terrace | — | | `hasGarden` | boolean | Garden | — | | `isFurnished` | boolean | Furnished (see [Amenities and furnishing](#amenities-and-furnishing)) | — | | `hasAirConditioning` | boolean | Air conditioning | — | | `hasLift` | boolean | Elevator / lift | — | | `images` | array | Property images | `[{ url, alt?, order, isFeatured?, width?, height? }]`; `url` must be an image you own (see [Images](#images)); `width`/`height` are set by Fondaro (see below) | | `thumbnails` | array | Thumbnail images | `[{ url, alt?, order, originalImageIndex }]`; `url` must be an image you own (see [Images](#images)) | | `additionalFeatures` | object | Custom key-value features | — | | `commission` | number | Your agency's commission %. Only your organization ever sees it | 0–100 | | `leadGroupId` | integer | Associated lead group. Only your organization ever sees it | Whole number >= 0 | | `agentId` | string | Assigned agent ID | — | | `autoRenewEnabled` | boolean | Renew the listing automatically when it reaches `expiresAt` (see [Renew a Property](#renew-a-property)) | Defaults to `false` | If you send `latitude` and `longitude`, the point must lie inside the listing's country. If the listing has no point but has an `addressLine1`, Fondaro looks the address up when you create the listing, or when an update changes the address, and fills in the point when it finds an exact street or building match; otherwise the listing is saved without one. ### Amenities and furnishing `features` lists the amenities as slugs from one catalogue: | Slug | Meaning | |------|---------| | `pool` | Swimming pool | | `sea_view` | Sea view | | `garage` | Garage | | `terrace` | Terrace | | `garden` | Garden | | `air_conditioning` | Air conditioning | | `lift` | Lift / elevator | | `beachfront` | Beachfront | | `frontline_golf` | Frontline golf | | `panoramic_views` | Panoramic views | | `gated_complex` | Gated complex | | `storage_room` | Storage room | | `built_in_wardrobes` | Built-in wardrobes | | `luxury` | Luxury | Furnishing is its own field, `furnished`, and a street-facing property is `isExterior`; neither is a feature slug. The earlier boolean fields keep working for integrations that already send them: - If you send `features`, it is what the listing stores, and the seven amenity booleans (`hasPool` to `hasLift`) are set from it. Booleans sent beside `features` are ignored. - If you send only booleans, each one adds or removes its own amenity and leaves every other slug on the listing as it was, so an integration that re-sends all eight booleans unchanged never loses the newer amenities. - If you send `furnished`, `isFurnished` follows it (`true` for `partly` or `fully`) and an `isFurnished` sent beside it is ignored. If you send only `isFurnished`, a value that already agrees with the stored `furnished` changes nothing; otherwise `true` stores `fully` and `false` clears `furnished` (it becomes empty). - A field you leave out is never changed. Responses always carry both `features` and the eight booleans. ### Validation A value the listing cannot store is refused with `400 Bad Request` before anything is saved. The response names the field at the start of each message: ```json { "statusCode": 400, "message": ["areaUnit must be one of the following values: sqm, sqft"], "error": "Bad Request" } ``` ### Images Every `images[].url`, `thumbnails[].url` and `floorPlans[].url` must be an image your organization owns. Upload each photo to Fondaro first, then send the URLs the upload returns: ```bash curl -X POST https://api.fondaro.com/properties/media/upload \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -F "file=@living-room.jpg" ``` The file must be an image of at most 20 MB. The response carries the two URLs to use: ```json { "success": true, "images": { "fullSize": "https://fra1.digitaloceanspaces.com/leadhql/properties/images/3f2a….jpg", "thumbnail": "https://fra1.digitaloceanspaces.com/leadhql/properties/thumbnails/3f2a….webp" } } ``` Put `images.fullSize` in `images[].url` and `images.thumbnail` in `thumbnails[].url`. Also accepted: - photos uploaded in the Fondaro dashboard or produced by Property Studio for your organization; - on an update, a URL the listing already has in the same field; - a thumbnail that repeats one of the `images` URLs in the same request. Any other URL is refused with `400` and the field path, for example `images.2.url is not an image this organization owns: upload it with POST /properties/media/upload first and use the returned URL`. An upload belongs to the organization of the key that made it; another organization cannot list it. Fondaro records the pixel size of every upload and adds it to the listing as `images[].width` and `images[].height`, so cards and feeds can lay out a photo before it loads. You never need to send them: any values you send are replaced by the upload's recorded size, or dropped for an image Fondaro has no size for. Older images and images you did not upload have no `width`/`height`. You can send back the `images` you read from `GET /properties/:id` unchanged; if you do send them, they must be positive whole numbers. Uploads that never end up on a listing are removed after a while: an upload older than 7 days that no listing (or other Fondaro feature, such as Studio) uses may be deleted. Upload photos when you are about to create or update the listing. `propertyType` must belong to `propertyCategory`. `other` is accepted under every category: | `propertyCategory` | `propertyType` | |--------------------|----------------| | `residential` | `apartment`, `condominium`, `house_detached`, `house_semi_detached`, `house_terraced`, `townhouse`, `multi_family_home`, `penthouse`, `studio`, `other` | | `commercial` | `office`, `retail`, `commercial_other`, `hospitality`, `other` | | `industrial` | `industrial_warehouse`, `industrial_other`, `other` | | `land` | `land_residential`, `land_commercial`, `land_agricultural`, `land_other`, `other` | ### Example ```bash curl -X POST https://api.fondaro.com/properties \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "listingType": "sale", "propertyCategory": "residential", "propertyType": "house_detached", "city": "Marbella", "countryCode": "ES", "currency": "EUR", "price": 1250000, "bedrooms": 4, "bathrooms": 3, "livingArea": 320, "plotArea": 800, "areaUnit": "sqm", "hasPool": true, "hasSeaView": true, "description": "Stunning sea-view villa in Nueva Andalucía with private pool and landscaped garden." }' ``` ### Response Returns the full created property object with the generated `id` and `referenceNumber`. ### Errors | Status | Condition | |--------|-----------| | `400` | A field fails [validation](#validation) | --- ## Get a Property ``` GET /properties/:id ``` **Authentication:** Required (API key or JWT) Listings belonging to other organizations are only returned while they are `active`. A non-active listing (`inactive`, `pending`, `sold`, `rented`, `deleted`) is visible only to the organization that owns it; anyone else gets a `404`. Your own listing comes back in full. An active listing from another organization comes back as its network version: | Private to the owning organization (left out) | Network data (included) | |-----------------------------------------------|-------------------------| | `commission` | Street address (`addressLine1`, `addressLine2`) and `postalCode` | | `viewingInstructions` | Exact `latitude` and `longitude` | | `leadGroupId` | `sharedCommission` and `sharedCommissionNotes` (the offer to collaborating agencies) | | `additionalFeatures` keys an import keeps for itself (those starting with `resales`) | Every other field, including your typed-in `additionalFeatures` | | The agent's `displayEmail`, `phoneNumber` and `whatsappNumber`, unless the agent shares them | `agentId`, `organizationBranding`, and the agent card's `id`, `clerkUserId`, names, `imageUrl` and `title` (`includeBranding=true`) | An agent shares their contact with the network by default. It is set per agent with `shareContactOnNetwork` on `PUT /agents/:id`: a member changes it on their own card, an organization admin on any card. ### Path Parameters | Parameter | Type | Description | |-----------|------|-------------| | `id` | UUID | Property ID | ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `filterByOrganization` | boolean | `false` | Restrict to your organization's properties | | `includeBranding` | boolean | `false` | Include organization branding data | | `includeAdCount` | boolean | `false` | Include count of ad creatives | | `includeAdCreatives` | boolean | `false` | Include full ad creative data | ### Example ```bash curl https://api.fondaro.com/properties/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d \ -H "Authorization: ApiKey fondaro_pk_abc123" ``` ### Errors | Status | Condition | |--------|-----------| | `404` | Property not found, or not active and not owned by your organization | --- ## Update a Property ``` PUT /properties/:id ``` **Authentication:** Required (API key or JWT) Updates an existing property. Only include the fields you want to change; all fields are optional. The property must belong to your organization. ### Request Body All fields from the create endpoint are available, with the same [validation](#validation), plus: | Field | Type | Description | |-------|------|-------------| | `status` | string | Mark the listing `sold`, `rented` or `deleted`. Sending the status the listing already has changes nothing; any other status is a `400`. | The required create fields (`listingType`, `propertyCategory`, `propertyType`, `city`, `countryCode`, `currency`) and the lists `features`, `floorPlans`, `videoUrls` and `virtualTourUrls` can be changed but not cleared with `null` (a `400`; send `[]` to empty a list). A field you leave out keeps its value, so an integration that does not know a newer field never erases it. A new `propertyType` or `propertyCategory` is checked against the listing's other value, and a changed point against its country. ### Example ```bash curl -X PUT https://api.fondaro.com/properties/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "price": 1150000, "description": "Price reduced! Stunning sea-view villa in Nueva Andalucía." }' ``` ### Errors | Status | Condition | |--------|-----------| | `400` | A field fails [validation](#validation), or `status` is not `sold`, `rented` or `deleted` | | `404` | Property not found or not owned by your organization | --- ## Delete a Property ``` DELETE /properties/:id ``` **Authentication:** Required (API key or JWT) Permanently deletes a property listing. The property must belong to your organization. ### Example ```bash curl -X DELETE https://api.fondaro.com/properties/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d \ -H "Authorization: ApiKey fondaro_pk_abc123" ``` ### Response ```json { "success": true } ``` ### Errors | Status | Condition | |--------|-----------| | `404` | Property not found or not owned by your organization | --- ## Renew a Property ``` PUT /properties/:id/renew ``` **Authentication:** Required (API key or JWT) Extends the expiration date of a property listing by 2 months. The property must belong to your organization. The renewal counts from the moment you renew, not from the old `expiresAt`, stamps `lastRenewedAt`, and makes an **Inactive** listing **Active** again. Other statuses are kept. The listing's History records a `renewed` event. ### Automatic renewal When `autoRenewEnabled` is `true`, Fondaro applies the same renewal for you: the daily expiry check renews an **Active** listing that has reached its `expiresAt` instead of making it **Inactive**, and records the `renewed` event with source `system` and no actor. You get no expiry reminder or expired email for that listing. A listing that is not **Active**, or that belongs to a disabled organization, is not renewed automatically and expires as usual. ### Example ```bash curl -X PUT https://api.fondaro.com/properties/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d/renew \ -H "Authorization: ApiKey fondaro_pk_abc123" ``` ### Response Returns the full updated property object with the new `expiresAt` date. ### Errors | Status | Condition | |--------|-----------| | `404` | Property not found or not owned by your organization | --- ## Get Expiring Properties ``` GET /properties/expiring/soon ``` **Authentication:** Required (API key or JWT) Returns properties in your organization that are expiring within a given number of days. ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `days` | number | `7` | Number of days to look ahead | | `agentUserIds` | string[] | none | Only listings assigned to any of these people, by Clerk user id (repeat the key, up to 50). Each id is matched to the roster row in your own agency. Absent means every listing. | ### Example ```bash curl "https://api.fondaro.com/properties/expiring/soon?days=14" \ -H "Authorization: ApiKey fondaro_pk_abc123" ``` Narrow it to some owners: ```bash curl "https://api.fondaro.com/properties/expiring/soon?days=14&agentUserIds=user_2abc&agentUserIds=user_2def" \ -H "Authorization: ApiKey fondaro_pk_abc123" ``` ### Response Returns an array of property objects that will expire within the specified timeframe. Listings with `autoRenewEnabled` are included: this shows the expiry date, whatever happens at it. --- # AI Property Descriptions Source: https://www.fondaro.com/docs/api/properties/descriptions > Generate marketing descriptions for property listings using AI. Generate professional marketing descriptions for property listings using AI. The generated text is based on the property's attributes and, optionally, its images. ## Endpoint ``` POST /properties/description/generate ``` **Authentication:** Required (API key or JWT). An API key needs the `properties:write` scope; a key without it gets `403` with `code: "API_KEY_SCOPE_MISSING"`. ## Request Body Provide as much property detail as possible for the best results. All fields are optional, but more data produces better descriptions. | Field | Type | Description | |-------|------|-------------| | `propertyId` | string | ID of an existing property (used to verify ownership) | | `propertyType` | string | Property type (e.g., `villa`, `apartment`, `penthouse`) | | `propertyCategory` | string | Property category: `residential`, `commercial`, `industrial`, `land` | | `listingType` | string | Listing type: `sale`, `rent`, `sale_or_rent`, `fraction` | | `bedrooms` | number | Number of bedrooms | | `bathrooms` | number | Number of bathrooms | | `city` | string | City name | | `provinceState` | string | Province or state | | `countryCode` | string | ISO 3166-1 alpha-2 country code | | `region` | string | Region name | | `community` | string | Community or neighborhood | | `livingArea` | number | Living area size | | `plotArea` | number | Plot area size | | `areaUnit` | string | Unit for area fields (e.g., `sqm`, `sqft`) | | `yearBuilt` | number | Year of construction | | `price` | number | Listing price | | `currency` | string | ISO 4217 currency code | | `hasPool` | boolean | Swimming pool | | `hasSeaView` | boolean | Sea view | | `hasGarage` | boolean | Garage | | `hasTerrace` | boolean | Terrace | | `hasGarden` | boolean | Garden | | `isFurnished` | boolean | Furnished | | `hasAirConditioning` | boolean | Air conditioning | | `hasLift` | boolean | Elevator / lift | | `additionalFeatures` | object | Custom key-value features | | `imageUrls` | string[] | URLs of property images for AI image analysis | ## Example ```bash curl -X POST https://api.fondaro.com/properties/description/generate \ -H "X-API-Key: fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "propertyType": "villa", "propertyCategory": "residential", "listingType": "sale", "bedrooms": 4, "bathrooms": 3, "city": "Marbella", "region": "Costa del Sol", "countryCode": "ES", "livingArea": 320, "plotArea": 800, "areaUnit": "sqm", "price": 1250000, "currency": "EUR", "hasPool": true, "hasSeaView": true, "hasGarden": true, "imageUrls": [ "https://cdn.example.com/property/img1.jpg", "https://cdn.example.com/property/img2.jpg" ] }' ``` ## Response ```json { "description": "Discover this exceptional 4-bedroom villa in the heart of Marbella's Costa del Sol, offering breathtaking sea views and a private swimming pool. Spanning 320 sqm of living space on an 800 sqm plot, this residence features beautifully landscaped gardens, spacious terraces, and contemporary finishes throughout..." } ``` | Field | Type | Description | |-------|------|-------------| | `description` | string | AI-generated marketing description | ## Tips - Include `imageUrls` when available: the AI analyzes images to describe visual details like interior finishes, views, and architectural style - Provide accurate location data (`city`, `region`, `countryCode`) for location-specific marketing language - The generated description is ready to use as-is or as a starting point for editing --- # JamesEdition Feed Source: https://www.fondaro.com/docs/api/properties/jamesedition-feed > Public XML feed consumed by the JamesEdition crawler, following the JamesEdition Multi-Office format. Public XML feed consumed by the JamesEdition crawler. Conforms to the JamesEdition Multi-Office specification, version `4.3`. Full tag reference: [https://docs.jamesedition.com/docs/je-multi-office/fields](https://docs.jamesedition.com/docs/je-multi-office/fields). This is the legacy Fondaro-wide route, not an organization-specific token feed. See [Portal Feeds](/docs/api/properties/portal-feeds) for the `:slug/:token.xml` contract used by other destinations. ## Endpoint ``` GET /properties/feeds/jamesedition.xml ``` **Authentication:** Public crawler route protected by the shared `JAMESEDITION_FEED_TOKEN` when that server setting is configured. Send the token in the `x-feed-token` header. The legacy `?token=` query parameter is also accepted for crawler compatibility, but the header avoids putting the secret in URLs and access logs. An absent or incorrect configured token returns `403` with an empty body. If `JAMESEDITION_FEED_TOKEN` is not configured, the route remains temporarily open and logs a server warning so an existing crawler is not silently broken during provisioning. The endpoint also expects a `User-Agent` header containing `JamesEdition Feed Crawler` (for example, `JamesEdition Feed Crawler 1.0`). This header is diagnostic only and is not authentication. ## Schedule JamesEdition polls the feed three times a day, at approximately: - `00:11 UTC` - `08:11 UTC` - `16:11 UTC` The feed is generated on request; there is no cache. Expect low latency on each call. ## Eligibility A listing is included in the feed when all of the following hold: | Condition | Value | |-----------|-------| | JamesEdition publication | Enabled | | `status` | `ACTIVE` | | `propertyType` | Mapped in our property-type table (see below) | Unsupported types are filtered at the feed boundary even if a JamesEdition publication row is enabled. ### Property-type mapping | Fondaro `propertyType` | JamesEdition type | |------------------------|-------------------| | `APARTMENT` | Apartment | | `CONDOMINIUM` | Apartment | | `PENTHOUSE` | Penthouse | | `STUDIO` | Apartment | | `HOUSE_DETACHED` | House | | `HOUSE_SEMI_DETACHED` | House | | `HOUSE_TERRACED` | House | | `TOWNHOUSE` | Townhouse | | `MULTI_FAMILY_HOME` | House | | `LAND_RESIDENTIAL` | Land | | `LAND_COMMERCIAL` | Land | | `LAND_AGRICULTURAL` | Land | | `LAND_OTHER` | Land | | `OFFICE` | (filtered) | | `RETAIL` | (filtered) | | `COMMERCIAL_OTHER` | (filtered) | | `INDUSTRIAL_WAREHOUSE` | (filtered) | | `INDUSTRIAL_OTHER` | (filtered) | | `HOSPITALITY` | (filtered) | | `OTHER` | (filtered) | ## Response format Content type: `application/xml; charset=utf-8`. Root element: `<jameslist_feed version="4.3">` with three children: | Element | Contents | |---------|----------| | `<offices>` | One `<office reference="{orgUuid}">` per organization that has at least one eligible listing. | | `<agents>` | One `<agent office_reference="{orgUuid}" reference="{agentUuid}">` per agent referenced by at least one eligible listing. Deduplicated. | | `<listings>` | One `<listing reference="{listingUuid}">` per eligible listing. | ### Reference IDs All `reference` attributes are internal Fondaro UUIDs: | Attribute | Source | |-----------|--------| | `<office reference>` | `organization.id` | | `<agent reference>` | `agent.id` | | `<agent office_reference>` | `organization.id` | | `<listing reference>` | `propertyListing.id` | These IDs are permanent. They never change for a given entity. ### Empty-closed vs. omitted tags The JamesEdition spec distinguishes required fields (must be present, empty-closed if no value) from optional fields (can be omitted). We follow the spec: - Required tag with no value: emitted empty-closed, for example `<zip />`. - Optional tag with no value: omitted from the XML entirely. ## Example request ```bash curl -H "User-Agent: JamesEdition Feed Crawler 1.0" \ -H "x-feed-token: REDACTED_SHARED_FEED_TOKEN" \ https://api.fondaro.com/properties/feeds/jamesedition.xml ``` ## Example response Abbreviated, showing one office, one agent, and one listing: ```xml <?xml version="1.0" encoding="UTF-8"?> <jameslist_feed version="4.3"> <offices> <office reference="a1b2c3d4-e5f6-7890-abcd-ef1234567890"> <name>Konrad Real Estate</name> <brokerage_license_id /> <zip /> <country_code>ES</country_code> <country_subdivision /> <city>Marbella</city> <address>Calle Ejemplo 12</address> <phone1>+34 000 000 000</phone1> <phone2 /> <fax /> <email>hello@example.com</email> <description>Boutique luxury brokerage on the Costa del Sol.</description> <external_url>https://konradrealestate.es</external_url> <office_logo_url>https://cdn.example.com/logo.png</office_logo_url> <language_codes>eng, spa</language_codes> </office> </offices> <agents> <agent office_reference="a1b2c3d4-e5f6-7890-abcd-ef1234567890" reference="b2c3d4e5-f6a7-8901-bcde-f12345678901"> <first_name>Jane</first_name> <last_name>Doe</last_name> <email>jane@example.com</email> <phone1>+34 111 111 111</phone1> <phone2>+34 222 222 222</phone2> <profile_picture_url>https://cdn.example.com/jane.jpg</profile_picture_url> <agent_license_id /> <biography>Ten years selling oceanfront villas in Marbella.</biography> </agent> </agents> <listings> <listing reference="c3d4e5f6-a7b8-9012-cdef-123456789012"> <display_reference>FDR-A2B3C</display_reference> <preowned>no</preowned> <year>2023</year> <price_on_request>no</price_on_request> <rental>no</rental> <price currency="EUR">2450000</price> <location> <country>ES</country> <region>Costa del Sol</region> <latitude>36.5101</latitude> <longitude>-4.8824</longitude> <city>Marbella</city> <address>Urbanizacion Ejemplo 42</address> <zip>29660</zip> </location> <title>Villa in Marbella, ES Newly built beachfront villa with panoramic sea views. Villa 5 4 420 1200 yes yes yes yes yes yes https://cdn.example.com/listings/villa-1/01.jpg https://cdn.example.com/listings/villa-1/02.jpg b2c3d4e5-f6a7-8901-bcde-f12345678901 a1b2c3d4-e5f6-7890-abcd-ef1234567890 no no ``` Images are capped at 100 per listing, ordered by their stored `order` value. ## Derived fields A handful of fields are computed at feed-build time rather than read directly from storage: | Field | Derivation | |-------|------------| | `` | `isNewConstruction === true` emits `no`, otherwise `yes`. | | `` | `listingType === RENT` emits `yes`, otherwise `no`. | | `` | `price == null` emits `yes`, otherwise `no`. | | `` | `` `${propertyTypeLabel} in ${city}, ${countryCode}` ``. Falls back to just `propertyTypeLabel` when city or country are missing. | | `<hide_address>` | Hardcoded `no`. | | `<address_is_confidential>` | Hardcoded `no`. | ## Amenity mapping Amenity tags are emitted only when the source field is `true`. False values are not emitted. | Source field | Emitted tag | |--------------|-------------| | `hasPool` | `<pool>yes</pool>` | | `hasSeaView` | `<sea_view>yes</sea_view>` | | `hasGarage` | `<garage>yes</garage>` | | `hasTerrace` | `<terrace>yes</terrace>` | | `hasGarden` | `<garden>yes</garden>` | | `isFurnished` | `<furnished>yes</furnished>` | | `hasAirConditioning` | `<air_conditioning>yes</air_conditioning>` | | `hasLift` | `<elevator>yes</elevator>` | ## Errors On an internal error the endpoint returns `HTTP 500` with an empty body. We log the error server-side; we do not leak details to the response to keep the feed output clean for the crawler. ## Notes - XML special characters (`&`, `<`, `>`, `"`) in user-entered titles, descriptions, and addresses are escaped by the feed builder. Downstream consumers should decode them as standard XML. - Organizations with zero eligible listings are omitted from `<offices>`. The feed never emits an `<office>` with no matching `<listing>` entries. - Reference stability means you can safely cache mappings between JamesEdition's internal IDs and our UUIDs without worrying about churn. --- # Own-Listing Lifecycle Source: https://www.fondaro.com/docs/api/properties/own-listings > Understand creation, editing, terminal statuses, soft deletion, import provenance, and server-written listing history. Fondaro own listings use the existing `/properties` create, update, delete, and renew routes, but their lifecycle is optimized for inventory administration rather than an advertising-review loop. Property routes accept either a Fondaro property API key or a Clerk session JWT through the existing properties authentication guard. Every mutation is scoped to the authenticated organization. ## Status semantics The listing status vocabulary is: `active` · `inactive` · `pending` · `sold` · `rented` · `deleted` The write rules are narrower than that vocabulary: | Operation | Status behaviour | |-----------|------------------| | `POST /properties` | Always creates the listing as `pending` and assigns a Fondaro reference | | `PUT /properties/:id` without `status` | Preserves the current status, including `active`; ordinary edits no longer reset the listing to `pending` | | `PUT /properties/:id` with customer status | Accepts terminal transitions to `sold`, `rented`, or `deleted` | | `PUT /properties/:id` with `active`, `inactive`, or `pending` | The customer write path ignores the unsupported status field; do not use it as a status-management route | | `DELETE /properties/:id` | Soft-deletes by setting `status` to `deleted`; it does not physically remove the property row | | First Resales own-listing import | Creates the listing as `active` because it is already public upstream | Admin moderation has a separate internal write path for activation and other administrative transitions. There is no customer endpoint for returning a sold, rented, or deleted listing to Active. ## Update without review reset Send only the fields that should change: ```http PUT /properties/29a1e184-237a-40c8-a1fe-624b0bcbb2f4 X-API-Key: fondaro_pk_REDACTED Content-Type: application/json ``` ```json { "price": 1195000, "commission": 4.5, "description": "Updated description." } ``` If the listing was Active, it remains Active. An unchanged payload is treated as a no-op: neither `updatedAt` nor listing history advances. `referenceNumber` is immutable and cannot be changed through the update route. ## Terminal status update ```http PUT /properties/29a1e184-237a-40c8-a1fe-624b0bcbb2f4 X-API-Key: fondaro_pk_REDACTED Content-Type: application/json ``` ```json { "status": "sold" } ``` Use `rented` for a completed rental. Use `DELETE /properties/:id` or `status: "deleted"` for a soft delete. The response is the updated property object. ## Import provenance Property responses can include: | Field | Type | Meaning | |-------|------|---------| | `externalSource` | string or `null` | Upstream system, currently `resales_online` for this importer | | `externalRef` | string or `null` | Stable upstream identity used for idempotent upserts | | `lastSyncedAt` | ISO date-time or `null` | Last successful write of synchronized fields | Manually created listings normally leave these fields null. Do not edit them through the normal property form. A Resales re-import updates source-owned property fields. It does not overwrite assigned agent, commission, lead-group connection, or a status that a user or admin has changed locally. Listings absent from a later manual Resales snapshot are not automatically inactivated or deleted. ## Server-written history Every effective listing mutation writes an append-only event. Event types are: `created` · `updated` · `status_changed` · `renewed` · `imported` · `deleted` Each event contains: ```json { "id": "307e83ce-6cb1-40da-87e4-81a76399f858", "propertyListingId": "29a1e184-237a-40c8-a1fe-624b0bcbb2f4", "organizationId": "69e9323f-e150-4317-9a8b-e952d0704fc4", "actorUserId": "user_2abc123", "type": "status_changed", "changes": [ { "field": "status", "from": "active", "to": "sold" } ], "source": "user", "createdAt": "2026-07-23T15:40:00.000Z" } ``` `source` is `user`, `admin`, `import`, or `system`. Clients do not post history entries. Read them through `GET /properties/:id/activity?types=change`; see [Property Activity & Viewings](/docs/api/properties/activity). Large collection fields such as images and translated descriptions may be summarized in history rather than copied in full. --- # Portal Feeds Source: https://www.fondaro.com/docs/api/properties/portal-feeds > Public token-bound XML feeds for portal syndication, including caching, crawl evidence and eligibility semantics. Fondaro exposes organization-specific XML feeds for property portals that pull a complete listing snapshot. Each connection has an opaque bearer token and is bound to one organization and one portal format. ## Public feed route ```http GET /properties/feeds/:slug/:token.xml ``` The route is public because third-party crawlers cannot use a Fondaro session or API key. The token is the authorization secret. It is generated from 32 random bytes, encoded for a URL path, and must be stored and transmitted as an opaque value. Do not parse it, put it in query parameters, log it, or expose it in a client application. ```bash curl -i \ https://api.fondaro.com/properties/feeds/kyero/REDACTED_OPAQUE_TOKEN.xml ``` A successful response has `Content-Type: application/xml; charset=utf-8`. ## Slugs, families and binding | Feed family | Accepted live or Beta slugs | |-------------|-----------------------------| | Kyero V3 | `kyero`, `thinkspain`, `a-place-in-the-sun`, `properstar`, `spain-property-portal`, `spainhouses`, `indomio`, `arkadia` | | Thribee | `trovit`, `mitula`, `nestoria`, `nuroa` | | Green-Acres | `green-acres` | | Homesgofast | `homesgofast` | For a non-grouped portal, a token resolves only with that connection's slug. Combining a Kyero token with the `green-acres` slug, for example, returns `404`. Trovit, Mitula, Nestoria and Nuroa are one Thribee feed group. They share a single physical connection, token, XML snapshot, crawl evidence and publication selection. Fondaro generates the canonical URL with the `trovit` slug; the resolver binds each of the four group slugs to that same connection. A Thribee token does not work with any slug outside the group. Every generated feed is absolute, not incremental. It contains the complete set of currently selected, Active and eligible listings for that organization. A listing that is removed, becomes ineligible, or leaves Active status is absent from the next response, which tells the portal to remove it on its next processing cycle. ## Not-found behaviour The route returns plain `404 Feed not found` without revealing which check failed when: - the slug is unknown or belongs to a coming-soon portal; - the token does not exist or is bound to another slug or group; - the owning organization does not exist or is disabled; or - a required connection fact is missing, including the Green-Acres account reference. Internal generation failures return plain `500 Feed unavailable`. Neither response exposes server details or organization data. ## Conditional requests and cache headers Every successful build returns: - `Cache-Control: no-store`, because the path contains a bearer secret and the feed is generated from current organization data; - `ETag`, derived from the complete XML response; and - `Last-Modified`, set to the latest `updatedAt` among eligible listings when the feed is not empty. Send the previous ETag in `If-None-Match`: ```bash curl -i \ -H 'If-None-Match: "PREVIOUS_ETAG"' \ https://api.fondaro.com/properties/feeds/kyero/REDACTED_OPAQUE_TOKEN.xml ``` An exact strong or weak ETag match, or `If-None-Match: *`, returns `304 Not Modified` with no XML body. `Last-Modified` is response metadata; this endpoint does not currently evaluate `If-Modified-Since`. An empty feed has no listing-derived `Last-Modified` value. `no-store` prevents treating the response as reusable cached content. The ETag still defines how the server responds when a crawler supplies an `If-None-Match` validator on a later direct request. ## Crawl and publication evidence Fondaro records evidence only after the HTTP response finishes. Both a `200` XML response and a `304` validation are successful crawl evidence. The connection stores its first and latest crawl time, the latest truncated user agent and a crawl count. Repeated writes are throttled to at most once per minute per connection within each running API process. Multiple API replicas can therefore record more than one write in a minute, so `crawlCount` is an operational signal rather than a raw request counter. Before recording the crawl timestamp, Fondaro marks each listing actually present in that feed as served. This drives the publication statuses: | Status | Evidence | |--------|----------| | `queued` | The listing is not Active, or it has not yet been served in the selected feed. | | `in_feed` | The listing was served, but no connection crawl at or after that first serve proves the portal fetched it. JamesEdition also stops at this state because its shared route has no per-organization connection evidence. | | `live` | The portal connection was crawled at or after the listing first entered the feed. This does not prove downstream acceptance or display. | | `not_connected` | The publication is selected but its organization connection does not exist. | | `ineligible` | At least one material portal rule currently fails. | ## Rotate a token Organization administrators rotate a connection through the dashboard or the authenticated management route: ```http POST /properties/portal-connections/:portal/rotate ``` Rotation replaces the token and invalidates the old URL immediately. It also resets first-crawl evidence for the replacement URL. Give the new URL to the portal and keep the rotation warning visible until a crawl of the replacement is recorded. Rotating any Thribee member rotates the shared four-brand connection. The connection list redacts `feedUrl` by default. An authenticated organization administrator may explicitly request secrets with `GET /properties/portal-connections?includeFeedUrl=true`. Connection creation, account-reference updates, rotation and disconnection are admin-only operations. ## Green-Acres account reference Green-Acres requires its agency identifier in every `<account_id>`. Save the value supplied by Green-Acres through the portal detail page or: ```http PATCH /properties/portal-connections/green_acres Content-Type: application/json { "accountRef": "YOUR_GREEN_ACRES_ACCOUNT_ID" } ``` The connection token can exist before this value is saved, but the public feed returns the same plain `404` response until a non-empty account reference is present. Updating `accountRef` does not rotate the URL. ## Eligibility reasons Listing detail and publication responses use these exact `PortalIneligibilityReason` values. A response can contain more than one. | Reason | Meaning | |--------|---------| | `not_active` | The listing is not Active. This is projected as `queued`, not as a material `ineligible` status. | | `no_price` | The portal family requires a price and none is stored. | | `price_out_of_range` | The numeric price is invalid or outside that serializer's supported range. | | `no_city` | A city is required. | | `no_province` | A province or state is required. | | `no_postcode` | A postcode is required. | | `no_coordinates` | Both latitude and longitude are required and must be finite numbers. | | `country_not_supported` | The selected portal does not accept the listing country. Kyero itself is restricted to Spain in this integration. | | `currency_not_supported` | The feed format does not support the listing currency. | | `type_not_supported` | The property type has no mapping for that portal family. | | `no_images` | No usable image URL meets the portal's image rule. Kyero-family URLs must end directly in GIF, JPEG, JPG or PNG with no query-string suffix. | | `rent_period_unsupported` | The listing type cannot be represented as a supported sale or rental period. | | `description_too_short` | No authoritative description meets the family's language or minimum plain-text length rule. Thribee requires at least 30 characters after HTML is removed. | | `no_public_url` | Thribee cannot resolve a stable public URL for the advert. | Eligibility is authoritative on the server. Clients should display these receipts and must not reimplement the portal rules. ## Legacy JamesEdition route JamesEdition remains on its existing Fondaro-wide multi-office endpoint: ```http GET /properties/feeds/jamesedition.xml ``` It does not use `:slug/:token.xml`, has no organization connection token, and is not part of the cache and ETag contract described above. Per-listing JamesEdition selection uses the shared publication model, but the XML envelope and crawler schedule remain the legacy JamesEdition 4.3 contract. See [JamesEdition Feed](/docs/api/properties/jamesedition-feed). --- # Search Properties Source: https://www.fondaro.com/docs/api/properties/search > Search and filter property listings with text queries, location filters, geo search, and more. Search for property listings using ranked text queries, structured filters, geographic search, sorting and cursor pagination. ## Endpoint ``` POST /properties/search ``` **Authentication:** Required (API key or JWT) ## Query Parameters These are passed as URL query parameters, not in the request body. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `filterByOrganization` | boolean | `false` | **Deprecated.** The old spelling of `"scope": "own"`. Still honoured; a request that sends it gets a `Deprecation: true` response header. Use `scope` in the body instead. | | `includeBranding` | boolean | `false` | Include organization branding settings in results | | `includeAdCount` | boolean | `false` | Include ad creative count per property | | `includeAdCreatives` | boolean | `false` | Include full ad creative data per property | ## Request Body All fields are optional. An empty body `{}` returns active properties from every agency with default pagination. ### Scope | Field | Type | Description | |-------|------|-------------| | `scope` | `"own"`, `"network"`, `"partners"` or `"followed"` | `own`: your organization's listings, in any status you request. `network`: every agency's active listings, yours included. `partners`: the active listings of your agency's partner agencies (dashboard only; an API key gets `400` `PROPERTY_FILTER_UNSUPPORTED`). `followed`: the active listings of the agencies you follow, and of the agencies of the agents you follow, never your own agency's (dashboard only; an API key gets the same `400`). | When `scope` is omitted, the search is `network`, except that requesting any non-active status, or sending `?filterByOrganization=true`, makes it `own`. Two combinations are refused with a `400`, so an older own-listings request can never turn into a network search: | Request | Result | |---------|--------| | `"scope": "network"` with `?filterByOrganization=true` | `400`, code `PROPERTY_SCOPE_CONFLICT` | | `"scope": "network"` with a non-active `status` | `400`, code `PROPERTY_SCOPE_NETWORK_ACTIVE_ONLY` | | `"scope": "network"` with a `commission` range or a `commission` sort | `400`, code `PROPERTY_FILTER_UNSUPPORTED` | | `agentId` with `"scope": "network"`, or with `scope` omitted on an active search | `400`, code `PROPERTY_FILTER_UNSUPPORTED` | | `"scope": "partners"` with `?filterByOrganization=true` | `400`, code `PROPERTY_SCOPE_CONFLICT` | | `"scope": "partners"` with a non-active `status` | `400`, code `PROPERTY_SCOPE_NETWORK_ACTIVE_ONLY` | | `"scope": "partners"` with a `commission` range or a `commission` sort | `400`, code `PROPERTY_FILTER_UNSUPPORTED` | | `agentId` with `"scope": "partners"` | `400`, code `PROPERTY_FILTER_UNSUPPORTED` | | `"scope": "followed"` with `?filterByOrganization=true` | `400`, code `PROPERTY_SCOPE_CONFLICT` | | `"scope": "followed"` with a non-active `status` | `400`, code `PROPERTY_SCOPE_NETWORK_ACTIVE_ONLY` | | `"scope": "followed"` with a `commission` range or a `commission` sort | `400`, code `PROPERTY_FILTER_UNSUPPORTED` | | `agentId` with `"scope": "followed"` | `400`, code `PROPERTY_FILTER_UNSUPPORTED` | | `"scope": "own"` with `?filterByOrganization=true` | `own` | ### Network results Your own listings come back in full. Another agency's listing comes back as its network version: - **Included:** everything else on the listing, including the street address (`addressLine1`, `addressLine2`), `postalCode`, exact `latitude` and `longitude`, `sharedCommission` (the commission the agency offers a collaborating agency), `organizationBranding` and `agentId`. - **Left out:** `commission`, `viewingInstructions`, `leadGroupId`, and the `additionalFeatures` keys an import keeps for itself (those starting with `resales`). - **Agent card** (`includeBranding=true`): `id`, `clerkUserId`, names, `imageUrl` and `title`, so you can message the agent. `displayEmail`, `phoneNumber` and `whatsappNumber` appear only when that agent shares their contact with the network (on by default; set per agent with `shareContactOnNetwork` on `PUT /agents/:id`). Search results never include `sharedCommissionNotes`; read the listing with [`GET /properties/:id`](/docs/api/properties/crud) for the terms of the offer. Another agency's listing that is not active is never returned. ### Text Search | Field | Type | Description | |-------|------|-------------| | `query` | string | Search words in web-search syntax, for example `villa benahavis "sea views" -golf` | `query` searches each listing's reference, title, town, community, region, province, postcode, property type and category, description, every translated description, amenities and street address. Accents and case are ignored (`Benahavis` finds `Benahavís`), English words match their other forms (`villas` finds `villa`), and a word from a Spanish, Swedish or any other translation matches as written. Viewing instructions and commission are never searched. | Syntax | Example | Matches | |--------|---------|---------| | Words | `villa pool marbella` | Listings containing every word | | `"phrase"` | `"La Quinta"` | The words next to each other, in that order | | `-word` | `villa -golf` | Excludes listings containing the word | | `OR` | `penthouse OR duplex` | Either word | | Reference prefix | `FDR-A2B` | Listings whose reference starts with it | Numbers are words like any other: `3 bedrooms under 500000` is not read as a bedroom count or a price. Use the `bedrooms` and `price` filters for those. With a `query` and no sort, results are ordered by relevance: an exact reference match first, then listings where the words appear in the reference or title, then town, community, type and category, then description, translations and amenities, then street address; ties go to the most recently published. See [Sorting](#sorting). ### IDs and References | Field | Type | Description | |-------|------|-------------| | `ids` | string[] | Fetch specific properties by their UUIDs. When provided, other filters still apply but progressive filter relaxation is skipped. Useful for batch-fetching a known set of properties. | | `referenceNumbers` | string[] | Match exact, case-sensitive listing references, for example `["FDR-A2B3C", "HQL-D4E5F"]`. This filter also applies when `query` or other filters are supplied. | ### Agent | Field | Type | Description | |-------|------|-------------| | `agentId` | string (UUID) | Your listings assigned to this agent, an `id` from your roster (`GET /agents`). Needs `"scope": "own"`. | `agentId` filters your own listings only, so it can never reveal another agency's roster. With network scope it is a `400` (`PROPERTY_FILTER_UNSUPPORTED`). An id that is not an agent on your roster, whether unknown or another agency's, is a `400` (`PROPERTY_FILTER_VALUE_INVALID`). ```bash curl -X POST https://api.fondaro.com/properties/search \ -H "X-API-Key: fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{"scope":"own","agentId":"5f1c2a9e-3b7d-4e8a-9c21-0d4b6a7e8f10","cursor":"*"}' ``` `agentUserIds` is the same filter for several people at once, by Clerk user id (the dashboard's Owner picker speaks users, not roster rows). It is optional, takes up to 50 ids, and needs `"scope": "own"`; with network scope it is a `400` (`PROPERTY_FILTER_UNSUPPORTED`). Each id is matched to the roster row in your own agency, so an id that is not on your roster matches nothing. An empty array means no filter. ```bash curl -X POST https://api.fondaro.com/properties/search \ -H "X-API-Key: fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{"scope":"own","agentUserIds":["user_2abc","user_2def"],"cursor":"*"}' ``` ### Array Filters Each accepts a single value or an array of values to match against. `propertyType`, `propertyCategory`, `listingType` and `status` accept only the values listed; any other value is a `400` naming the field. Categories and types filter independently, so a type outside the chosen categories simply matches nothing. | Field | Type | Values | |-------|------|--------| | `propertyType` | string[] | `apartment`, `condominium`, `house_detached`, `house_semi_detached`, `house_terraced`, `townhouse`, `multi_family_home`, `penthouse`, `studio`, `land_residential`, `land_commercial`, `land_agricultural`, `land_other`, `office`, `retail`, `commercial_other`, `industrial_warehouse`, `industrial_other`, `hospitality`, `other` | | `propertyCategory` | string[] | `residential`, `commercial`, `industrial`, `land` | | `listingType` | string[] | `sale`, `rent`, `sale_or_rent`, `fraction` | | `countryCode` | string[] | ISO 3166-1 alpha-2 codes (e.g., `ES`, `AE`, `US`) | | `region` | string[] | Region names (e.g., `Costa del Sol`, `Dubai`) | | `city` | string[] | City names (e.g., `Marbella`, `Dubai Marina`) | | `status` | string[] | `active`, `inactive`, `pending`, `sold`, `rented`, `deleted` | ### Range Filters Each range filter is an object with optional `min` and `max` fields. | Field | Type | Description | |-------|------|-------------| | `bedrooms` | `{ min?, max? }` | Number of bedrooms | | `bathrooms` | `{ min?, max? }` | Number of bathrooms | | `price` | `{ min?, max? }` | Price in the property's currency | | `livingArea` | `{ min?, max? }` | Living area in the property's area unit | | `plotArea` | `{ min?, max? }` | Plot area in the property's area unit | | `commission` | `{ min?, max? }` | Your private commission percentage (0–100). See [Commission is private](#commission-is-private). | ### Feature Filters `features` takes either a list of feature slugs or an object of booleans. A list of 1 to 30 slugs from the [amenity catalogue](/docs/api/properties/crud#amenities-and-furnishing) returns listings that have every one of them: ```json { "features": ["pool", "sea_view", "gated_complex"] } ``` The object form accepts the eight fields below. `true` requires the amenity and `false` excludes it; `isFurnished: true` matches `partly` and `fully` furnished listings, and `isFurnished: false` matches `no` or unknown. Furnishing is only available in the object form. | Field | Type | Description | |-------|------|-------------| | `hasPool` | boolean | Swimming pool | | `hasSeaView` | boolean | Sea or ocean view | | `hasGarage` | boolean | Garage or covered parking | | `hasTerrace` | boolean | Terrace or balcony | | `hasGarden` | boolean | Garden | | `isFurnished` | boolean | Comes furnished | | `hasAirConditioning` | boolean | Air conditioning | | `hasLift` | boolean | Elevator / lift | ### Geographic Filters Use at most one geographic filter. If more than one is provided, the API uses `boundingBox` first, then `polygon`, then `location`. **Radius search** (`location`): | Field | Type | Required | Description | |-------|------|----------|-------------| | `location.lat` | number | Yes | Latitude of center point | | `location.lon` | number | Yes | Longitude of center point | | `location.distance` | string | No | Search radius (default: `"10km"`) | **Bounding box** (`boundingBox`): | Field | Type | Required | Description | |-------|------|----------|-------------| | `boundingBox.northEast.lat` | number | Yes | North-east corner latitude | | `boundingBox.northEast.lon` | number | Yes | North-east corner longitude | | `boundingBox.southWest.lat` | number | Yes | South-west corner latitude | | `boundingBox.southWest.lon` | number | Yes | South-west corner longitude | **Polygon** (`polygon`): An array forming a ring of at least three `[longitude, latitude]` coordinate pairs. The API closes the ring automatically. ```json { "polygon": [ [-4.92, 36.48], [-4.81, 36.48], [-4.84, 36.56] ] } ``` ### Sorting Use either the `sort` array or the flat `sortBy`/`sortOrder` fields. **`sort` array:** ```json { "sort": [{ "field": "price", "order": "asc" }] } ``` **Flat fields:** | Field | Type | Description | |-------|------|-------------| | `sortBy` | string | Sort field name | | `sortOrder` | `"asc"` or `"desc"` | Sort direction (default: `desc`) | **Allowed sort fields:** `relevance`, `price`, `createdAt`, `updatedAt`, `publishedAt`, `bedrooms`, `bathrooms`, `livingArea`, `plotArea`, `commission`, `yearBuilt`, `city`, `countryCode`, `referenceNumber`, `status`, `propertyType`, `propertyCategory`, `listingType` With no sort, a search with a `query` is ordered by `relevance` and a search without one by most recently published. `"sort": "relevance"` (or `"sortBy": "relevance"`) asks for relevance explicitly; it cannot be combined with another sort field, and without a `query` it falls back to the most recently published first. Any other sort replaces relevance. Every order ends with the listing id, so equal values always come back in the same order. ### Commission is private Each agency's `commission` is visible only to that agency, so it never selects or orders another agency's listings: - With `"scope": "own"` (or `?filterByOrganization=true`), the `commission` range and a `commission` sort work on your listings as usual. - With `"scope": "network"`, a `commission` range or sort is refused with a `400` (`PROPERTY_FILTER_UNSUPPORTED`). - With `scope` omitted, another agency's commission counts as unknown: the `commission` range matches only your own listings, and a `commission` sort orders your listings by commission with other agencies' listings after them. - `query` never searches commission in any scope. To compare what agencies offer collaborators, read `sharedCommission` on each result. ### Pagination Two ways to page, chosen per request. **Cursor (recommended).** Send `"cursor": "*"` for the first page. Each response carries `nextCursor` while more results exist; send it back as `cursor` with the same body to get the next page. Pages continue after the last result you received, so listings published while you page never repeat or push a result off a page. A cursor is bound to your organization, the scope and the exact criteria (query, filters, geometry, sort); changing any of them, or sending a cursor older than 30 minutes, is a `400` with code `PROPERTY_CURSOR_INVALID`: start again with `"cursor": "*"`. `limit` may change between pages. Sending `page` together with `cursor` is a `400`. | Field | Type | Default | Description | |-------|------|---------|-------------| | `cursor` | string | none | `"*"` to start, then the previous `nextCursor` | | `limit` | integer | `20` | Results per page (1–100) | **Page numbers.** Without `cursor`, `page` and `limit` work as before and `total` is always the exact count. | Field | Type | Default | Description | |-------|------|---------|-------------| | `page` | integer | `1` | Page number (1-indexed) | | `limit` | integer | `20` | Results per page (1–100) | A `boundingBox` (map) search returns up to 1,000 results per page by default. ### Price Distribution Request histogram data for building price range charts. The histogram counts the listings matching every filter of the search except the `price` range itself, so a price slider can show the prices on either side of the current selection. Buckets are exactly `interval` wide, starting at the lowest price. | Field | Type | Default | Description | |-------|------|---------|-------------| | `priceDistribution.interval` | number | `50000` | Bucket size | | `priceDistribution.min` | number | none | Count only prices at or above this; the first bucket starts here | | `priceDistribution.max` | number | none | Count only prices at or below this | ## Response ```json { "results": [ { "id": "a1b2c3d4-...", "referenceNumber": "FDR-A2B3C", "listingType": "sale", "propertyCategory": "residential", "propertyType": "house_detached", "city": "Marbella", "countryCode": "ES", "price": 1250000, "currency": "EUR", "bedrooms": 4, "bathrooms": 3, "livingArea": 320, "hasPool": true, "hasSeaView": true, "status": "active", ... } ], "total": 142, "priceDistribution": { "interval": 50000, "minPrice": 200000, "maxPrice": 5000000, "buckets": [ { "key": 200000, "count": 5 }, { "key": 250000, "count": 12 }, ... ] }, "relaxedFilters": ["bedrooms"] } ``` | Field | Type | Description | |-------|------|-------------| | `results` | array | Array of property listing objects | | `total` | number | Page numbers: the exact number of matching listings, on every page. Cursor: first page only, see below | | `totalIsLowerBound` | boolean | Cursor first page only: `true` when `total` is `10000` and more listings match | | `nextCursor` | string | Cursor only: present while more results exist | | `priceDistribution` | object | Only present if `priceDistribution` was included in the request | | `relaxedFilters` | string[] | Present when a network search found nothing and was widened. Steps, in order and cumulative: `features`, `query`, `listingType`, `price` (half the minimum, 1.5 times the maximum), `priceDropped`, `bedrooms`, `propertyType`; the list names every step applied. A cursor search keeps the same widening on every page. | **Counting on cursor pages.** Only the first page (`"cursor": "*"`) carries `total`: exact up to 10,000, beyond that `10000` with `totalIsLowerBound: true`. Keep it while you page; later pages have no `total`. A `boundingBox` search has no `total` at all; read the number of results and `nextCursor`. Whether more pages exist is `nextCursor`, never a comparison with `total`. ```json { "results": [ ... ], "total": 10000, "totalIsLowerBound": true, "nextCursor": "Zp3x...kQ" } ``` ## Examples ### Basic Search ```bash curl -X POST https://api.fondaro.com/properties/search \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "query": "villa pool marbella -golf", "limit": 10 }' ``` ### Cursor Pages ```bash curl -X POST https://api.fondaro.com/properties/search \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "query": "\"sea views\" benahavis", "cursor": "*", "limit": 20 }' # Next page: the same body with the previous response's nextCursor curl -X POST https://api.fondaro.com/properties/search \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "query": "\"sea views\" benahavis", "cursor": "Zp3x...kQ", "limit": 20 }' ``` ### Filtered Search ```bash curl -X POST https://api.fondaro.com/properties/search \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "propertyType": ["house_detached", "townhouse"], "listingType": ["sale"], "countryCode": ["ES"], "price": { "min": 500000, "max": 2000000 }, "bedrooms": { "min": 3 }, "features": { "hasPool": true, "hasSeaView": true }, "sort": [{ "field": "price", "order": "asc" }], "page": 1, "limit": 20 }' ``` ### Geo Radius Search ```bash curl -X POST https://api.fondaro.com/properties/search \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "location": { "lat": 36.5101, "lon": -4.8824, "distance": "5km" }, "propertyType": ["apartment"], "limit": 20 }' ``` ### Batch Fetch by IDs ```bash curl -X POST "https://api.fondaro.com/properties/search?includeBranding=true" \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "ids": [ "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901" ] }' ``` ### Your Own Listings with Branding ```bash curl -X POST "https://api.fondaro.com/properties/search?includeBranding=true" \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "scope": "own", "status": ["active", "pending"], "sort": [{ "field": "createdAt", "order": "desc" }] }' ``` ### Network Listings ```bash curl -X POST "https://api.fondaro.com/properties/search?includeBranding=true" \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{ "scope": "network", "city": ["Marbella"], "limit": 20 }' ``` --- # Similar Properties Source: https://www.fondaro.com/docs/api/properties/similar > Find properties similar to a given listing using text-based similarity matching. Find properties that are similar to a specific listing. This is useful for "you might also like" sections on property detail pages. ## Endpoint ``` GET /properties/:id/similar ``` **Authentication:** Required (API key or JWT) ## How It Works Similarity uses seven progressively broader PostgreSQL query tiers until the requested limit is filled: city, property type, bedrooms and price; city; province; region; country; property type globally; then any active property. Results within each tier are ordered by publication date. Similar listings from other agencies come back as their [network version](/docs/api/properties/crud#get-a-property): no private commission, viewing instructions or lead group. If the reference property belongs to another agency and is not active, the response is a `404`. ## Path Parameters | Parameter | Type | Description | |-----------|------|-------------| | `id` | UUID | The ID of the reference property to find similar listings for | ## Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `limit` | number | `5` | Maximum number of similar properties to return | | `filterByOrganization` | boolean | `false` | Only return properties from your organization | | `includeBranding` | boolean | `false` | Include organization branding data | | `includeAdCount` | boolean | `false` | Include ad creative count per property | | `includeAdCreatives` | boolean | `false` | Include full ad creative data | ## Example ```bash curl "https://api.fondaro.com/properties/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d/similar?limit=4" \ -H "Authorization: ApiKey fondaro_pk_abc123" ``` ## Response Returns an array of property listing objects, ordered by similarity: ```json [ { "id": "f1e2d3c4-...", "referenceNumber": "FDR-A2B3C", "listingType": "sale", "propertyType": "villa", "city": "Marbella", "countryCode": "ES", "price": 1350000, "currency": "EUR", "bedrooms": 4, "bathrooms": 3, "hasPool": true, "status": "active", ... }, ... ] ``` ## Errors | Status | Condition | |--------|-----------| | `400` | Invalid UUID format for property ID | --- # Property Sources Source: https://www.fondaro.com/docs/api/properties/sources > Discover connected property sources and use one search, detail, location and option contract. Use `/property-sources` to discover the sources available to your organization. The same endpoints accept a property API key or an authenticated dashboard JWT with an active organization. Source credentials belong to that organization; a source connected elsewhere does not become available to your key. ## Endpoints | Method | Path | Result | |--------|------|--------| | `GET` | `/property-sources` | Source descriptors and current organization connectivity | | `POST` | `/property-sources/:source/search` | Unified listings, receipt and optional continuation | | `GET` | `/property-sources/:source/detail/:id?country=` | One unified listing with its receipt | | `GET` | `/property-sources/:source/locations?q=&country=` | Location choices with opaque `locationId` values | | `GET` | `/property-sources/:source/options/:facet` | Supported option values, labels and optional aliases | | `POST` | `/property-sources/search` | One to five sources in one interleaved page (signed-in dashboard sessions only) | | `GET` | `/property-sources/allowance` | Today's use of the daily portal allowance (signed-in dashboard sessions only) | ## Discover capabilities ```bash curl https://api.fondaro.com/property-sources \ -H "Authorization: ApiKey fondaro_pk_abc123" ``` Each descriptor includes `id`, `label`, `family`, `connected`, `connection`, country information and `capabilities`. A disconnected source can include a display-safe `reason`. `connection` is the verified state for your organization: | `connection.status` | Meaning | |---|---| | `connected` | The saved credentials passed their latest live check (the Fondaro network is always connected; a property portal is connected when Fondaro's shared RealtyAPI key is configured, since portals have no per-organization credential). | | `attention` | Credentials are saved but the latest check failed or could not run. `connection.reason` is one of `credentials_incomplete`, `credentials_rejected`, `access_refused`, `provider_unavailable`, `network_unavailable` or `check_failed`, and `connection.message` says what to fix. | | `disconnected` | Nothing is saved for this source. | `connected` is `true` only when `connection.status` is `connected`; it is kept for existing clients. A source that needs attention is refused like a disconnected one (`PROPERTY_SOURCE_NOT_CONNECTED`). Checks are cached per organization and credential for 6 hours after a pass and 5 minutes after a failure; changing the credentials checks them again on the next read. ```json { "id": "zoddak", "label": "Zoddak", "connected": false, "reason": "Zoddak did not accept this API token. Copy it again from your Zoddak portal, disconnect, and save the new one.", "connection": { "status": "attention", "reason": "credentials_rejected", "message": "Zoddak did not accept this API token. Copy it again from your Zoddak portal, disconnect, and save the new one.", "checkedAt": "2026-09-24T10:15:00.000Z" } } ``` Capabilities describe supported views, detail, adding by link, brochure eligibility, image policy, spend and filter facets. Use these fields to build controls; a returned listing field does not imply a corresponding search filter. `brochureImageLimit`, when present, is the number of photos a brochure keeps from one listing. `nativePageSize`, when present (the property portals), is the number of rows in one of the source's own result pages. `detailOptions` lists the extra detail-request options a source honours. Resales Online declares `viewerId` (opening a listing through an agency's property-viewer share link). Fondaro uses it server-side for brochures. The HTTP detail endpoint accepts only `country`, and a viewer id is never part of a listing `ref`. A facet can require a value, expose discoverable options, support multiple values, constrain a numeric range or name supported geometries. Respect its `min`, `max`, `integer`, `range` and `rangeConstraint` fields. `verifiable` describes whether returned facts can verify a filter; the search receipt records what was verified for that particular response. Canonical source ids are `internal` (the Fondaro network), `resales_online`, `zoddak`, `inmobalia`, and the portal ids `idealista`, `rightmove`, `immobiliare`, `immoscout`, `funda`, `zoopla`, `seloger`, `otodom`, `immowelt`, `realtor`, `homes`, `trulia`, `propertyfinder`, `dubizzle`, `centris`, `loopnet`, `zillow`, `redfin` and `bayut`. Use the canonical id in the URL. Stored client-site aliases such as `fondaro` and `resales-online` are not endpoint source ids. ## Search Search one source with the fields its descriptor supports. Unknown fields, unsupported filters and invalid values fail explicitly; criteria are never silently relaxed. ```bash curl -X POST https://api.fondaro.com/property-sources/internal/search \ -H "Authorization: ApiKey fondaro_pk_abc123" \ -H "Content-Type: application/json" \ -d '{"scope":"own","minPrice":250000,"minBedrooms":3,"limit":10}' ``` | Input | Meaning | |-------|---------| | `scope` | `own` or `network` on sources that advertise scope; Fondaro defaults to active network listings. Signed-in dashboard sessions may also send `partners` or `followed` | | `organizationId`, `agentUserId` | Signed-in dashboard sessions only: one agency's (or one of its agents') network listings, on sources that advertise scope | | `openHouseWithinDays` | Signed-in dashboard sessions only: 1 to 14; only listings with a published open house for other agencies that has not ended and starts within that many days | | `query` | Text search, when supported | | `locationIds` | Opaque tokens returned by this source's location endpoint | | `locations` | Signed-in dashboard sessions only: stored `location` objects from location rows, instead of `locationIds` | | `country` | Backend country selected from the descriptor | | `operation`, `propertyTypes`, `features`, `stages`, `statuses`, `sort` | Source-supported option values | | `minPrice`, `maxPrice` | Price bounds | | `minBedrooms`, `maxBedrooms`, `minBathrooms`, `maxBathrooms` | Bedroom and bathroom bounds | | `minRooms`, `maxRooms` | Total-room bounds, distinct from bedrooms | | `minBuildSize`, `maxBuildSize`, `minPlotSize`, `maxPlotSize` | Area bounds | | `referenceNumbers` | Exact listing references, when supported | | `newDevelopments` | Development filter, when supported | | `geo` | A supported `circle`, `polygon` or `boundingBox` | | `limit` | Page size from 1 to 20 | | `cursor` | Opaque continuation from an earlier response | ImmoScout, Immobiliare and Otodom filter total rooms using `minRooms`, with integer minima. Otodom accepts up to ten. These sources do not advertise bedroom filters or room maxima. Returned room facts can still contain halves, ranges or an open upper bound (`roomsTo: null`). Genuine detail bedrooms remain separate. The optional `sources` array accepts exactly one source matching the URL: this route searches the source its URL names, and more than one entry returns `MULTI_SOURCE_NOT_SUPPORTED`. To search one or more sources through one door, see [One to five sources in one search](#one-to-five-sources-in-one-search). An optional body `source` must also match the URL. Search responses include `source`, `showing`, `results` and `receipt`. The count has three states: | Response | Meaning | |----------|---------| | `total` without `totalIsLowerBound` | Exact number of matches | | `total` with `totalIsLowerBound: true` | At least that many; the source stopped counting | | No `total` | Unknown: the provider did not supply a reliable count, or this is a continuation page | The Fondaro network counts on the first page only, up to 10,000: beyond that it returns `total: 10000` with `totalIsLowerBound: true`, and later pages carry no count. Keep the first page's count while you page. Other sources never set `totalIsLowerBound`. Treat `nextCursor` as the continuation authority rather than calculating pages from `total`. To continue, send the cursor to the same source: ```json { "cursor": "<nextCursor from the preceding response>" } ``` Cursors bind organization, source, criteria and page size. Reset pagination when any of those changes. Provider sessions remain inside the opaque cursor; do not extract or submit a separate Resales query id. An expired or mismatched cursor returns `PROPERTY_CURSOR_INVALID`; restart the search with its original criteria. The receipt contains requested and resolved criteria, applied facets, redacted provider parameter names and `excludedUpstreamMismatches`. `result_verified` means returned facts support the applied filters. `wire_applied_only` means the request was mapped to the provider but some response facts could not verify it. ## One to five sources in one search `POST /property-sources/search` runs one set of criteria over 1 to 5 sources and returns one interleaved page. It is available to signed-in dashboard sessions only: a property API key receives `401`. Keys, MCP and the assistant keep the one-source route for now. ```bash curl -X POST https://api.fondaro.com/property-sources/search \ -H "Authorization: Bearer <dashboard session token>" \ -H "Content-Type: application/json" \ -d '{ "sources": ["internal", "resales_online", "idealista"], "placeIds": ["<place id from GET /places>"], "propertyTypes": ["villa"], "minBedrooms": 3, "maxPrice": 1200000, "sort": "mixed", "unsupportedFacets": "ignore" }' ``` | Input | Meaning | |-------|---------| | `sources` | 1 to 5 distinct source ids, in display order. Always explicit; there is no "all sources" | | `placeIds` | 1 to 5 place ids from `GET /places`; not combinable with `geo` | | `sort` | `mixed` (default), `price_asc` or `price_desc` | | `cursor` | The previous page's `nextCursor`; anything sent beside it must repeat the original request | | `scope` | `own`, `network`, `partners` or `followed`, sent only to sources that advertise scope (the Fondaro network); other sources are searched without it and never dropped for it | | `organizationId` | One agency's network listings, on the same sources as `scope` | | `openHouseWithinDays` | 1 to 14, on the same sources as `scope` (see the one-source table) | | `unsupportedFacets` | `drop_source` (default) or `ignore`: what a source that cannot apply a requested filter does (below) | | Criteria | The one-source search fields (`propertyTypes`, `minPrice`, `minBedrooms`, `features`, `geo`, …) except `agentUserId`, `locationIds`, `locations`, `sort` and `limit` | **Filters are an intersection, never widened silently.** Each source compiles the request through its own contract. By default (`drop_source`), a source that does not offer a requested filter is not searched: its receipt says `dropped: { "reason": "facet_unsupported", "facets": [...] }`. With `unsupportedFacets: "ignore"`, the source is searched without the filters it lacks, and without option values it cannot resolve, and its receipt names them in `ignoredFacets` (for example `["bathrooms"]`), so the caller can say "not applied on this source". A place is never ignored: a source that cannot filter by place is still dropped. A source with a total-rooms filter and no bedroom filter receives the bedroom minimum as rooms. Filters a source applies but cannot check against its rows keep `verification: "wire_applied_only"` on that source's receipt. **Places.** Each source receives its own location value for a place. When a source has no value for a place yet, Fondaro looks the place name up once at that source (on a portal this costs one unit of the daily allowance, shown as `placeLookupUnits`) and remembers an exact match. A place the source still cannot name, or whose country the source does not serve, drops that source with `place_unresolved`. Inmobalia has no location filter, so any `placeIds` drop it. A source your organization has not connected is dropped with `not_connected`. **Fondaro network** rows default to the network scope: every agency's active listings, including your own. `scope: "own"` narrows them to your agency's active listings. **Order.** `mixed` takes rows in turn from each source in the order of `sources`, each source in its own default order. `price_asc` and `price_desc` compare the price in EUR (USD converts at the latest stored rate; another currency, or no price, sorts last); ties keep source order. A source that offers the same sort receives it too. There is no newest or relevance sort across sources. **Pages.** Each page asks every source that still has results for its next 12 rows and orders that batch, so order is per page, not across pages. `nextCursor` is absent when every source is exhausted. A cursor expires after 10 minutes and belongs to your organization and the original request; a changed request returns `PROPERTY_CURSOR_INVALID`. **Time limit.** A source that has not answered within 8 seconds is reported in `errors` with `PROPERTY_SOURCE_TIMEOUT` and `retryable: true`, and the other sources' rows are returned. Loading more tries it again. A portal unit it already spent stays spent and appears on its receipt. **Duplicates are grouped, never removed.** `groups` lists rows of this page that are the same home: `imported_from` when a Fondaro network listing was imported from another row on the page (Fondaro rows carry `importedFrom: { source, id }` for imported listings), and `same_home` when rows of different sources are within 100 metres, within 2% in EUR and have the same bedrooms. Show a group as one card with "Also on …". ```json { "sources": ["internal", "resales_online", "idealista"], "sort": "mixed", "results": [ { "id": "…", "source": "internal", "ref": { "source": "internal", "id": "…" }, "importedFrom": { "source": "resales_online", "id": "R4381122" } }, { "id": "R4381122", "source": "resales_online", "ref": { "source": "resales_online", "id": "R4381122" } }, { "id": "i1", "source": "idealista", "ref": { "source": "idealista", "id": "i1", "country": "es" } } ], "receipts": { "internal": { "source": "internal", "verification": "result_verified", "upstreamUnits": 0, "placeLookupUnits": 0 }, "resales_online": { "source": "resales_online", "verification": "result_verified", "upstreamUnits": 0, "placeLookupUnits": 0 }, "idealista": { "source": "idealista", "verification": "result_verified", "upstreamUnits": 1, "placeLookupUnits": 1 } }, "totals": { "internal": 41, "resales_online": 118 }, "totalIsLowerBound": {}, "errors": {}, "groups": [ { "id": "group:…", "refs": [ { "source": "internal", "id": "…" }, { "source": "resales_online", "id": "R4381122" } ], "reason": "imported_from" } ], "nextCursor": "<opaque>" } ``` Receipts are shortened above; each also carries the usual `requested`, `resolved`, `appliedFacets`, `wireFacets`, `criteriaLabel` and `excludedUpstreamMismatches`. A dropped or failed source's receipt has its requested criteria and an empty resolution: read `dropped` and `errors` first. `totals` has per-source counts where the source counts, on the first page only; there is no total across sources. ### Allowance `GET /property-sources/allowance` returns today's use of the organization's daily portal allowance (the one the dashboard, MCP, the assistant and API keys draw). The dashboard shows what is left under **Sites** in Search once some is used: ```json { "used": 86, "limit": 300, "resetsAt": "2026-09-25T00:00:00.000Z" } ``` A search over several portals costs up to one unit per portal per page (none on a cache hit), plus one unit for each first-time place lookup on a portal. `upstreamUnits` on a portal's receipt is `0` when its page came from the cache. ## Locations, options and identity Discover options using the advertised facet, such as `/property-sources/internal/options/property_type` or `/property-sources/internal/options/scope`. The response is an array of `{ "value": "...", "label": "..." }` choices, with optional `family` and `aliases`. Location responses contain `locationId`, `label` and optional `type` and `parent`. Pass the complete token to search; tokens belong to the organization and source that issued them and expire after 15 minutes. Portal tokens also bind the backend country. Fondaro, Resales and Zoddak location discovery reject a country qualifier rather than silently ignoring it. Fondaro network location rows come in three kinds (`type`): `city` rows for the towns you can see, `community` rows (an area within a town, whose `parent` is the town) and `region` rows. Several locations in one search match a listing in any of them, so `Benahavís` with `Nueva Andalucía` returns both. A community matches only inside its own town, because one community name can exist in two towns. Location is optional for the Fondaro network, Resales Online and Zoddak: a search without one covers the whole feed. Inmobalia has no location filter. Portals require a location or a supported `geo` shape. In a signed-in dashboard session (not with an API key), each location row also carries `location: { kind, value, label, parent?, country? }`. It does not expire, so the dashboard stores it in saved views and tabs and sends it back as `locations` in place of `locationIds`. The search applies it to the same source and organization and verifies it like a token, including the portal backend country. API keys that send `locations` get `PROPERTY_FILTER_VALUE_INVALID`. Every new search and detail row carries `ref: { source, id, country? }`. Preserve the entire reference in selections, links and caches. Identical portal ids in different countries refer to different listings. Fondaro ids are UUIDs; `referenceNumber` is a separate display and search field. Provider ids are opaque strings: URL-encode the whole id when fetching detail, including any slashes, colons or Unicode characters, and forward its country qualifier. Fondaro network (`internal`) search defaults to the `network` scope: active listings from every agency on Fondaro. An active listing from another agency includes its street address, postcode and exact coordinates, in search and detail alike. Only the listing agency's private `commission` is withheld. Another agency's non-active listing is not returned, and its detail returns `PROPERTY_SOURCE_LISTING_NOT_FOUND`. Use `scope: "own"` to search your own listings. ## Errors and metering Typed errors include `code`, a safe `message`, and, for search errors, `source` and `nextAction`. Unsupported filters, invalid options, missing required locations, disconnected sources and expired cursors are explicit failures. `PROPERTY_SOURCE_LISTING_NOT_FOUND` identifies an unavailable detail; `PROPERTY_UPSTREAM_FAILED` identifies a provider failure; `PROPERTY_SOURCE_TIMEOUT` (several-source search only) identifies a source that did not answer in time. Handle the code rather than matching message text. Each portal source meters itself: it consumes one unit of the organization's daily allowance per real call to the paid provider, whichever endpoint, tool or brochure caused it. Shared cache hits and repeated cached missing-detail results do not consume another unit. `PROPERTY_PORTAL_QUOTA_EXHAUSTED` reports exhaustion. HTTP request rate limits still apply to cached responses; see [API Overview](/docs/api/overview). --- # Property Statistics Source: https://www.fondaro.com/docs/api/properties/stats > Get property counts by status and aggregated statistics grouped by location and type. Retrieve summary statistics about your property inventory: status counts and location/type aggregations. ## Status Counts ``` GET /properties/stats/counts ``` **Authentication:** Required (API key or JWT) Returns the number of properties in each listing status for your organization. ### Example ```bash curl https://api.fondaro.com/properties/stats/counts \ -H "X-API-Key: fondaro_pk_abc123" ``` ### Response ```json { "active": 48, "inactive": 12, "pending": 5, "sold": 23, "rented": 8, "deleted": 2 } ``` --- ## Aggregations ``` GET /properties/stats/aggregations ``` **Authentication:** Required (API key or JWT) Returns property counts grouped by city, region, country, property type, and property category. Useful for building mega menus, filter dropdowns, and location-based widgets. ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `filterByOrganization` | boolean | `false` | Only count your organization's properties | | `status` | string | `active` | Filter by listing status: `active`, `inactive`, `pending`, `sold`, `rented`, `deleted`. Any status other than `active` returns counts for your organization's properties only | | `countryCode` | string or string[] | — | Filter by country codes (e.g., `countryCode=ES&countryCode=PT`) | | `region` | string or string[] | — | Filter by region names | | `city` | string or string[] | — | Filter by city names | | `q` | string | none | Place search for pickers and autocomplete: narrows `byCity`, `byRegion`, `availableCities` and `availableRegions` to names containing `q` (accents and case ignored) and adds `communities`. Without `q` the response has no `communities` | Counts across all agencies (no `filterByOrganization`, status `active`) are refreshed every 5 minutes, so a listing published a moment ago may take up to 5 minutes to appear in them. ### Place search ```bash curl "https://api.fondaro.com/properties/stats/aggregations?q=quinta" \ -H "X-API-Key: fondaro_pk_abc123" ``` With `q`, the response adds `communities`: the communities whose name, town or region contains `q`, most listings first, at most 50. A community name can exist in two towns, so each entry carries its town: ```json { "communities": [ { "name": "La Quinta", "city": "Benahavís", "region": "Málaga", "total": 7 } ] } ``` ### Example ```bash curl "https://api.fondaro.com/properties/stats/aggregations?status=active&countryCode=ES" \ -H "X-API-Key: fondaro_pk_abc123" ``` ### Response ```json { "byCity": { "Marbella": { "total": 45, "byType": { "villa": 12, "apartment": 28, "penthouse": 5 } }, "Calpe": { "total": 18, "byType": { "apartment": 14, "townhouse": 4 } } }, "byRegion": { "Costa del Sol": { "total": 45, "byType": { "villa": 12, "apartment": 28, "penthouse": 5 } }, "Costa Blanca": { "total": 18, "byType": { "apartment": 14, "townhouse": 4 } } }, "byCountry": { "ES": { "total": 63, "byType": { "villa": 12, "apartment": 42, "penthouse": 5, "townhouse": 4 } } }, "byPropertyType": { "villa": 12, "apartment": 42, "penthouse": 5, "townhouse": 4 }, "byPropertyCategory": { "residential": 60, "commercial": 3 }, "availableCities": ["Marbella", "Calpe", "Estepona"], "availableRegions": ["Costa del Sol", "Costa Blanca"], "availableCountries": ["ES"], "availablePropertyTypes": ["villa", "apartment", "penthouse", "townhouse"], "availablePropertyCategories": ["residential", "commercial"], "totalProperties": 63 } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `byCity` | object | Property counts per city, with property type breakdown | | `byRegion` | object | Property counts per region, with property type breakdown | | `byCountry` | object | Property counts per country code, with property type breakdown | | `byPropertyType` | object | Total count per property type across all locations | | `byPropertyCategory` | object | Total count per property category | | `availableCities` | string[] | All cities with matching properties | | `availableRegions` | string[] | All regions with matching properties | | `availableCountries` | string[] | All country codes with matching properties | | `availablePropertyTypes` | string[] | All property types present | | `availablePropertyCategories` | string[] | All property categories present | | `totalProperties` | number | Total count matching the filters | | `communities` | object[] | Only with `q`: `{ name, city, region?, total }` for each matching community | --- # Resales Online Source: https://www.fondaro.com/docs/api/resales-online > Search, fetch, and check connection status for Resales Online inventory through the Fondaro API. The Resales Online endpoints proxy searches against the Resales Online MLS using the credentials your organization has stored under Integrations. Results are always live; nothing is cached in Fondaro's database. Every endpoint requires a valid Clerk session and an active organization. If your organization has not configured Resales Online credentials, search and detail requests return a `400` with a message telling you where to add them. ## Deprecated routes The search, detail, locations, property-types and features routes are deprecated compatibility routes. They stay available with the same request and response shapes for existing web and mobile app versions, and will be removed once those versions are retired. Every response carries a `Deprecation: true` header and a `Link` header to its successor under [`/property-sources/resales_online/...`](/docs/api/properties/sources), for example `Link: </property-sources/resales_online/search>; rel="successor-version"`. New integrations should use the [Property Sources](/docs/api/properties/sources) API. ## Connection status ``` GET /resales-online/status ``` **Authentication:** Required (Clerk JWT + organization) Returns whether the current organization's saved Resales Online connection is working. `connected` is `true` only when the saved credentials passed their latest live check with Resales Online (a live search with your Identifier, API key and filter). Saved credentials that fail the check return `connected: false` with `connection.status` `attention`. The result is cached (6 hours after a pass, 5 minutes after a failure, and re-checked immediately when the credentials change), so the route is cheap to call. ### Response ```json { "connected": false, "connection": { "status": "attention", "reason": "credentials_rejected", "message": "Resales Online did not accept these credentials.", "checkedAt": "2026-09-24T10:15:00.000Z" } } ``` | Field | Type | Description | |-------|------|-------------| | `connected` | boolean | `true` only when `connection.status` is `connected`. Kept for existing app versions. | | `connection.status` | string | `connected` (the latest check passed), `attention` (credentials are saved but the check failed or could not run) or `disconnected` (nothing saved). | | `connection.reason` | string | Present for `attention`: `credentials_incomplete`, `credentials_rejected`, `access_refused`, `provider_unavailable` (the provider did not answer), `network_unavailable` (our egress could not reach the provider) or `check_failed`. | | `connection.message` | string | Present for `attention`: display-safe text that says what to fix. Never provider text or a secret. | | `connection.checkedAt` | string | ISO time of the live check behind this state. | ### Example ```bash curl -X GET https://api.fondaro.com/resales-online/status \ -H "Authorization: Bearer <clerk_jwt>" \ -H "x-organization-id: <organization_id>" ``` ## Search ``` POST /resales-online/search ``` **Authentication:** Required (Clerk JWT + organization, must be connected) Runs a search against Resales Online V6 and returns normalized properties. Feature filters (`featureIds`) are applied server-side via Resales' `P_MustHaveFeatures=1` flag, so paging is stable. ### Request Body All fields are optional except `page` and `limit`. | Field | Type | Description | |-------|------|-------------| | `minPrice` | number | Minimum price | | `maxPrice` | number | Maximum price | | `minBedrooms` | integer | "At least N" bedrooms (maps to Resales `P_Beds=Nx`) | | `minBathrooms` | integer | "At least N" bathrooms (maps to Resales `P_Baths=Nx`) | | `minBuiltSize` | integer | Minimum built size in m² | | `maxBuiltSize` | integer | Maximum built size in m² | | `minPlotSize` | integer | Minimum plot size in m² | | `maxPlotSize` | integer | Maximum plot size in m² | | `propertyTypes` | string[] | Pre-translated Resales `TypeId-SubtypeId` codes, e.g. `["1-1", "2-2"]` | | `propertyTypeEnums` | string[] | Fondaro `PropertyType` enum values (e.g. `["apartment", "townhouse"]`), translated server-side. Prefer this unless you already know the Resales codes. | | `location` | string | Resales `P_Location` (CSV) | | `province` | string | Resales `P_Province` | | `sort` | string | One of `"newest"`, `"price_asc"`, `"price_desc"`, `"last_updated"` | | `featureIds` | string[] | Resales feature `paramName` values (e.g. `["1Pool2", "1Views1"]`). Applied AND-wise via `P_MustHaveFeatures=1`. Fetch the full catalog from `GET /resales-online/features`. Unknown IDs are rejected. | | `page` | integer | **Required.** 1-indexed page number | | `limit` | integer | **Required.** Page size (1–40) | | `queryId` | string | Omit on page 1; pass the `queryId` from the page-1 response on pages 2+ for stable pagination | ### Response ```json { "properties": [ { "id": "R123456", "source": "resales_online", "title": "3-bed Apartment in Marbella", "price": 850000, "currency": "EUR", "bedrooms": 3, "bathrooms": 2, "propertyType": "Apartment", "location": "Marbella", "area": "Costa del Sol", "description": "...", "mainImage": "https://cdn.resales-online.com/...", "images": ["https://..."], "builtSize": 120, "plotSize": 0, "terraceSize": 15, "hasPool": true, "hasParking": true, "hasGarden": false, "features": ["Climate Control: Air Conditioning"], "latitude": 36.5101, "longitude": -4.8824, "reference": "R123456", "agencyRef": "AG-789", "status": "available", "virtualTour": null, "energyRating": "C" } ], "page": 1, "pageSize": 24, "totalResults": 142, "queryId": "q-abc-123" } ``` | Field | Type | Description | |-------|------|-------------| | `properties` | array | Normalized Resales properties | | `page` | integer | Echo of the returned page number | | `pageSize` | integer | Echo of the returned page size | | `totalResults` | integer | Total matching properties upstream | | `queryId` | string | Must be passed on page 2+ requests | ### Pagination lifecycle Resales Online V6 requires a `P_QueryId` on every request after page 1 so that listings added or removed between requests don't shift your results. Fondaro's proxy handles this for you: 1. Make the page-1 request without a `queryId`. 2. Capture `queryId` from the response. 3. Include that `queryId` on every subsequent page request. If you jump directly to a later page without a cached `queryId`, the frontend helper re-fetches page 1 silently to establish one. ### Example ```bash curl -X POST https://api.fondaro.com/resales-online/search \ -H "Authorization: Bearer <clerk_jwt>" \ -H "x-organization-id: <organization_id>" \ -H "Content-Type: application/json" \ -d '{ "minPrice": 500000, "maxPrice": 1500000, "minBedrooms": 3, "propertyTypeEnums": ["apartment", "townhouse"], "sort": "price_asc", "featureIds": ["1Pool2", "1Views1"], "page": 1, "limit": 24 }' ``` ## Feature catalog ``` GET /resales-online/features ``` **Authentication:** Required (Clerk JWT + organization; connection not required) Returns the hardcoded Resales feature catalog: the reference list for any `featureIds` values you send to `/resales-online/search`. The response is static for a given Fondaro deploy and safe to cache forever client-side. ### Response ```json { "categories": [ { "name": "Pool", "features": [ { "paramName": "1Pool1", "name": "Communal Pool" }, { "paramName": "1Pool2", "name": "Private Pool" } ] }, { "name": "Views", "features": [ { "paramName": "1Views1", "name": "Sea Views" } ] } ] } ``` | Field | Type | Description | |-------|------|-------------| | `categories` | array | Feature categories as Resales exposes them (Setting, Orientation, Condition, Pool, Climate Control, Views, Features, Furniture, Kitchen, Garden, Security, Parking, Utilities, Category, Plots and Ventures, Rentals) | | `categories[].name` | string | Category label | | `categories[].features` | array | Features in this category | | `categories[].features[].paramName` | string | Value to send in `featureIds` | | `categories[].features[].name` | string | Human-readable English label | ## Property detail ``` GET /resales-online/detail/:reference ``` **Authentication:** Required (Clerk JWT + organization, must be connected) Returns a single Resales Online property by its reference number. The detail response is richer than search results and includes community fees, annual taxes, the full picture array, completion dates, and GPS coordinates where available. ### Response ```json { "property": { "id": "R123456", "source": "resales_online", "title": "3-bed Apartment in Marbella", "price": 850000, "currency": "EUR", "bedrooms": 3, "bathrooms": 2, "propertyType": "Apartment", "location": "Marbella", "description": "Fully furnished, walking distance to the beach...", "mainImage": "https://cdn.resales-online.com/...", "images": ["https://...", "https://..."], "features": ["Climate Control: Air Conditioning", "Views: Sea"], "latitude": 36.5101, "longitude": -4.8824, "reference": "R123456", "status": "available", "virtualTour": "https://tour.example.com/R123456", "energyRating": "C", "communityFeesPerYear": 2400, "basuraTaxPerYear": 180, "ibiFeesPerYear": 1200, "completionDate": "2018-06-15", "builtYear": "2018", "pictures": [ { "id": 0, "url": "https://..." }, { "id": 1, "url": "https://..." } ] } } ``` Detail-only fields (`communityFeesPerYear`, `basuraTaxPerYear`, `ibiFeesPerYear`, `completionDate`, `builtYear`, `pictures`) appear only on this endpoint. The same normalized shape is returned from `/search` with these fields omitted. ### Example ```bash curl -X GET https://api.fondaro.com/resales-online/detail/R123456 \ -H "Authorization: Bearer <clerk_jwt>" \ -H "x-organization-id: <organization_id>" ``` ## Status values The `status` field on every normalized property is one of: | Value | Meaning | |-------|---------| | `available` | Listed and available | | `under_offer` | Offer received, awaiting acceptance | | `sale_agreed` | Sale agreed, property reserved | | `unknown` | Status not recognized or not provided | ## Property type mapping When you send `propertyTypeEnums`, the server translates each Fondaro enum value to a Resales `TypeId-SubtypeId` code. Unmapped enums are silently omitted (the search then returns all property types). Current mapping: | Fondaro enum | Resales code | |--------------|--------------| | `apartment` | `1-1` | | `house_detached` | `2-2` | | `house_semi_detached` | `2-1` | | `house_terraced` | `2-5` | | `townhouse` | `2-5` | | `land_residential` | `3-1` | | `land_commercial` | `3-1` | | `land_agricultural` | `3-1` | | `land_other` | `3-1` | ## Errors | Status | Meaning | |--------|---------| | `400` | Organization has no Resales Online credentials, or request body failed validation | | `404` | Detail endpoint: reference not found | | `401` | Missing or invalid Clerk JWT | --- # Resales Own-Listings Import Source: https://www.fondaro.com/docs/api/resales-own-listings-import > Check import configuration, dry-run a safe manifest, and manually copy Resales own inventory into Fondaro. The Resales own-listings import is a Clerk-authenticated organization workflow. It uses the Resales credentials already stored for the organization plus a dedicated `p_own_filterid`. It does not use the normal Resales search cache. These endpoints require a **Clerk session JWT and active organization**; they do not accept a Fondaro property API key. ```http Authorization: Bearer CLERK_SESSION_JWT x-organization-id: ORGANIZATION_ID ``` The dashboard configuration steps and filter-safety rules are in [Import Own Listings from Resales Online](/docs/dashboard/properties/resales-own-listings-import). ## Route catalogue | Method | Route | Access | Purpose | |--------|-------|--------|---------| | `GET` | `/resales-online/import/own-listings/status` | Any organization member | Configuration, last-run metadata, and current imported counts | | `POST` | `/resales-online/import/own-listings` | Organization admin | Dry-run or execute an import | ## Get import status ```http GET /resales-online/import/own-listings/status Authorization: Bearer CLERK_SESSION_JWT x-organization-id: ORGANIZATION_ID ``` ```json { "configured": true, "lastRun": { "completedAt": "2026-07-23T16:15:42.000Z", "counts": { "total": 12, "created": 2, "updated": 7, "skipped": 3, "errors": 0 } }, "counts": { "imported": 12, "active": 10, "inactive": 1 } } ``` | Field | Meaning | |-------|---------| | `configured` | `true` only when active `p1`, `p2`, and `p_own_filterid` values exist | | `lastRun` | Last completed real import; dry runs do not replace it. `null` before the first real run | | `counts.imported` | Current number of Fondaro properties with `externalSource: "resales_online"` | | `counts.active` | Imported rows currently in Active status | | `counts.inactive` | Imported rows currently in Inactive status | Other statuses such as Sold, Rented, or Deleted are included in `imported` but not in `active` or `inactive`. ## Dry-run an import Always dry-run immediately before a real import: ```http POST /resales-online/import/own-listings Authorization: Bearer CLERK_SESSION_JWT x-organization-id: ORGANIZATION_ID Content-Type: application/json ``` ```json { "dryRun": true } ``` The only request field is optional `dryRun: boolean`. Omission defaults to `false`, so an integration should send `true` explicitly for its preview request. ```json { "dryRun": true, "counts": { "total": 12, "created": 2, "updated": 7, "skipped": 3, "errors": 0 }, "errors": [] } ``` Dry run fetches a fresh complete Resales snapshot and compares mapped values with the properties database. It does not create properties, update `lastSyncedAt`, write history, persist last-run metadata, or send the completion summary. `updated` means at least one synchronized field differs. `skipped` includes unchanged rows and duplicate external references found in the same upstream snapshot. Mapping or inspection problems increment `errors` and add an item such as: ```json { "externalRef": "VIVI-1042", "message": "Resales reference exceeds 64 characters" } ``` ## Execute the import After reviewing the dry-run result, send a fresh request: ```json { "dryRun": false } ``` The response has the same shape with `dryRun: false`. A real run: 1. fetches and maps the complete upstream snapshot before the first listing write; 2. upserts by `(organizationId, externalSource, externalRef)`; 3. creates new listings as Active with auto-renew enabled; 4. updates synchronized fields on existing rows; 5. preserves local agent, commission, lead-group, and locally changed status; 6. stamps `lastSyncedAt` and writes an `imported` listing event for each processed row; and 7. persists last-run counts and sends one operational summary after the run. Each listing write uses its own transaction. A per-listing failure is reported in `errors`, while other rows can succeed. Treat a response with `counts.errors > 0` as a partial success and surface its issue list. ## Safety failures before writes Both dry-run and real modes enforce the same hard boundary: - If Resales reports more than **50 properties**, the API returns `400` and writes no listings. - If the complete expected snapshot cannot be fetched, the API returns `502` and writes no listings. - Missing own-listings configuration returns `400` before import. - A non-admin import request returns `403`. The 50-property response is intentionally actionable: ```json { "statusCode": 400, "message": "Your Resales own-listings filter returned 3142 properties, which exceeds the 50-property safety limit. This looks like the shared MLS feed, not your own inventory. Check the filter's “Only own properties” setting. No listings were imported." } ``` The integration-credential service also refuses to save `p_own_filterid` when it exactly matches the active `p_agency_filterid`, and applies the same check when the browse filter is changed later. ## Current synchronization model This endpoint is manual-only. There is no Fondaro cron or automatic background run. Re-run it when you want current Resales fields copied into Fondaro. The import is sale-only. It does not infer deletion from an absent upstream row and does not mark a missing listing Inactive. A locally changed status remains authoritative on subsequent imports. --- # Setup Source: https://www.fondaro.com/docs/api/setup > REST endpoints behind the setup page: read each person's setup steps and the organization's, and record that a step was done, skipped or dismissed. Also the invitation events Fondaro keeps. ## Overview The setup page shows each person the steps that are theirs: an admin sees **Your agency** and **You**, a member sees **You**. Nothing about a step is stored except the choices a person makes about it. Whether a step is done is read from what the step itself produces: branding, leads, the team, the website, your own number, your inbox, your profile and the phone app. The endpoints on this page use the dashboard's Clerk bearer token and resolve the organization from the request. Any member can read their own setup and record their own choices. The organization's steps, invitations and the "Already on for you" group are only in an admin's response. ## Endpoints | Method & path | Who | Purpose | |---|---|---| | `GET /me/setup` | Any member | Your steps and, for an admin, the organization's | | `POST /me/setup/events` | Any member | Record that you did, skipped or dismissed a step | ## Read your setup ```bash curl https://api.fondaro.com/me/setup \ -H "Authorization: Bearer <clerk session token>" ``` ```json { "role": "admin", "plan": { "kind": "crm", "trialing": true, "trialEndsAt": "2026-10-10T09:00:00.000Z" }, "entitled": true, "teamMode": "team", "dismissed": false, "agency": [ { "id": "agency", "status": "done", "hidden": false, "detail": null }, { "id": "leads", "status": "done", "hidden": false, "detail": { "count": 5 } }, { "id": "team", "status": "done", "hidden": false, "detail": { "count": 2, "invited": 1 } }, { "id": "website", "status": "skipped", "hidden": false, "detail": null } ], "you": [ { "id": "phone", "status": "done", "hidden": false, "detail": { "phoneNumber": "+34612345678" } }, { "id": "email", "status": "open", "hidden": false, "detail": null }, { "id": "profile", "status": "open", "hidden": false, "detail": { "photo": true, "languages": true, "areas": false } }, { "id": "app", "status": "open", "hidden": false, "detail": null } ], "progress": { "done": 4, "total": 8 }, "invitations": [ { "clerkInvitationId": "orginv_2x...", "role": "org:member", "status": "pending", "invitedAt": "2026-09-26T09:05:00.000Z" } ], "kicked": [], "alreadyOn": { "ask": true, "smartRouting": true, "calling": false } } ``` ### The response | Field | Type | Notes | |---|---|---| | `role` | `admin` or `member` | From your Clerk organization role | | `plan.kind` | `none`, `crm`, `growth` or `crm_and_growth` | What the organization holds now | | `plan.trialing` / `plan.trialEndsAt` | boolean / string or null | The free 14 days | | `entitled` | boolean | False before any plan: `agency` is null, `you` is empty and `progress` is 0 of 0 | | `teamMode` | `solo` or `team` | Solo is one person and no invitation; the team step is then `hidden` | | `dismissed` | boolean | "I'm done": an admin's dismissal for admins, your own for a member | | `agency` | step[] or null | Admins only; `ads` appears only with Growth | | `you` | step[] | Your own four steps | | `progress` | `{ done, total }` | Visible steps only; drives the sidebar marker | | `invitations` | array | Admins only, newest first, from the invitation events below | | `kicked` | `{ name, at }[]` | Admins only: people removed in the last 30 days because the team was past its seats | | `alreadyOn` | object or null | Admins only: Ask Fondaro on, Smart routing on, Calling on | ### Steps Each step is `{ id, status, hidden, detail }`. `status` is `done`, `open` or `skipped`. A step is done when its fact exists; otherwise the latest event decides (`done` or `skipped`). Agency events count from any admin; personal events count only from you. | `id` | Group | Done when | |---|---|---| | `agency` | Agency | The agency name and both logos are set | | `leads` | Agency | A lead exists, an import or CRM migration was committed, or a listing source is connected | | `team` | Agency | Two or more people on the team, or an invitation sent | | `website` | Agency | The website exists | | `ads` | Agency (Growth) | A Growth plan with at least one language chosen | | `phone` | You | Your own number is verified and assigned to you | | `email` | You | Your own mailbox is connected and working | | `profile` | You | A profile photo, your languages and your areas | | `app` | You | You signed in on the phone app | ## Record a step ```bash curl -X POST https://api.fondaro.com/me/setup/events \ -H "Authorization: Bearer <clerk session token>" \ -H "Content-Type: application/json" \ -d '{ "step": "website", "action": "skipped" }' ``` Returns `204 No Content`. | Field | Type | Notes | |---|---|---| | `step` | string | A step `id` above, or `setup` for the whole page. A short lowercase key (`^[a-z][a-z0-9_-]{0,39}$`); anything else is `400` | | `action` | `done`, `skipped` or `dismissed` | Anything else is `400` | Every call writes one row; the person is always the caller, never the body. `{ "step": "setup", "action": "dismissed" }` is "I'm done": from an admin it dismisses the page for the organization's admins, from a member only for that member. Nothing is ever deleted: the page reads the latest row per person and step, so a step skipped and later done reads as done. ## Invitation events Fondaro keeps a local record of the organization's invitations, because the setup page and the activation report need one and Clerk is not read on those paths. The Clerk webhook (`POST /webhooks/clerk`, signed, not for integrators) handles three events: | Clerk event | What Fondaro records | |---|---| | `organizationInvitation.created` | One `invitation_sent` organization event | | `organizationInvitation.accepted` | One `invitation_accepted` organization event | | `organizationInvitation.revoked` | `revokedAt` stamped on the sent row; no new row | Each row is keyed by the Clerk invitation id, so a redelivered webhook writes nothing new. The rows hold the invitation id, the role and when it was sent, never the invitee's email address. The endpoint must be subscribed to these three events in Clerk. --- # Admin social exports Source: https://www.fondaro.com/docs/api/social-exports > Staff-only, collection-scoped image generation and manual downloads. All routes require a Clerk bearer token with Fondaro staff access. Callers cannot supply an organization ID, agent identity, image URL or destination. The existing internal brand-site organization owns the export records; collection ownership is enforced separately by the template namespace. `GET /admin/website/social/collections` returns `penrosebay` and `fondaro`. Omitting `collectionId` retains the historical Penrose Bay default. Include `?collectionId=fondaro` when listing, downloading or cancelling Fondaro posts. Cross-collection IDs are refused. ## Fondaro sources and generation - `GET /admin/website/social/fondaro/catalogue`: English integration copy/logo entries, editorial seeds and review candidates with original text, exact excerpt, dates and unresolved provenance flags. Generate a review post with `reviewId`, optional `platform` and `variant: "poster"`. It creates a `fondaro:review` row with a normal caption and downloadable kit. The snapshot retains owner authorization and the original source; `verifiedUrl` remains null unless independently verified. The legacy `previewOnly` request flag is accepted for compatibility but no longer produces watermarks. - `GET /admin/website/social/fondaro/listings?q=Marbella`: at most 30 recent active, published, unexpired Spanish MLS/CRM listings, including Resales own-listings imports. Each result contains a narrow public property projection or an actionable identity issue. Agent lookup requires the exact listing `agentId`, matching organization and active roster status when assigned; a missing agent is allowed and uses agency-only attribution. The snapshot preserves `importedFrom` source/reference. Private contacts and CRM information are excluded. ```bash curl -X POST https://api.fondaro.com/admin/website/social/generate \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"collectionId":"fondaro","topicId":"introduction","platform":"instagram","kind":"post","variant":"carousel"}' ``` For a property, use `listingId` from the MLS picker instead of `topicId`. Optional `photos` is an ordered array of `{ "index": 0, "x": 0.5, "y": 0.5 }`: indices must belong to that listing, be unique, and total 1–8. Crop fractions must be finite and between 0 and 1. A single-card variant requires exactly one. Instagram property variants are `deck` and `poster`. Facebook property variants are `facebook-photos`, `facebook-photo`, or portrait `deck`. Editorial posts use `carousel` on either platform. Set `kind: "story"` for 1080×1920 output on either platform: properties use `deck` or `poster`, editorial uses `carousel`, and reviews/integrations use `poster`. Story kits contain numbered frames and link-sticker instructions. For an integration, send `integrationId` from the catalogue (for example `resalesOnline`) instead of another source ID. Both `kind: "post"` and `kind: "story"` use `variant: "poster"`. The editorial snapshot freezes the integration name, logo filename, authored creative copy and caption context. Portal availability comes from the serving registry; coming-soon entries are refused. Fondaro rejects Penrose Bay references, guide slugs and colourways. Penrose Bay rejects Fondaro source fields. Generation returns a pending row and renders asynchronously. Poll `GET /admin/website/social?collectionId=fondaro` until `rendered` or `failed`. ```bash curl 'https://api.fondaro.com/admin/website/social/POST_ID/kit.zip?collectionId=fondaro' \ -H "Authorization: Bearer $TOKEN" -o fondaro-kit.zip ``` Fondaro uses `fondaro:` template IDs and a versioned nullable `source_snapshot` JSON column. The snapshot freezes public source identity, facts, caption context, photo ordering/crops, dimensions and canonical destination. The existing public listing URL resolver is authoritative; a missing listing URL falls back to the shared canonical Fondaro product URL with honest product wording. Downloads require a rendered, correctly owned record and storage objects under that exact post’s namespace. MLS eligibility is rechecked. The ZIP includes ordered PNGs, `caption.txt`, `url.txt`, `posting.txt` and `post.json`. Preview guides are absent from image bytes. `POST /:id/cancel?collectionId=fondaro` archives locally; render completion cannot overwrite that cancellation. ## Penrose Bay compatibility ```bash curl -X POST https://api.fondaro.com/admin/website/social/generate \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"collectionId":"penrosebay","reference":"R12345","kind":"post","variant":"facebook-photos"}' ``` Use a real available Resales reference. Existing deck/poster/guide/Story and Facebook formats retain their behavior. Legacy unprefixed templates and null snapshots remain Penrose Bay history. Scheduled/published audit rows remain readable and downloadable, but local cancellation refuses them. Reels retain `/admin/website/reels` generation/list/download routes. No route publishes, schedules or contacts the retired publishing provider. Fondaro editorial snapshots retain authored line breaks, composition and tone. Own-bucket public `properties/images/<uuid>.<image-extension>` sources are read through the configured storage client with byte/deadline limits. External URLs retain SSRF-safe fetching. This supports private Spaces DNS inside the cluster without allowing arbitrary authenticated object reads. Agent avatars in the configured bucket use bounded authenticated storage only when the key is exactly `agents/avatars/<listing-agent-id>.webp`. Other URLs keep SSRF-safe fetching. Avatar fetch failures log the post/agent IDs and preserve the original snapshot URL instead of erasing it. New property renders crop the source to the full export dimensions, then apply contrast and identity overlays. A property artwork shows the agent name only alongside a successfully decoded avatar. Missing or failed avatars produce the agency name alone, with no initials placeholder. Stories reserve the top 240 px and bottom 280 px for platform controls. Public HTTPS photo URLs retain CDN query parameters such as Resales Online’s `?v=` version. URL credentials and fragments remain excluded, and external fetches still enforce destination, byte and deadline checks. For paginated discovery, send `GET /admin/website/social/fondaro/listings?q=Marbella&page=2`. The response is `{ items, page, pageSize: 30, hasMore }`, ordered by publication time then ID descending. Page must be an integer from 1 to 100000. Only the current page is hydrated. Omitting `page` preserves the original first-page array response. --- # Teams Source: https://www.fondaro.com/docs/api/teams > REST endpoints for organization teams, and how teamIds assign a whole team to leads, deals, tasks, documents and phone numbers. ## Overview A team is a named group of members in your organization, for example **Sales** or **Rentals**. Teams save you from picking the same people one by one: anywhere you can assign several people, you can send a team instead. Assigning a team is a shortcut, not a live link. When a request names a team, Fondaro looks up the team's members **at that moment** and assigns those people. Someone who joins the team later does not gain the records the team was assigned before, and someone who leaves the team keeps what they already have. To give a new member the team's current records, call [the backfill endpoint](#give-a-new-member-the-teams-records) after adding them. Tags are the one exception: a tag scoped to a team is visible to whoever is in the team today (see [Tag visibility by team](#tag-visibility-by-team)). The endpoints on this page use the dashboard's Clerk bearer token and resolve the organization from the request. Reading teams is open to every member, because every assignee picker needs the list. Creating, changing, archiving and backfilling are limited to organization admins; a member receives `403`. ## Endpoints | Method & path | Who | Purpose | |---|---|---| | `GET /teams` | Any member | List active teams with their members | | `POST /teams` | Admin | Create a team, optionally with members | | `PATCH /teams/:id` | Admin | Rename, recolor, archive or restore | | `DELETE /teams/:id` | Admin | Archive the team | | `PUT /teams/:id/members` | Admin | Replace the member list | | `POST /teams/:id/members/:userId/backfill-assignments` | Admin | Give a member the team's current records | ## The team object | Field | Type | Notes | |---|---|---| | `id` | string | UUID | | `organizationId` | string | UUID | | `name` | string | Up to 80 characters, unique in the organization ignoring case | | `color` | string or null | One of the tag colors: `slate`, `blue`, `green`, `amber`, `red`, `purple`, `pink`, `teal` | | `archivedAt` | string or null | Set while the team is archived | | `memberIds` | string[] | Clerk user ids, up to 50 | | `memberCount` | number | | | `createdAt` / `updatedAt` | string | ISO-8601 | ## List teams `GET /teams` returns `{ "teams": [...] }`, ordered by name. Archived teams are left out; add `?includeArchived=1` to include them. ```bash curl 'https://api.fondaro.com/teams' \ -H 'Authorization: Bearer <clerk-session-token>' ``` ```json { "teams": [ { "id": "0f6d8f7e-3c1a-4a52-9d61-2b7a1c9e4d10", "organizationId": "5b1e2c3d-4f50-4a61-8b72-9c8d7e6f5a40", "name": "Rentals", "color": "teal", "archivedAt": null, "memberIds": ["user_2a", "user_2b"], "memberCount": 2, "createdAt": "2026-09-17T09:00:00.000Z", "updatedAt": "2026-09-17T09:00:00.000Z" } ] } ``` ## Create, change and archive `POST /teams` takes `name` (required), `color` and `memberIds`. Every member id must belong to your organization, otherwise the request returns `400` naming the ids that do not. A name that already exists (ignoring case, archived teams included) returns `409`. ```bash curl -X POST 'https://api.fondaro.com/teams' \ -H 'Authorization: Bearer <clerk-session-token>' \ -H 'Content-Type: application/json' \ -d '{"name":"Rentals","color":"teal","memberIds":["user_2a","user_2b"]}' ``` `PATCH /teams/:id` accepts any subset of `name`, `color` (or `null`) and `archived`. `archived: true` archives the team and `archived: false` restores it with its members. `DELETE /teams/:id` archives the team and returns it. Teams are never hard-deleted from the dashboard: past assignments keep pointing at the team, and restoring it brings everything back. An archived team cannot be assigned (`400`) and does not appear in `GET /teams`. ## Replace the members `PUT /teams/:id/members` takes the complete list as `{ "userIds": [...] }`. Up to 50 unique ids; an empty list empties the team. Only the people you add are checked against your organization, so a list that still contains someone who has since left never blocks the edit. Removing someone from a team never unassigns them from anything. ```bash curl -X PUT 'https://api.fondaro.com/teams/0f6d8f7e-3c1a-4a52-9d61-2b7a1c9e4d10/members' \ -H 'Authorization: Bearer <clerk-session-token>' \ -H 'Content-Type: application/json' \ -d '{"userIds":["user_2a","user_2b","user_2c"]}' ``` When someone is removed from your organization, Fondaro removes them from every team automatically. ## Assign a team Every write that takes a list of assignees also accepts `teamIds`: up to 20 team UUIDs. Fondaro looks up each team's current members, adds them to the people you listed, removes duplicates, and writes plain user ids, exactly as if you had picked everyone by hand. Requests without `teamIds` behave exactly as before. | Endpoint | Field it expands into | |---|---| | `PATCH` and `POST /crm/leads/:id/assignees` | `userIds` | | `PATCH /crm/leads/bulk/assignees` | `userIds` | | `POST /crm/leads` (admins) | `assigneeIds` | | `POST /crm/leads/:id/tasks`, `PATCH /crm/tasks/:taskId` | `assigneeIds` | | `PATCH` and `POST /crm/tasks/:taskId/assignees` | `userIds` | | `POST /deals`, `PATCH /deals/:id` | `assigneeIds` | | `PATCH` and `POST /deals/:id/assignees` | `userIds` | | `PATCH /documents/:id` | `assigneeIds` | | `PATCH /twilio/phone-numbers/:id` | `assignedUserIds` | For a replace (`PATCH ... /assignees`, `assigneeIds` on an update), sending only `teamIds` replaces the list with the teams' members. ```bash curl -X POST 'https://api.fondaro.com/crm/leads/10132/assignees' \ -H 'Authorization: Bearer <clerk-session-token>' \ -H 'Content-Type: application/json' \ -d '{"userIds":[],"teamIds":["0f6d8f7e-3c1a-4a52-9d61-2b7a1c9e4d10"]}' ``` Deals and tasks give their lead to the same people, as they always do. ### Limits A single request can assign at most **50 people**, whether you pick them one by one or through teams. A team itself holds at most 50 members, so any one team always fits on its own. When teams push a request over the limit, the request is refused with `400` and nothing is written: ```json { "statusCode": 400, "error": "Bad Request", "code": "ASSIGNEE_LIMIT_EXCEEDED", "message": "Assigning the team \"Sales\" would give 53 assignees; the limit is 50.", "limit": 50, "requested": 53, "teams": [{ "id": "0f6d8f7e-3c1a-4a52-9d61-2b7a1c9e4d10", "name": "Sales" }] } ``` A team id that is not a team of your organization, or an archived team, returns `400` as well. ### Assignment history Assignment history entries (`GET /crm/leads/:id/assignee-history`, the lead timeline's `assignee-change` entries) record which people came through which team. An entry that added a team's members carries that team's `teamId`; its `source` and `changedBy` still name who made the request, as for any other assignment. A request that mixes picked people and teams produces one entry for the picked people (with any removals, and no `teamId`) and one per team, all at the same time. Webhooks, CRM events and notifications still fire once per request. ## Filter lists by team `GET /crm/leads`, `GET /crm/leads/counts`, `GET /crm/tasks`, `GET /deals` and `GET /deals/export` accept `teamIds` next to `assigneeIds` (repeat the parameter, for example `?teamIds=a&teamIds=b`). For organization admins, the teams' current members are added to the `assigneeIds` filter. Members keep seeing only their own records; the parameter changes nothing for them. A team with no members matches no records. ## Tag visibility by team `POST /tags` now accepts `assigneeIds` and `teamIds`, and `PATCH /tags/:id` accepts `teamIds`, replacing the stored list. `GET /tags` returns `teamIds` on every tag. A tag scoped to teams is shown in pickers to the members of those teams, to the people in `assigneeIds`, and to admins. Unlike assignments, this follows the team as it changes. Like `assigneeIds`, it tidies pickers and is not a permission: every member can still see the tag on a lead. ## Give a new member the team's records Because assigning a team is a snapshot, adding someone to a team does not give them anything by itself. `POST /teams/:id/members/:userId/backfill-assignments` adds that member to the team's current records: leads, deals and tasks that were assigned through this team and are still held by at least one other current member. A task is a calendar event on a lead: the member joins it as a teammate, and it is held by its owner and teammates. Deals and tasks also give the member their lead. The `tasks` count is those events. ```bash curl -X POST 'https://api.fondaro.com/teams/0f6d8f7e-3c1a-4a52-9d61-2b7a1c9e4d10/members/user_2c/backfill-assignments' \ -H 'Authorization: Bearer <clerk-session-token>' ``` ```json { "leads": 42, "deals": 5, "tasks": 11, "hasMore": false } ``` The counts are the records that changed. Each call handles up to 1,000 records of each type; when `hasMore` is `true`, call again. The member must already be in the team (`400` otherwise). A backfill is recorded in each record's assignment history as made by you through the team (with its `teamId`), but it does not send a notification or a `lead.assigned` webhook per record, so automations built on new assignments are not replayed on old leads. ## Other surfaces - **Integrations API** (`/integrations/v1`): lead create, deal create, task create and `POST /leads/:id/assignees` accept `teamIds` in the same way. On the assignees endpoint, `userIds` may be omitted when `teamIds` names at least one team. See [n8n workflows](/docs/dashboard/n8n). - **MCP** (`/mcp/v1`) and **Ask Fondaro**: `list_org_teams` lists the teams, and the assignee inputs of `create_lead`, `set_lead_assignees`, `add_lead_assignees`, `create_task`, `update_task`, `create_deal` and `update_deal` accept `teamIds`. See [MCP Server](/docs/api/mcp). --- # Zoddak Source: https://www.fondaro.com/docs/api/zoddak > Search, fetch, and check connection status for Zoddak off-plan property developments through the Fondaro API. The Zoddak endpoints proxy live calls to the [Zoddak v1 API](https://api.zoddak.com/v1) using the API token stored under the organization's Integrations. Results are always live; nothing is cached on Fondaro's side. Every endpoint requires a valid Clerk session and an active organization. If your organization has no Zoddak token configured, every endpoint returns a `400` with a message telling you where to add one. ## Deprecated routes The search, detail, locations and stages routes are deprecated compatibility routes. They stay available with the same request and response shapes for existing web and mobile app versions, and will be removed once those versions are retired. Every response carries a `Deprecation: true` header and a `Link` header to its successor under [`/property-sources/zoddak/...`](/docs/api/properties/sources), for example `Link: </property-sources/zoddak/search>; rel="successor-version"`. New integrations should use the [Property Sources](/docs/api/properties/sources) API. ## Connection status ``` GET /zoddak/status ``` **Authentication:** Required (Clerk JWT + organization) Returns whether the current organization's saved Zoddak connection is working. `connected` is `true` only when the saved credentials passed their latest live check with Zoddak. Saved credentials that fail the check return `connected: false` with `connection.status` `attention`. The result is cached (6 hours after a pass, 5 minutes after a failure, and re-checked immediately when the credentials change), so the route is cheap to call. ### Response ```json { "connected": false, "connection": { "status": "attention", "reason": "credentials_rejected", "message": "Zoddak did not accept these credentials.", "checkedAt": "2026-09-24T10:15:00.000Z" } } ``` | Field | Type | Description | |-------|------|-------------| | `connected` | boolean | `true` only when `connection.status` is `connected`. Kept for existing app versions. | | `connection.status` | string | `connected` (the latest check passed), `attention` (credentials are saved but the check failed or could not run) or `disconnected` (nothing saved). | | `connection.reason` | string | Present for `attention`: `credentials_incomplete`, `credentials_rejected`, `access_refused`, `provider_unavailable` (the provider did not answer), `network_unavailable` (our egress could not reach the provider) or `check_failed`. | | `connection.message` | string | Present for `attention`: display-safe text that says what to fix. Never provider text or a secret. | | `connection.checkedAt` | string | ISO time of the live check behind this state. | ## Search developments ``` POST /zoddak/search ``` **Authentication:** Required (Clerk JWT + organization) Live-fetches a page of Zoddak developments using the Squid proxy that routes all Zoddak egress through `188.166.0.181`. Translates `page` + `limit` to Zoddak's `start` + `end` indexes; max page size is 20. ### Request body | Field | Type | Description | |-------|------|-------------| | `page` | integer (optional, default `1`) | 1-based page index. | | `limit` | integer (optional, default `20`, max `20`) | Page size; Zoddak caps at 20. | | `propertyTypes` | string[] (optional) | Pre-translated Zoddak labels (`Apartments`, `Villas`, `Townhouses`). | | `propertyTypeEnums` | string[] (optional) | Fondaro `PropertyType` enum values; translated server-side. `propertyTypes` wins when both are set. | | `towns` | string[] (optional) | CSV-joined into Zoddak's `towns`. | | `places` | string[] (optional) | CSV-joined into Zoddak's `places`. | | `provinces` | string[] (optional) | CSV-joined into Zoddak's `provinces`. | | `zones` | string[] (optional) | CSV-joined into Zoddak's `zones`. | | `stages` | number[] (optional) | Stage IDs (1–5). See `/zoddak/stages`. | | `minPrice` / `maxPrice` | number (optional) | EUR price bounds. | | `minBeds` / `maxBeds` | integer (optional) | Filters on the development's `min_beds`. A `4+` filter matches developments whose smallest unit has 4+ beds. | | `minBaths` / `maxBaths` | integer (optional) | Same range semantics as beds. | | `sort` | `price_asc` \| `price_desc` (optional) | Other unified sort modes collapse to default order. | ### Response ```json { "developments": [ { "id": 42, "reference": "CHL", "name": "Cerrado Hills", "propertyType": "Villas", "priceTitle": "From", "minPrice": 2300000, "maxPrice": 4100000, "minBeds": 4, "maxBeds": 6, "minBaths": 4, "maxBaths": 6, "minBuiltArea": 420, "maxBuiltArea": 780, "minPlotArea": 1200, "maxPlotArea": 3200, "town": "Marbella", "place": "El Madroñal", "postcode": "29679", "latitude": 36.5234, "longitude": -4.9876, "stage": "Offplan", "unitsTotal": 13, "unitsAvailable": 9, "imagesSm": ["..."], "imagesMd": ["..."], "imagesLg": ["..."] } ], "page": 1, "pageSize": 20, "totalResults": 39 } ``` `units[]` is omitted on list responses. See `GET /zoddak/detail/:reference` to retrieve the full per-unit table for a single development. ## Development detail ``` GET /zoddak/detail/:reference ``` **Authentication:** Required (Clerk JWT + organization) Fetches the full payload for a single development, including the populated `units[]` array (unit, beds, baths, built area, plot, price). Use the development's `reference` from the search response. ### Response ```json { "development": { "id": 42, "reference": "CHL", "name": "Cerrado Hills", "...": "...", "units": [ { "block": "A", "floor": "0", "door": "1", "unit": "Villa 1", "beds": 5, "baths": 5, "toilets": 1, "builtArea": 580, "usefulArea": 520, "coveredTerrace": 80, "outdoorTerrace": 120, "solarium": 0, "basement": 60, "plot": 1800, "garden": 600, "commonAreas": 0, "garage": 80, "storageRoom": 12, "price": 2300000 } ] } } ``` ## Locations ``` GET /zoddak/locations?query=<string> ``` **Authentication:** Required (Clerk JWT + organization) Returns the flattened Zoddak location hierarchy (zones → provinces → towns → places) for use in the dashboard's location autocomplete. Optional `query` does a case-insensitive prefix-then-contains filter server-side. ### Response ```json { "locations": [ { "kind": "zone", "label": "Costa del Sol", "totalItems": 1200 }, { "kind": "province", "label": "Málaga", "parent": "Costa del Sol", "totalItems": 1100 }, { "kind": "town", "label": "Marbella", "parent": "Málaga", "totalItems": 320 }, { "kind": "place", "label": "Nueva Andalucía","parent": "Marbella", "totalItems": 80 } ] } ``` ## Stages ``` GET /zoddak/stages ``` **Authentication:** Required (Clerk JWT + organization) Returns the canonical Zoddak development stages with localized labels for all 11 supported languages. Used by the stage filter pill in the property-listings view. ### Response ```json { "stages": [ { "id": 2, "labels": { "en": "Offplan", "es": "Sobre plano", "de": "In Planung", "fr": "Sur plan" } } ] } ``` --- # OpenAPI document Source: https://www.fondaro.com/openapi/properties.json ```json {"components":{"schemas":{"AgentResponseDto":{"properties":{"additionalInfo":{"description":"Free-form extra data.","example":{"languages":["en","es"]},"nullable":true,"type":"object"},"avatarThumbnailUrl":{"description":"Small avatar image URL.","example":"https://img.clerk.com/avatar-thumb.png","nullable":true,"type":"string"},"avatarUrl":{"description":"Avatar image URL.","example":"https://img.clerk.com/avatar.png","nullable":true,"type":"string"},"clerkUserId":{"description":"The Fondaro user behind the row; null for a standalone agent.","example":"user_2abcDEF","nullable":true,"type":"string"},"createdAt":{"description":"Created.","example":"2026-01-10T09:00:00.000Z","format":"date-time","type":"string"},"description":{"description":"Short biography.","example":"Twelve years selling villas on the Golden Mile.","nullable":true,"type":"string"},"displayEmail":{"description":"Public email shown on listings and brochures.","example":"ana@agency.example","nullable":true,"type":"string"},"firstName":{"description":"First name (for a Fondaro member, their account name).","example":"Ana","nullable":true,"type":"string"},"first_name":{"description":"Legacy spelling of `firstName`.","example":"Ana","nullable":true,"type":"string"},"id":{"description":"Agent id; the only identifier any endpoint accepts for an agent.","example":"6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b","type":"string"},"image_url":{"description":"Legacy spelling of `avatarUrl`.","example":"https://img.clerk.com/avatar.png","type":"string"},"isActive":{"description":"Active on the roster (inactive agents are hidden from listing pickers).","example":true,"type":"boolean"},"isClerkLinked":{"description":"True for a Fondaro member's row.","example":true,"type":"boolean"},"lastName":{"description":"Last name.","example":"García","nullable":true,"type":"string"},"last_name":{"description":"Legacy spelling of `lastName`.","example":"García","nullable":true,"type":"string"},"organizationId":{"description":"The agency the row belongs to.","example":"0b8e3f4a-1c2d-4e5f-8a9b-0c1d2e3f4a5b","type":"string"},"phoneNumber":{"description":"Public phone number.","example":"+34 600 000 000","nullable":true,"type":"string"},"shareContactOnNetwork":{"description":"Show this card's email, phone and WhatsApp to other agencies on network listings (a member sets their own; an admin any).","example":false,"type":"boolean"},"title":{"description":"Job title.","example":"Senior agent","nullable":true,"type":"string"},"updatedAt":{"description":"Last change.","example":"2026-09-01T09:00:00.000Z","format":"date-time","type":"string"},"whatsappNumber":{"description":"WhatsApp number.","example":"+34 600 000 000","nullable":true,"type":"string"}},"required":["id","clerkUserId","isClerkLinked","first_name","last_name","image_url","firstName","lastName","avatarUrl","avatarThumbnailUrl","organizationId","displayEmail","phoneNumber","whatsappNumber","description","title","isActive","shareContactOnNetwork","additionalInfo","createdAt","updatedAt"],"type":"object"},"BoundingBoxDto":{"properties":{"northEast":{"allOf":[{"$ref":"#/components/schemas/CoordinateDto"}],"description":"North-east corner of the map view."},"southWest":{"allOf":[{"$ref":"#/components/schemas/CoordinateDto"}],"description":"South-west corner of the map view."}},"required":["northEast","southWest"],"type":"object"},"CoordinateDto":{"properties":{"lat":{"description":"Latitude.","example":36.55,"type":"number"},"lon":{"description":"Longitude.","example":-4.85,"type":"number"}},"required":["lat","lon"],"type":"object"},"CreatePropertyDto":{"properties":{"additionalFeatures":{"description":"Free-form extra facts, as key/value pairs.","example":{"views":"panoramic"},"type":"object"},"addressLine1":{"description":"Street address. Shared with other agencies on the network for ACTIVE listings.","example":"Calle Sierra Blanca 12","type":"string"},"addressLine2":{"description":"Second address line (building, floor, door).","example":"Urbanización Sierra Blanca","type":"string"},"agentId":{"description":"The listing agent (an id from `GET /agents`).","example":"6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b","format":"uuid","type":"string"},"areaUnit":{"description":"Unit of `livingArea` and `plotArea`. Values: `sqm` (square metres); `sqft` (square feet).","enum":["sqm","sqft"],"example":"sqm","type":"string"},"autoRenewEnabled":{"description":"Renew the listing automatically when it reaches `expiresAt`.","example":true,"type":"boolean"},"availableFrom":{"description":"Date the property is available (`YYYY-MM-DD`).","example":"2026-11-01","format":"date","nullable":true,"type":"string"},"basuraPerYear":{"description":"Refuse collection tax per year.","example":180,"nullable":true,"type":"number"},"bathrooms":{"description":"Number of bathrooms (one decimal, so 2.5 is allowed).","example":4,"type":"number"},"bedrooms":{"description":"Number of bedrooms.","example":5,"type":"integer"},"city":{"description":"Town or city, up to 100 characters.","example":"Marbella","type":"string"},"co2Emissions":{"description":"CO₂ emissions in kg per m² per year.","example":18.2,"nullable":true,"type":"number"},"commission":{"description":"Your private commission percentage (0-100). Never shown to other agencies; the offer to the network is `sharedCommission`. Own listings only: absent from other agencies' rows.","example":5,"type":"number"},"community":{"description":"Neighbourhood, urbanisation or community name.","example":"Sierra Blanca","type":"string"},"communityFeesPerYear":{"description":"Community fees per year in `currency`.","example":3600,"nullable":true,"type":"number"},"completionDate":{"description":"Completion date of a new build (`YYYY-MM-DD`).","example":"2027-06-30","format":"date","nullable":true,"type":"string"},"condition":{"description":"State of the property. Values: `new` (newly built, never lived in); `excellent` (excellent); `good` (good); `needs_renovation` (needs renovation); `under_construction` (still being built).","enum":["new","excellent","good","needs_renovation","under_construction"],"example":"excellent","nullable":true,"type":"string"},"countryCode":{"description":"ISO 3166-1 alpha-2 country code, upper case.","example":"ES","type":"string"},"currency":{"description":"ISO 4217 currency code, upper case.","example":"EUR","type":"string"},"description":{"description":"Main description, plain text with line breaks.","example":"South-facing villa with sea views, a heated pool and a guest apartment.","type":"string"},"energyConsumptionKwh":{"description":"Energy consumption in kWh per m² per year.","example":85.5,"nullable":true,"type":"number"},"energyRating":{"description":"EU energy performance certificate letter. Values: `A+` (best); `A` (very efficient); `B` (efficient); `C` (fairly efficient); `D` (average); `E` (below average); `F` (inefficient); `G` (least efficient); `exempt` (no certificate required); `in_progress` (certificate being issued).","enum":["A+","A","B","C","D","E","F","G","exempt","in_progress"],"example":"B","nullable":true,"type":"string"},"energyRatingDetails":{"description":"Free-text energy certificate details.","example":"Certificate issued 2024, consumption rating B.","type":"string"},"features":{"description":"Amenity slugs from the shared catalogue (the canonical amenity list; wins over the legacy `has*` flags). Omit to keep, never null.","example":["pool","sea_view","garage"],"items":{"enum":["pool","sea_view","garage","terrace","garden","air_conditioning","lift","beachfront","frontline_golf","panoramic_views","gated_complex","storage_room","built_in_wardrobes","luxury"],"type":"string"},"type":"array"},"floor":{"description":"Floor the unit is on (-20 to 300; 0 is the ground floor).","example":3,"nullable":true,"type":"integer"},"floorPlans":{"description":"Floor plan images in display order; uploaded like photos. Omit to keep, never null.","items":{"$ref":"#/components/schemas/PropertyFloorPlanDto"},"type":"array"},"furnished":{"description":"Furnishing. Values: `no` (unfurnished); `partly` (partly furnished); `fully` (fully furnished).","enum":["no","partly","fully"],"example":"partly","nullable":true,"type":"string"},"hasAirConditioning":{"description":"Legacy amenity flag; `features` holds `air_conditioning` too.","example":true,"type":"boolean"},"hasGarage":{"description":"Legacy amenity flag; `features` holds `garage` too.","example":true,"type":"boolean"},"hasGarden":{"description":"Legacy amenity flag; `features` holds `garden` too.","example":true,"type":"boolean"},"hasLift":{"description":"Legacy amenity flag; `features` holds `lift` too.","example":false,"type":"boolean"},"hasPool":{"description":"Legacy amenity flag; `features` holds `pool` too.","example":true,"type":"boolean"},"hasSeaView":{"description":"Legacy amenity flag; `features` holds `sea_view` too.","example":true,"type":"boolean"},"hasTerrace":{"description":"Legacy amenity flag; `features` holds `terrace` too.","example":true,"type":"boolean"},"ibiPerYear":{"description":"IBI (Spanish property tax) per year.","example":2100,"nullable":true,"type":"number"},"images":{"description":"Photos in display order. Every URL must come from `POST /properties/media/upload` (or already be on the listing). `width`/`height` are set by the server from the upload.","items":{"$ref":"#/components/schemas/PropertyImageDto"},"type":"array"},"isExterior":{"description":"The unit faces the street or outside (not an inner patio).","example":true,"nullable":true,"type":"boolean"},"isFurnished":{"description":"Legacy flag, true when `furnished` is `partly` or `fully`.","example":false,"type":"boolean"},"isNewConstruction":{"description":"A new-build property.","example":false,"type":"boolean"},"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","example":36.5201,"type":"number"},"leadGroupId":{"description":"Internal lead plan the listing belongs to. Own listings only: absent from other agencies' rows.","example":12,"type":"integer"},"listingType":{"description":"How the property is offered. Values: `sale` (for sale; `price` is the asking price); `rent` (for rent; see `rentalPrice` and `rentalPeriod`); `sale_or_rent` (for sale or for rent); `fraction` (a fractional ownership share).","enum":["sale","rent","sale_or_rent","fraction"],"example":"sale","type":"string"},"livingArea":{"description":"Built living area in `areaUnit`.","example":420,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","example":-4.9112,"type":"number"},"orientation":{"description":"Direction the main facade faces. Values: `N` (north); `NE` (north-east); `E` (east); `SE` (south-east); `S` (south); `SW` (south-west); `W` (west); `NW` (north-west).","enum":["N","NE","E","SE","S","SW","W","NW"],"example":"S","nullable":true,"type":"string"},"parkingSpaces":{"description":"Number of parking spaces included.","example":2,"nullable":true,"type":"integer"},"parkingType":{"description":"Kind of parking. Values: `garage` (private garage); `underground` (underground space); `covered` (covered space or carport); `open` (open private space); `street` (on the street); `communal` (shared community parking).","enum":["garage","underground","covered","open","street","communal"],"example":"garage","nullable":true,"type":"string"},"plotArea":{"description":"Plot size in `areaUnit`.","example":1500,"type":"number"},"postalCode":{"description":"Postal code, up to 20 characters.","example":"29602","type":"string"},"price":{"description":"Asking price in `currency` (two decimals).","example":2450000,"type":"number"},"propertyCategory":{"description":"Broad kind of property; `propertyType` must belong to it (`other` fits every category). Values: `residential` (homes); `commercial` (offices, shops and other business premises); `industrial` (warehouses and industrial premises); `land` (plots and land).","enum":["residential","commercial","industrial","land"],"example":"residential","type":"string"},"propertyType":{"description":"Specific kind of property (category: meaning). Values: `apartment` (residential: flat); `condominium` (residential: condominium unit); `house_detached` (residential: detached house or villa); `house_semi_detached` (residential: semi-detached house); `house_terraced` (residential: terraced house); `townhouse` (residential: townhouse); `multi_family_home` (residential: building of several homes); `penthouse` (residential: top-floor apartment); `studio` (residential: studio apartment); `land_residential` (land: plot zoned for homes); `land_commercial` (land: plot zoned for business); `land_agricultural` (land: rural or agricultural land); `land_other` (land: other); `office` (commercial: office); `retail` (commercial: shop or retail unit); `commercial_other` (commercial: other); `industrial_warehouse` (industrial: warehouse); `industrial_other` (industrial: other); `hospitality` (commercial: hotel, restaurant or similar); `other` (any category: other).","enum":["apartment","condominium","house_detached","house_semi_detached","house_terraced","townhouse","multi_family_home","penthouse","studio","land_residential","land_commercial","land_agricultural","land_other","office","retail","commercial_other","industrial_warehouse","industrial_other","hospitality","other"],"example":"house_detached","type":"string"},"provinceState":{"description":"Province or state.","example":"Málaga","type":"string"},"region":{"description":"Region or area (a coast, a valley).","example":"Costa del Sol","type":"string"},"rentalPeriod":{"description":"What `rentalPrice` is charged per. Values: `day` (per night or day); `week` (per week); `month` (per month); `year` (per year).","enum":["day","week","month","year"],"example":"month","nullable":true,"type":"string"},"rentalPrice":{"description":"Rent in `currency` per `rentalPeriod`.","example":6500,"nullable":true,"type":"number"},"rentalPriceHighSeason":{"description":"High-season rent per `rentalPeriod`.","example":9000,"nullable":true,"type":"number"},"rentalPriceLowSeason":{"description":"Low-season rent per `rentalPeriod`.","example":4500,"nullable":true,"type":"number"},"sharedCommission":{"description":"Commission percentage your agency offers another agency that sells the property (0-100). Visible to every agency on the network.","example":2.5,"nullable":true,"type":"number"},"sharedCommissionNotes":{"description":"Terms of the shared commission, visible to the network.","example":"50/50 split on the agreed fee, paid on completion.","nullable":true,"type":"string"},"thumbnails":{"description":"Small versions of `images` (the upload returns one per photo); `originalImageIndex` points at the image.","items":{"$ref":"#/components/schemas/PropertyThumbnailDto"},"type":"array"},"title":{"description":"Headline shown above the price, up to 140 characters.","example":"Modern villa with sea views in Sierra Blanca","nullable":true,"type":"string"},"totalFloors":{"description":"Floors in the building.","example":6,"nullable":true,"type":"integer"},"translatedDescriptions":{"description":"The description in other languages, keyed by language code (`es`, `de`, `fr`...).","example":{"es":"Villa orientada al sur con vistas al mar."},"type":"object"},"translatedTitles":{"description":"The title in other languages, keyed by language code.","example":{"es":"Villa moderna con vistas al mar en Sierra Blanca"},"nullable":true,"type":"object"},"videoUrls":{"description":"Video links (http or https). Omit to keep, never null.","example":["https://www.youtube.com/watch?v=abc123"],"items":{"type":"string"},"type":"array"},"viewingInstructions":{"description":"Private notes for arranging viewings. Never shared. Own listings only: absent from other agencies' rows.","example":"Keys at the office; call the owner a day ahead.","nullable":true,"type":"string"},"virtualTourUrls":{"description":"Virtual tour links (http or https). Omit to keep, never null.","example":["https://my.matterport.com/show/?m=abc123"],"items":{"type":"string"},"type":"array"},"yearBuilt":{"description":"Year of construction (1801 up to a few years ahead).","example":2018,"type":"integer"}},"required":["listingType","propertyCategory","propertyType","city","countryCode","currency"],"type":"object"},"CreateStandaloneAgentDto":{"properties":{"additionalInfo":{"description":"Free-form extra data.","example":{"languages":["en","es"]},"type":"object"},"description":{"description":"Short biography.","example":"Twelve years selling villas on the Golden Mile.","type":"string"},"displayEmail":{"description":"Public email shown on listings and brochures.","example":"ana@agency.example","type":"string"},"firstName":{"description":"First name (for a Fondaro member, their account name).","example":"Ana","type":"string"},"isActive":{"description":"Active on the roster (inactive agents are hidden from listing pickers).","example":true,"type":"boolean"},"lastName":{"description":"Last name.","example":"García","type":"string"},"phoneNumber":{"description":"Public phone number.","example":"+34 600 000 000","type":"string"},"title":{"description":"Job title.","example":"Senior agent","type":"string"},"whatsappNumber":{"description":"WhatsApp number.","example":"+34 600 000 000","type":"string"}},"required":["firstName"],"type":"object"},"FeaturesDto":{"properties":{"hasAirConditioning":{"description":"Has air conditioning. `true` requires it.","example":true,"type":"boolean"},"hasGarage":{"description":"Has a garage. `true` requires it.","example":true,"type":"boolean"},"hasGarden":{"description":"Has a garden. `true` requires it.","example":true,"type":"boolean"},"hasLift":{"description":"Has a lift. `true` requires it.","example":true,"type":"boolean"},"hasPool":{"description":"Has a pool. `true` requires it.","example":true,"type":"boolean"},"hasSeaView":{"description":"Has sea views. `true` requires it.","example":true,"type":"boolean"},"hasTerrace":{"description":"Has a terrace. `true` requires it.","example":true,"type":"boolean"},"isFurnished":{"description":"Is furnished. `true` requires it.","example":true,"type":"boolean"}},"type":"object"},"ListingAgentCardDto":{"properties":{"clerkUserId":{"description":"The Fondaro user behind the card, when there is one (used for in-app messages).","example":"user_2abcDEF","nullable":true,"type":"object"},"displayEmail":{"description":"Public email. On another agency's listing only when that agent shares contact details with the network.","example":"ana@agency.example","type":"string"},"firstName":{"description":"First name.","example":"Ana","type":"string"},"id":{"description":"Agent id (the roster row).","example":"6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b","type":"string"},"imageUrl":{"description":"Avatar URL.","example":"https://img.clerk.com/avatar.png","type":"string"},"lastName":{"description":"Last name.","example":"García","type":"string"},"phoneNumber":{"description":"Phone, under the same sharing rule as `displayEmail`.","example":"+34 600 000 000","type":"string"},"title":{"description":"Job title.","example":"Senior agent","type":"string"},"whatsappNumber":{"description":"WhatsApp number, under the same sharing rule.","example":"+34 600 000 000","type":"string"}},"required":["id"],"type":"object"},"ListingOrganizationBrandingDto":{"properties":{"description":{"description":"Agency description.","example":"Independent agency on the Costa del Sol since 2004.","type":"string"},"email":{"description":"Agency email.","example":"hello@agency.example","type":"string"},"logoDarkUrl":{"description":"Logo for dark backgrounds.","example":"https://cdn.example/logo-dark.png","type":"string"},"logoLightUrl":{"description":"Logo for light backgrounds.","example":"https://cdn.example/logo-light.png","type":"string"},"name":{"description":"Agency name.","example":"Example Estates","type":"string"},"phone":{"description":"Agency phone.","example":"+34 952 000 000","type":"string"},"website":{"description":"Agency website.","example":"https://www.agency.example","type":"string"}},"type":"object"},"ListingResponseDto":{"properties":{"additionalFeatures":{"description":"Free-form extra facts, as key/value pairs.","example":{"views":"panoramic"},"type":"object"},"addressLine1":{"description":"Street address. Shared with other agencies on the network for ACTIVE listings.","example":"Calle Sierra Blanca 12","type":"string"},"addressLine2":{"description":"Second address line (building, floor, door).","example":"Urbanización Sierra Blanca","type":"string"},"agent":{"allOf":[{"$ref":"#/components/schemas/ListingAgentCardDto"}],"description":"The listing agent, with `includeBranding=true`."},"agentId":{"description":"The listing agent (an id from `GET /agents`).","example":"6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b","format":"uuid","type":"string"},"areaUnit":{"description":"Unit of `livingArea` and `plotArea`. Values: `sqm` (square metres); `sqft` (square feet).","enum":["sqm","sqft"],"example":"sqm","type":"string"},"autoRenewEnabled":{"description":"Renew the listing automatically when it reaches `expiresAt`.","example":true,"type":"boolean"},"availableFrom":{"description":"Date the property is available (`YYYY-MM-DD`).","example":"2026-11-01","format":"date","nullable":true,"type":"string"},"basuraPerYear":{"description":"Refuse collection tax per year.","example":180,"nullable":true,"type":"number"},"bathrooms":{"description":"Number of bathrooms (one decimal, so 2.5 is allowed).","example":4,"type":"number"},"bedrooms":{"description":"Number of bedrooms.","example":5,"type":"integer"},"city":{"description":"Town or city, up to 100 characters.","example":"Marbella","type":"string"},"co2Emissions":{"description":"CO₂ emissions in kg per m² per year.","example":18.2,"nullable":true,"type":"number"},"commission":{"description":"Your private commission percentage (0-100). Never shown to other agencies; the offer to the network is `sharedCommission`. Own listings only: absent from other agencies' rows.","example":5,"type":"number"},"community":{"description":"Neighbourhood, urbanisation or community name.","example":"Sierra Blanca","type":"string"},"communityFeesPerYear":{"description":"Community fees per year in `currency`.","example":3600,"nullable":true,"type":"number"},"completionDate":{"description":"Completion date of a new build (`YYYY-MM-DD`).","example":"2027-06-30","format":"date","nullable":true,"type":"string"},"condition":{"description":"State of the property. Values: `new` (newly built, never lived in); `excellent` (excellent); `good` (good); `needs_renovation` (needs renovation); `under_construction` (still being built).","enum":["new","excellent","good","needs_renovation","under_construction"],"example":"excellent","nullable":true,"type":"string"},"countryCode":{"description":"ISO 3166-1 alpha-2 country code, upper case.","example":"ES","type":"string"},"createdAt":{"description":"When the listing was created.","example":"2026-09-01T10:15:00.000Z","format":"date-time","type":"string"},"currency":{"description":"ISO 4217 currency code, upper case.","example":"EUR","type":"string"},"description":{"description":"Main description, plain text with line breaks.","example":"South-facing villa with sea views, a heated pool and a guest apartment.","type":"string"},"energyConsumptionKwh":{"description":"Energy consumption in kWh per m² per year.","example":85.5,"nullable":true,"type":"number"},"energyRating":{"description":"EU energy performance certificate letter. Values: `A+` (best); `A` (very efficient); `B` (efficient); `C` (fairly efficient); `D` (average); `E` (below average); `F` (inefficient); `G` (least efficient); `exempt` (no certificate required); `in_progress` (certificate being issued).","enum":["A+","A","B","C","D","E","F","G","exempt","in_progress"],"example":"B","nullable":true,"type":"string"},"energyRatingDetails":{"description":"Free-text energy certificate details.","example":"Certificate issued 2024, consumption rating B.","type":"string"},"expiresAt":{"description":"When the listing expires unless renewed.","example":"2026-11-02T09:00:00.000Z","format":"date-time","nullable":true,"type":"string"},"features":{"description":"Amenity slugs from the shared catalogue (the canonical amenity list; wins over the legacy `has*` flags). Omit to keep, never null.","example":["pool","sea_view","garage"],"items":{"enum":["pool","sea_view","garage","terrace","garden","air_conditioning","lift","beachfront","frontline_golf","panoramic_views","gated_complex","storage_room","built_in_wardrobes","luxury"],"type":"string"},"type":"array"},"floor":{"description":"Floor the unit is on (-20 to 300; 0 is the ground floor).","example":3,"nullable":true,"type":"integer"},"floorPlans":{"description":"Floor plan images in display order; uploaded like photos. Omit to keep, never null.","items":{"$ref":"#/components/schemas/PropertyFloorPlanDto"},"type":"array"},"furnished":{"description":"Furnishing. Values: `no` (unfurnished); `partly` (partly furnished); `fully` (fully furnished).","enum":["no","partly","fully"],"example":"partly","nullable":true,"type":"string"},"hasAirConditioning":{"description":"Legacy amenity flag; `features` holds `air_conditioning` too.","example":true,"type":"boolean"},"hasGarage":{"description":"Legacy amenity flag; `features` holds `garage` too.","example":true,"type":"boolean"},"hasGarden":{"description":"Legacy amenity flag; `features` holds `garden` too.","example":true,"type":"boolean"},"hasLift":{"description":"Legacy amenity flag; `features` holds `lift` too.","example":false,"type":"boolean"},"hasPool":{"description":"Legacy amenity flag; `features` holds `pool` too.","example":true,"type":"boolean"},"hasSeaView":{"description":"Legacy amenity flag; `features` holds `sea_view` too.","example":true,"type":"boolean"},"hasTerrace":{"description":"Legacy amenity flag; `features` holds `terrace` too.","example":true,"type":"boolean"},"ibiPerYear":{"description":"IBI (Spanish property tax) per year.","example":2100,"nullable":true,"type":"number"},"id":{"description":"Listing id. Use it for detail, update and delete.","example":"2b9d6c1e-8f7a-4c3b-9e21-5a4d3c2b1a09","format":"uuid","type":"string"},"images":{"description":"Photos in display order. Every URL must come from `POST /properties/media/upload` (or already be on the listing). `width`/`height` are set by the server from the upload.","items":{"$ref":"#/components/schemas/PropertyImageDto"},"type":"array"},"isExterior":{"description":"The unit faces the street or outside (not an inner patio).","example":true,"nullable":true,"type":"boolean"},"isFurnished":{"description":"Legacy flag, true when `furnished` is `partly` or `fully`.","example":false,"type":"boolean"},"isNewConstruction":{"description":"A new-build property.","example":false,"type":"boolean"},"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","example":36.5201,"type":"number"},"leadGroupId":{"description":"Internal lead plan the listing belongs to. Own listings only: absent from other agencies' rows.","example":12,"type":"integer"},"listingType":{"description":"How the property is offered. Values: `sale` (for sale; `price` is the asking price); `rent` (for rent; see `rentalPrice` and `rentalPeriod`); `sale_or_rent` (for sale or for rent); `fraction` (a fractional ownership share).","enum":["sale","rent","sale_or_rent","fraction"],"example":"sale","type":"string"},"livingArea":{"description":"Built living area in `areaUnit`.","example":420,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","example":-4.9112,"type":"number"},"organizationBranding":{"allOf":[{"$ref":"#/components/schemas/ListingOrganizationBrandingDto"}],"description":"The listing agency, with `includeBranding=true`."},"organizationId":{"description":"The agency that owns the listing.","example":"0b8e3f4a-1c2d-4e5f-8a9b-0c1d2e3f4a5b","format":"uuid","type":"string"},"orientation":{"description":"Direction the main facade faces. Values: `N` (north); `NE` (north-east); `E` (east); `SE` (south-east); `S` (south); `SW` (south-west); `W` (west); `NW` (north-west).","enum":["N","NE","E","SE","S","SW","W","NW"],"example":"S","nullable":true,"type":"string"},"parkingSpaces":{"description":"Number of parking spaces included.","example":2,"nullable":true,"type":"integer"},"parkingType":{"description":"Kind of parking. Values: `garage` (private garage); `underground` (underground space); `covered` (covered space or carport); `open` (open private space); `street` (on the street); `communal` (shared community parking).","enum":["garage","underground","covered","open","street","communal"],"example":"garage","nullable":true,"type":"string"},"plotArea":{"description":"Plot size in `areaUnit`.","example":1500,"type":"number"},"postalCode":{"description":"Postal code, up to 20 characters.","example":"29602","type":"string"},"price":{"description":"Asking price in `currency` (two decimals).","example":2450000,"type":"number"},"propertyCategory":{"description":"Broad kind of property; `propertyType` must belong to it (`other` fits every category). Values: `residential` (homes); `commercial` (offices, shops and other business premises); `industrial` (warehouses and industrial premises); `land` (plots and land).","enum":["residential","commercial","industrial","land"],"example":"residential","type":"string"},"propertyType":{"description":"Specific kind of property (category: meaning). Values: `apartment` (residential: flat); `condominium` (residential: condominium unit); `house_detached` (residential: detached house or villa); `house_semi_detached` (residential: semi-detached house); `house_terraced` (residential: terraced house); `townhouse` (residential: townhouse); `multi_family_home` (residential: building of several homes); `penthouse` (residential: top-floor apartment); `studio` (residential: studio apartment); `land_residential` (land: plot zoned for homes); `land_commercial` (land: plot zoned for business); `land_agricultural` (land: rural or agricultural land); `land_other` (land: other); `office` (commercial: office); `retail` (commercial: shop or retail unit); `commercial_other` (commercial: other); `industrial_warehouse` (industrial: warehouse); `industrial_other` (industrial: other); `hospitality` (commercial: hotel, restaurant or similar); `other` (any category: other).","enum":["apartment","condominium","house_detached","house_semi_detached","house_terraced","townhouse","multi_family_home","penthouse","studio","land_residential","land_commercial","land_agricultural","land_other","office","retail","commercial_other","industrial_warehouse","industrial_other","hospitality","other"],"example":"house_detached","type":"string"},"provinceState":{"description":"Province or state.","example":"Málaga","type":"string"},"publishedAt":{"description":"When the listing first became ACTIVE.","example":"2026-09-02T09:00:00.000Z","format":"date-time","nullable":true,"type":"string"},"referenceNumber":{"description":"Human reference shown to buyers (`FDR-` + 8 characters).","example":"FDR-7K2M9QXA","type":"string"},"region":{"description":"Region or area (a coast, a valley).","example":"Costa del Sol","type":"string"},"rentalPeriod":{"description":"What `rentalPrice` is charged per. Values: `day` (per night or day); `week` (per week); `month` (per month); `year` (per year).","enum":["day","week","month","year"],"example":"month","nullable":true,"type":"string"},"rentalPrice":{"description":"Rent in `currency` per `rentalPeriod`.","example":6500,"nullable":true,"type":"number"},"rentalPriceHighSeason":{"description":"High-season rent per `rentalPeriod`.","example":9000,"nullable":true,"type":"number"},"rentalPriceLowSeason":{"description":"Low-season rent per `rentalPeriod`.","example":4500,"nullable":true,"type":"number"},"sharedCommission":{"description":"Commission percentage your agency offers another agency that sells the property (0-100). Visible to every agency on the network.","example":2.5,"nullable":true,"type":"number"},"sharedCommissionNotes":{"description":"Terms of the shared commission, visible to the network.","example":"50/50 split on the agreed fee, paid on completion.","nullable":true,"type":"string"},"status":{"description":"Publication state. Values: `active` (published and visible to every agency on the network); `inactive` (draft or paused, visible only to your agency); `pending` (under offer or awaiting approval, yours only); `sold` (sold, yours only); `rented` (let, yours only); `deleted` (removed, yours only).","enum":["active","inactive","pending","sold","rented","deleted"],"example":"active","type":"string"},"thumbnails":{"description":"Small versions of `images` (the upload returns one per photo); `originalImageIndex` points at the image.","items":{"$ref":"#/components/schemas/PropertyThumbnailDto"},"type":"array"},"title":{"description":"Headline shown above the price, up to 140 characters.","example":"Modern villa with sea views in Sierra Blanca","nullable":true,"type":"string"},"totalFloors":{"description":"Floors in the building.","example":6,"nullable":true,"type":"integer"},"translatedDescriptions":{"description":"The description in other languages, keyed by language code (`es`, `de`, `fr`...).","example":{"es":"Villa orientada al sur con vistas al mar."},"type":"object"},"translatedTitles":{"description":"The title in other languages, keyed by language code.","example":{"es":"Villa moderna con vistas al mar en Sierra Blanca"},"nullable":true,"type":"object"},"updatedAt":{"description":"Last change.","example":"2026-09-20T08:00:00.000Z","format":"date-time","type":"string"},"videoUrls":{"description":"Video links (http or https). Omit to keep, never null.","example":["https://www.youtube.com/watch?v=abc123"],"items":{"type":"string"},"type":"array"},"viewingInstructions":{"description":"Private notes for arranging viewings. Never shared. Own listings only: absent from other agencies' rows.","example":"Keys at the office; call the owner a day ahead.","nullable":true,"type":"string"},"virtualTourUrls":{"description":"Virtual tour links (http or https). Omit to keep, never null.","example":["https://my.matterport.com/show/?m=abc123"],"items":{"type":"string"},"type":"array"},"yearBuilt":{"description":"Year of construction (1801 up to a few years ahead).","example":2018,"type":"integer"}},"required":["id","referenceNumber","organizationId","status","listingType","propertyCategory","propertyType","city","countryCode","currency"],"type":"object"},"LocationDto":{"properties":{"distance":{"default":"10km","description":"Radius with unit (`km` or `m`).","example":"5km","type":"string"},"lat":{"description":"Centre latitude.","example":36.5101,"type":"number"},"lon":{"description":"Centre longitude.","example":-4.8825,"type":"number"}},"required":["lat","lon"],"type":"object"},"PriceDistributionDto":{"properties":{"interval":{"default":50000,"description":"Histogram bucket width in the listing currency.","example":50000,"type":"number"},"max":{"description":"Highest price of the histogram.","example":5000000,"type":"number"},"min":{"description":"Lowest price of the histogram.","example":0,"type":"number"}},"type":"object"},"PriceDistributionResultDto":{"properties":{"buckets":{"description":"Listings per bucket; `key` is the bucket start.","example":[{"count":4,"key":250000},{"count":7,"key":300000}],"items":{"type":"string"},"type":"array"},"interval":{"description":"Bucket width.","example":50000,"type":"number"},"maxPrice":{"description":"Highest price among the matches.","example":4950000,"type":"number"},"minPrice":{"description":"Lowest price among the matches.","example":250000,"type":"number"}},"required":["interval","minPrice","maxPrice","buckets"],"type":"object"},"PropertyAggregationsResponseDto":{"properties":{"availableCities":{"description":"Towns with at least one match.","example":["Benahavís","Marbella"],"items":{"type":"string"},"type":"array"},"availableCountries":{"description":"Country codes with at least one match.","example":["ES"],"items":{"type":"string"},"type":"array"},"availablePropertyCategories":{"description":"Categories with at least one match.","example":["residential"],"items":{"type":"string"},"type":"array"},"availablePropertyTypes":{"description":"Property types with at least one match.","example":["apartment","house_detached"],"items":{"type":"string"},"type":"array"},"availableRegions":{"description":"Regions with at least one match.","example":["Costa del Sol"],"items":{"type":"string"},"type":"array"},"byCity":{"additionalProperties":{"properties":{"byType":{"additionalProperties":{"type":"integer"},"type":"object"},"total":{"type":"integer"}},"type":"object"},"description":"Listings per town, with a breakdown by property type.","example":{"Marbella":{"byType":{"apartment":30,"house_detached":12},"total":45}},"type":"object"},"byCountry":{"additionalProperties":{"properties":{"byType":{"additionalProperties":{"type":"integer"},"type":"object"},"total":{"type":"integer"}},"type":"object"},"description":"Listings per ISO country code, with a breakdown by property type.","example":{"ES":{"byType":{"apartment":37,"house_detached":20},"total":60}},"type":"object"},"byPropertyCategory":{"additionalProperties":{"type":"integer"},"description":"Listings per category.","example":{"commercial":3,"residential":65},"type":"object"},"byPropertyType":{"additionalProperties":{"type":"integer"},"description":"Listings per property type.","example":{"apartment":45,"house_detached":20,"penthouse":3},"type":"object"},"byRegion":{"additionalProperties":{"properties":{"byType":{"additionalProperties":{"type":"integer"},"type":"object"},"total":{"type":"integer"}},"type":"object"},"description":"Listings per region, with a breakdown by property type.","example":{"Costa del Sol":{"byType":{"apartment":37,"house_detached":20},"total":60}},"type":"object"},"communities":{"description":"Only with `q`: communities whose name, town or region contains it, most listings first.","example":[{"city":"Marbella","name":"Sierra Blanca","region":"Costa del Sol","total":6}],"items":{"properties":{"city":{"type":"string"},"name":{"type":"string"},"region":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"type":"array"},"totalProperties":{"description":"Listings matching the filters.","example":68,"type":"integer"}},"required":["byCity","byRegion","byCountry","byPropertyType","byPropertyCategory","availableCities","availableRegions","availableCountries","availablePropertyTypes","availablePropertyCategories","totalProperties"],"type":"object"},"PropertyFloorPlanDto":{"properties":{"alt":{"description":"Alternative text.","example":"Ground floor","type":"string"},"order":{"description":"Display position, 0 first.","example":0,"type":"number"},"url":{"description":"Floor plan image URL, uploaded like a photo.","example":"https://fondaro.fra1.cdn.digitaloceanspaces.com/properties/images/7a1b2c3d.jpg","type":"string"}},"required":["url","order"],"type":"object"},"PropertyImageDto":{"properties":{"alt":{"description":"Alternative text for screen readers.","example":"Pool terrace at sunset","type":"string"},"height":{"description":"Pixel height, set by the server from the upload.","example":1080,"type":"number"},"isFeatured":{"description":"The cover photo.","example":true,"type":"boolean"},"order":{"description":"Display position, 0 first.","example":0,"type":"number"},"url":{"description":"Full-size photo URL (JPEG, up to 1920x1080) from `POST /properties/media/upload` `images.fullSize`.","example":"https://fondaro.fra1.cdn.digitaloceanspaces.com/properties/images/0f8c2d1e.jpg","type":"string"},"width":{"description":"Pixel width, set by the server from the upload (a sent value is ignored).","example":1920,"type":"number"}},"required":["url","order"],"type":"object"},"PropertySearchDto":{"properties":{"agentId":{"description":"Your listings assigned to this agent (an `id` from `GET /agents`). Needs `scope: own`: with network scope it is a 400 `PROPERTY_FILTER_UNSUPPORTED`; an id not on your roster is a 400 `PROPERTY_FILTER_VALUE_INVALID`.","example":"5f1c2a9e-3b7d-4e8a-9c21-0d4b6a7e8f10","format":"uuid","type":"string"},"agentUserIds":{"description":"Your listings assigned to any of these people (Clerk user ids). Needs `scope: own`: with network scope it is a 400 `PROPERTY_FILTER_UNSUPPORTED`.","example":["user_2abc"],"items":{"type":"string"},"type":"array"},"bathrooms":{"allOf":[{"$ref":"#/components/schemas/RangeDto"}],"description":"Bathrooms between `min` and `max`."},"bedrooms":{"allOf":[{"$ref":"#/components/schemas/RangeDto"}],"description":"Bedrooms between `min` and `max`."},"boundingBox":{"allOf":[{"$ref":"#/components/schemas/BoundingBoxDto"}],"description":"Listings inside a map rectangle (up to 1,000 slim rows; takes precedence over `polygon` and `location`)."},"city":{"description":"Towns, as `stats/aggregations` lists them.","example":["Marbella","Benahavís"],"items":{"type":"string"},"type":"array"},"commission":{"allOf":[{"$ref":"#/components/schemas/RangeDto"}],"description":"Your private commission percentage; own listings only (a 400 with `scope: network`)."},"countryCode":{"description":"ISO 3166-1 alpha-2 country codes.","example":["ES"],"items":{"type":"string"},"type":"array"},"cursor":{"description":"Keyset paging: send `*` for the first page, then each response's `nextCursor` with the same criteria. Never together with `page`.","example":"*","maxLength":4096,"type":"string"},"features":{"description":"Required amenities: a list of 1 to 30 feature slugs (every one required), or the legacy object of eight booleans.","example":["pool","sea_view"],"oneOf":[{"items":{"enum":["pool","sea_view","garage","terrace","garden","air_conditioning","lift","beachfront","frontline_golf","panoramic_views","gated_complex","storage_room","built_in_wardrobes","luxury"],"type":"string"},"type":"array"},{"$ref":"#/components/schemas/FeaturesDto"}]},"ids":{"description":"Fetch these listing ids (other filters still apply).","example":["2b9d6c1e-8f7a-4c3b-9e21-5a4d3c2b1a09"],"items":{"type":"string"},"type":"array"},"limit":{"default":20,"description":"Listings per page (1-100).","example":24,"maximum":100,"minimum":1,"type":"number"},"listingType":{"description":"Values: `sale`, `rent`, `sale_or_rent`, `fraction`.","example":["sale"],"items":{"enum":["sale","rent","sale_or_rent","fraction"],"type":"string"},"type":"array"},"livingArea":{"allOf":[{"$ref":"#/components/schemas/RangeDto"}],"description":"Living area between `min` and `max`."},"location":{"allOf":[{"$ref":"#/components/schemas/LocationDto"}],"description":"Listings within a radius of a point."},"organizationId":{"deprecated":true,"description":"Ignored on this route (the organization always comes from the key or session).","example":null,"type":"string"},"page":{"default":1,"description":"Legacy page number (1-indexed) when `cursor` is not sent.","example":1,"minimum":1,"type":"number"},"plotArea":{"allOf":[{"$ref":"#/components/schemas/RangeDto"}],"description":"Plot area between `min` and `max`."},"polygon":{"description":"Drawn area: a GeoJSON ring of at least three `[longitude, latitude]` pairs.","example":[[-4.95,36.5],[-4.85,36.5],[-4.85,36.55]],"items":{"items":{"type":"number"},"type":"array"},"type":"array"},"price":{"allOf":[{"$ref":"#/components/schemas/RangeDto"}],"description":"Price between `min` and `max`, in the listing currency.","example":{"max":2000000,"min":500000}},"priceDistribution":{"allOf":[{"$ref":"#/components/schemas/PriceDistributionDto"}],"description":"Also return a price histogram of every match (all filters but price)."},"propertyCategory":{"description":"Any of these categories.","example":["residential"],"items":{"enum":["residential","commercial","industrial","land"],"type":"string"},"type":"array"},"propertyType":{"description":"Any of these property types (see the listing `propertyType` field for meanings).","example":["house_detached","penthouse"],"items":{"enum":["apartment","condominium","house_detached","house_semi_detached","house_terraced","townhouse","multi_family_home","penthouse","studio","land_residential","land_commercial","land_agricultural","land_other","office","retail","commercial_other","industrial_warehouse","industrial_other","hospitality","other"],"type":"string"},"type":"array"},"query":{"description":"Free text in web-search syntax: words (all must match, accents ignored), \"quoted phrases\", -exclusion and OR. Matches reference, title, places, type, description, amenities and street address. Numbers are not read as bedrooms or prices: use the filters.","example":"villa \"sea views\" -golf","type":"string"},"referenceNumbers":{"description":"Exact listing references.","example":["FDR-7K2M9QXA"],"items":{"type":"string"},"type":"array"},"region":{"description":"Regions, as `stats/aggregations` lists them.","example":["Costa del Sol"],"items":{"type":"string"},"type":"array"},"scope":{"description":"Whose listings to search. Values: `network` (every agency's ACTIVE listings; other agencies' rows without private fields); `own` (your agency's listings in any status); `partners` (the ACTIVE listings of your agency's partners; dashboard sessions only, an API key gets a 400 `PROPERTY_FILTER_UNSUPPORTED`); `followed` (the ACTIVE listings of the agencies you follow; dashboard sessions only, same 400 for an API key). Omitted: `network`, or `own` when `status` asks for a non-active status.","enum":["own","network","partners","followed"],"example":"network","type":"string"},"sort":{"description":"Order of results. With a `query` and no sort, results are ranked by relevance.","example":[{"field":"price","order":"asc"}],"items":{"$ref":"#/components/schemas/SortDto"},"type":"array"},"sortBy":{"deprecated":true,"description":"Legacy single sort field; prefer `sort`.","enum":["price","createdAt","updatedAt","publishedAt","bedrooms","bathrooms","livingArea","plotArea","commission","yearBuilt","city","countryCode","referenceNumber","status","propertyType","propertyCategory","listingType","relevance"],"example":"price","type":"string"},"sortOrder":{"deprecated":true,"description":"Legacy sort order for `sortBy`.","enum":["asc","desc"],"example":"asc","type":"string"},"status":{"description":"Listing statuses. Anything but `active` searches your own listings only.","example":["active"],"items":{"enum":["active","inactive","pending","sold","rented","deleted"],"type":"string"},"type":"array"}},"type":"object"},"PropertySearchPageDto":{"properties":{"nextCursor":{"description":"Send as `cursor` with the same criteria for the next page; absent on the last page.","example":"eyJ2IjoxLCJpdiI6Ii4uLiJ9","type":"string"},"priceDistribution":{"allOf":[{"$ref":"#/components/schemas/PriceDistributionResultDto"}],"description":"Only when `priceDistribution` was requested."},"relaxedFilters":{"description":"Filters loosened because the exact search found nothing (network searches only).","example":["bedrooms"],"items":{"type":"string"},"type":"array"},"results":{"description":"One page of listings. Other agencies' rows are the network projection.","items":{"$ref":"#/components/schemas/ListingResponseDto"},"type":"array"},"total":{"description":"Number of matches, counted up to 10,000; first page only; absent on map (boundingBox) searches.","example":142,"type":"number"},"totalIsLowerBound":{"description":"True when more than 10,000 listings match (`total` is then 10,000).","example":false,"type":"boolean"}},"required":["results"],"type":"object"},"PropertySearchResponseDto":{"properties":{"priceDistribution":{"allOf":[{"$ref":"#/components/schemas/PriceDistributionResultDto"}],"description":"Only when `priceDistribution` was requested."},"relaxedFilters":{"description":"Filters loosened because the exact search found nothing (network searches only).","example":["bedrooms"],"items":{"type":"string"},"type":"array"},"results":{"description":"One page of listings. Other agencies' rows are the network projection.","items":{"$ref":"#/components/schemas/ListingResponseDto"},"type":"array"},"total":{"description":"Exact number of matches (page-based envelope).","example":142,"type":"number"}},"required":["results","total"],"type":"object"},"PropertyThumbnailDto":{"properties":{"alt":{"description":"Alternative text.","example":"Pool terrace at sunset","type":"string"},"order":{"description":"Display position, 0 first.","example":0,"type":"number"},"originalImageIndex":{"description":"Index in `images` of the photo this thumbnail shows.","example":0,"type":"number"},"url":{"description":"Thumbnail URL (WebP, up to 400x225) from `POST /properties/media/upload` `images.thumbnail`.","example":"https://fondaro.fra1.cdn.digitaloceanspaces.com/properties/thumbnails/0f8c2d1e.webp","type":"string"}},"required":["url","order","originalImageIndex"],"type":"object"},"RangeDto":{"properties":{"max":{"description":"Upper bound, inclusive.","example":4,"type":"number"},"min":{"description":"Lower bound, inclusive.","example":2,"type":"number"}},"type":"object"},"SetListingPublicationsDto":{"properties":{"portals":{"description":"The complete set of portals the listing should be published to (replaces the current set; `[]` unpublishes everywhere). `GET /properties/portals` lists the portals and what each needs.","example":["kyero"],"items":{"enum":["jamesedition","kyero","thinkspain","a_place_in_the_sun","properstar","spain_property_portal","spainhouses","indomio","arkadia","trovit","mitula","nestoria","nuroa","green_acres","homesgofast","idealista","fotocasa","habitaclia","pisos_com","rightmove","zoopla"],"type":"string"},"type":"array"}},"required":["portals"],"type":"object"},"SortDto":{"properties":{"field":{"description":"Field to order by. `relevance` ranks by the `query` match (exact reference first); without a query it is newest first.","enum":["price","createdAt","updatedAt","publishedAt","bedrooms","bathrooms","livingArea","plotArea","commission","yearBuilt","city","countryCode","referenceNumber","status","propertyType","propertyCategory","listingType","relevance"],"example":"price","type":"string"},"order":{"default":"desc","description":"Values: `asc` (smallest or oldest first); `desc` (largest or newest first).","enum":["asc","desc"],"example":"asc","type":"string"}},"required":["field"],"type":"object"},"UpdateAgentDto":{"properties":{"additionalInfo":{"description":"Free-form extra data.","example":{"languages":["en","es"]},"type":"object"},"avatarThumbnailUrl":{"description":"Small avatar image URL.","example":"https://img.clerk.com/avatar-thumb.png","type":"string"},"avatarUrl":{"description":"Avatar image URL.","example":"https://img.clerk.com/avatar.png","type":"string"},"description":{"description":"Short biography.","example":"Twelve years selling villas on the Golden Mile.","type":"string"},"displayEmail":{"description":"Public email shown on listings and brochures.","example":"ana@agency.example","type":"string"},"firstName":{"description":"First name (for a Fondaro member, their account name).","example":"Ana","type":"string"},"isActive":{"description":"Active on the roster (inactive agents are hidden from listing pickers).","example":true,"type":"boolean"},"lastName":{"description":"Last name.","example":"García","type":"string"},"phoneNumber":{"description":"Public phone number.","example":"+34 600 000 000","type":"string"},"shareContactOnNetwork":{"description":"Show this card's email, phone and WhatsApp to other agencies on network listings (a member sets their own; an admin any).","example":false,"type":"boolean"},"title":{"description":"Job title.","example":"Senior agent","type":"string"},"whatsappNumber":{"description":"WhatsApp number.","example":"+34 600 000 000","type":"string"}},"type":"object"},"UpdatePropertyDto":{"properties":{"additionalFeatures":{"description":"Free-form extra facts, as key/value pairs.","example":{"views":"panoramic"},"type":"object"},"addressLine1":{"description":"Street address. Shared with other agencies on the network for ACTIVE listings.","example":"Calle Sierra Blanca 12","type":"string"},"addressLine2":{"description":"Second address line (building, floor, door).","example":"Urbanización Sierra Blanca","type":"string"},"agentId":{"description":"The listing agent (an id from `GET /agents`).","example":"6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b","format":"uuid","type":"string"},"areaUnit":{"description":"Unit of `livingArea` and `plotArea`. Values: `sqm` (square metres); `sqft` (square feet).","enum":["sqm","sqft"],"example":"sqm","type":"string"},"autoRenewEnabled":{"description":"Renew the listing automatically when it reaches `expiresAt`.","example":true,"type":"boolean"},"availableFrom":{"description":"Date the property is available (`YYYY-MM-DD`).","example":"2026-11-01","format":"date","nullable":true,"type":"string"},"basuraPerYear":{"description":"Refuse collection tax per year.","example":180,"nullable":true,"type":"number"},"bathrooms":{"description":"Number of bathrooms (one decimal, so 2.5 is allowed).","example":4,"type":"number"},"bedrooms":{"description":"Number of bedrooms.","example":5,"type":"integer"},"city":{"description":"Town or city, up to 100 characters.","example":"Marbella","type":"string"},"co2Emissions":{"description":"CO₂ emissions in kg per m² per year.","example":18.2,"nullable":true,"type":"number"},"commission":{"description":"Your private commission percentage (0-100). Never shown to other agencies; the offer to the network is `sharedCommission`. Own listings only: absent from other agencies' rows.","example":5,"type":"number"},"community":{"description":"Neighbourhood, urbanisation or community name.","example":"Sierra Blanca","type":"string"},"communityFeesPerYear":{"description":"Community fees per year in `currency`.","example":3600,"nullable":true,"type":"number"},"completionDate":{"description":"Completion date of a new build (`YYYY-MM-DD`).","example":"2027-06-30","format":"date","nullable":true,"type":"string"},"condition":{"description":"State of the property. Values: `new` (newly built, never lived in); `excellent` (excellent); `good` (good); `needs_renovation` (needs renovation); `under_construction` (still being built).","enum":["new","excellent","good","needs_renovation","under_construction"],"example":"excellent","nullable":true,"type":"string"},"countryCode":{"description":"ISO 3166-1 alpha-2 country code, upper case.","example":"ES","type":"string"},"currency":{"description":"ISO 4217 currency code, upper case.","example":"EUR","type":"string"},"description":{"description":"Main description, plain text with line breaks.","example":"South-facing villa with sea views, a heated pool and a guest apartment.","type":"string"},"energyConsumptionKwh":{"description":"Energy consumption in kWh per m² per year.","example":85.5,"nullable":true,"type":"number"},"energyRating":{"description":"EU energy performance certificate letter. Values: `A+` (best); `A` (very efficient); `B` (efficient); `C` (fairly efficient); `D` (average); `E` (below average); `F` (inefficient); `G` (least efficient); `exempt` (no certificate required); `in_progress` (certificate being issued).","enum":["A+","A","B","C","D","E","F","G","exempt","in_progress"],"example":"B","nullable":true,"type":"string"},"energyRatingDetails":{"description":"Free-text energy certificate details.","example":"Certificate issued 2024, consumption rating B.","type":"string"},"features":{"description":"Amenity slugs from the shared catalogue (the canonical amenity list; wins over the legacy `has*` flags). Omit to keep, never null.","example":["pool","sea_view","garage"],"items":{"enum":["pool","sea_view","garage","terrace","garden","air_conditioning","lift","beachfront","frontline_golf","panoramic_views","gated_complex","storage_room","built_in_wardrobes","luxury"],"type":"string"},"type":"array"},"floor":{"description":"Floor the unit is on (-20 to 300; 0 is the ground floor).","example":3,"nullable":true,"type":"integer"},"floorPlans":{"description":"Floor plan images in display order; uploaded like photos. Omit to keep, never null.","items":{"$ref":"#/components/schemas/PropertyFloorPlanDto"},"type":"array"},"furnished":{"description":"Furnishing. Values: `no` (unfurnished); `partly` (partly furnished); `fully` (fully furnished).","enum":["no","partly","fully"],"example":"partly","nullable":true,"type":"string"},"hasAirConditioning":{"description":"Legacy amenity flag; `features` holds `air_conditioning` too.","example":true,"type":"boolean"},"hasGarage":{"description":"Legacy amenity flag; `features` holds `garage` too.","example":true,"type":"boolean"},"hasGarden":{"description":"Legacy amenity flag; `features` holds `garden` too.","example":true,"type":"boolean"},"hasLift":{"description":"Legacy amenity flag; `features` holds `lift` too.","example":false,"type":"boolean"},"hasPool":{"description":"Legacy amenity flag; `features` holds `pool` too.","example":true,"type":"boolean"},"hasSeaView":{"description":"Legacy amenity flag; `features` holds `sea_view` too.","example":true,"type":"boolean"},"hasTerrace":{"description":"Legacy amenity flag; `features` holds `terrace` too.","example":true,"type":"boolean"},"ibiPerYear":{"description":"IBI (Spanish property tax) per year.","example":2100,"nullable":true,"type":"number"},"images":{"description":"Photos in display order. Every URL must come from `POST /properties/media/upload` (or already be on the listing). `width`/`height` are set by the server from the upload.","items":{"$ref":"#/components/schemas/PropertyImageDto"},"type":"array"},"isExterior":{"description":"The unit faces the street or outside (not an inner patio).","example":true,"nullable":true,"type":"boolean"},"isFurnished":{"description":"Legacy flag, true when `furnished` is `partly` or `fully`.","example":false,"type":"boolean"},"isNewConstruction":{"description":"A new-build property.","example":false,"type":"boolean"},"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","example":36.5201,"type":"number"},"leadGroupId":{"description":"Internal lead plan the listing belongs to. Own listings only: absent from other agencies' rows.","example":12,"type":"integer"},"listingType":{"description":"How the property is offered. Values: `sale` (for sale; `price` is the asking price); `rent` (for rent; see `rentalPrice` and `rentalPeriod`); `sale_or_rent` (for sale or for rent); `fraction` (a fractional ownership share).","enum":["sale","rent","sale_or_rent","fraction"],"example":"sale","type":"string"},"livingArea":{"description":"Built living area in `areaUnit`.","example":420,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","example":-4.9112,"type":"number"},"orientation":{"description":"Direction the main facade faces. Values: `N` (north); `NE` (north-east); `E` (east); `SE` (south-east); `S` (south); `SW` (south-west); `W` (west); `NW` (north-west).","enum":["N","NE","E","SE","S","SW","W","NW"],"example":"S","nullable":true,"type":"string"},"parkingSpaces":{"description":"Number of parking spaces included.","example":2,"nullable":true,"type":"integer"},"parkingType":{"description":"Kind of parking. Values: `garage` (private garage); `underground` (underground space); `covered` (covered space or carport); `open` (open private space); `street` (on the street); `communal` (shared community parking).","enum":["garage","underground","covered","open","street","communal"],"example":"garage","nullable":true,"type":"string"},"plotArea":{"description":"Plot size in `areaUnit`.","example":1500,"type":"number"},"postalCode":{"description":"Postal code, up to 20 characters.","example":"29602","type":"string"},"price":{"description":"Asking price in `currency` (two decimals).","example":2450000,"type":"number"},"propertyCategory":{"description":"Broad kind of property; `propertyType` must belong to it (`other` fits every category). Values: `residential` (homes); `commercial` (offices, shops and other business premises); `industrial` (warehouses and industrial premises); `land` (plots and land).","enum":["residential","commercial","industrial","land"],"example":"residential","type":"string"},"propertyType":{"description":"Specific kind of property (category: meaning). Values: `apartment` (residential: flat); `condominium` (residential: condominium unit); `house_detached` (residential: detached house or villa); `house_semi_detached` (residential: semi-detached house); `house_terraced` (residential: terraced house); `townhouse` (residential: townhouse); `multi_family_home` (residential: building of several homes); `penthouse` (residential: top-floor apartment); `studio` (residential: studio apartment); `land_residential` (land: plot zoned for homes); `land_commercial` (land: plot zoned for business); `land_agricultural` (land: rural or agricultural land); `land_other` (land: other); `office` (commercial: office); `retail` (commercial: shop or retail unit); `commercial_other` (commercial: other); `industrial_warehouse` (industrial: warehouse); `industrial_other` (industrial: other); `hospitality` (commercial: hotel, restaurant or similar); `other` (any category: other).","enum":["apartment","condominium","house_detached","house_semi_detached","house_terraced","townhouse","multi_family_home","penthouse","studio","land_residential","land_commercial","land_agricultural","land_other","office","retail","commercial_other","industrial_warehouse","industrial_other","hospitality","other"],"example":"house_detached","type":"string"},"provinceState":{"description":"Province or state.","example":"Málaga","type":"string"},"region":{"description":"Region or area (a coast, a valley).","example":"Costa del Sol","type":"string"},"rentalPeriod":{"description":"What `rentalPrice` is charged per. Values: `day` (per night or day); `week` (per week); `month` (per month); `year` (per year).","enum":["day","week","month","year"],"example":"month","nullable":true,"type":"string"},"rentalPrice":{"description":"Rent in `currency` per `rentalPeriod`.","example":6500,"nullable":true,"type":"number"},"rentalPriceHighSeason":{"description":"High-season rent per `rentalPeriod`.","example":9000,"nullable":true,"type":"number"},"rentalPriceLowSeason":{"description":"Low-season rent per `rentalPeriod`.","example":4500,"nullable":true,"type":"number"},"sharedCommission":{"description":"Commission percentage your agency offers another agency that sells the property (0-100). Visible to every agency on the network.","example":2.5,"nullable":true,"type":"number"},"sharedCommissionNotes":{"description":"Terms of the shared commission, visible to the network.","example":"50/50 split on the agreed fee, paid on completion.","nullable":true,"type":"string"},"status":{"description":"Publication state. Values: `active` (published and visible to every agency on the network); `inactive` (draft or paused, visible only to your agency); `pending` (under offer or awaiting approval, yours only); `sold` (sold, yours only); `rented` (let, yours only); `deleted` (removed, yours only).","enum":["active","inactive","pending","sold","rented","deleted"],"example":"active","type":"string"},"thumbnails":{"description":"Small versions of `images` (the upload returns one per photo); `originalImageIndex` points at the image.","items":{"$ref":"#/components/schemas/PropertyThumbnailDto"},"type":"array"},"title":{"description":"Headline shown above the price, up to 140 characters.","example":"Modern villa with sea views in Sierra Blanca","nullable":true,"type":"string"},"totalFloors":{"description":"Floors in the building.","example":6,"nullable":true,"type":"integer"},"translatedDescriptions":{"description":"The description in other languages, keyed by language code (`es`, `de`, `fr`...).","example":{"es":"Villa orientada al sur con vistas al mar."},"type":"object"},"translatedTitles":{"description":"The title in other languages, keyed by language code.","example":{"es":"Villa moderna con vistas al mar en Sierra Blanca"},"nullable":true,"type":"object"},"videoUrls":{"description":"Video links (http or https). Omit to keep, never null.","example":["https://www.youtube.com/watch?v=abc123"],"items":{"type":"string"},"type":"array"},"viewingInstructions":{"description":"Private notes for arranging viewings. Never shared. Own listings only: absent from other agencies' rows.","example":"Keys at the office; call the owner a day ahead.","nullable":true,"type":"string"},"virtualTourUrls":{"description":"Virtual tour links (http or https). Omit to keep, never null.","example":["https://my.matterport.com/show/?m=abc123"],"items":{"type":"string"},"type":"array"},"yearBuilt":{"description":"Year of construction (1801 up to a few years ahead).","example":2018,"type":"integer"}},"type":"object"}},"securitySchemes":{"ApiKeyAuthorization":{"description":"The value `ApiKey fondaro_pk_…`.","in":"header","name":"Authorization","type":"apiKey"},"ApiKeyHeader":{"description":"Your property API key (`fondaro_pk_…`).","in":"header","name":"X-API-Key","type":"apiKey"}}},"info":{"contact":{},"description":"Read and manage Fondaro MLS listings, upload listing photos, read your agent roster and browse your connected property sources.\n\n**Authentication.** Send your key as `X-API-Key: fondaro_pk_…` or `Authorization: ApiKey fondaro_pk_…`. Never send a key as a Bearer token. Keys are created by an organization admin under Integrations → API keys.\n\n**Scopes.** Each operation names the scope an API key needs (`x-required-scopes`); a key without it gets 403 `API_KEY_SCOPE_MISSING`.\n\n- `properties:read`: Search and read Fondaro MLS listings, counts, aggregations and themes.\n- `properties:write`: Create, update, renew and delete your own listings, set their portal publications and generate listing descriptions.\n- `media:write`: Upload listing images (POST /properties/media/upload) to reference from a listing.\n- `agents:read`: Reserved for reading your organization's agent roster (`/agents` accepts dashboard sessions only today).\n- `sources:read`: Browse connected property sources through /property-sources (Fondaro MLS and every source your organization has connected).\n\n**Browser use.** A key with allowed origins answers 403 `API_KEY_ORIGIN_NOT_ALLOWED` to a browser request from any other origin. Only put a read-only key with allowed origins in client-side code.\n\n**Rate limits.** Per key and endpoint: 120 requests a minute, 600 per 10 minutes and 3,000 an hour unless your key has a raised limit. A 429 carries `Retry-After`.\n\n**Network listings.** Another agency's ACTIVE listing is returned without its private commission, viewing instructions or lead plan, and its agent card shows contact details only when the agent shares them.","title":"Fondaro MLS API","version":"v1"},"openapi":"3.0.0","paths":{"/agents":{"get":{"description":"Clerk session only: not available to API keys.","operationId":"Agents_findAll","parameters":[{"description":"Only active (`true`) or inactive (`false`) agents.","in":"query","name":"isActive","required":false,"schema":{"example":true,"type":"boolean"}},{"description":"Case-insensitive match on name or email.","in":"query","name":"search","required":false,"schema":{"example":"ana","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/AgentResponseDto"},"type":"array"}}},"description":""}},"summary":"List your organization's agents","tags":["Agents"]}},"/agents/standalone":{"post":{"description":"An agent card with no Fondaro account (for example an external collaborator). Members of your organization get their row automatically. Clerk session only: not available to API keys.","operationId":"Agents_createStandalone","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateStandaloneAgentDto"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponseDto"}}},"description":""}},"summary":"Create a standalone agent","tags":["Agents"]}},"/agents/{id}":{"delete":{"description":"Clerk session only: not available to API keys.","operationId":"Agents_remove","parameters":[{"description":"Agent id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"`{ \"success\": true }`"}},"summary":"Delete an agent","tags":["Agents"]},"get":{"description":"Clerk session only: not available to API keys.","operationId":"Agents_findOne","parameters":[{"description":"Agent id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponseDto"}}},"description":""}},"summary":"Get one agent","tags":["Agents"]},"put":{"description":"Name, photo and network contact sharing of a Fondaro member's card can be changed only by an organization admin or that member. Clerk session only: not available to API keys.","operationId":"Agents_update","parameters":[{"description":"Agent id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAgentDto"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponseDto"}}},"description":""}},"summary":"Update an agent","tags":["Agents"]}},"/agents/{id}/avatar":{"delete":{"description":"Clerk session only: not available to API keys.","operationId":"Agents_deleteAvatar","parameters":[{"description":"Agent id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponseDto"}}},"description":""}},"summary":"Remove an agent's photo","tags":["Agents"]},"post":{"description":"Clerk session only: not available to API keys.","operationId":"Agents_uploadAvatar","parameters":[{"description":"Agent id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"properties":{"avatar":{"description":"JPEG, PNG, WebP or GIF.","format":"binary","type":"string"}},"required":["avatar"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponseDto"}}},"description":""}},"summary":"Upload an agent's photo (up to 5 MB)","tags":["Agents"]}},"/agents/{id}/from-email":{"put":{"description":"Any valid address. Email is sent from the person's connected mailbox, not this address. Clerk session only: not available to API keys.","operationId":"Agents_setFromEmail","parameters":[{"description":"Agent id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"displayEmail":{"description":"The public contact address.","example":"ana@youragency.com","nullable":true,"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponseDto"}}},"description":""}},"summary":"Set an agent's public contact address (admins)","tags":["Agents"]}},"/properties":{"post":{"description":"Upload photos first (`POST /properties/media/upload`) and reference the returned URLs. API keys need the `properties:write` scope.","operationId":"Properties_create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePropertyDto"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingResponseDto"}}},"description":""}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Create a listing","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:write"]}},"/properties/expiring/soon":{"get":{"description":"API keys need the `properties:read` scope.","operationId":"Properties_getExpiringProperties","parameters":[{"description":"Only listings assigned to any of these people (Clerk user ids, repeat the key). Each id is matched to the roster row in your own agency. Absent means every listing.","in":"query","name":"agentUserIds","required":false,"schema":{"example":["user_2abc"],"items":{"type":"string"},"type":"array"}},{"description":"Window in days (default 30).","in":"query","name":"days","required":false,"schema":{"example":30,"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ListingResponseDto"},"type":"array"}}},"description":""}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"List your listings expiring soon","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:read"]}},"/properties/media/upload":{"post":{"description":"Multipart upload of one image (field `file`, up to 20 MB). Returns a full-size JPEG (up to 1920x1080) and a WebP thumbnail (up to 400x225) hosted by Fondaro; put `images.fullSize` in a listing `images[].url` and `images.thumbnail` in `thumbnails[].url`. Listings accept only uploaded image URLs. API keys need the `media:write` scope.","operationId":"Media_uploadPropertyImage","parameters":[],"requestBody":{"content":{"multipart/form-data":{"schema":{"properties":{"file":{"description":"The image (any image/* type).","format":"binary","type":"string"}},"required":["file"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"images":{"properties":{"fullSize":{"example":"https://fondaro.fra1.cdn.digitaloceanspaces.com/properties/images/0f8c2d1e.jpg","type":"string"},"thumbnail":{"example":"https://fondaro.fra1.cdn.digitaloceanspaces.com/properties/thumbnails/0f8c2d1e.webp","type":"string"}},"type":"object"},"metadata":{"description":"Upload id, original file name and size, and original, full-size and thumbnail dimensions.","type":"object"},"success":{"example":true,"type":"boolean"}},"type":"object"}}},"description":"The hosted image URLs and their dimensions."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Upload one listing photo","tags":["Media"],"x-required-scopes":["media:write"]}},"/properties/portals":{"get":{"description":"API keys need the `properties:read` scope.","operationId":"Properties_getPortalCatalog","parameters":[],"responses":{"200":{"description":"Portal catalogue: id, name, what each portal requires."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"List the portals a listing can be published to","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:read"]}},"/properties/search":{"post":{"description":"Structured filters plus ranked free text. Send `cursor: \"*\"` for keyset pages (`nextCursor`, total capped at 10,000); without `cursor` the legacy `page`/`total` envelope is returned. Other agencies' rows are the network projection. API keys need the `properties:read` scope.","operationId":"Properties_search","parameters":[{"deprecated":true,"description":"Legacy spelling of `scope: \"own\"`.","in":"query","name":"filterByOrganization","required":false,"schema":{"type":"boolean"}},{"in":"query","name":"includeAdCount","required":true,"schema":{"type":"boolean"}},{"in":"query","name":"includeAdCreatives","required":true,"schema":{"type":"boolean"}},{"description":"Add `organizationBranding` and the `agent` card to each row.","in":"query","name":"includeBranding","required":false,"schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertySearchDto"}}},"required":false},"responses":{"201":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/PropertySearchPageDto"},{"$ref":"#/components/schemas/PropertySearchResponseDto"}]}}},"description":"A page of listings: `PropertySearchPageDto` when `cursor` was sent, else `PropertySearchResponseDto`."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Search Fondaro MLS listings","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:read"]}},"/properties/stats/aggregations":{"get":{"description":"Builds location menus and filter dropdowns. Network-wide ACTIVE counts unless `filterByOrganization=true` or a non-active `status`; cached 5 minutes. `q` narrows towns and regions and adds matching communities. API keys need the `properties:read` scope.","operationId":"Properties_getPropertyAggregations","parameters":[{"description":"Limit to these towns.","in":"query","name":"city","required":false,"schema":{"items":{"type":"string"},"type":"array"}},{"description":"Limit to these country codes.","in":"query","name":"countryCode","required":false,"schema":{"example":"ES","items":{"type":"string"},"type":"array"}},{"description":"Count only your own listings.","in":"query","name":"filterByOrganization","required":false,"schema":{"type":"boolean"}},{"description":"Place picker text (accents ignored).","in":"query","name":"q","required":false,"schema":{"example":"sierra","type":"string"}},{"description":"Limit to these regions.","in":"query","name":"region","required":false,"schema":{"items":{"type":"string"},"type":"array"}},{"description":"Default `active`; any other status counts your own listings only.","in":"query","name":"status","required":false,"schema":{"enum":["active","inactive","pending","sold","rented","deleted"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertyAggregationsResponseDto"}}},"description":""}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Count listings per town, region, country and type","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:read"]}},"/properties/stats/counts":{"get":{"description":"API keys need the `properties:read` scope.","operationId":"Properties_getStatusCounts","parameters":[],"responses":{"200":{"description":"Listings per status, e.g. `{ \"active\": 12, \"inactive\": 3 }`."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Count your listings per status","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:read"]}},"/properties/themes":{"get":{"description":"API keys need the `properties:read` scope.","operationId":"Properties_getAvailableThemes","parameters":[],"responses":{"200":{"description":"Theme name, label, description, preview colours and font."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"List the brochure themes","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:read"]}},"/properties/{id}":{"delete":{"description":"API keys need the `properties:write` scope.","operationId":"Properties_remove","parameters":[{"description":"Your listing id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"`{ \"success\": true }`"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Delete one of your listings","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:write"]},"get":{"description":"Your own listing in any status, or another agency's ACTIVE listing (network projection). Anything else is a 404. API keys need the `properties:read` scope.","operationId":"Properties_findOne","parameters":[{"description":"Listing id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"filterByOrganization","required":true,"schema":{"type":"boolean"}},{"in":"query","name":"includeAdCount","required":true,"schema":{"type":"boolean"}},{"in":"query","name":"includeAdCreatives","required":true,"schema":{"type":"boolean"}},{"description":"Add agency branding and the agent card.","in":"query","name":"includeBranding","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingResponseDto"}}},"description":""},"404":{"description":"No such listing visible to you."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Get one listing","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:read"]},"put":{"description":"Send only the fields to change. API keys need the `properties:write` scope.","operationId":"Properties_update","parameters":[{"description":"Your listing id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePropertyDto"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingResponseDto"}}},"description":""}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Update one of your listings","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:write"]}},"/properties/{id}/ad-creatives":{"get":{"description":"API keys need the `properties:read` scope.","operationId":"Properties_getPropertyAdCreatives","parameters":[{"description":"Your listing id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Ad creative summaries."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"List the ad creatives of one of your listings","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:read"]}},"/properties/{id}/publications":{"put":{"description":"Replaces the whole set. Returns the publication state per portal. API keys need the `properties:write` scope.","operationId":"Properties_setListingPublications","parameters":[{"description":"Your listing id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetListingPublicationsDto"}}},"required":true},"responses":{"200":{"description":"Publication state per portal."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Set the portals one of your listings is published to","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:write"]}},"/properties/{id}/renew":{"put":{"description":"API keys need the `properties:write` scope.","operationId":"Properties_renewProperty","parameters":[{"description":"Your listing id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingResponseDto"}}},"description":""}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Renew one of your listings for two more months","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:write"]}},"/properties/{id}/similar":{"get":{"description":"Seven progressively broader tiers (same town, type, bedrooms and price band first). Other agencies' rows are projected. API keys need the `properties:read` scope.","operationId":"Properties_findSimilar","parameters":[{"description":"Listing id.","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"filterByOrganization","required":true,"schema":{"type":"boolean"}},{"in":"query","name":"includeAdCount","required":true,"schema":{"type":"boolean"}},{"in":"query","name":"includeAdCreatives","required":true,"schema":{"type":"boolean"}},{"description":"Add agency branding and the agent card.","in":"query","name":"includeBranding","required":false,"schema":{"type":"boolean"}},{"description":"How many (default 5).","in":"query","name":"limit","required":false,"schema":{"example":6,"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ListingResponseDto"},"type":"array"}}},"description":""}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Find listings similar to one listing","tags":["Fondaro MLS listings"],"x-required-scopes":["properties:read"]}},"/property-sources":{"get":{"description":"Every source with its descriptor (filters it supports, view modes, detail and brochure flags) and whether your organization is connected. `internal` is Fondaro MLS. API keys need the `sources:read` scope.","operationId":"PropertySources_list","parameters":[],"responses":{"200":{"description":"Source catalogue."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"List property sources","tags":["Property sources"],"x-required-scopes":["sources:read"]}},"/property-sources/{source}/detail/{id}":{"get":{"description":"Detail by the `ref.id` a search returned (for Fondaro MLS, the listing uuid). API keys need the `sources:read` scope.","operationId":"PropertySources_detail","parameters":[{"description":"The `ref.id` from a search row.","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Source id: `internal` (Fondaro MLS), `resales_online`, `zoddak`, `inmobalia` or a portal id. All: `internal`, `resales_online`, `zoddak`, `inmobalia`, `idealista`, `rightmove`, `immobiliare`, `immoscout`, `funda`, `zoopla`, `seloger`, `otodom`, `immowelt`, `realtor`, `homes`, `trulia`, `propertyfinder`, `dubizzle`, `centris`, `loopnet`, `zillow`, `redfin`, `bayut`.","in":"path","name":"source","required":true,"schema":{"example":"internal","type":"string"}},{"description":"The `ref.country` from the search row, when it has one.","in":"query","name":"country","required":false,"schema":{}}],"responses":{"200":{"description":"The unified listing."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Get one listing from a property source","tags":["Property sources"],"x-required-scopes":["sources:read"]}},"/property-sources/{source}/locations":{"get":{"description":"Type-ahead places. Send the returned opaque location ids back as `locationIds` in a search. Each row names its `type`; Fondaro MLS returns `region`, `city` and `community` rows (a community names its town as `parent`). API keys need the `sources:read` scope.","operationId":"PropertySources_locations","parameters":[{"description":"Source id: `internal` (Fondaro MLS), `resales_online`, `zoddak`, `inmobalia` or a portal id. All: `internal`, `resales_online`, `zoddak`, `inmobalia`, `idealista`, `rightmove`, `immobiliare`, `immoscout`, `funda`, `zoopla`, `seloger`, `otodom`, `immowelt`, `realtor`, `homes`, `trulia`, `propertyfinder`, `dubizzle`, `centris`, `loopnet`, `zillow`, `redfin`, `bayut`.","in":"path","name":"source","required":true,"schema":{"example":"internal","type":"string"}},{"description":"ISO country code, for sources that cover several countries.","in":"query","name":"country","required":false,"schema":{}},{"description":"Text to match.","in":"query","name":"q","required":true,"schema":{"example":"marb"}}],"responses":{"200":{"description":"Matching locations with opaque ids."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Look up locations in a property source","tags":["Property sources"],"x-required-scopes":["sources:read"]}},"/property-sources/{source}/options/{facet}":{"get":{"description":"For example the property types a source understands. API keys need the `sources:read` scope.","operationId":"PropertySources_options","parameters":[{"description":"Filter name, e.g. `propertyType`.","in":"path","name":"facet","required":true,"schema":{"type":"string"}},{"description":"Source id: `internal` (Fondaro MLS), `resales_online`, `zoddak`, `inmobalia` or a portal id. All: `internal`, `resales_online`, `zoddak`, `inmobalia`, `idealista`, `rightmove`, `immobiliare`, `immoscout`, `funda`, `zoopla`, `seloger`, `otodom`, `immowelt`, `realtor`, `homes`, `trulia`, `propertyfinder`, `dubizzle`, `centris`, `loopnet`, `zillow`, `redfin`, `bayut`.","in":"path","name":"source","required":true,"schema":{"example":"internal","type":"string"}}],"responses":{"200":{"description":"The facet values."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"List the values of one filter of a property source","tags":["Property sources"],"x-required-scopes":["sources:read"]}},"/property-sources/{source}/search":{"post":{"description":"Strict search: a filter the source cannot apply is a 400, never silently dropped. Continue with the returned `nextCursor`; reset it when the source or criteria change. Page size at most 20. API keys need the `sources:read` scope.","operationId":"PropertySources_search","parameters":[{"description":"Source id: `internal` (Fondaro MLS), `resales_online`, `zoddak`, `inmobalia` or a portal id. All: `internal`, `resales_online`, `zoddak`, `inmobalia`, `idealista`, `rightmove`, `immobiliare`, `immoscout`, `funda`, `zoopla`, `seloger`, `otodom`, `immowelt`, `realtor`, `homes`, `trulia`, `propertyfinder`, `dubizzle`, `centris`, `loopnet`, `zillow`, `redfin`, `bayut`.","in":"path","name":"source","required":true,"schema":{"example":"internal","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"agentUserId":{"description":"Fondaro MLS, dashboard sessions only: one agent's network listings (their user id). Narrows `network`; `own` refuses it.","maxLength":200,"minLength":1,"type":"string"},"country":{"description":"ISO country code for sources that cover several countries.","maxLength":10,"minLength":1,"type":"string"},"cursor":{"description":"The `nextCursor` of the previous page; same criteria.","maxLength":4000,"minLength":1,"type":"string"},"features":{"description":"Required amenities, as the source names them.","items":{"maxLength":160,"minLength":1,"type":"string"},"maxItems":30,"minItems":1,"type":"array"},"geo":{"anyOf":[{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"enum":["circle"],"type":"string"},"latitude":{"maximum":90,"minimum":-90,"type":"number"},"longitude":{"maximum":180,"minimum":-180,"type":"number"},"radiusKm":{"exclusiveMinimum":true,"minimum":0,"type":"number"}},"required":["kind","latitude","longitude","radiusKm"],"type":"object"},{"additionalProperties":false,"properties":{"coordinates":{"items":{"items":{"type":"number"},"maxItems":2,"minItems":2,"type":"array"},"maxItems":200,"minItems":3,"type":"array"},"kind":{"enum":["polygon"],"type":"string"}},"required":["kind","coordinates"],"type":"object"}]},{"additionalProperties":false,"properties":{"kind":{"enum":["boundingBox"],"type":"string"},"northEast":{"additionalProperties":false,"properties":{"latitude":{"maximum":90,"minimum":-90,"type":"number"},"longitude":{"maximum":180,"minimum":-180,"type":"number"}},"required":["latitude","longitude"],"type":"object"},"southWest":{"additionalProperties":false,"properties":{"latitude":{"maximum":90,"minimum":-90,"type":"number"},"longitude":{"maximum":180,"minimum":-180,"type":"number"}},"required":["latitude","longitude"],"type":"object"}},"required":["kind","northEast","southWest"],"type":"object"}],"description":"A circle (`kind: circle`, `radiusKm`), a drawn polygon (`kind: polygon`) or a map box (`kind: boundingBox`)."},"limit":{"description":"Rows per page, 1 to 20.","maximum":20,"minimum":1,"type":"integer"},"listingTypes":{"description":"Values: `sale`, `rent`, `sale_or_rent`, `fraction`.","items":{"enum":["sale","rent","sale_or_rent","fraction"],"type":"string"},"maxItems":20,"minItems":1,"type":"array"},"locationIds":{"description":"Opaque location ids from `GET /property-sources/{source}/locations`.","items":{"maxLength":1200,"minLength":1,"type":"string"},"maxItems":20,"minItems":1,"type":"array"},"locations":{"description":"Dashboard sessions only (API keys send `locationIds`).","items":{"additionalProperties":false,"properties":{"country":{"maxLength":10,"minLength":1,"type":"string"},"kind":{"maxLength":60,"minLength":1,"type":"string"},"label":{"maxLength":300,"minLength":1,"type":"string"},"parent":{"maxLength":300,"minLength":1,"type":"string"},"value":{"maxLength":1200,"minLength":1,"type":"string"}},"required":["kind","value","label"],"type":"object"},"maxItems":20,"minItems":1,"type":"array"},"maxBathrooms":{"description":"Most bathrooms.","minimum":0,"type":"number"},"maxBedrooms":{"description":"Most bedrooms.","minimum":0,"type":"number"},"maxBuildSize":{"description":"Largest built area (m²).","minimum":0,"type":"number"},"maxPlotSize":{"description":"Largest plot (m²).","minimum":0,"type":"number"},"maxPrice":{"description":"Highest price.","minimum":0,"type":"number"},"maxRooms":{"description":"Most rooms.","minimum":0,"type":"number"},"minBathrooms":{"description":"Fewest bathrooms.","minimum":0,"type":"number"},"minBedrooms":{"description":"Fewest bedrooms.","minimum":0,"type":"number"},"minBuildSize":{"description":"Smallest built area (m²).","minimum":0,"type":"number"},"minPlotSize":{"description":"Smallest plot (m²).","minimum":0,"type":"number"},"minPrice":{"description":"Lowest price.","minimum":0,"type":"number"},"minRooms":{"description":"Fewest rooms (sources that count rooms, not bedrooms).","minimum":0,"type":"number"},"newDevelopments":{"description":"Only new developments (sources that support it).","type":"boolean"},"openHouseWithinDays":{"description":"Fondaro MLS, dashboard sessions only: only listings with an open house starting within the next N days (1 to 14). Narrows any scope.","maximum":14,"minimum":1,"type":"integer"},"operation":{"description":"Source-specific operation (for example sale or rent).","maxLength":100,"minLength":1,"type":"string"},"organizationId":{"description":"Fondaro MLS, dashboard sessions only: one agency's network listings (its organization id). Narrows `network`; `own` refuses it.","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","type":"string"},"propertyTypes":{"description":"Property types from `GET /property-sources/{source}/options/propertyType`.","items":{"maxLength":160,"minLength":1,"type":"string"},"maxItems":20,"minItems":1,"type":"array"},"query":{"description":"Free text, where the source supports it.","maxLength":200,"minLength":1,"type":"string"},"referenceNumbers":{"description":"Exact listing references.","items":{"maxLength":160,"minLength":1,"type":"string"},"maxItems":50,"minItems":1,"type":"array"},"scope":{"description":"Fondaro MLS only. Values: `network` (every agency's ACTIVE listings, the default); `own` (your listings, any status); `partners` (your partner agencies' ACTIVE listings; dashboard sessions only, an API key gets a 400 `PROPERTY_FILTER_UNSUPPORTED`); `followed` (the ACTIVE listings of the agencies you follow; dashboard sessions only, same 400 for an API key).","enum":["own","network","partners","followed"],"type":"string"},"sort":{"description":"One of the sort options the source lists in `GET /property-sources`.","maxLength":100,"minLength":1,"type":"string"},"source":{"description":"Optional; must equal the route source.","enum":["internal","resales_online","zoddak","inmobalia","idealista","rightmove","immobiliare","immoscout","funda","zoopla","seloger","otodom","immowelt","realtor","homes","trulia","propertyfinder","dubizzle","centris","loopnet","zillow","redfin","bayut"],"type":"string"},"sources":{"description":"Reserved for multi-source search; more than one entry is a 400.","items":{"enum":["internal","resales_online","zoddak","inmobalia","idealista","rightmove","immobiliare","immoscout","funda","zoopla","seloger","otodom","immowelt","realtor","homes","trulia","propertyfinder","dubizzle","centris","loopnet","zillow","redfin","bayut"],"type":"string"},"type":"array"},"stages":{"description":"Development stages (sources with new developments).","items":{"maxLength":160,"minLength":1,"type":"string"},"maxItems":20,"minItems":1,"type":"array"},"statuses":{"description":"Listing statuses the source understands.","items":{"maxLength":160,"minLength":1,"type":"string"},"maxItems":20,"minItems":1,"type":"array"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"Rows with a `ref` ({ source, id, country? }), `nextCursor`, optional `total` and a receipt of the filters applied. `total` is exact unless `totalIsLowerBound` is true (at least that many; Fondaro MLS stops counting at 10,000, first page only); no `total` means unknown, and `nextCursor` alone says whether more rows exist."}},"security":[{"ApiKeyHeader":[]},{"ApiKeyAuthorization":[]}],"summary":"Search one property source","tags":["Property sources"],"x-required-scopes":["sources:read"]}}},"servers":[{"url":"https://api.fondaro.com"}],"tags":[{"description":"Search, read and manage listings.","name":"Fondaro MLS listings"},{"description":"Upload listing photos.","name":"Media"},{"description":"Your organization's agent roster.","name":"Agents"},{"description":"Unified search, detail, locations and options across Fondaro MLS and your connected sources.","name":"Property sources"}]} ```