---
title: "Webhooks"
description: "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."
last_updated: "2026-10-21"
lang: "en"
status: "coming-soon"
canonical: "https://matchai.co/docs/developers/webhooks"
---

# Webhooks

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

Source: https://matchai.co/docs/developers/webhooks

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 [#how-it-works]

<Steps>
<Step>

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

</Step>
<Step>

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

</Step>
<Step>

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

</Step>
<Step>

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

</Step>
</Steps>

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](https://matchai.co/docs/developers/authentication/connect-with-matchai) and [API keys](https://matchai.co/docs/developers/authentication/api-keys) for how a connection is made.

## Subscribe to events [#subscribe-to-events]

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

<TypeTable
  type={{
    url: { type: 'string', required: true, description: 'Your endpoint. https only.' },
    events: { type: 'string[]', required: true, description: 'The event types to receive, at least one, with no repeats. See the event catalog below.' },
    description: { type: 'string', description: 'Your note, up to 200 characters. Shown back to you in the subscription.' },
  }}
/>

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.

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

```json
{
  "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](#rotate-the-signing-secret).

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 [#manage-subscriptions]

All of these need `webhooks:manage`.

| To | Call | Reference |
|---|---|---|
| List your subscriptions | `GET /webhook-subscriptions` | [listWebhookSubscriptions](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/listWebhookSubscriptions) |
| Read one subscription (the secret is never returned again) | `GET /webhook-subscriptions/{webhook_subscription_id}` | [getWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/getWebhookSubscription) |
| Change the endpoint or the events, or turn it off or on | `PATCH /webhook-subscriptions/{webhook_subscription_id}` | [updateWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/updateWebhookSubscription) |
| Stop sending events to an endpoint | `DELETE /webhook-subscriptions/{webhook_subscription_id}` | [deleteWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/deleteWebhookSubscription) |
| Issue a new signing secret | `POST /webhook-subscriptions/{webhook_subscription_id}/rotate-secret` | [rotateWebhookSecret](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/rotateWebhookSecret) |
| Send a test event now | `POST /webhook-subscriptions/{webhook_subscription_id}/test` | [testWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/testWebhookSubscription) |
| See delivery attempts | `GET /webhook-subscriptions/{webhook_subscription_id}/deliveries` | [listWebhookDeliveries](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/listWebhookDeliveries) |
| Send a failed delivery again | `POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry` | [retryWebhookDelivery](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/retryWebhookDelivery) |
| Catch up on past events | `GET /events` and `GET /events/{event_id}` | [listEvents](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/listEvents), [getEvent](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/getEvent) |

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

A subscription has these fields:

<TypeTable
  type={{
    id: { type: 'string', description: 'The subscription id.' },
    url: { type: 'string', description: 'Your endpoint.' },
    description: { type: 'string | null', description: 'Your note.' },
    events: { type: 'string[]', description: 'The event types sent to this endpoint.' },
    status: { type: '"enabled" | "disabled"', description: 'Whether events are being sent.' },
    disabled_reason: { type: '"by_you" | "failing" | "connection_ended" | null', description: 'Why it is off. failing means every delivery failed for 3 days. connection_ended means the account holder disconnected your app. null while it is enabled.' },
    created_at: { type: 'string', description: 'When it was created, as an ISO 8601 timestamp.' },
    updated_at: { type: 'string', description: 'When it last changed.' },
  }}
/>

## Event catalog [#event-catalog]

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

<Callout title="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.
</Callout>

### Contacts and leads [#contacts-and-leads]

| Event | Sent when | `data.object` | Scope to receive |
|---|---|---|---|
| [`lead.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onLeadCreated) | 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`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactCreated) | Any new contact, whatever its type. A new lead also sends `lead.created`. | The contact | `contacts:read` |
| [`contact.updated`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactUpdated) | 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`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactConverted) | A lead became a client, or a client became a professional. `data.previous.type` holds the old type. | The contact | `contacts:read` |
| [`contact.deleted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactDeleted) | A contact was deleted. | A deleted object | `contacts:read` |

### Conversations and messages [#conversations-and-messages]

| Event | Sent when | `data.object` | Scope to receive |
|---|---|---|---|
| [`conversation.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onConversationCreated) | A new conversation the account is in. | The conversation | `messages:read` |
| [`message.received`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onMessageReceived) | Someone else wrote in a conversation the account is in. | The message | `messages:read` |
| [`message.sent`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onMessageSent) | The account sent a message, from the app, the Matchai Assistant or an app like yours. | The message | `messages:read` |

### Appointments [#appointments]

| Event | Sent when | `data.object` | Scope to receive |
|---|---|---|---|
| [`appointment.requested`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentRequested) | Someone asked the account holder for a time. It waits for their answer. | The appointment | `calendar:read` |
| [`appointment.booked`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentBooked) | An appointment is confirmed: booked directly, or a request was accepted. | The appointment | `calendar:read` |
| [`appointment.rescheduled`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentRescheduled) | An appointment has a new time, or a new time was proposed (`status: reschedule_pending`). | The appointment | `calendar:read` |
| [`appointment.cancelled`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentCancelled) | An appointment was cancelled or a request was declined. | The appointment | `calendar:read` |

### Listings [#listings]

| Event | Sent when | `data.object` | Scope to receive |
|---|---|---|---|
| [`listing.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingCreated) | A new draft listing, made by hand, by AI creation or by import. | The listing | `listings:read` |
| [`listing.updated`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingUpdated) | 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`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingPublished) | A listing went on the public web. | The listing | `listings:read` |
| [`listing.unpublished`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingUnpublished) | A listing went back to draft. | The listing | `listings:read` |
| [`listing.archived`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingArchived) | A listing was taken off the market. | The listing | `listings:read` |
| [`listing.deleted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingDeleted) | A listing was deleted. | A deleted object | `listings:read` |
| [`listing.saved`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingSaved) | 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`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingGenerationCompleted) | An AI listing creation run succeeded or failed. | The listing generation | `listings:read` |

### Documents, invitations and notifications [#documents-invitations-and-notifications]

| Event | Sent when | `data.object` | Scope to receive |
|---|---|---|---|
| [`document.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onDocumentCreated) | A document was added to Matchai Cloud, by upload, by email or through a drop link. | The document | `documents:read` |
| [`drop_link.upload_received`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onDropLinkUploadReceived) | Someone uploaded a file through one of the account's drop links. | The drop link and the document | `documents:read` |
| [`invitation.accepted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onInvitationAccepted) | Someone accepted an invitation the account sent. | The invitation | `teams:read` or `projects:read` |
| [`notification.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onNotificationCreated) | The account received a notification. | The notification | `notifications:read` |

### Approvals and testing [#approvals-and-testing]

| Event | Sent when | `data.object` | Scope to receive |
|---|---|---|---|
| [`approval.resolved`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onApprovalResolved) | 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`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onWebhookTest) | 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 [#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](#rotate-the-signing-secret) 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.

<TypeTable
  type={{
    id: { type: 'string', description: 'The event id. A delivery can repeat, so use this to ignore duplicates.' },
    type: { type: 'string', description: 'The event type, for example lead.created.' },
    created_at: { type: 'string', description: 'When it happened, as an ISO 8601 timestamp in UTC.' },
    api_version: { type: '"v1"', description: 'The API version the payload follows.' },
    test_mode: { type: 'boolean', description: 'true for a test connection and for events you send with the test call.' },
    account: { type: '{ user_id: string, professional_id: string | null }', description: 'The Matchai account the event happened in. Use user_id to tell your connections apart.' },
    'data.object': { type: 'object', description: 'The resource as it is after the change, in the same shape the API returns.' },
    'data.previous': { type: 'object', description: 'On updated and converted events only: the fields that changed, with their previous values.' },
  }}
/>

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

```json
{
  "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 [#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:

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

<Steps>
<Step>

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

</Step>
<Step>

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.

</Step>
<Step>

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

</Step>
<Step>

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

</Step>
</Steps>

<Callout title="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.
</Callout>

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

<Tabs items={['TypeScript', 'Python']}>
<Tab value="TypeScript">

```ts title="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);
  });
}
```

</Tab>
<Tab value="Python">

```python title="verify_signature.py"
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_matchai_signature(
    raw_body: bytes,
    header: str | None,
    secret: str,
    now: float | None = None,
) -> bool:
    """Return True when the timestamp is fresh and one v1 signature matches."""
    if not header:
        return False

    # Header: t=1792177965,v1=<hex>[,v1=<hex>]
    timestamp = ""
    signatures: list[str] = []
    for part in header.split(","):
        name, separator, value = part.strip().partition("=")
        if not separator:
            continue
        if name == "t":
            timestamp = value
        elif name == "v1":
            signatures.append(value)

    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if not signatures:
        return False

    # Reject a timestamp more than 5 minutes from your clock. This stops replays.
    current = time.time() if now is None else now
    if abs(current - sent_at) > TOLERANCE_SECONDS:
        return False

    # HMAC-SHA256 of "<t>.<raw body>" with the signing secret, as lowercase hex.
    signed_payload = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest().encode()

    # Compare with every v1 value in constant time. One match is enough.
    return any(hmac.compare_digest(candidate.encode(), expected) for candidate in signatures)
```

The Python version needs Python 3.10 or newer.

</Tab>
</Tabs>

### Check your code against a known signature [#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.

```text title="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.

<Tabs items={['TypeScript', 'Python']}>
<Tab value="TypeScript">

```ts title="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.');
```

</Tab>
<Tab value="Python">

```python title="test_verify_signature.py"
from verify_signature import verify_matchai_signature

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

assert verify_matchai_signature(body, header, secret, now=1792177965)
assert not verify_matchai_signature(body + b" ", header, secret, now=1792177965)  # body changed
assert not verify_matchai_signature(body, header, secret, now=1792177965 + 301)  # too old
print("The signature check works.")
```

</Tab>
</Tabs>

## Receive a delivery [#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.

<Tabs items={['TypeScript', 'TypeScript (Express)', 'Python (Flask)']}>
<Tab value="TypeScript">

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

```ts title="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;
  }
}
```

</Tab>
<Tab value="TypeScript (Express)">

Express needs the raw body. Register `express.raw` on this route, and keep `express.json()` away from it.

```ts title="server.ts"
import express from 'express';
import { verifyMatchaiSignature } from './verify-signature.js';

const app = express();
const secret = process.env.MATCHAI_WEBHOOK_SECRET ?? '';
const seenEventIds = new Set<string>(); // use a database in production

app.post('/hooks/matchai', express.raw({ type: '*/*', limit: '1mb' }), (req, res) => {
  const rawBody = req.body.toString('utf8');

  if (!verifyMatchaiSignature(rawBody, req.get('Matchai-Signature'), secret)) {
    res.status(400).send('Invalid signature');
    return;
  }

  const event = JSON.parse(rawBody);
  if (!seenEventIds.has(event.id)) {
    seenEventIds.add(event.id);
    setImmediate(() => console.log('Received', event.type, event.id)); // queue real work here
  }

  res.sendStatus(200);
});

app.listen(3000);
```

</Tab>
<Tab value="Python (Flask)">

```python title="app.py"
import json
import os

from flask import Flask, request

from verify_signature import verify_matchai_signature

app = Flask(__name__)
secret = os.environ["MATCHAI_WEBHOOK_SECRET"]
seen_event_ids: set[str] = set()  # use a database with a unique key in production


def process_event_later(event: dict) -> None:
    # Put the event on a queue and return. You have 10 seconds to answer.
    print("Received", event["type"], event["id"])


@app.post("/hooks/matchai")
def matchai_webhook():
    raw_body = request.get_data()  # the bytes exactly as received

    if not verify_matchai_signature(raw_body, request.headers.get("Matchai-Signature"), secret):
        return "Invalid signature", 400

    event = json.loads(raw_body)

    # A delivery can arrive more than once. Acknowledge duplicates and do nothing.
    if event["id"] not in seen_event_ids:
        seen_event_ids.add(event["id"])
        process_event_later(event)

    return "", 200
```

</Tab>
</Tabs>

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 [#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](#catch-up-after-downtime) 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 [#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`.

```ts title="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 [#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`.

<TypeTable
  type={{
    type: { type: 'string', description: 'Only events of this type.' },
    since: { type: 'string', description: 'Only events after this time, as an ISO 8601 timestamp.' },
    limit: { type: 'integer', default: '25', description: 'Events per page, 1 to 100.' },
    cursor: { type: 'string', description: 'The next_cursor from the previous page.' },
  }}
/>

```ts title="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 [#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.

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

```json
{
  "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-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.

<Steps>
<Step>

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

</Step>
<Step>

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

</Step>
<Step>

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.

</Step>
<Step>

After 24 hours, only the new secret signs deliveries.

</Step>
</Steps>

## Delivery logs [#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`.

```json
{
  "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
}
```

<TypeTable
  type={{
    id: { type: 'string', description: 'The delivery id.' },
    event_id: { type: 'string', description: 'The event that was sent.' },
    event_type: { type: 'string', description: 'Its type.' },
    attempt: { type: 'integer', description: 'The attempt number, from 1.' },
    status: { type: '"pending" | "succeeded" | "failed"', description: 'The result of this attempt.' },
    response_status: { type: 'integer | null', description: 'The HTTP status your endpoint returned.' },
    error: { type: 'string | null', description: 'Why it failed when there was no answer, for example "timeout after 10 s".' },
    created_at: { type: 'string', description: 'When this attempt was made.' },
    next_attempt_at: { type: 'string | null', description: 'When the next retry is due.' },
  }}
/>

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 [#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.

```ts title="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](https://matchai.co/docs/developers/api-reference/approvals/getApproval).

## Before you go live [#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.

<Cards>
  <Card title="Website builders" href="https://matchai.co/docs/developers/guides/website-builders">
    Use webhooks with a listings feed, lead intake and viewing requests.
  </Card>
  <Card title="Connect with Matchai" href="https://matchai.co/docs/developers/authentication/connect-with-matchai">
    How an app gets a connection to a professional's account.
  </Card>
  <Card title="Errors" href="https://matchai.co/docs/developers/errors">
    Error codes and how to handle them.
  </Card>
  <Card title="Rate limits" href="https://matchai.co/docs/developers/rate-limits">
    Limits per connection, and what to do on a 429.
  </Card>
</Cards>

## Sitemap

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