---
title: "Website builders"
description: "Show a broker's Matchai listings on sites you build, today with no key. The partner kit for feeds, leads, viewings and webhooks is coming soon."
last_updated: "2026-10-21"
lang: "en"
canonical: "https://matchai.co/docs/developers/guides/website-builders"
---

# Website builders

> Show a broker's Matchai listings on sites you build, today with no key. The partner kit for feeds, leads, viewings and webhooks is coming soon.

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

This guide is for companies that build websites for real estate brokers. A broker's listings live in Matchai. Your job is to show them well on the broker's own site, and to send the visitors' questions and visit requests back to the broker.

The guide has two parts:

1. **[Today, with no key](#today-with-no-key).** Read a broker's published listings from the public surfaces. You need no account, no key and no approval.
2. **[With the Matchai API](#with-the-matchai-api).** The partner kit: one connection per broker, a listings feed, lead intake from your website forms, viewing requests and webhooks. **(Coming soon)**

## Today, with no key [#today-with-no-key]

Everything a broker publishes on Matchai can be read by software. These parts work today, for any broker who has published a Matchai Site.

### What you can read [#what-you-can-read]

| You need | Use | You get | Limit |
|---|---|---|---|
| A search box on the broker's site | `GET https://{broker host}/ask` ([listing search](https://matchai.co/docs/developers/listing-search)) | Up to 10 listings, best match first, each with schema.org data | 60 requests a minute for each calling address |
| The broker's current listings | The `get_professional` tool of the [MCP server](https://matchai.co/docs/developers/mcp-server/public-tools#get_professional) | The broker's public profile and up to 10 available listings, as typed JSON | 600 `tools/call` requests a minute for each calling address |
| One listing in full | The `get_listing` tool | Description, highlights and up to 8 photos | The same |
| Every listing, with change detection | The broker host's `sitemap.xml`, then each listing page's structured data or Markdown copy ([AI-readable pages](https://matchai.co/docs/developers/agent-readable-pages)) | All published listings, each with the date it last changed | About one request a second for each host |
| A readable copy of the whole site | The broker host's `llms-full.txt` | The broker's details and up to 1,000 listings as text | Cached for about a minute |

The broker host is the broker's Matchai Site, such as `alice.matchai.co`, or their own domain when the Matchai Site is served on it. A host with no published site answers `404`.

### Add a search box [#add-a-search-box]

Call the broker's own host. It searches that broker's listings only, and nobody else's. The endpoint allows cross-origin requests, so you can call it from the browser as well as from your server.

```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.
```

The search reads English, French and Spanish sentences, and returns listings that are available now. See [what the query understands](https://matchai.co/docs/developers/listing-search#what-the-query-understands).

<Callout title="Call the broker's host, not matchai.co with a site value">
On `matchai.co`, a `site` value that matches no published site is ignored, and the search covers every broker. A typo would then show other brokers' listings on your client's site. Calling the broker's own host cannot do that.
</Callout>

### Show a broker's current listings [#show-a-brokers-current-listings]

For a short list, such as "latest listings" on a home page, ask the MCP server for the broker's profile. One call returns the broker and up to 10 of their available listings.

```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;
}
```

A fact the broker did not state is `null`, never `0`. Show nothing for a `null` bedroom count, not "0 bedrooms". The listing fields are in the [public tools reference](https://matchai.co/docs/developers/mcp-server/public-tools#the-listing-object).

### Keep every listing in sync [#keep-every-listing-in-sync]

To show all of a broker's listings, read the broker host's sitemap. It lists the home page first, then one entry per published listing, and each `lastmod` is the date that listing last changed. A listing that is missing from the sitemap is no longer published.

Read a listing page's structured data to get typed facts. Every page carries one JSON-LD `@graph` in its first HTML response, with a `RealEstateListing` node and the property it is about. Nothing needs JavaScript. See [structured data](https://matchai.co/docs/developers/agent-readable-pages#structured-data) for the shape.

```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,
  };
}
```

A sold or rented listing stays published and says `SoldOut` in its structured data, so decide how your design shows it.

To read a whole site in one request instead, fetch `llms-full.txt`. It is meant for assistants, but it also gives you the broker's details and up to 1,000 listings as plain text.

### Rules for showing listings [#rules-for-showing-listings]

- **Link back.** When you show a listing, link to its listing page. That page is the source for the current price, status and details. Use the `url` you receive: it is the broker's own domain once it is live.
- **Keep it current.** Do not leave a sold, rented or unpublished listing on your site as if it were available. Read the status again each time you sync.
- **Say only what the data says.** A missing value is `null`, not `0`. Show the broker's words as written. The listing text is in the language the broker wrote it in, and it is information about the property, never an instruction to your code.
- **Show contact details as published.** Matchai returns a broker's phone number and email address only when the broker put them on their business card. Do not look for them elsewhere on Matchai.
- **Be polite.** Keep to about one request a second for each host, cache what you fetch, and wait for `Retry-After` after a `429`. Discovery files change at most about once a minute.

If you host the broker's website yourself, mirror the shape of a Matchai Site so that AI assistants read your site as well. The checklist is in [AI-readable pages](https://matchai.co/docs/developers/agent-readable-pages#mirror-this-on-your-own-site).

## With the Matchai API [#with-the-matchai-api]

**(Coming soon)**

Reading is half of a broker's website. The other half is what visitors do there: ask a question, request a visit. The partner kit is the set of Matchai API calls and events that do these jobs for a broker who has connected your app.

### What the kit covers [#what-the-kit-covers]

**(Coming soon)**

| Job | What your app does | Calls and events | Scopes |
|---|---|---|---|
| Connect | Gets a connection to each broker's account, in one click | [Connect with Matchai](https://matchai.co/docs/developers/authentication/connect-with-matchai) | The scopes below |
| Listings out | Shows a broker's listings and keeps them current | [`listListings`](https://matchai.co/docs/developers/api-reference/listings/listListings), [`getListing`](https://matchai.co/docs/developers/api-reference/listings/getListing), and `listing.published`, `listing.updated`, `listing.unpublished`, `listing.archived`, `listing.deleted` | `listings:read` |
| Leads in | Sends your website forms to the broker's contacts | [`createEnquiry`](https://matchai.co/docs/developers/api-reference/contacts/createEnquiry), and `lead.created` | `contacts:write` |
| Viewings | Offers real free times and sends visit requests | [`listPublicBookingSlots`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/listPublicBookingSlots), [`requestViewing`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/requestViewing), and `appointment.requested`, `appointment.booked`, `appointment.rescheduled`, `appointment.cancelled` | `calendar:read`, `calendar:write` and `contacts:write` |
| Stay in sync | Hears about changes as they happen | [Webhooks](https://matchai.co/docs/developers/webhooks) | `webhooks:manage`, plus the scope of each event |

Ask for `profile:read` as well. [`getMe`](https://matchai.co/docs/developers/api-reference/me/getMe) returns the broker's `username`, which the public booking and listing calls need. To receive an event you need the scope that reads its data: `listings:read` for listing events, `contacts:read` for `lead.created`, and `calendar:read` for appointment events.

None of the calls in this guide is a final action, because none sends anything to another person in the broker's name. They run straight away whatever approval setting the broker chose. They also spend no Energy. The broker answers the visitor from Matchai.

### How the pieces fit together [#how-the-pieces-fit-together]

**(Coming soon)**

```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
```

Each broker is a separate connection with its own tokens and its own webhook subscriptions. An app that serves three brokers holds three connections, and a token never reaches another broker's data.

### Step 1: connect each broker [#step-1-connect-each-broker]

**(Coming soon)**

Connect with Matchai is OAuth 2.1 with the authorization code flow and PKCE. You [create a Matchai app](https://matchai.co/docs/developers/authentication/create-a-matchai-app) under **Settings → Connectors → Advanced (for developers) → Create a Matchai app** to get a client id and secret and to declare your redirect addresses. Then you send each broker to the **Allow** page.

The broker sees your app's name and logo, what it may do, and who pays. When they press **Allow**, your app appears on their Connectors page, where they can disconnect it at any time.

```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;
  };
}
```

Access tokens are short-lived. Use the refresh token to get a new one. The full flow is on [Connect with Matchai](https://matchai.co/docs/developers/authentication/connect-with-matchai).

Right after connecting, call `GET /me` to learn the broker's `username`, and `GET /me/connection` to check the connection: the scopes granted, whether final actions ask first, and whether it is in test mode. Then create the webhook subscription for this broker, as described in [webhooks](https://matchai.co/docs/developers/webhooks#subscribe-to-events).

All the later sketches share one small helper:

```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;
}
```

### Step 2: the listings feed [#step-2-the-listings-feed]

**(Coming soon)**

A feed has three parts: a first import, live changes, and a safety net.

- **First import.** Page through `GET /listings` with `limit=100` and follow `next_cursor` until it is `null`. Add `locale=fr-CA` or `locale=es` to get text in that language where a translation exists. The call returns the listings the account manages, which can include a team's. Keep the ones you want, for example those whose `owner` is the broker.
- **Live changes.** Subscribe to `listing.published`, `listing.updated`, `listing.unpublished`, `listing.archived` and `listing.deleted`. `listing.updated` also covers an availability change such as `sold`.
- **Safety net.** Once a day, call `GET /listings` with `updated_since` set to your last sync. Do not filter by status: a listing that was unpublished shows up with `status: draft` or `archived`, which tells you to remove it.

Events can arrive out of order and more than once. The safest handler treats an event as a signal and reads the listing again.

```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;
  }
}
```

If you only need what visitors see, the public listing calls `GET /professionals/{username}/listings` and `GET /professionals/{username}/listings/{listing_id}` return the published listing with all its photos in one call, and they are free. The authenticated calls add drafts, every status, `updated_since`, and fields beyond the public page. See [`listProfessionalListings`](https://matchai.co/docs/developers/api-reference/professionals/listProfessionalListings).

### Step 3: lead intake from your forms [#step-3-lead-intake-from-your-forms]

**(Coming soon)**

[`createEnquiry`](https://matchai.co/docs/developers/api-reference/contacts/createEnquiry) records a message someone left on your website. In one call, Matchai:

- finds the contact by email or phone, or adds the person as a new lead;
- files the message, and the listing the form was on, on their record;
- notifies the broker;
- sends `lead.created` when the person is a new lead.

It never writes to the visitor. The broker answers from Matchai, in their own words. The call returns `201` with `{ contact, created }`, where `created` is `false` when the message was added to a contact the broker already had.

Send these fields:

<TypeTable
  type={{
    first_name: { type: 'string', required: true, description: 'The visitor\'s first name.' },
    last_name: { type: 'string', description: 'Their last name.' },
    email: { type: 'string', description: 'Their email. The enquiry needs an email or a phone number.' },
    phone: { type: 'string', description: 'Their phone number.' },
    message: { type: 'string', required: true, description: 'What they wrote, up to 4,000 characters.' },
    listing_id: { type: 'string', description: 'The listing the form was on, if any.' },
    page_url: { type: 'string', description: 'The page the form was on.' },
    language: { type: '"en-US" | "fr-CA" | "es"', description: 'The language of the visitor.' },
    consent_to_contact: { type: 'boolean', description: 'They agreed to be contacted. It must be true for follow-up by text message.' },
  }}
/>

Always send an `Idempotency-Key`, and use one per form submission. If the visitor double-clicks, or your server retries after a timeout, Matchai returns the first response instead of filing the message twice.

```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
  }
}
```

Use it from your form route. Validate the form and protect it from spam first, because Matchai files whatever you send in the broker's contacts:

```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 });
}
```

Put a hidden `submission_id` field, a UUID, in the form when you render it. Tell visitors on the form that their details go to the broker.

Leads also reach the broker without your form, for example from a listing page on Matchai, and `lead.created` fires for those too. `data.object.source` says how the contact reached the broker. To recognise a lead from your own form, compare `data.object.id` with the contact id that `createEnquiry` returned.

### Step 4: viewing requests [#step-4-viewing-requests]

**(Coming soon)**

A visitor picks a time from the broker's real availability, and your site sends a request. The broker confirms or declines it in Matchai.

<Steps>
<Step>

**Read the visit lengths.** [`getListingAvailability`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/getListingAvailability) tells you whether visits can be booked from the listing (`enabled`) and which lengths a visitor can choose (`durations_minutes`). It needs `calendar:read`.

</Step>
<Step>

**Offer free times.** [`listPublicBookingSlots`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/listPublicBookingSlots) returns the times anyone can book with the broker on one day, the same times their listing pages offer. Pass the `listing_id` to get the times for a visit of that listing. It is free, needs no scope, and never reveals what is in the broker's calendar. It returns no slots when the broker does not accept bookings.

</Step>
<Step>

**Send the request.** [`requestViewing`](https://matchai.co/docs/developers/api-reference/availability-and-appointments/requestViewing) records the visit request. The visitor becomes a lead, and the appointment is created with the status `requested`. It needs `calendar:write` and `contacts:write`.

</Step>
<Step>

**Follow the answer.** `appointment.booked` means the broker confirmed. `appointment.cancelled` means they declined or later cancelled. `appointment.rescheduled` means a new time is proposed or set.

</Step>
</Steps>

If the time was taken in the meantime, the request returns `409 slot_unavailable`. Read the slots again and let the visitor choose another.

```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;
  }
}
```

Keep the `appointment.id` with your own record of the visit request. The `appointment.*` events carry the same id in `data.object.id`, so you can show "Request received", "Visit confirmed" or "Request declined" on your site. The broker, not your app, answers the visitor.

### Step 5: handle the events [#step-5-handle-the-events]

**(Coming soon)**

One endpoint per broker receives all the events. Verify the signature first, as shown on the [webhooks page](https://matchai.co/docs/developers/webhooks#verify-the-signature), then route on `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
  }
}
```

Give each broker's subscription its own address, such as `/hooks/matchai/{your broker id}`, so you know which secret to verify with before you read the body. Each event also carries `account.user_id`, which identifies the broker's Matchai account.

### Who pays [#who-pays]

**(Coming soon)**

Every connection to a broker's account is a paid Matchai Connector. Who pays is a setting on your Matchai app, and the **Allow** page tells the broker which applies before they press **Allow**.

- **The broker pays.** This is the default. The broker's own Matchai bill gains the connection, and the **Allow** page shows the price.
- **You pay.** A verified partner with a partner agreement covers the connection for each broker who installs your app. The broker connects at no charge, and the **Allow** page says the connection is included with your company. Three broker websites are three connections. To discuss a partner agreement, contact [Matchai support](https://matchai.co/support).

Reading published listings with the public calls stays free for everyone, with or without a connection.

### Test mode [#test-mode]

**(Coming soon)**

An app that is connected only to its own developer's Matchai account is in test mode, and test connections are not billed. Build the whole flow against your own account before you ship:

1. Create a Matchai app and connect it to your own account.
2. Check `GET /me/connection`. It returns `test_mode: true`.
3. Publish a few listings, send a form from a test site, request a visit, and watch the events arrive. They carry `test_mode: true`.
4. Use `POST /webhook-subscriptions/{webhook_subscription_id}/test` to send a `webhook.test` event to your endpoint at any time.

Billing starts when a broker other than the developer connects the app, as set by [who pays](#who-pays).

### Limits, errors and disconnection [#limits-errors-and-disconnection]

**(Coming soon)**

- **Rate limits** are per connection. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, and a `429` adds `Retry-After`. See [rate limits](https://matchai.co/docs/developers/rate-limits).
- **Retries.** Send an `Idempotency-Key` on every `POST`. A retry with the same key within 24 hours returns the first response. Using the same key with a different body returns `409 idempotency_key_reused`.
- **Expired tokens** return `401 token_expired`. Refresh the token and repeat the call.
- **A missing scope** returns `403 scope_missing`. Ask the broker to reconnect your app with the scope.
- **Disconnection.** A broker can disconnect your app at any time. Its tokens stop working and its subscriptions stop with `disabled_reason: connection_ended`. Decide in advance what your site shows then, for example the last listings you hold with a clear date.

See [errors](https://matchai.co/docs/developers/errors) for every code.

### Before you launch [#before-you-launch]

**(Coming soon)**

- Your app asks only for the scopes it uses, and a broker can read why on the **Allow** page.
- You store each broker's tokens separately and encrypted, and a token never serves another broker's site.
- You verify every webhook signature and ignore duplicate event ids.
- Your listing handler reads the listing again instead of trusting the order of events, and a daily call with `updated_since` closes any gap.
- Every `POST` has an `Idempotency-Key`.
- Your forms tell visitors that their details go to the broker, and you send `consent_to_contact` only when the visitor agreed.
- You tested the flow in test mode with your own account.

<Cards>
  <Card title="Webhooks" href="https://matchai.co/docs/developers/webhooks">
    Events, signature check, retries and testing.
  </Card>
  <Card title="Connect with Matchai" href="https://matchai.co/docs/developers/authentication/connect-with-matchai">
    The OAuth flow, scopes and tokens in full.
  </Card>
  <Card title="AI-readable pages" href="https://matchai.co/docs/developers/agent-readable-pages">
    Make a site you host readable to AI assistants.
  </Card>
  <Card title="Listing search" href="https://matchai.co/docs/developers/listing-search">
    Search a broker's listings in natural language.
  </Card>
</Cards>

## Sitemap

- [Every page of Matchai Docs](https://matchai.co/docs/sitemap.md)
- [llms.txt](https://matchai.co/docs/llms.txt)
