---
title: "Webhooks"
description: "Recevez des événements signés quand un prospect arrive, qu'une visite est réservée ou qu'une inscription change. Catalogue, signature, tests et journaux."
last_updated: "2026-10-21"
lang: "fr-CA"
status: "coming-soon"
canonical: "https://matchai.co/docs/fr/developers/webhooks"
---

# Webhooks

> Recevez des événements signés quand un prospect arrive, qu'une visite est réservée ou qu'une inscription change. Catalogue, signature, tests et journaux.

**Bientôt disponible. Cette fonctionnalité est documentée avant son lancement pour que vous puissiez planifier votre intégration; elle n’est pas encore offerte.**

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

Un abonnement webhook indique à Matchai où envoyer les événements. Quand quelque chose se produit dans un compte Matchai connecté, comme un nouveau prospect, une visite réservée ou une inscription publiée, Matchai envoie une requête `POST` signée à votre point de terminaison, avec une description JSON de ce qui s'est passé.

Les webhooks remplacent l'interrogation périodique. Votre application apprend qu'un changement a eu lieu au moment où il se produit, au lieu de redemander sans cesse, et la signature prouve que le message vient de Matchai.

## Fonctionnement [#how-it-works]

<Steps>
<Step>

**S'abonner.** Votre application appelle `POST /webhook-subscriptions` avec l'adresse `https` de votre point de terminaison et les événements voulus. La réponse contient le secret de signature, affiché une seule fois.

</Step>
<Step>

**Recevoir.** Chaque fois qu'un événement correspondant se produit, Matchai envoie un `POST` à votre point de terminaison. Le corps est un `Event` en JSON. Trois en-têtes l'accompagnent : `Matchai-Signature`, `Matchai-Event-Id` et `Matchai-Delivery-Attempt`.

</Step>
<Step>

**Vérifier et répondre.** Votre point de terminaison vérifie la signature, répond avec n'importe quel statut `2xx` en moins de 10 secondes et fait le vrai travail ensuite.

</Step>
<Step>

**Réessayer.** Une livraison qui n'obtient pas de `2xx` est renvoyée à des intervalles de plus en plus longs, pendant au plus 3 jours.

</Step>
</Steps>

Un abonnement appartient à une seule connexion, c'est-à-dire à un compte Matchai et à une application. Une application qui sert trois professionnels détient trois connexions et crée des abonnements pour chacune. Consultez [Se connecter avec Matchai](https://matchai.co/docs/fr/developers/authentication/connect-with-matchai) et [Clés d'API](https://matchai.co/docs/fr/developers/authentication/api-keys) pour savoir comment une connexion est établie.

## S'abonner à des événements [#subscribe-to-events]

La création d'un abonnement exige la portée `webhooks:manage`. Les webhooks sont inclus avec la connexion, et ils n'utilisent aucune énergie.

<TypeTable
  type={{
    url: { type: 'string', required: true, description: 'Votre point de terminaison. https seulement.' },
    events: { type: 'string[]', required: true, description: "Les types d'événements à recevoir, au moins un, sans doublon. Consultez le catalogue des événements plus bas." },
    description: { type: 'string', description: "Votre note, jusqu'à 200 caractères. Elle vous est renvoyée dans l'abonnement." },
  }}
/>

Envoyez un en-tête `Idempotency-Key` avec la requête. Une nouvelle tentative avec la même clé dans les 24 heures renvoie la première réponse au lieu de créer un deuxième abonnement.

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

La réponse est `201 Created`. Le `secret` figure dans cette réponse une seule fois, et plus jamais ensuite : conservez-le tout de suite.

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

Le secret de signature commence par `whsec_`. Gardez-le dans votre coffre de secrets, jamais dans le code ni dans les journaux. Si vous le perdez, [renouvelez-le](#rotate-the-signing-secret).

Vous ne recevez que des événements sur les données que vos portées vous permettent de lire. La dernière colonne du catalogue indique la portée qu'exige chaque événement : demandez ces portées quand le titulaire du compte connecte votre application.

### Gérer les abonnements [#manage-subscriptions]

Toutes ces opérations exigent `webhooks:manage`.

| Pour | Appel | Référence |
|---|---|---|
| Lister vos abonnements | `GET /webhook-subscriptions` | [listWebhookSubscriptions](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/listWebhookSubscriptions) |
| Lire un abonnement (le secret n'est plus jamais renvoyé) | `GET /webhook-subscriptions/{webhook_subscription_id}` | [getWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/getWebhookSubscription) |
| Changer le point de terminaison ou les événements, ou désactiver ou réactiver l'abonnement | `PATCH /webhook-subscriptions/{webhook_subscription_id}` | [updateWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/updateWebhookSubscription) |
| Cesser d'envoyer des événements à un point de terminaison | `DELETE /webhook-subscriptions/{webhook_subscription_id}` | [deleteWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/deleteWebhookSubscription) |
| Émettre un nouveau secret de signature | `POST /webhook-subscriptions/{webhook_subscription_id}/rotate-secret` | [rotateWebhookSecret](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/rotateWebhookSecret) |
| Envoyer un événement de test maintenant | `POST /webhook-subscriptions/{webhook_subscription_id}/test` | [testWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/testWebhookSubscription) |
| Voir les tentatives de livraison | `GET /webhook-subscriptions/{webhook_subscription_id}/deliveries` | [listWebhookDeliveries](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/listWebhookDeliveries) |
| Renvoyer une livraison échouée | `POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry` | [retryWebhookDelivery](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/retryWebhookDelivery) |
| Rattraper les événements passés | `GET /events` et `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) |

Supprimer un abonnement arrête les événements pour ce point de terminaison. Rien n'est supprimé dans le compte Matchai.

Un abonnement comporte ces champs :

<TypeTable
  type={{
    id: { type: 'string', description: "L'identifiant de l'abonnement." },
    url: { type: 'string', description: 'Votre point de terminaison.' },
    description: { type: 'string | null', description: 'Votre note.' },
    events: { type: 'string[]', description: "Les types d'événements envoyés à ce point de terminaison." },
    status: { type: '"enabled" | "disabled"', description: 'Indique si les événements sont envoyés.' },
    disabled_reason: { type: '"by_you" | "failing" | "connection_ended" | null', description: "La raison de la désactivation. failing signifie que toutes les livraisons ont échoué pendant 3 jours. connection_ended signifie que le titulaire du compte a déconnecté votre application. null tant que l'abonnement est actif." },
    created_at: { type: 'string', description: "Le moment de sa création, sous forme d'horodatage ISO 8601." },
    updated_at: { type: 'string', description: 'Le moment de son dernier changement.' },
  }}
/>

## Catalogue des événements [#event-catalog]

Voici les types d'événements, regroupés par sujet. Chaque nom mène à sa page de référence, qui montre la charge utile.

<Callout title="De nouveaux types d'événements peuvent apparaître">
Matchai peut ajouter des types d'événements, et de nouveaux champs aux événements existants, sans nouvelle version de l'API. Ignorez tout type d'événement que vous ne connaissez pas et tout champ que vous n'utilisez pas.
</Callout>

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

| Événement | Envoyé quand | `data.object` | Portée requise |
|---|---|---|---|
| [`lead.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onLeadCreated) | Un nouveau prospect est arrivé dans le compte : une demande de renseignements à partir d'une inscription, du site Matchai, de la Page Matchai ou de votre site Web, une demande de visite, ou une personne qui a ajouté une inscription à ses favoris. | Le contact | `contacts:read` |
| [`contact.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactCreated) | Tout nouveau contact, quel que soit son type. Un nouveau prospect envoie aussi `lead.created`. | Le contact | `contacts:read` |
| [`contact.updated`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactUpdated) | Les détails d'un contact ou ses détails de prospect ont changé. `data.previous` contient les anciennes valeurs des champs qui ont changé. | Le contact | `contacts:read` |
| [`contact.converted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactConverted) | Un prospect est devenu client, ou un client est devenu professionnel. `data.previous.type` contient l'ancien type. | Le contact | `contacts:read` |
| [`contact.deleted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactDeleted) | Un contact a été supprimé. | Un objet supprimé | `contacts:read` |

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

| Événement | Envoyé quand | `data.object` | Portée requise |
|---|---|---|---|
| [`conversation.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onConversationCreated) | Une nouvelle conversation dont le compte fait partie. | La conversation | `messages:read` |
| [`message.received`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onMessageReceived) | Quelqu'un d'autre a écrit dans une conversation dont le compte fait partie. | Le message | `messages:read` |
| [`message.sent`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onMessageSent) | Le compte a envoyé un message, depuis l'application, l'Assistant Matchai ou une application comme la vôtre. | Le message | `messages:read` |

### Rendez-vous [#appointments]

| Événement | Envoyé quand | `data.object` | Portée requise |
|---|---|---|---|
| [`appointment.requested`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentRequested) | Quelqu'un a demandé un moment au titulaire du compte. La demande attend sa réponse. | Le rendez-vous | `calendar:read` |
| [`appointment.booked`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentBooked) | Un rendez-vous est confirmé : il a été réservé directement, ou une demande a été acceptée. | Le rendez-vous | `calendar:read` |
| [`appointment.rescheduled`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentRescheduled) | Un rendez-vous a une nouvelle heure, ou une nouvelle heure a été proposée (`status: reschedule_pending`). | Le rendez-vous | `calendar:read` |
| [`appointment.cancelled`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentCancelled) | Un rendez-vous a été annulé, ou une demande a été refusée. | Le rendez-vous | `calendar:read` |

### Inscriptions [#listings]

| Événement | Envoyé quand | `data.object` | Portée requise |
|---|---|---|---|
| [`listing.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingCreated) | Une nouvelle inscription en brouillon, créée à la main, par la création par IA ou par importation. | L'inscription | `listings:read` |
| [`listing.updated`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingUpdated) | Les champs, les photos ou les pièces d'une inscription ont changé. `data.previous` contient les anciennes valeurs des champs de premier niveau qui ont changé. | L'inscription | `listings:read` |
| [`listing.published`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingPublished) | Une inscription a été mise en ligne sur le Web public. | L'inscription | `listings:read` |
| [`listing.unpublished`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingUnpublished) | Une inscription est redevenue un brouillon. | L'inscription | `listings:read` |
| [`listing.archived`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingArchived) | Une inscription a été retirée du marché. | L'inscription | `listings:read` |
| [`listing.deleted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingDeleted) | Une inscription a été supprimée. | Un objet supprimé | `listings:read` |
| [`listing.saved`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingSaved) | Quelqu'un a ajouté l'une des inscriptions du compte à ses favoris. `data.object.contact` est votre fiche de contact pour cette personne. | L'inscription, le contact et `saved_at` | `listings:read` et `contacts:read` |
| [`listing.generation.completed`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingGenerationCompleted) | Une création d'inscription par IA a réussi ou échoué. | La génération d'inscription | `listings:read` |

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

| Événement | Envoyé quand | `data.object` | Portée requise |
|---|---|---|---|
| [`document.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onDocumentCreated) | Un document a été ajouté à Matchai Cloud, par téléversement, par courriel ou au moyen d'un lien de dépôt. | Le document | `documents:read` |
| [`drop_link.upload_received`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onDropLinkUploadReceived) | Quelqu'un a téléversé un fichier au moyen de l'un des liens de dépôt du compte. | Le lien de dépôt et le document | `documents:read` |
| [`invitation.accepted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onInvitationAccepted) | Quelqu'un a accepté une invitation envoyée par le compte. | L'invitation | `teams:read` ou `projects:read` |
| [`notification.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onNotificationCreated) | Le compte a reçu une notification. | La notification | `notifications:read` |

### Approbations et tests [#approvals-and-testing]

| Événement | Envoyé quand | `data.object` | Portée requise |
|---|---|---|---|
| [`approval.resolved`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onApprovalResolved) | Une action finale demandée par votre application a été approuvée (et effectuée), refusée ou a expiré. `data.object.result` contient le résultat. | L'approbation | Aucune. L'événement va à l'application qui a fait la demande. |
| [`webhook.test`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onWebhookTest) | Vous l'avez envoyé avec `POST /webhook-subscriptions/{webhook_subscription_id}/test`. | Un message fixe | `webhooks:manage` |

Un objet supprimé a la forme `{ "id": "…", "object": "listing", "deleted": true }`, où `object` vaut `contact` ou `listing`.

## À quoi ressemble une livraison [#what-a-delivery-looks-like]

Chaque livraison est un `POST` avec un corps JSON et ces en-têtes.

| En-tête | Signification |
|---|---|
| `Matchai-Signature` | La signature à vérifier. Elle ressemble à `t=1792177965,v1=5257a869…`. Pendant un [renouvellement du secret](#rotate-the-signing-secret), elle porte deux valeurs `v1`. |
| `Matchai-Event-Id` | La même valeur que l'`id` du corps. Servez-vous-en pour ignorer les doublons. |
| `Matchai-Delivery-Attempt` | Le numéro de la tentative, à partir de 1. |

Le corps est le même objet `Event` pour tous les types. `GET /events` renvoie les mêmes objets.

<TypeTable
  type={{
    id: { type: 'string', description: "L'identifiant de l'événement. Une livraison peut se répéter : servez-vous-en pour ignorer les doublons." },
    type: { type: 'string', description: "Le type d'événement, par exemple appointment.booked." },
    created_at: { type: 'string', description: "Le moment où il s'est produit, sous forme d'horodatage ISO 8601 en UTC." },
    api_version: { type: '"v1"', description: "La version de l'API que suit la charge utile." },
    test_mode: { type: 'boolean', description: "true pour une connexion de test et pour les événements que vous envoyez avec l'appel de test." },
    account: { type: '{ user_id: string, professional_id: string | null }', description: "Le compte Matchai dans lequel l'événement s'est produit. Servez-vous de user_id pour distinguer vos connexions." },
    'data.object': { type: 'object', description: "La ressource telle qu'elle est après le changement, sous la même forme que celle que renvoie l'API." },
    'data.previous': { type: 'object', description: 'Pour les événements de mise à jour et de conversion seulement : les champs qui ont changé, avec leurs valeurs précédentes.' },
  }}
/>

Une livraison `lead.created`, abrégée aux champs qu'utilisent la plupart des applications :

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

Les noms de champs sont en `snake_case`, les identifiants sont des chaînes opaques, et un événement ne contient que les données que vos portées vous permettent de lire.

## Vérifier la signature [#verify-the-signature]

N'importe qui peut envoyer une requête à votre point de terminaison. Vérifiez `Matchai-Signature` à chaque livraison avant de vous fier au corps.

L'en-tête ressemble à ceci :

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

<Steps>
<Step>

Prenez `t` (en secondes Unix) et le **corps brut de la requête**, exactement tel que vous l'avez reçu, avant toute analyse JSON.

</Step>
<Step>

Calculez le HMAC-SHA256 de `<t>.<raw body>` avec le secret de signature de votre abonnement (`whsec_…`), en hexadécimal minuscule. Le texte signé est l'horodatage, un point, puis le corps.

</Step>
<Step>

Comparez le résultat à chaque valeur `v1` en temps constant. Acceptez la livraison si l'une d'elles correspond.

</Step>
<Step>

Refusez la livraison si `t` s'écarte de plus de 5 minutes de votre horloge. Cela empêche quelqu'un de rejouer une ancienne requête.

</Step>
</Steps>

<Callout title="Utilisez le corps brut">
Signez et comparez les octets que Matchai a envoyés. Un corps analysé puis réécrit peut différer par ses espaces ou par l'ordre de ses clés, et alors aucune signature ne correspondra. Dans la plupart des cadriciels Web, cela veut dire lire le corps comme du texte ou des octets avant l'exécution de tout intergiciel (middleware) JSON.
</Callout>

Utilisez le secret exactement comme vous l'avez reçu, y compris le préfixe `whsec_`.

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

La version Python exige Python 3.10 ou une version plus récente.

</Tab>
</Tabs>

### Vérifier votre code avec une signature connue [#check-your-code-against-a-known-signature]

Cet exemple est fictif. Servez-vous-en pour vérifier que votre code calcule la signature de la même façon que Matchai, avant de le brancher sur un véritable abonnement.

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

L'exemple est ancien : passez donc son propre horodatage comme heure actuelle. Ainsi, la vérification de la fenêtre de 5 minutes ne sera pas la cause d'un échec.

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

## Recevoir une livraison [#receive-a-delivery]

Répondez avec n'importe quel statut `2xx` en moins de 10 secondes. Toute autre réponse, ou une absence de réponse à temps, compte comme une livraison échouée et entraîne une nouvelle tentative. Alors vérifiez, enregistrez l'événement ou mettez-le en file d'attente, répondez, et faites le travail long ensuite.

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

Un gestionnaire pour tout environnement d'exécution qui utilise les objets standards `Request` et `Response`.

```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 a besoin du corps brut. Enregistrez `express.raw` sur cette route, et tenez `express.json()` à l'écart de celle-ci.

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

Si une même application sert plusieurs comptes, donnez à chaque abonnement sa propre adresse, par exemple `/hooks/matchai/{your broker id}`. Chaque abonnement a son propre secret, et l'adresse vous indique avec quel secret vérifier avant de lire le corps.

## Nouvelles tentatives et échecs [#retries-and-failures]

Une livraison qui n'obtient pas de `2xx` est retentée à des intervalles de plus en plus longs : environ 1 minute, 5 minutes, 30 minutes, 2 heures et 6 heures, puis toutes les 12 heures, pendant au plus 3 jours. `Matchai-Delivery-Attempt` vous indique de quelle tentative il s'agit.

Si toutes les livraisons à un abonnement échouent pendant 3 jours, Matchai désactive l'abonnement. Celui-ci affiche alors `disabled_reason: failing`, et le développeur de l'application en est avisé par courriel.

Le réactiver avec `PATCH /webhook-subscriptions/{webhook_subscription_id}` et `{ "status": "enabled" }` reprend les livraisons à partir de ce moment. Les événements survenus pendant la désactivation ne sont pas renvoyés : [rattrapez-les](#catch-up-after-downtime) avec `GET /events`.

Quand le titulaire du compte déconnecte votre application, ses abonnements s'arrêtent et affichent `disabled_reason: connection_ended`. Une fois que le titulaire du compte a connecté votre application de nouveau, listez vos abonnements, puis créez ou réactivez ceux dont vous avez besoin.

## Ordre et doublons [#order-and-duplicates]

Les événements peuvent arriver dans le désordre et plus d'une fois. Écrivez votre gestionnaire de façon que cela n'ait pas d'importance.

- **Ignorez les doublons grâce à `id`.** Conservez les identifiants que vous avez traités pendant au moins les 3 jours au cours desquels Matchai continue de réessayer.
- **Déterminez ce qui est à jour avec `created_at`, ou redemandez.** Un événement plus ancien sur le même objet peut arriver après un plus récent. Comparez `created_at` avec ce que vous avez, ou relisez l'objet avec un `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;
}
```

Pour tout ce qui doit être parfaitement exact, traitez l'événement comme un signal et lisez l'objet dans l'API. `data.object` est aussi offert à `GET /events/{event_id}`.

## Rattraper le retard après une interruption [#catch-up-after-downtime]

`GET /events` conserve 30 jours d'événements pour la connexion, les plus récents en premier, sous la même forme que les livraisons. Utilisez-le après une panne, après avoir réactivé un abonnement, ou à intervalles réguliers comme filet de sécurité. Il exige `webhooks:manage`.

<TypeTable
  type={{
    type: { type: 'string', description: 'Seulement les événements de ce type.' },
    since: { type: 'string', description: "Seulement les événements postérieurs à ce moment, sous forme d'horodatage ISO 8601." },
    limit: { type: 'integer', default: '25', description: "Le nombre d'événements par page, de 1 à 100." },
    cursor: { type: 'string', description: 'Le next_cursor de la page précédente.' },
  }}
/>

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

## Tester votre point de terminaison [#test-your-endpoint]

`POST /webhook-subscriptions/{webhook_subscription_id}/test` envoie immédiatement un événement `webhook.test` au point de terminaison et renvoie la livraison. L'événement a `test_mode: true`, et `data.object.message` est un texte fixe. Renvoyez un `2xx` pour réussir le 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
}
```

D'autres façons de tester :

- **Utilisez le mode test.** Une application connectée seulement au compte Matchai de son propre développeur est en mode test et n'est pas facturée. Ses événements portent `test_mode: true`, alors vous pouvez d'abord construire et vérifier tout le parcours avec votre propre compte.
- **Rendez votre machine joignable.** Votre point de terminaison doit être en `https`. Pendant le développement, utilisez un tunnel qui donne à votre serveur local une adresse `https` publique, et abonnez cette adresse.
- **Rejouez un véritable événement.** Lisez-le avec `GET /events/{event_id}`, ou renvoyez une livraison échouée avec `POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry`.

## Renouveler le secret de signature [#rotate-the-signing-secret]

Renouvelez un secret à intervalles réguliers, ou sur-le-champ s'il a pu être divulgué. Le renouvellement ne fait jamais perdre d'événement, car l'ancien secret continue de fonctionner pendant 24 heures.

<Steps>
<Step>

Appelez `POST /webhook-subscriptions/{webhook_subscription_id}/rotate-secret`. La réponse est l'abonnement avec un nouveau `secret`, affiché une seule fois.

</Step>
<Step>

Pendant les 24 heures suivantes, chaque livraison porte une valeur `v1` par secret dans `Matchai-Signature`.

</Step>
<Step>

Déployez le nouveau secret. Le code de vérification ci-dessus accepte une livraison dès qu'une valeur `v1` correspond : il fonctionne donc avec l'ancien secret avant le déploiement et avec le nouveau après.

</Step>
<Step>

Après 24 heures, seul le nouveau secret signe les livraisons.

</Step>
</Steps>

## Journaux de livraison [#delivery-logs]

`GET /webhook-subscriptions/{webhook_subscription_id}/deliveries` liste les tentatives de livraison récentes pour un point de terminaison, les plus récentes en premier. Elles sont conservées pendant 30 jours. Utilisez `status` (`pending`, `succeeded` ou `failed`) pour restreindre la liste. Il exige `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: "L'identifiant de la livraison." },
    event_id: { type: 'string', description: "L'événement qui a été envoyé." },
    event_type: { type: 'string', description: 'Son type.' },
    attempt: { type: 'integer', description: 'Le numéro de la tentative, à partir de 1.' },
    status: { type: '"pending" | "succeeded" | "failed"', description: 'Le résultat de cette tentative.' },
    response_status: { type: 'integer | null', description: 'Le statut HTTP renvoyé par votre point de terminaison.' },
    error: { type: 'string | null', description: "La raison de l'échec quand il n'y a eu aucune réponse, par exemple « timeout after 10 s »." },
    created_at: { type: 'string', description: 'Le moment de cette tentative.' },
    next_attempt_at: { type: 'string | null', description: 'Le moment prévu de la prochaine tentative.' },
  }}
/>

Pour renvoyer immédiatement une livraison échouée, appelez `POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry`. L'appel renvoie une nouvelle livraison.

## Actions finales et approbations [#final-actions-and-approvals]

Quand votre connexion est réglée sur **Me demander**, une action finale, comme envoyer un message ou publier une inscription, renvoie `202 Accepted` avec un `approval_id`. Rien ne se passe tant que le titulaire du compte ne l'a pas approuvée dans Matchai.

Matchai envoie ensuite `approval.resolved` à votre application, quelles que soient ses portées. `data.object.id` est l'`approval_id` du `202`. `status` indique si l'action a été approuvée, si elle a été refusée ou si elle a expiré. Si elle a été approuvée, `result.status_code` et `result.body` sont ce que l'appel a renvoyé quand Matchai l'a effectué.

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

Si vous ne pouvez pas recevoir de webhooks, interrogez plutôt `GET /approvals/{approval_id}` à intervalles réguliers. Consultez [getApproval](https://matchai.co/docs/developers/api-reference/approvals/getApproval).

## Avant la mise en production [#before-you-go-live]

- Votre point de terminaison est en `https` et répond un `2xx` en moins de 10 secondes.
- Vous vérifiez `Matchai-Signature` sur le corps brut, vous comparez en temps constant et vous refusez un horodatage décalé de plus de 5 minutes.
- Vous ignorez les doublons grâce à `id`, et vous gérez les événements qui arrivent dans le désordre.
- Vous ignorez les types d'événements et les champs que vous ne connaissez pas.
- Le secret de signature est dans un coffre de secrets, et vous savez comment le renouveler.
- Un appel planifié à `GET /events` comble les éventuelles lacunes.

<Cards>
  <Card title="Créateurs de sites Web" href="https://matchai.co/docs/fr/developers/guides/website-builders">
    Utilisez les webhooks avec un flux d'inscriptions, la réception des prospects et les demandes de visite.
  </Card>
  <Card title="Se connecter avec Matchai" href="https://matchai.co/docs/fr/developers/authentication/connect-with-matchai">
    Comment une application obtient une connexion au compte d'un professionnel.
  </Card>
  <Card title="Erreurs" href="https://matchai.co/docs/fr/developers/errors">
    Les codes d'erreur et la façon de les traiter.
  </Card>
  <Card title="Limites de débit" href="https://matchai.co/docs/fr/developers/rate-limits">
    Les limites par connexion, et que faire en cas de 429.
  </Card>
</Cards>

## Sitemap

- [Toutes les pages de la documentation Matchai](https://matchai.co/docs/sitemap.md)
- [llms.txt](https://matchai.co/docs/fr/llms.txt)
