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.
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.
| To | Call | Reference |
|---|---|---|
| List your subscriptions | GET /webhook-subscriptions | listWebhookSubscriptions |
| 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 on | PATCH /webhook-subscriptions/{webhook_subscription_id} | updateWebhookSubscription |
| Stop sending events to an endpoint | DELETE /webhook-subscriptions/{webhook_subscription_id} | deleteWebhookSubscription |
| Issue a new signing secret | POST /webhook-subscriptions/{webhook_subscription_id}/rotate-secret | rotateWebhookSecret |
| Send a test event now | POST /webhook-subscriptions/{webhook_subscription_id}/test | testWebhookSubscription |
| See delivery attempts | GET /webhook-subscriptions/{webhook_subscription_id}/deliveries | listWebhookDeliveries |
| Send a failed delivery again | POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry | retryWebhookDelivery |
| Catch up on past events | GET /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
| Event | Sent when | data.object | Scope to receive |
|---|---|---|---|
lead.created | A 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 contact | contacts:read |
contact.created | Any new contact, whatever its type. A new lead also sends lead.created. | The contact | contacts:read |
contact.updated | A contact's details or lead details changed. data.previous holds the old values of the fields that changed. | The contact | contacts:read |
contact.converted | A lead became a client, or a client became a professional. data.previous.type holds the old type. | The contact | contacts:read |
contact.deleted | A contact was deleted. | A deleted object | contacts:read |
Conversations and messages
| Event | Sent when | data.object | Scope to receive |
|---|---|---|---|
conversation.created | A new conversation the account is in. | The conversation | messages:read |
message.received | Someone else wrote in a conversation the account is in. | The message | messages:read |
message.sent | The account sent a message, from the app, the Matchai Assistant or an app like yours. | The message | messages:read |
Appointments
| Event | Sent when | data.object | Scope to receive |
|---|---|---|---|
appointment.requested | Someone asked the account holder for a time. It waits for their answer. | The appointment | calendar:read |
appointment.booked | An appointment is confirmed: booked directly, or a request was accepted. | The appointment | calendar:read |
appointment.rescheduled | An appointment has a new time, or a new time was proposed (status: reschedule_pending). | The appointment | calendar:read |
appointment.cancelled | An appointment was cancelled or a request was declined. | The appointment | calendar:read |
Listings
| Event | Sent when | data.object | Scope to receive |
|---|---|---|---|
listing.created | A new draft listing, made by hand, by AI creation or by import. | The listing | listings:read |
listing.updated | A listing's fields, photos or rooms changed. data.previous holds the old values of the top-level fields that changed. | The listing | listings:read |
listing.published | A listing went on the public web. | The listing | listings:read |
listing.unpublished | A listing went back to draft. | The listing | listings:read |
listing.archived | A listing was taken off the market. | The listing | listings:read |
listing.deleted | A listing was deleted. | A deleted object | listings:read |
listing.saved | Someone saved one of the account's listings. data.object.contact is your contact record for them. | The listing, the contact and saved_at | listings:read and contacts:read |
listing.generation.completed | An AI listing creation run succeeded or failed. | The listing generation | listings:read |
Documents, invitations and notifications
| Event | Sent when | data.object | Scope to receive |
|---|---|---|---|
document.created | A document was added to Matchai Cloud, by upload, by email or through a drop link. | The document | documents:read |
drop_link.upload_received | Someone uploaded a file through one of the account's drop links. | The drop link and the document | documents:read |
invitation.accepted | Someone accepted an invitation the account sent. | The invitation | teams:read or projects:read |
notification.created | The account received a notification. | The notification | notifications:read |
Approvals and testing
| Event | Sent when | data.object | Scope to receive |
|---|---|---|---|
approval.resolved | A final action your app asked for was approved (and performed), declined, or expired. data.object.result holds the outcome. | The approval | None. It goes to the app that asked. |
webhook.test | You sent it with POST /webhook-subscriptions/{webhook_subscription_id}/test. | A fixed message | webhooks: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.
| Header | Meaning |
|---|---|
Matchai-Signature | The signature to verify. It looks like t=1792177965,v1=5257a869…. During a secret rotation it carries two v1 values. |
Matchai-Event-Id | The same value as the body's id. Use it to ignore duplicates. |
Matchai-Delivery-Attempt | The 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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdTake 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.
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.
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.
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.
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. Comparecreated_atwith what you hold, or read the object again with aGET.
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
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 publichttpsaddress, and subscribe to that address. - Replay a real event. Read it with
GET /events/{event_id}, or send a failed delivery again withPOST /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.
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
httpsand answers a2xxwithin 10 seconds. - You verify
Matchai-Signatureon the raw body, compare in constant time, and reject a timestamp more than 5 minutes off. - You ignore duplicates by
idand 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 /eventsfills any gap.