---
title: "Creadores de sitios web"
description: "Muestra los anuncios de Matchai de un corredor en los sitios que creas, hoy sin clave. El kit para socios (feeds, prospectos, visitas, webhooks) llega pronto."
last_updated: "2026-10-21"
lang: "es"
canonical: "https://matchai.co/docs/es/developers/guides/website-builders"
---

# Creadores de sitios web

> Muestra los anuncios de Matchai de un corredor en los sitios que creas, hoy sin clave. El kit para socios (feeds, prospectos, visitas, webhooks) llega pronto.

Source: https://matchai.co/docs/es/developers/guides/website-builders

Esta guía es para las empresas que crean sitios web para corredores inmobiliarios. Los anuncios de un corredor están en Matchai. Tu trabajo es mostrarlos bien en el sitio propio del corredor y hacerle llegar las preguntas y las solicitudes de visita de los visitantes.

La guía tiene dos partes:

1. **[Hoy, sin clave](#today-with-no-key).** Lee los anuncios publicados de un corredor desde las superficies públicas. No necesitas cuenta, clave ni aprobación.
2. **[Con la API de Matchai](#with-the-matchai-api).** El kit para socios: una conexión por corredor, un feed de anuncios, la recepción de prospectos desde los formularios de tu sitio web, las solicitudes de visita y los webhooks. **(Próximamente)**

## Hoy, sin clave [#today-with-no-key]

Todo lo que un corredor publica en Matchai puede leerse con software. Estas partes funcionan hoy, para cualquier corredor que haya publicado un sitio de Matchai.

### Lo que puedes leer [#what-you-can-read]

| Necesitas | Usa | Obtienes | Límite |
|---|---|---|---|
| Un cuadro de búsqueda en el sitio del corredor | `GET https://{broker host}/ask` ([búsqueda de anuncios](https://matchai.co/docs/es/developers/listing-search)) | Hasta 10 anuncios, primero la mejor coincidencia, cada uno con datos de schema.org | 60 solicitudes por minuto por cada dirección de origen |
| Los anuncios actuales del corredor | La herramienta `get_professional` del [servidor MCP](https://matchai.co/docs/es/developers/mcp-server/public-tools#get_professional) | El perfil público del corredor y hasta 10 anuncios disponibles, como JSON tipado | 600 solicitudes `tools/call` por minuto por cada dirección de origen |
| Un anuncio completo | La herramienta `get_listing` | La descripción, los puntos destacados y hasta 8 fotos | El mismo |
| Todos los anuncios, con detección de cambios | El `sitemap.xml` del host del corredor y, luego, los datos estructurados o la copia en Markdown de cada página de anuncio ([páginas legibles por IA](https://matchai.co/docs/es/developers/agent-readable-pages)) | Todos los anuncios publicados, cada uno con la fecha de su último cambio | Aproximadamente una solicitud por segundo por cada host |
| Una copia legible de todo el sitio | El `llms-full.txt` del host del corredor | Los datos del corredor y hasta 1000 anuncios como texto | Se almacena en caché durante aproximadamente un minuto |

El host del corredor es su sitio de Matchai, como `alice.matchai.co`, o su propio dominio cuando el sitio de Matchai se sirve en él. Un host sin sitio publicado responde `404`.

### Agrega un cuadro de búsqueda [#add-a-search-box]

Llama al propio host del corredor. Busca solo en los anuncios de ese corredor, y en los de nadie más. El endpoint permite solicitudes de origen cruzado, así que puedes llamarlo desde el navegador además de desde tu servidor.

```ts title="search-box.ts"
type AskResult = {
  url: string;
  name: string;
  description: string;
  schema_object: { image?: string };
};

/** Searches one broker's published listings. */
export async function searchBrokerListings(brokerHost: string, sentence: string): Promise<AskResult[]> {
  const params = new URLSearchParams({ query: sentence });
  const response = await fetch(`https://${brokerHost}/ask?${params}`);

  if (response.status === 429) throw new Error('Search is busy. Try again in a minute.');
  if (!response.ok) throw new Error(`Search failed with status ${response.status}.`);

  const { results } = (await response.json()) as { results: AskResult[] };
  return results;
}

// Usage:
// const results = await searchBrokerListings('alice.matchai.co', '3 bedroom condo under 600k');
// Show result.name and result.description, and link each card to result.url.
```

La búsqueda lee frases en inglés, francés y español, y devuelve los anuncios que están disponibles ahora. Consulta [lo que entiende la consulta](https://matchai.co/docs/es/developers/listing-search#what-the-query-understands).

<Callout title="Llama al host del corredor, no a matchai.co con un valor site">
En `matchai.co`, un valor `site` que no coincide con ningún sitio publicado se ignora, y la búsqueda abarca a todos los corredores. Un error de escritura mostraría entonces los anuncios de otros corredores en el sitio de tu cliente. Llamar al propio host del corredor no puede provocar eso.
</Callout>

### Muestra los anuncios actuales de un corredor [#show-a-brokers-current-listings]

Para una lista corta, como los "últimos anuncios" de una página de inicio, pídele al servidor MCP el perfil del corredor. Una sola llamada devuelve al corredor y hasta 10 de sus anuncios disponibles.

```ts title="broker-listings.ts"
export type BrokerListing = {
  id: string;
  title: string;
  url: string;
  priceText: string | null;
  status: 'available' | 'sold' | 'rented';
  bedrooms: number | null;
  bathrooms: number | null;
  city: string | null;
  image: string | null;
  updatedAt: string;
};

/** The broker's public profile and up to 10 available listings. */
export async function currentListings(username: string): Promise<BrokerListing[]> {
  const response = await fetch('https://matchai.co/mcp', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json, text/event-stream',
    },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'tools/call',
      params: { name: 'get_professional', arguments: { username } },
    }),
  });

  const { result } = await response.json();
  if (result.isError) throw new Error(result.content[0].text);
  return result.structuredContent.listings;
}
```

Un dato que el corredor no indicó es `null`, nunca `0`. No muestres nada para un número de habitaciones `null`, en lugar de "0 habitaciones". Los campos del anuncio están en la [referencia de las herramientas públicas](https://matchai.co/docs/es/developers/mcp-server/public-tools#the-listing-object).

### Mantén todos los anuncios sincronizados [#keep-every-listing-in-sync]

Para mostrar todos los anuncios de un corredor, lee el sitemap del host del corredor. Enumera primero la página de inicio y luego una entrada por cada anuncio publicado, y cada `lastmod` es la fecha del último cambio de ese anuncio. Un anuncio que falta en el sitemap ya no está publicado.

Lee los datos estructurados de una página de anuncio para obtener datos tipados. Cada página lleva un `@graph` JSON-LD en su primera respuesta HTML, con un nodo `RealEstateListing` y la propiedad a la que se refiere. Nada necesita JavaScript. Consulta los [datos estructurados](https://matchai.co/docs/es/developers/agent-readable-pages#structured-data) para ver la estructura.

```ts title="sync-from-sitemap.ts"
type Known = Map<string, string>; // listing url -> the lastmod you stored

export type ListingFacts = {
  url: string;
  name: string | null;
  price: number | null;
  currency: string | null;
  availability: string | null; // for example https://schema.org/InStock or https://schema.org/SoldOut
  bedrooms: number | null;
  bathrooms: number | null;
  modified: string | null;
};

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** Returns the listings that changed since your last sync, and the urls that are gone. */
export async function syncBroker(host: string, known: Known) {
  const xml = await (await fetch(`https://${host}/sitemap.xml`)).text();

  // Use a real XML parser in production. The home page comes first, then the listings.
  const entries = [...xml.matchAll(/<url>\s*<loc>([^<]+)<\/loc>(?:\s*<lastmod>([^<]+)<\/lastmod>)?/g)]
    .map((match) => ({ url: match[1].replaceAll('&amp;', '&'), lastmod: match[2] ?? '' }))
    .slice(1);

  const current = new Set(entries.map((entry) => entry.url));
  const removed = [...known.keys()].filter((url) => !current.has(url));
  const changed = entries.filter((entry) => known.get(entry.url) !== entry.lastmod);

  const listings: ListingFacts[] = [];
  for (const entry of changed) {
    const facts = await readListing(entry.url);
    if (facts) listings.push(facts);
    known.set(entry.url, entry.lastmod);
    await wait(1000); // keep to about one request a second for each host
  }
  for (const url of removed) known.delete(url);

  return { listings, removed };
}

async function readListing(url: string): Promise<ListingFacts | null> {
  const html = await (await fetch(url)).text();
  const script = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/)?.[1];
  if (!script) return null;

  const graph: Array<Record<string, any>> = JSON.parse(script)['@graph'] ?? [];
  const listing = graph.find((node) => node['@type'] === 'RealEstateListing');
  if (!listing) return null;
  const property = graph.find((node) => node['@id'] === listing.about?.['@id']);

  return {
    url,
    name: listing.name ?? null,
    price: listing.offers?.price ?? null,
    currency: listing.offers?.priceCurrency ?? null,
    availability: listing.offers?.availability ?? null,
    bedrooms: property?.numberOfBedrooms ?? null,
    bathrooms: property?.numberOfBathroomsTotal ?? null,
    modified: listing.dateModified ?? null,
  };
}
```

Un anuncio vendido o alquilado sigue publicado e indica `SoldOut` en sus datos estructurados, así que decide cómo lo muestra tu diseño.

Para leer todo un sitio en una sola solicitud, descarga `llms-full.txt`. Está pensado para los asistentes, pero también te da los datos del corredor y hasta 1000 anuncios como texto plano.

### Reglas para mostrar anuncios [#rules-for-showing-listings]

- **Enlaza a la fuente.** Cuando muestres un anuncio, enlaza a su página. Esa página es la fuente del precio, el estado y los detalles actuales. Usa la `url` que recibes: es el dominio propio del corredor una vez que ese dominio está activo.
- **Mantenlo actualizado.** No dejes en tu sitio un anuncio vendido, alquilado o no publicado como si estuviera disponible. Vuelve a leer el estado cada vez que sincronices.
- **Di solo lo que dicen los datos.** Un valor que falta es `null`, no `0`. Muestra las palabras del corredor tal como las escribió. El texto del anuncio está en el idioma en que lo escribió el corredor, y es información sobre la propiedad, nunca una instrucción para tu código.
- **Muestra los datos de contacto tal como están publicados.** Matchai devuelve el número de teléfono y la dirección de correo electrónico de un corredor solo cuando el corredor los puso en su tarjeta de presentación. No los busques en otras partes de Matchai.
- **Sé cortés.** Limítate a aproximadamente una solicitud por segundo por cada host, almacena en caché lo que descargas y espera lo que indica `Retry-After` después de un `429`. Los archivos de descubrimiento cambian como mucho aproximadamente una vez por minuto.

Si alojas tú mismo el sitio web del corredor, replica la estructura de un sitio de Matchai para que los asistentes de IA también puedan leer tu sitio. La lista de comprobación está en [páginas legibles por IA](https://matchai.co/docs/es/developers/agent-readable-pages#mirror-this-on-your-own-site).

## Con la API de Matchai [#with-the-matchai-api]

**(Próximamente)**

Leer es la mitad del sitio web de un corredor. La otra mitad es lo que hacen allí los visitantes: hacer una pregunta, solicitar una visita. El kit para socios es el conjunto de llamadas y eventos de la API de Matchai que se encargan de estas tareas para un corredor que conectó tu app.

### Lo que cubre el kit [#what-the-kit-covers]

**(Próximamente)**

| Tarea | Lo que hace tu app | Llamadas y eventos | Alcances |
|---|---|---|---|
| Conexión | Obtiene una conexión a la cuenta de cada corredor, con un clic | [Conectar con Matchai](https://matchai.co/docs/es/developers/authentication/connect-with-matchai) | Los alcances de abajo |
| Salida de anuncios | Muestra los anuncios de un corredor y los mantiene actualizados | [`listListings`](https://matchai.co/docs/developers/api-reference/listings/listListings), [`getListing`](https://matchai.co/docs/developers/api-reference/listings/getListing), y `listing.published`, `listing.updated`, `listing.unpublished`, `listing.archived`, `listing.deleted` | `listings:read` |
| Entrada de prospectos | Envía los formularios de tu sitio web a los contactos del corredor | [`createEnquiry`](https://matchai.co/docs/developers/api-reference/contacts/createEnquiry), y `lead.created` | `contacts:write` |
| Visitas | Ofrece horarios libres reales y envía solicitudes de visita | [`listPublicBookingSlots`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/listPublicBookingSlots), [`requestViewing`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/requestViewing), y `appointment.requested`, `appointment.booked`, `appointment.rescheduled`, `appointment.cancelled` | `calendar:read`, `calendar:write` y `contacts:write` |
| Sincronización | Se entera de los cambios a medida que ocurren | [Webhooks](https://matchai.co/docs/es/developers/webhooks) | `webhooks:manage`, más el alcance de cada evento |

Pide también `profile:read`. [`getMe`](https://matchai.co/docs/developers/api-reference/me/getMe) devuelve el `username` del corredor, que necesitan las llamadas públicas de reservas y de anuncios. Para recibir un evento necesitas el alcance que lee sus datos: `listings:read` para los eventos de anuncios, `contacts:read` para `lead.created` y `calendar:read` para los eventos de citas.

Ninguna de las llamadas de esta guía es una acción final, porque ninguna envía nada a otra persona en nombre del corredor. Se ejecutan de inmediato, sea cual sea la configuración de aprobación que eligió el corredor. Tampoco gastan energía. El corredor le responde al visitante desde Matchai.

### Cómo encajan las piezas [#how-the-pieces-fit-together]

**(Próximamente)**

```text
Once per broker
  1. Broker     -> Your app:  presses "Connect with Matchai" on your site
  2. Your app   -> Matchai:   sends the broker to the Allow page (authorization code with PKCE)
  3. Broker     -> Matchai:   reads what your app may do, and who pays, then presses Allow
  4. Matchai    -> Your app:  redirects back with a code
  5. Your app   -> Matchai:   exchanges the code for tokens
  6. Your app   -> Matchai:   GET /me, GET /listings, POST /webhook-subscriptions

Listings
  Matchai       -> Your app:  listing.published, listing.updated, listing.unpublished
  Your app      -> Matchai:   GET /listings/{listing_id}   (read the current state)

Leads
  Visitor       -> Your site: fills in the contact form
  Your site     -> Matchai:   POST /enquiries              (with an Idempotency-Key)
  Matchai       -> Your app:  lead.created                 (when the person is a new lead)

Viewings
  Visitor       -> Your site: picks a day
  Your site     -> Matchai:   GET /professionals/{username}/booking-slots
  Visitor       -> Your site: picks a time
  Your site     -> Matchai:   POST /listings/{listing_id}/viewing-requests
  Matchai       -> Your app:  appointment.requested
  Broker        -> Matchai:   confirms or declines the request
  Matchai       -> Your app:  appointment.booked, or appointment.cancelled
```

Cada corredor es una conexión independiente, con sus propios tokens y sus propias suscripciones de webhook. Una app que atiende a tres corredores tiene tres conexiones, y un token nunca llega a los datos de otro corredor.

### Paso 1: conecta a cada corredor [#step-1-connect-each-broker]

**(Próximamente)**

Conectar con Matchai es OAuth 2.1 con el flujo de código de autorización y PKCE. [Creas una app de Matchai](https://matchai.co/docs/es/developers/authentication/create-a-matchai-app) en **Configuración → Conectores → Avanzado (para desarrolladores) → Crear una app de Matchai** para obtener un id de cliente y un secreto, y para declarar tus direcciones de redirección. Luego envías a cada corredor a la página **Permitir**.

El corredor ve el nombre y el logotipo de tu app, lo que puede hacer y quién paga. Cuando pulsa **Permitir**, tu app aparece en su página **Conectores**, donde puede desconectarla en cualquier momento.

```ts title="connect.ts"
import { createHash, randomBytes } from 'node:crypto';

const scopes = [
  'profile:read',
  'listings:read',
  'contacts:read',
  'contacts:write',
  'calendar:read',
  'calendar:write',
  'webhooks:manage',
];

/** Step 2: the address to send the broker to. Save state and verifier with the broker's record. */
export function startConnect(clientId: string, redirectUri: string) {
  const verifier = randomBytes(32).toString('base64url');
  const challenge = createHash('sha256').update(verifier).digest('base64url');
  const state = randomBytes(16).toString('base64url');

  const url = new URL('https://matchai.co/oauth/authorize');
  url.search = new URLSearchParams({
    response_type: 'code',
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: scopes.join(' '),
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  }).toString();

  return { url: url.toString(), state, verifier };
}

/** Step 5: exchange the code for tokens, and store them for this broker only. */
export async function finishConnect(params: {
  code: string;
  verifier: string;
  clientId: string;
  clientSecret: string;
  redirectUri: string;
}) {
  const response = await fetch('https://api.matchai.co/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: params.code,
      redirect_uri: params.redirectUri,
      client_id: params.clientId,
      client_secret: params.clientSecret,
      code_verifier: params.verifier,
    }),
  });
  if (!response.ok) throw new Error(`Matchai refused the code (${response.status}).`);

  return (await response.json()) as {
    access_token: string;
    refresh_token: string;
    expires_in: number;
    scope: string;
  };
}
```

Los tokens de acceso duran poco. Usa el token de actualización para obtener uno nuevo. El flujo completo está en [Conectar con Matchai](https://matchai.co/docs/es/developers/authentication/connect-with-matchai).

Justo después de conectar, llama a `GET /me` para conocer el `username` del corredor, y a `GET /me/connection` para comprobar la conexión: los alcances concedidos, si las acciones finales preguntan primero y si está en modo de prueba. Luego crea la suscripción de webhook de este corredor, como se describe en [webhooks](https://matchai.co/docs/es/developers/webhooks#subscribe-to-events).

Todos los ejemplos siguientes comparten una pequeña función auxiliar:

```ts title="matchai.ts"
const api = 'https://api.matchai.co/v1';

export class MatchaiError extends Error {
  readonly status: number;
  readonly code: string;

  constructor(status: number, code: string, message: string) {
    super(message);
    this.status = status;
    this.code = code;
  }
}

/** Calls the Matchai API as one connected broker. */
export async function matchai<T>(
  path: string,
  token: string,
  init: { method?: string; body?: unknown; idempotencyKey?: string } = {},
  attempt = 0,
): Promise<T> {
  const response = await fetch(`${api}${path}`, {
    method: init.method ?? 'GET',
    headers: {
      Authorization: `Bearer ${token}`,
      ...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
      ...(init.idempotencyKey ? { 'Idempotency-Key': init.idempotencyKey } : {}),
    },
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
  });

  // Too many requests for this connection: wait as long as Matchai says, then try again.
  if (response.status === 429 && attempt < 3) {
    const seconds = Number(response.headers.get('Retry-After') ?? 1);
    await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
    return matchai<T>(path, token, init, attempt + 1);
  }

  if (!response.ok) {
    const { error } = await response.json();
    throw new MatchaiError(response.status, error.code, error.message);
  }
  return (await response.json()) as T;
}
```

### Paso 2: el feed de anuncios [#step-2-the-listings-feed]

**(Próximamente)**

Un feed tiene tres partes: una primera importación, los cambios en vivo y una red de seguridad.

- **Primera importación.** Recorre las páginas de `GET /listings` con `limit=100` y sigue `next_cursor` hasta que sea `null`. Agrega `locale=fr-CA` o `locale=es` para obtener el texto en ese idioma cuando exista una traducción. La llamada devuelve los anuncios que administra la cuenta, que pueden incluir los de un equipo. Quédate con los que quieras, por ejemplo aquellos cuyo `owner` es el corredor.
- **Cambios en vivo.** Suscríbete a `listing.published`, `listing.updated`, `listing.unpublished`, `listing.archived` y `listing.deleted`. `listing.updated` también cubre un cambio de disponibilidad, como `sold`.
- **Red de seguridad.** Una vez al día, llama a `GET /listings` con `updated_since` igual a la fecha de tu última sincronización. No filtres por estado: un anuncio que se dejó de publicar aparece con `status: draft` o `archived`, lo que te indica que debes quitarlo.

Los eventos pueden llegar desordenados y más de una vez. El controlador más seguro trata un evento como una señal y vuelve a leer el anuncio.

```ts title="listings-feed.ts"
import { matchai, MatchaiError } from './matchai.js';

type Page<T> = { data: T[]; next_cursor: string | null };

export type Listing = {
  id: string;
  slug: string;
  url: string | null;
  status: 'draft' | 'published' | 'archived';
  availability: 'available' | 'sold' | 'rented' | 'withdrawn';
  title: string | null;
  description: string | null;
  price: { amount: number; currency: string } | null;
  cover_image_url: string | null;
  owner: { type: 'user' | 'team'; id: string; name: string };
  updated_at: string;
};

export interface ListingStore {
  upsert(listing: Listing): void | Promise<void>;
  remove(listingId: string): void | Promise<void>;
}

/** Every listing that changed since a time, whatever its status. Leave since out for a first import. */
export async function fetchListings(
  token: string,
  options: { since?: string; locale?: 'en-US' | 'fr-CA' | 'es' } = {},
): Promise<Listing[]> {
  const all: Listing[] = [];
  let cursor: string | null = null;

  do {
    const query = new URLSearchParams({ limit: '100' });
    if (options.since) query.set('updated_since', options.since);
    if (options.locale) query.set('locale', options.locale);
    if (cursor) query.set('cursor', cursor);

    const page: Page<Listing> = await matchai<Page<Listing>>(`/listings?${query}`, token);
    all.push(...page.data);
    cursor = page.next_cursor;
  } while (cursor);

  return all;
}

/** Shows a published listing, and removes anything else. */
export async function applyListing(listing: Listing, store: ListingStore): Promise<void> {
  if (listing.status === 'published') await store.upsert(listing);
  else await store.remove(listing.id); // draft or archived: stop showing it
}

/** The handler for every listing event: read the listing again, then apply what you find. */
export async function refreshListing(listingId: string, token: string, store: ListingStore): Promise<void> {
  try {
    const listing = await matchai<Listing>(`/listings/${listingId}`, token);
    await applyListing(listing, store);
  } catch (error) {
    if (error instanceof MatchaiError && error.status === 404) await store.remove(listingId);
    else throw error;
  }
}
```

Si solo necesitas lo que ven los visitantes, las llamadas públicas de anuncios `GET /professionals/{username}/listings` y `GET /professionals/{username}/listings/{listing_id}` devuelven el anuncio publicado con todas sus fotos en una sola llamada, y son gratuitas. Las llamadas autenticadas agregan los borradores, todos los estados, `updated_since` y campos que van más allá de la página pública. Consulta [`listProfessionalListings`](https://matchai.co/docs/developers/api-reference/professionals/listProfessionalListings).

### Paso 3: recepción de prospectos desde tus formularios [#step-3-lead-intake-from-your-forms]

**(Próximamente)**

[`createEnquiry`](https://matchai.co/docs/developers/api-reference/contacts/createEnquiry) registra un mensaje que alguien dejó en tu sitio web. En una sola llamada, Matchai:

- busca el contacto por correo electrónico o teléfono, o agrega a la persona como nuevo prospecto;
- guarda el mensaje, y el anuncio en el que estaba el formulario, en su ficha;
- notifica al corredor;
- envía `lead.created` cuando la persona es un nuevo prospecto.

Nunca le escribe al visitante. El corredor responde desde Matchai, con sus propias palabras. La llamada devuelve `201` con `{ contact, created }`, donde `created` es `false` cuando el mensaje se agregó a un contacto que el corredor ya tenía.

Envía estos campos:

<TypeTable
  type={{
    first_name: { type: 'string', required: true, description: 'El nombre del visitante.' },
    last_name: { type: 'string', description: 'Su apellido.' },
    email: { type: 'string', description: 'Su correo electrónico. La consulta necesita un correo electrónico o un número de teléfono.' },
    phone: { type: 'string', description: 'Su número de teléfono.' },
    message: { type: 'string', required: true, description: 'Lo que escribió, hasta 4000 caracteres.' },
    listing_id: { type: 'string', description: 'El anuncio en el que estaba el formulario, si lo hay.' },
    page_url: { type: 'string', description: 'La página en la que estaba el formulario.' },
    language: { type: '"en-US" | "fr-CA" | "es"', description: 'El idioma del visitante.' },
    consent_to_contact: { type: 'boolean', description: 'Aceptó que lo contacten. Debe ser true para hacer el seguimiento por mensaje de texto.' },
  }}
/>

Envía siempre un `Idempotency-Key`, y usa uno por cada envío del formulario. Si el visitante hace doble clic, o tu servidor reintenta después de que se agota el tiempo de espera, Matchai devuelve la primera respuesta en lugar de guardar el mensaje dos veces.

```ts title="enquiry.ts"
import { matchai, MatchaiError } from './matchai.js';

export type Enquiry = {
  first_name: string;
  last_name?: string;
  email?: string;
  phone?: string;
  message: string;
  listing_id?: string;
  page_url?: string;
  language?: 'en-US' | 'fr-CA' | 'es';
  consent_to_contact?: boolean;
};

type EnquiryResult = { contact: { id: string; type: string; display_name: string }; created: boolean };

/** Sends one form submission to the broker's contacts. submissionId is one value per submission. */
export async function sendEnquiry(token: string, submissionId: string, enquiry: Enquiry) {
  try {
    const result = await matchai<EnquiryResult>('/enquiries', token, {
      method: 'POST',
      body: enquiry,
      idempotencyKey: submissionId,
    });
    return { ok: true as const, contactId: result.contact.id, newLead: result.created };
  } catch (error) {
    // 400 and 422 carry a message that is safe to show: what happened and what to do.
    if (error instanceof MatchaiError && (error.status === 400 || error.status === 422)) {
      return { ok: false as const, message: error.message };
    }
    throw error; // anything else is yours to log and retry with the same submissionId
  }
}
```

Úsalo desde la ruta de tu formulario. Primero valida el formulario y protégelo del spam, porque Matchai guarda en los contactos del corredor todo lo que envíes:

```ts title="contact-form.ts"
import { sendEnquiry } from './enquiry.js';

export async function handleContactForm(request: Request, brokerToken: string): Promise<Response> {
  const form = await request.formData();
  const submissionId = String(form.get('submission_id') ?? '').trim();
  const email = String(form.get('email') ?? '').trim();
  const phone = String(form.get('phone') ?? '').trim();
  const firstName = String(form.get('first_name') ?? '').trim();
  const message = String(form.get('message') ?? '').trim();

  if (!submissionId || !firstName || !message || (!email && !phone)) {
    return Response.json({ message: 'Please add your name, a message, and an email or a phone number.' }, { status: 400 });
  }

  const result = await sendEnquiry(brokerToken, submissionId, {
    first_name: firstName,
    email: email || undefined,
    phone: phone || undefined,
    message,
    listing_id: String(form.get('listing_id') ?? '') || undefined,
    page_url: request.headers.get('Referer') ?? undefined,
    consent_to_contact: form.get('consent') === 'on',
  });

  if (!result.ok) return Response.json({ message: result.message }, { status: 422 });
  return Response.json({ message: 'Thank you. The broker will get back to you.' }, { status: 201 });
}
```

Coloca en el formulario un campo oculto `submission_id`, un UUID, cuando lo renderices. Indica a los visitantes en el formulario que sus datos se envían al corredor.

Los prospectos también llegan al corredor sin pasar por tu formulario, por ejemplo desde una página de anuncio en Matchai, y `lead.created` también se activa para ellos. `data.object.source` indica cómo llegó el contacto al corredor. Para reconocer un prospecto de tu propio formulario, compara `data.object.id` con el id de contacto que devolvió `createEnquiry`.

### Paso 4: solicitudes de visita [#step-4-viewing-requests]

**(Próximamente)**

Un visitante elige un horario de la disponibilidad real del corredor, y tu sitio envía una solicitud. El corredor la confirma o la rechaza en Matchai.

<Steps>
<Step>

**Lee las duraciones de las visitas.** [`getListingAvailability`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/getListingAvailability) te indica si se pueden reservar visitas desde el anuncio (`enabled`) y qué duraciones puede elegir un visitante (`durations_minutes`). Necesita `calendar:read`.

</Step>
<Step>

**Ofrece horarios libres.** [`listPublicBookingSlots`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/listPublicBookingSlots) devuelve los horarios que cualquiera puede reservar con el corredor en un día, los mismos que ofrecen sus páginas de anuncios. Pasa el `listing_id` para obtener los horarios de una visita de ese anuncio. Es gratis, no necesita alcance y nunca revela lo que hay en el calendario del corredor. No devuelve horarios cuando el corredor no acepta reservas.

</Step>
<Step>

**Envía la solicitud.** [`requestViewing`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/requestViewing) registra la solicitud de visita. El visitante se convierte en un prospecto, y la cita se crea con el estado `requested`. Necesita `calendar:write` y `contacts:write`.

</Step>
<Step>

**Sigue la respuesta.** `appointment.booked` significa que el corredor confirmó. `appointment.cancelled` significa que la rechazó o que la canceló más tarde. `appointment.rescheduled` significa que se propuso o se fijó un nuevo horario.

</Step>
</Steps>

Si el horario se ocupó mientras tanto, la solicitud devuelve `409 slot_unavailable`. Vuelve a leer los horarios y deja que el visitante elija otro.

```ts title="viewing.ts"
import { matchai, MatchaiError } from './matchai.js';

type Slots = {
  date: string;
  timezone: string;
  duration_minutes: number;
  slots: Array<{ starts_at: string; ends_at: string }>;
};

type Visitor = {
  first_name: string;
  last_name?: string;
  email?: string;
  phone?: string;
  language?: 'en-US' | 'fr-CA' | 'es';
};

/** The visit lengths a visitor can choose for this listing. Empty when visits cannot be booked. */
export async function visitLengths(token: string, listingId: string): Promise<number[]> {
  const settings = await matchai<{ enabled: boolean; durations_minutes: number[] }>(
    `/listings/${listingId}/availability`,
    token,
  );
  return settings.enabled ? settings.durations_minutes : [];
}

/** Free times on one day (date is YYYY-MM-DD). Slot times keep the broker's time zone offset. */
export async function bookableSlots(
  token: string,
  username: string,
  listingId: string,
  date: string,
  durationMinutes: number,
): Promise<Slots> {
  const query = new URLSearchParams({
    date,
    duration_minutes: String(durationMinutes),
    listing_id: listingId,
  });
  return matchai<Slots>(`/professionals/${username}/booking-slots?${query}`, token);
}

/** Sends the visit request. requestId is one value per request, so a retry never asks twice. */
export async function requestViewing(
  token: string,
  requestId: string,
  listingId: string,
  visitor: Visitor,
  startsAt: string,
  durationMinutes: number,
  notes?: string,
) {
  try {
    const result = await matchai<{ appointment: { id: string; status: string } }>(
      `/listings/${listingId}/viewing-requests`,
      token,
      {
        method: 'POST',
        idempotencyKey: requestId,
        body: { visitor, starts_at: startsAt, duration_minutes: durationMinutes, notes },
      },
    );
    return { ok: true as const, appointmentId: result.appointment.id, status: result.appointment.status };
  } catch (error) {
    if (error instanceof MatchaiError && error.code === 'slot_unavailable') {
      return { ok: false as const, reason: 'slot_taken' as const };
    }
    throw error;
  }
}
```

Guarda el `appointment.id` junto con tu propio registro de la solicitud de visita. Los eventos `appointment.*` llevan el mismo id en `data.object.id`, así que puedes mostrar "Solicitud recibida", "Visita confirmada" o "Solicitud rechazada" en tu sitio. Es el corredor, no tu app, quien le responde al visitante.

### Paso 5: procesa los eventos [#step-5-handle-the-events]

**(Próximamente)**

Un endpoint por corredor recibe todos los eventos. Primero verifica la firma, como se muestra en la [página de webhooks](https://matchai.co/docs/es/developers/webhooks#verify-the-signature), y luego enruta según `type`.

```ts title="partner-events.ts"
import { refreshListing, type ListingStore } from './listings-feed.js';

type MatchaiEvent = {
  id: string;
  type: string;
  created_at: string;
  test_mode: boolean;
  account: { user_id: string };
  data: { object: { id: string; status?: string } };
};

/** Call this after the signature is verified and after you have skipped duplicate event ids. */
export async function handlePartnerEvent(event: MatchaiEvent, brokerToken: string, store: ListingStore): Promise<void> {
  switch (event.type) {
    case 'listing.published':
    case 'listing.updated':
    case 'listing.unpublished':
    case 'listing.archived':
    case 'listing.deleted':
      await refreshListing(event.data.object.id, brokerToken, store);
      break;

    case 'appointment.requested':
    case 'appointment.booked':
    case 'appointment.rescheduled':
    case 'appointment.cancelled':
      // Update the visit request you stored, found by event.data.object.id.
      console.log('Visit', event.data.object.id, event.data.object.status);
      break;

    case 'lead.created':
      // Leads also arrive from Matchai itself. Compare the id with the contact your own form created.
      console.log('New lead', event.data.object.id);
      break;

    default:
      break; // new event types can appear at any time
  }
}
```

Dale a la suscripción de cada corredor su propia dirección, como `/hooks/matchai/{your broker id}`, para saber con qué secreto verificar antes de leer el cuerpo. Cada evento también lleva `account.user_id`, que identifica la cuenta de Matchai del corredor.

### Quién paga [#who-pays]

**(Próximamente)**

Cada conexión a la cuenta de un corredor es un conector de Matchai de pago. Quién paga es una configuración de tu app de Matchai, y la página **Permitir** le indica al corredor cuál se aplica antes de que pulse **Permitir**.

- **Paga el corredor.** Es la opción predeterminada. La conexión se suma a la factura de Matchai del propio corredor, y la página **Permitir** muestra el precio.
- **Pagas tú.** Un socio verificado con un acuerdo de socio cubre la conexión de cada corredor que instala tu app. El corredor se conecta sin costo, y la página **Permitir** indica que la conexión está incluida por tu empresa. Tres sitios web de corredores son tres conexiones. Para hablar de un acuerdo de socio, contacta al [soporte de Matchai](https://matchai.co/support).

Leer anuncios publicados con las llamadas públicas sigue siendo gratis para todos, con o sin conexión.

### Modo de prueba [#test-mode]

**(Próximamente)**

Una app que está conectada solo a la cuenta de Matchai de su propio desarrollador está en modo de prueba, y las conexiones de prueba no se facturan. Crea todo el flujo con tu propia cuenta antes de lanzarlo:

1. Crea una app de Matchai y conéctala a tu propia cuenta.
2. Revisa `GET /me/connection`. Devuelve `test_mode: true`.
3. Publica algunos anuncios, envía un formulario desde un sitio de prueba, solicita una visita y observa cómo llegan los eventos. Llevan `test_mode: true`.
4. Usa `POST /webhook-subscriptions/{webhook_subscription_id}/test` para enviar un evento `webhook.test` a tu endpoint en cualquier momento.

La facturación empieza cuando un corredor distinto del desarrollador conecta la app, según lo que se haya configurado en [quién paga](#who-pays).

### Límites, errores y desconexión [#limits-errors-and-disconnection]

**(Próximamente)**

- **Los límites de solicitudes** son por conexión. Cada respuesta lleva `RateLimit-Limit`, `RateLimit-Remaining` y `RateLimit-Reset`, y un `429` agrega `Retry-After`. Consulta los [límites de solicitudes](https://matchai.co/docs/es/developers/rate-limits).
- **Reintentos.** Envía un `Idempotency-Key` en cada `POST`. Un reintento con la misma clave dentro de las 24 horas devuelve la primera respuesta. Usar la misma clave con un cuerpo distinto devuelve `409 idempotency_key_reused`.
- **Los tokens caducados** devuelven `401 token_expired`. Actualiza el token y repite la llamada.
- **Un alcance faltante** devuelve `403 scope_missing`. Pídele al corredor que vuelva a conectar tu app con ese alcance.
- **Desconexión.** Un corredor puede desconectar tu app en cualquier momento. Sus tokens dejan de funcionar y sus suscripciones se detienen con `disabled_reason: connection_ended`. Decide de antemano qué mostrará tu sitio en ese caso, por ejemplo los últimos anuncios que tienes, con una fecha clara.

Consulta los [errores](https://matchai.co/docs/es/developers/errors) para ver todos los códigos.

### Antes de lanzar [#before-you-launch]

**(Próximamente)**

- Tu app pide solo los alcances que usa, y un corredor puede leer por qué en la página **Permitir**.
- Guardas los tokens de cada corredor por separado y cifrados, y un token nunca sirve al sitio de otro corredor.
- Verificas la firma de cada webhook e ignoras los id de evento duplicados.
- Tu controlador de anuncios vuelve a leer el anuncio en lugar de confiar en el orden de los eventos, y una llamada diaria con `updated_since` cubre cualquier hueco.
- Cada `POST` tiene un `Idempotency-Key`.
- Tus formularios les indican a los visitantes que sus datos se envían al corredor, y envías `consent_to_contact` solo cuando el visitante aceptó.
- Probaste el flujo en modo de prueba con tu propia cuenta.

<Cards>
  <Card title="Webhooks" href="https://matchai.co/docs/es/developers/webhooks">
    Eventos, verificación de firma, reintentos y pruebas.
  </Card>
  <Card title="Conectar con Matchai" href="https://matchai.co/docs/es/developers/authentication/connect-with-matchai">
    El flujo OAuth, los alcances y los tokens, en detalle.
  </Card>
  <Card title="Páginas legibles por IA" href="https://matchai.co/docs/es/developers/agent-readable-pages">
    Haz que un sitio que alojas sea legible para los asistentes de IA.
  </Card>
  <Card title="Búsqueda de anuncios" href="https://matchai.co/docs/es/developers/listing-search">
    Busca en los anuncios de un corredor en lenguaje natural.
  </Card>
</Cards>

## Sitemap

- [Todas las páginas de la documentación de Matchai](https://matchai.co/docs/sitemap.md)
- [llms.txt](https://matchai.co/docs/es/llms.txt)
