Matchai Docs

WebhooksComing soon

Get signed events when a lead arrives, a visit is booked or a listing changes. Event catalog, payload, signature check, retries, testing and delivery logs.

Coming soon. This is documented ahead of launch so you can plan your integration; it is not available yet.

A webhook subscription tells Matchai where to send events. When something happens in a connected Matchai account, such as a new lead, a booked visit or a published listing, Matchai sends a signed POST request to your endpoint with a JSON description of what happened.

Webhooks replace polling. Your app hears about a change when it happens instead of asking again and again, and the signature proves that the message came from Matchai.

How it works

Subscribe. Your app calls POST /webhook-subscriptions with the https address of your endpoint and the events you want. The response holds the signing secret, shown once.

Receive. Each time a matching event happens, Matchai sends a POST to your endpoint. The body is an Event in JSON. Three headers come with it: Matchai-Signature, Matchai-Event-Id and Matchai-Delivery-Attempt.

Verify and answer. Your endpoint checks the signature, answers with any 2xx status within 10 seconds, and does the real work afterwards.

Retry. A delivery that does not get a 2xx is sent again with growing delays, for up to 3 days.

A subscription belongs to one connection, which means one Matchai account and one app. An app that serves three professionals holds three connections and creates subscriptions for each of them. See Connect with Matchai and API keys for how a connection is made.

Subscribe to events

Creating a subscription needs the webhooks:manage scope. Webhooks are included with the connection, and they cost no Energy.

Field

Type

Send an Idempotency-Key header with the request. A retry with the same key within 24 hours returns the first response instead of creating a second subscription.

curl https://api.matchai.co/v1/webhook-subscriptions \
  -H "Authorization: Bearer $MATCHAI_API_KEY" \
  -H "Idempotency-Key: 6f1d3c2a-0b7e-4f5a-9c8d-1e2f3a4b5c6d" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://maisonweb.example.com/hooks/matchai",
    "events": ["lead.created", "appointment.booked"],
    "description": "Leads and visits for camilletremblay.ca"
  }'

The response is 201 Created. The secret is in the response once and never again, so store it now.

{
  "id": "8b9c0d1e-2f3a-4b4c-9d5e-6f7a8b9c0d1e",
  "url": "https://maisonweb.example.com/hooks/matchai",
  "description": "Leads and visits for camilletremblay.ca",
  "events": ["lead.created", "appointment.booked"],
  "status": "enabled",
  "disabled_reason": null,
  "created_at": "2026-10-10T18:05:00Z",
  "updated_at": "2026-10-10T18:05:00Z",
  "secret": "whsec_4fT9qLmZ2xVb7NcR8sKd3HwPj6YeA1uG"
}

The signing secret starts with whsec_. Keep it in your secret store, never in code or logs. If you lose it, rotate it.

You only receive events about data your scopes let you read. The last column of the catalog names the scope each event needs, so ask for those scopes when the account holder connects your app.

Manage subscriptions

All of these need webhooks:manage.

ToCallReference
List your subscriptionsGET /webhook-subscriptionslistWebhookSubscriptions
Read one subscription (the secret is never returned again)GET /webhook-subscriptions/{webhook_subscription_id}getWebhookSubscription
Change the endpoint or the events, or turn it off or onPATCH /webhook-subscriptions/{webhook_subscription_id}updateWebhookSubscription
Stop sending events to an endpointDELETE /webhook-subscriptions/{webhook_subscription_id}deleteWebhookSubscription
Issue a new signing secretPOST /webhook-subscriptions/{webhook_subscription_id}/rotate-secretrotateWebhookSecret
Send a test event nowPOST /webhook-subscriptions/{webhook_subscription_id}/testtestWebhookSubscription
See delivery attemptsGET /webhook-subscriptions/{webhook_subscription_id}/deliverieslistWebhookDeliveries
Send a failed delivery againPOST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retryretryWebhookDelivery
Catch up on past eventsGET /events and GET /events/{event_id}listEvents, getEvent

Deleting a subscription stops events for that endpoint. It deletes nothing in the Matchai account.

A subscription has these fields:

Field

Type

Event catalog

These are the event types, grouped by what they are about. Each name links to its reference page, which shows the payload.

New event types can appear

Matchai can add event types, and new fields to existing events, without a new API version. Ignore any event type you do not know, and any field you do not use.

Contacts and leads

EventSent whendata.objectScope to receive
lead.createdA new lead reached the account: an enquiry from a listing, the Matchai Site, the Matchai Page or your website, a visit request, or someone saving a listing.The contactcontacts:read
contact.createdAny new contact, whatever its type. A new lead also sends lead.created.The contactcontacts:read
contact.updatedA contact's details or lead details changed. data.previous holds the old values of the fields that changed.The contactcontacts:read
contact.convertedA lead became a client, or a client became a professional. data.previous.type holds the old type.The contactcontacts:read
contact.deletedA contact was deleted.A deleted objectcontacts:read

Conversations and messages

EventSent whendata.objectScope to receive
conversation.createdA new conversation the account is in.The conversationmessages:read
message.receivedSomeone else wrote in a conversation the account is in.The messagemessages:read
message.sentThe account sent a message, from the app, the Matchai Assistant or an app like yours.The messagemessages:read

Appointments

EventSent whendata.objectScope to receive
appointment.requestedSomeone asked the account holder for a time. It waits for their answer.The appointmentcalendar:read
appointment.bookedAn appointment is confirmed: booked directly, or a request was accepted.The appointmentcalendar:read
appointment.rescheduledAn appointment has a new time, or a new time was proposed (status: reschedule_pending).The appointmentcalendar:read
appointment.cancelledAn appointment was cancelled or a request was declined.The appointmentcalendar:read

Listings

EventSent whendata.objectScope to receive
listing.createdA new draft listing, made by hand, by AI creation or by import.The listinglistings:read
listing.updatedA listing's fields, photos or rooms changed. data.previous holds the old values of the top-level fields that changed.The listinglistings:read
listing.publishedA listing went on the public web.The listinglistings:read
listing.unpublishedA listing went back to draft.The listinglistings:read
listing.archivedA listing was taken off the market.The listinglistings:read
listing.deletedA listing was deleted.A deleted objectlistings:read
listing.savedSomeone saved one of the account's listings. data.object.contact is your contact record for them.The listing, the contact and saved_atlistings:read and contacts:read
listing.generation.completedAn AI listing creation run succeeded or failed.The listing generationlistings:read

Documents, invitations and notifications

EventSent whendata.objectScope to receive
document.createdA document was added to Matchai Cloud, by upload, by email or through a drop link.The documentdocuments:read
drop_link.upload_receivedSomeone uploaded a file through one of the account's drop links.The drop link and the documentdocuments:read
invitation.acceptedSomeone accepted an invitation the account sent.The invitationteams:read or projects:read
notification.createdThe account received a notification.The notificationnotifications:read

Approvals and testing

EventSent whendata.objectScope to receive
approval.resolvedA final action your app asked for was approved (and performed), declined, or expired. data.object.result holds the outcome.The approvalNone. It goes to the app that asked.
webhook.testYou sent it with POST /webhook-subscriptions/{webhook_subscription_id}/test.A fixed messagewebhooks:manage

A deleted object is { "id": "…", "object": "listing", "deleted": true }, where object is contact or listing.

What a delivery looks like

Every delivery is a POST with a JSON body and these headers.

HeaderMeaning
Matchai-SignatureThe signature to verify. It looks like t=1792177965,v1=5257a869…. During a secret rotation it carries two v1 values.
Matchai-Event-IdThe same value as the body's id. Use it to ignore duplicates.
Matchai-Delivery-AttemptThe attempt number, starting at 1.

The body is the same Event object for every type. GET /events returns the same objects.

Field

Type

A lead.created delivery, abridged to the fields most apps use:

{
  "id": "0d1e2f3a-4b5c-4d6e-9f7a-8b9c0d1e2f3a",
  "type": "lead.created",
  "created_at": "2026-10-16T19:12:45Z",
  "api_version": "v1",
  "test_mode": false,
  "account": {
    "user_id": "3f1c9a2e-7b44-4d0e-9c1a-5e8f2b6d4a10",
    "professional_id": "8a2d4f6b-1c3e-4a5b-9d7f-0e2c4b6a8d1f"
  },
  "data": {
    "object": {
      "id": "c5d8e1f2-3a4b-4c6d-9e8f-7a6b5c4d3e2f",
      "type": "lead",
      "first_name": "Julien",
      "last_name": "Bouchard",
      "display_name": "Julien Bouchard",
      "email": "julien.bouchard@example.com",
      "phone": "+15145550142",
      "notes": "Looking for a 2-bedroom on the Plateau, close to the orange line.",
      "source": "listing",
      "source_listing_id": "b7e4c2a1-9f3d-4e6b-8a5c-1d2f3e4a5b6c",
      "conversation_id": "d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a",
      "created_at": "2026-10-16T19:12:44Z",
      "updated_at": "2026-10-17T16:20:00Z"
    }
  }
}

Field names are snake_case, ids are opaque strings, and an event only carries data your scopes let you read.

Verify the signature

Anyone can send a request to your endpoint. Verify Matchai-Signature on every delivery before you trust the body.

The header looks like this:

Matchai-Signature: t=1792177965,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Take t (Unix seconds) and the raw request body, exactly as received, before any JSON parsing.

Compute the HMAC-SHA256 of <t>.<raw body> with your subscription's signing secret (whsec_…), as lowercase hex. The signed text is the timestamp, a dot, then the body.

Compare the result with each v1 value in constant time. Accept the delivery if one of them matches.

Reject the delivery if t is more than 5 minutes from your clock. This stops someone replaying an old request.

Use the raw body

Sign and compare the bytes Matchai sent. A body that was parsed and written out again can differ in spacing or key order, and then no signature will match. In most web frameworks this means reading the body as text or bytes before any JSON middleware runs.

Use the secret exactly as you received it, including the whsec_ prefix.

verify-signature.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

/**
 * Checks a Matchai-Signature header against the raw request body.
 * Returns true only when the timestamp is fresh and one v1 signature matches.
 */
export function verifyMatchaiSignature(
  rawBody: string,
  header: string | null | undefined,
  secret: string,
  nowSeconds: number = Math.floor(Date.now() / 1000),
): boolean {
  if (!header) return false;

  // Header: t=1792177965,v1=<hex>[,v1=<hex>]
  let timestamp = '';
  const signatures: string[] = [];
  for (const part of header.split(',')) {
    const separator = part.indexOf('=');
    if (separator === -1) continue;
    const name = part.slice(0, separator).trim();
    const value = part.slice(separator + 1).trim();
    if (name === 't') timestamp = value;
    else if (name === 'v1') signatures.push(value);
  }

  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt) || signatures.length === 0) return false;

  // Reject a timestamp more than 5 minutes from your clock. This stops replays.
  if (Math.abs(nowSeconds - sentAt) > TOLERANCE_SECONDS) return false;

  // HMAC-SHA256 of "<t>.<raw body>" with the signing secret, as lowercase hex.
  const expected = Buffer.from(
    createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'),
  );

  // Compare with every v1 value in constant time. One match is enough.
  return signatures.some((candidate) => {
    const received = Buffer.from(candidate);
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

Check your code against a known signature

This example is made up. Use it to check that your code computes the signature the way Matchai does, before you point it at a real subscription.

Test vector
secret: whsec_test_0123456789abcdef
header: t=1792177965,v1=f222c924e7438518b6cec8b46239f468624169721a58570efc3084921aa229c1
body:   {"id":"0d1e2f3a-4b5c-4d6e-9f7a-8b9c0d1e2f3a","type":"webhook.test","created_at":"2026-10-16T19:12:45Z","api_version":"v1","test_mode":true,"account":{"user_id":"3f1c9a2e-7b44-4d0e-9c1a-5e8f2b6d4a10","professional_id":null},"data":{"object":{"message":"This is a test event from Matchai."}}}

The sample is old, so pass its own timestamp as the current time. The check on the 5-minute window is then not the reason it fails.

verify-signature.test.ts
import assert from 'node:assert/strict';
import { verifyMatchaiSignature } from './verify-signature.js';

const secret = 'whsec_test_0123456789abcdef';
const header =
  't=1792177965,v1=f222c924e7438518b6cec8b46239f468624169721a58570efc3084921aa229c1';
const body =
  '{"id":"0d1e2f3a-4b5c-4d6e-9f7a-8b9c0d1e2f3a","type":"webhook.test","created_at":"2026-10-16T19:12:45Z","api_version":"v1","test_mode":true,"account":{"user_id":"3f1c9a2e-7b44-4d0e-9c1a-5e8f2b6d4a10","professional_id":null},"data":{"object":{"message":"This is a test event from Matchai."}}}';

assert.equal(verifyMatchaiSignature(body, header, secret, 1792177965), true);
assert.equal(verifyMatchaiSignature(`${body} `, header, secret, 1792177965), false); // body changed
assert.equal(verifyMatchaiSignature(body, header, secret, 1792177965 + 301), false); // too old
console.log('The signature check works.');

Receive a delivery

Answer with any 2xx status within 10 seconds. Anything else, or no answer in time, counts as a failed delivery and is retried. So verify, store or queue the event, answer, and do the slow work afterwards.

A handler for any runtime that uses the standard Request and Response objects.

webhook-handler.ts
import { verifyMatchaiSignature } from './verify-signature.js';

type MatchaiEvent = {
  id: string;
  type: string;
  created_at: string;
  api_version: 'v1';
  test_mode: boolean;
  account: { user_id: string; professional_id: string | null };
  data: { object: Record<string, unknown>; previous?: Record<string, unknown> };
};

const secret = process.env.MATCHAI_WEBHOOK_SECRET ?? '';

// Use a database with a unique key on the event id in production.
const seenEventIds = new Set<string>();

export async function handleMatchaiWebhook(request: Request): Promise<Response> {
  // Read the body as text first. Do not parse JSON before you verify.
  const rawBody = await request.text();

  if (!verifyMatchaiSignature(rawBody, request.headers.get('Matchai-Signature'), secret)) {
    return new Response('Invalid signature', { status: 400 });
  }

  const event = JSON.parse(rawBody) as MatchaiEvent;

  // A delivery can arrive more than once. Acknowledge duplicates and do nothing.
  if (seenEventIds.has(event.id)) return new Response(null, { status: 200 });
  seenEventIds.add(event.id);

  // Answer now and do the slow work afterwards. In production, put the event on a queue.
  void processEvent(event).catch((error) => console.error('Event failed', event.id, error));
  return new Response(null, { status: 200 });
}

async function processEvent(event: MatchaiEvent): Promise<void> {
  switch (event.type) {
    case 'lead.created':
      console.log('New lead', event.data.object.id);
      break;
    case 'appointment.booked':
      console.log('Visit booked', event.data.object.id);
      break;
    default:
      // Ignore event types you do not know. New ones can appear at any time.
      break;
  }
}

If one app serves several accounts, give each subscription its own address, for example /hooks/matchai/{your broker id}. Each subscription has its own secret, and the address tells you which secret to verify with before you read the body.

Retries and failures

A delivery that does not get a 2xx is retried with growing delays: about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then every 12 hours, for up to 3 days. Matchai-Delivery-Attempt tells you which attempt you are looking at.

If every delivery to a subscription fails for 3 days, Matchai turns the subscription off. It shows disabled_reason: failing, and the app's developer is told by email.

Turning it back on with PATCH /webhook-subscriptions/{webhook_subscription_id} and { "status": "enabled" } resumes deliveries from that moment. Events from the time it was off are not sent again, so catch up with GET /events.

When the account holder disconnects your app, its subscriptions stop and show disabled_reason: connection_ended. After the account holder connects your app again, list your subscriptions and create or re-enable the ones you need.

Order and duplicates

Events can arrive out of order and more than once. Write your handler so that this does not matter.

  • Ignore duplicates with id. Keep the ids you have handled for at least the 3 days that Matchai keeps retrying.
  • Decide what is current with created_at, or ask again. An older event for the same object can arrive after a newer one. Compare created_at with what you hold, or read the object again with a GET.
apply-event.ts
type StoredObject = { lastEventAt: number };

// Your own storage, keyed by the object's id.
const store = new Map<string, StoredObject>();

/** Returns true when the event is newer than what you hold, so you should apply it. */
export function isNewer(objectId: string, eventCreatedAt: string): boolean {
  const eventAt = Date.parse(eventCreatedAt);
  const current = store.get(objectId);
  if (current && current.lastEventAt >= eventAt) return false; // a late, older event
  store.set(objectId, { lastEventAt: eventAt });
  return true;
}

For anything that must be exactly right, treat the event as a signal and read the object from the API. data.object is also available at GET /events/{event_id}.

Catch up after downtime

GET /events keeps 30 days of events for the connection, newest first, in the same shape as the deliveries. Use it after an outage, after you turn a subscription back on, or on a schedule as a safety net. It needs webhooks:manage.

Field

Type

catch-up.ts
const api = 'https://api.matchai.co/v1';

export async function eventsSince(since: string, token: string) {
  const events: Array<{ id: string; type: string; created_at: string }> = [];
  let cursor: string | null = null;

  do {
    const url = new URL(`${api}/events`);
    url.searchParams.set('since', since);
    url.searchParams.set('limit', '100');
    if (cursor) url.searchParams.set('cursor', cursor);

    const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (!response.ok) throw new Error(`Matchai answered ${response.status}`);

    const page = await response.json();
    events.push(...page.data);
    cursor = page.next_cursor;
  } while (cursor);

  // The list is newest first. Replay oldest first, skipping ids you already handled.
  return events.reverse();
}

Test your endpoint

POST /webhook-subscriptions/{webhook_subscription_id}/test sends a webhook.test event to the endpoint right away and returns the delivery. The event has test_mode: true, and data.object.message is a fixed text. Return a 2xx to pass the test.

curl -X POST https://api.matchai.co/v1/webhook-subscriptions/8b9c0d1e-2f3a-4b4c-9d5e-6f7a8b9c0d1e/test \
  -H "Authorization: Bearer $MATCHAI_API_KEY" \
  -H "Idempotency-Key: 2c7d1a40-6e3b-4f8a-b9d2-0a1b2c3d4e5f"
{
  "id": "9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
  "event_id": "0d1e2f3a-4b5c-4d6e-9f7a-8b9c0d1e2f3a",
  "event_type": "webhook.test",
  "attempt": 1,
  "status": "succeeded",
  "response_status": 200,
  "error": null,
  "created_at": "2026-10-16T19:12:46Z",
  "next_attempt_at": null
}

Other ways to test:

  • Use test mode. An app that is connected only to its own developer's Matchai account is in test mode and is not billed. Its events carry test_mode: true, so you can build and check the whole flow with your own account first.
  • Reach your machine. Your endpoint must be https. While you develop, use a tunnel that gives your local server a public https address, and subscribe to that address.
  • Replay a real event. Read it with GET /events/{event_id}, or send a failed delivery again with POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry.

Rotate the signing secret

Rotate a secret on a schedule, or at once if it may have leaked. Rotation never drops an event, because the old secret keeps working for 24 hours.

Call POST /webhook-subscriptions/{webhook_subscription_id}/rotate-secret. The response is the subscription with a new secret, shown once.

For the next 24 hours, each delivery carries one v1 value per secret in Matchai-Signature.

Deploy the new secret. The verification code above accepts a delivery when any v1 value matches, so it works with the old secret before you deploy and with the new one after.

After 24 hours, only the new secret signs deliveries.

Delivery logs

GET /webhook-subscriptions/{webhook_subscription_id}/deliveries lists recent delivery attempts for one endpoint, newest first, kept for 30 days. Use status (pending, succeeded or failed) to narrow the list. It needs webhooks:manage.

{
  "data": [
    {
      "id": "9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
      "event_id": "0d1e2f3a-4b5c-4d6e-9f7a-8b9c0d1e2f3a",
      "event_type": "lead.created",
      "attempt": 2,
      "status": "failed",
      "response_status": 503,
      "error": null,
      "created_at": "2026-10-16T19:13:46Z",
      "next_attempt_at": "2026-10-16T19:18:46Z"
    }
  ],
  "next_cursor": null
}

Field

Type

To send a failed delivery again right now, call POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry. It returns a new delivery.

Final actions and approvals

When your connection is set to Ask me, a final action such as sending a message or publishing a listing returns 202 Accepted with an approval_id. Nothing happens until the account holder approves it in Matchai.

Matchai then sends approval.resolved to your app, whatever scopes it holds. data.object.id is the approval_id from the 202. status says whether the action was approved, declined or expired. When it was approved, result.status_code and result.body are what the call returned when Matchai performed it.

approvals.ts
type Approval = {
  id: string;
  status: 'pending' | 'approved' | 'declined' | 'expired' | 'cancelled';
  summary: string;
  result: { status_code: number; body: Record<string, unknown> } | null;
};

export function onApprovalResolved(approval: Approval): void {
  if (approval.status === 'approved') {
    console.log('Done:', approval.summary, approval.result?.status_code);
  } else {
    console.log('Not done:', approval.summary, approval.status);
  }
}

If you cannot receive webhooks, poll GET /approvals/{approval_id} instead. See getApproval.

Before you go live

  • Your endpoint is https and answers a 2xx within 10 seconds.
  • You verify Matchai-Signature on the raw body, compare in constant time, and reject a timestamp more than 5 minutes off.
  • You ignore duplicates by id and cope with events out of order.
  • You ignore event types and fields you do not know.
  • The signing secret is in a secret store, and you know how to rotate it.
  • A scheduled call to GET /events fills any gap.

On this page