Matchai Docs

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.

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. Read a broker's published listings from the public surfaces. You need no account, no key and no approval.
  2. 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

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

You needUseYou getLimit
A search box on the broker's siteGET https://{broker host}/ask (listing search)Up to 10 listings, best match first, each with schema.org data60 requests a minute for each calling address
The broker's current listingsThe get_professional tool of the MCP serverThe broker's public profile and up to 10 available listings, as typed JSON600 tools/call requests a minute for each calling address
One listing in fullThe get_listing toolDescription, highlights and up to 8 photosThe same
Every listing, with change detectionThe broker host's sitemap.xml, then each listing page's structured data or Markdown copy (AI-readable pages)All published listings, each with the date it last changedAbout one request a second for each host
A readable copy of the whole siteThe broker host's llms-full.txtThe broker's details and up to 1,000 listings as textCached 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.

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.

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.

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.

Show a broker's 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.

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.

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 for the shape.

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

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

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

Coming soon
JobWhat your app doesCalls and eventsScopes
ConnectGets a connection to each broker's account, in one clickConnect with MatchaiThe scopes below
Listings outShows a broker's listings and keeps them currentlistListings, getListing, and listing.published, listing.updated, listing.unpublished, listing.archived, listing.deletedlistings:read
Leads inSends your website forms to the broker's contactscreateEnquiry, and lead.createdcontacts:write
ViewingsOffers real free times and sends visit requestslistPublicBookingSlots, requestViewing, and appointment.requested, appointment.booked, appointment.rescheduled, appointment.cancelledcalendar:read, calendar:write and contacts:write
Stay in syncHears about changes as they happenWebhookswebhooks:manage, plus the scope of each event

Ask for profile:read as well. 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

Coming soon
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

Coming soon

Connect with Matchai is OAuth 2.1 with the authorization code flow and PKCE. You 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.

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.

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.

All the later sketches share one small helper:

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

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.

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.

Step 3: lead intake from your forms

Coming soon

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:

Field

Type

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.

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:

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

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.

Read the visit lengths. 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.

Offer free times. 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.

Send the request. 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.

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.

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

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

Coming soon

One endpoint per broker receives all the events. Verify the signature first, as shown on the webhooks page, then route on type.

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

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.

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

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.

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.
  • 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 for every code.

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.

On this page