---
title: "Webhooks"
description: "Recibe eventos firmados cuando llega un prospecto, se reserva una visita o cambia un anuncio. Catálogo, contenido, firma, reintentos, pruebas y registros."
last_updated: "2026-10-21"
lang: "es"
status: "coming-soon"
canonical: "https://matchai.co/docs/es/developers/webhooks"
---

# Webhooks

> Recibe eventos firmados cuando llega un prospecto, se reserva una visita o cambia un anuncio. Catálogo, contenido, firma, reintentos, pruebas y registros.

**Próximamente. Esto se documenta antes del lanzamiento para que puedas planificar tu integración; todavía no está disponible.**

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

Una suscripción de webhook le indica a Matchai adónde enviar los eventos. Cuando ocurre algo en una cuenta de Matchai conectada, como un nuevo prospecto, una visita reservada o un anuncio publicado, Matchai envía una solicitud `POST` firmada a tu endpoint con una descripción en JSON de lo que ocurrió.

Los webhooks reemplazan las consultas periódicas. Tu app se entera de un cambio en el momento en que ocurre, en lugar de preguntar una y otra vez, y la firma demuestra que el mensaje proviene de Matchai.

## Cómo funciona [#how-it-works]

<Steps>
<Step>

**Suscríbete.** Tu app llama a `POST /webhook-subscriptions` con la dirección `https` de tu endpoint y los eventos que quieres. La respuesta contiene el secreto de firma, que se muestra una sola vez.

</Step>
<Step>

**Recibe.** Cada vez que ocurre un evento que coincide, Matchai envía un `POST` a tu endpoint. El cuerpo es un `Event` en JSON. Lo acompañan tres encabezados: `Matchai-Signature`, `Matchai-Event-Id` y `Matchai-Delivery-Attempt`.

</Step>
<Step>

**Verifica y responde.** Tu endpoint comprueba la firma, responde con cualquier estado `2xx` en menos de 10 segundos y hace el trabajo real después.

</Step>
<Step>

**Reintentos.** Una entrega que no recibe un `2xx` se vuelve a enviar con esperas cada vez más largas, durante un máximo de 3 días.

</Step>
</Steps>

Una suscripción pertenece a una conexión, es decir, a una cuenta de Matchai y a una app. Una app que atiende a tres profesionales tiene tres conexiones y crea suscripciones para cada una de ellas. Consulta [Conectar con Matchai](https://matchai.co/docs/es/developers/authentication/connect-with-matchai) y [Claves de API](https://matchai.co/docs/es/developers/authentication/api-keys) para saber cómo se crea una conexión.

## Suscríbete a eventos [#subscribe-to-events]

Para crear una suscripción se necesita el alcance `webhooks:manage`. Los webhooks están incluidos con la conexión y no gastan energía.

<TypeTable
  type={{
    url: { type: 'string', required: true, description: 'Tu endpoint. Solo https.' },
    events: { type: 'string[]', required: true, description: 'Los tipos de evento que quieres recibir: al menos uno, sin repeticiones. Consulta el catálogo de eventos más abajo.' },
    description: { type: 'string', description: 'Tu nota, de hasta 200 caracteres. Se te vuelve a mostrar en la suscripción.' },
  }}
/>

Envía un encabezado `Idempotency-Key` con la solicitud. Un reintento con la misma clave dentro de las 24 horas devuelve la primera respuesta en lugar de crear una segunda suscripción.

```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 respuesta es `201 Created`. El `secret` aparece en la respuesta una vez y nunca más, así que guárdalo ahora.

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

El secreto de firma empieza con `whsec_`. Guárdalo en tu almacén de secretos, nunca en el código ni en los registros. Si lo pierdes, [rótalo](#rotate-the-signing-secret).

Solo recibes eventos sobre datos que tus alcances te permiten leer. La última columna del catálogo indica el alcance que necesita cada evento, así que pide esos alcances cuando el titular de la cuenta conecte tu app.

### Administra las suscripciones [#manage-subscriptions]

Todas estas llamadas necesitan `webhooks:manage`.

| Para | Llamada | Referencia |
|---|---|---|
| Listar tus suscripciones | `GET /webhook-subscriptions` | [listWebhookSubscriptions](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/listWebhookSubscriptions) |
| Leer una suscripción (el secreto nunca se vuelve a devolver) | `GET /webhook-subscriptions/{webhook_subscription_id}` | [getWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/getWebhookSubscription) |
| Cambiar el endpoint o los eventos, o desactivarla o activarla | `PATCH /webhook-subscriptions/{webhook_subscription_id}` | [updateWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/updateWebhookSubscription) |
| Dejar de enviar eventos a un endpoint | `DELETE /webhook-subscriptions/{webhook_subscription_id}` | [deleteWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/deleteWebhookSubscription) |
| Emitir un nuevo secreto de firma | `POST /webhook-subscriptions/{webhook_subscription_id}/rotate-secret` | [rotateWebhookSecret](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/rotateWebhookSecret) |
| Enviar ahora un evento de prueba | `POST /webhook-subscriptions/{webhook_subscription_id}/test` | [testWebhookSubscription](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/testWebhookSubscription) |
| Ver los intentos de entrega | `GET /webhook-subscriptions/{webhook_subscription_id}/deliveries` | [listWebhookDeliveries](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/listWebhookDeliveries) |
| Volver a enviar una entrega fallida | `POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry` | [retryWebhookDelivery](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/retryWebhookDelivery) |
| Ponerte al día con eventos pasados | `GET /events` y `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) |

Eliminar una suscripción detiene los eventos para ese endpoint. No elimina nada en la cuenta de Matchai.

Una suscripción tiene estos campos:

<TypeTable
  type={{
    id: { type: 'string', description: 'El id de la suscripción.' },
    url: { type: 'string', description: 'Tu endpoint.' },
    description: { type: 'string | null', description: 'Tu nota.' },
    events: { type: 'string[]', description: 'Los tipos de evento que se envían a este endpoint.' },
    status: { type: '"enabled" | "disabled"', description: 'Si se están enviando eventos.' },
    disabled_reason: { type: '"by_you" | "failing" | "connection_ended" | null', description: 'Por qué está desactivada. failing significa que todas las entregas fallaron durante 3 días. connection_ended significa que el titular de la cuenta desconectó tu app. null mientras está activada.' },
    created_at: { type: 'string', description: 'Cuándo se creó, como marca de tiempo ISO 8601.' },
    updated_at: { type: 'string', description: 'Cuándo cambió por última vez.' },
  }}
/>

## Catálogo de eventos [#event-catalog]

Estos son los tipos de evento, agrupados según su tema. Cada nombre enlaza a su página de referencia, que muestra el contenido del evento.

<Callout title="Pueden aparecer nuevos tipos de evento">
Matchai puede agregar tipos de evento, y campos nuevos a los eventos existentes, sin una nueva versión de la API. Ignora cualquier tipo de evento que no conozcas y cualquier campo que no uses.
</Callout>

### Contactos y prospectos [#contacts-and-leads]

| Evento | Se envía cuando | `data.object` | Alcance para recibirlo |
|---|---|---|---|
| [`lead.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onLeadCreated) | Llegó un nuevo prospecto a la cuenta: una consulta desde un anuncio, el sitio de Matchai, la Página Matchai o tu sitio web, una solicitud de visita, o alguien que guardó un anuncio. | El contacto | `contacts:read` |
| [`contact.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactCreated) | Cualquier contacto nuevo, sea cual sea su tipo. Un nuevo prospecto también envía `lead.created`. | El contacto | `contacts:read` |
| [`contact.updated`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactUpdated) | Cambiaron los datos de un contacto o sus datos de prospecto. `data.previous` contiene los valores anteriores de los campos que cambiaron. | El contacto | `contacts:read` |
| [`contact.converted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactConverted) | Un prospecto se convirtió en cliente, o un cliente se convirtió en profesional. `data.previous.type` contiene el tipo anterior. | El contacto | `contacts:read` |
| [`contact.deleted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onContactDeleted) | Se eliminó un contacto. | Un objeto eliminado | `contacts:read` |

### Conversaciones y mensajes [#conversations-and-messages]

| Evento | Se envía cuando | `data.object` | Alcance para recibirlo |
|---|---|---|---|
| [`conversation.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onConversationCreated) | Una nueva conversación en la que participa la cuenta. | La conversación | `messages:read` |
| [`message.received`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onMessageReceived) | Otra persona escribió en una conversación en la que participa la cuenta. | El mensaje | `messages:read` |
| [`message.sent`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onMessageSent) | La cuenta envió un mensaje, desde la app, el Asistente de Matchai o una app como la tuya. | El mensaje | `messages:read` |

### Citas [#appointments]

| Evento | Se envía cuando | `data.object` | Alcance para recibirlo |
|---|---|---|---|
| [`appointment.requested`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentRequested) | Alguien le pidió un horario al titular de la cuenta. Queda a la espera de su respuesta. | La cita | `calendar:read` |
| [`appointment.booked`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentBooked) | Se confirmó una cita: se reservó directamente, o se aceptó una solicitud. | La cita | `calendar:read` |
| [`appointment.rescheduled`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentRescheduled) | Una cita tiene un nuevo horario, o se propuso un nuevo horario (`status: reschedule_pending`). | La cita | `calendar:read` |
| [`appointment.cancelled`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onAppointmentCancelled) | Se canceló una cita o se rechazó una solicitud. | La cita | `calendar:read` |

### Anuncios [#listings]

| Evento | Se envía cuando | `data.object` | Alcance para recibirlo |
|---|---|---|---|
| [`listing.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingCreated) | Un nuevo anuncio en borrador, hecho a mano, con la creación con IA o por importación. | El anuncio | `listings:read` |
| [`listing.updated`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingUpdated) | Cambiaron los campos, las fotos o las habitaciones de un anuncio. `data.previous` contiene los valores anteriores de los campos de primer nivel que cambiaron. | El anuncio | `listings:read` |
| [`listing.published`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingPublished) | Un anuncio se publicó en la web pública. | El anuncio | `listings:read` |
| [`listing.unpublished`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingUnpublished) | Un anuncio volvió a borrador. | El anuncio | `listings:read` |
| [`listing.archived`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingArchived) | Un anuncio se retiró del mercado. | El anuncio | `listings:read` |
| [`listing.deleted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingDeleted) | Se eliminó un anuncio. | Un objeto eliminado | `listings:read` |
| [`listing.saved`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingSaved) | Alguien guardó uno de los anuncios de la cuenta. `data.object.contact` es tu registro de contacto de esa persona. | El anuncio, el contacto y `saved_at` | `listings:read` y `contacts:read` |
| [`listing.generation.completed`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onListingGenerationCompleted) | Una ejecución de creación de un anuncio con IA terminó con éxito o falló. | La generación del anuncio | `listings:read` |

### Documentos, invitaciones y notificaciones [#documents-invitations-and-notifications]

| Evento | Se envía cuando | `data.object` | Alcance para recibirlo |
|---|---|---|---|
| [`document.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onDocumentCreated) | Se agregó un documento a Matchai Cloud, por carga, por correo electrónico o a través de un enlace de entrega. | El documento | `documents:read` |
| [`drop_link.upload_received`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onDropLinkUploadReceived) | Alguien subió un archivo a través de uno de los enlaces de entrega de la cuenta. | El enlace de entrega y el documento | `documents:read` |
| [`invitation.accepted`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onInvitationAccepted) | Alguien aceptó una invitación que envió la cuenta. | La invitación | `teams:read` o `projects:read` |
| [`notification.created`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onNotificationCreated) | La cuenta recibió una notificación. | La notificación | `notifications:read` |

### Aprobaciones y pruebas [#approvals-and-testing]

| Evento | Se envía cuando | `data.object` | Alcance para recibirlo |
|---|---|---|---|
| [`approval.resolved`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onApprovalResolved) | Una acción final que pidió tu app se aprobó (y se realizó), se rechazó o caducó. `data.object.result` contiene el resultado. | La aprobación | Ninguno. Se envía a la app que la pidió. |
| [`webhook.test`](https://matchai.co/docs/developers/api-reference/webhook-subscriptions/onWebhookTest) | Lo enviaste con `POST /webhook-subscriptions/{webhook_subscription_id}/test`. | Un mensaje fijo | `webhooks:manage` |

Un objeto eliminado es `{ "id": "…", "object": "listing", "deleted": true }`, donde `object` es `contact` o `listing`.

## Cómo es una entrega [#what-a-delivery-looks-like]

Cada entrega es un `POST` con un cuerpo JSON y estos encabezados.

| Encabezado | Significado |
|---|---|
| `Matchai-Signature` | La firma que debes verificar. Tiene este aspecto: `t=1792177965,v1=5257a869…`. Durante una [rotación del secreto](#rotate-the-signing-secret) lleva dos valores `v1`. |
| `Matchai-Event-Id` | El mismo valor que el `id` del cuerpo. Úsalo para ignorar los duplicados. |
| `Matchai-Delivery-Attempt` | El número de intento, a partir de 1. |

El cuerpo es el mismo objeto `Event` para todos los tipos. `GET /events` devuelve los mismos objetos.

<TypeTable
  type={{
    id: { type: 'string', description: 'El id del evento. Una entrega puede repetirse, así que úsalo para ignorar los duplicados.' },
    type: { type: 'string', description: 'El tipo de evento, por ejemplo contact.created.' },
    created_at: { type: 'string', description: 'Cuándo ocurrió, como marca de tiempo ISO 8601 en UTC.' },
    api_version: { type: '"v1"', description: 'La versión de la API que sigue el contenido.' },
    test_mode: { type: 'boolean', description: 'true para una conexión de prueba y para los eventos que envías con la llamada de prueba.' },
    account: { type: '{ user_id: string, professional_id: string | null }', description: 'La cuenta de Matchai en la que ocurrió el evento. Usa user_id para distinguir tus conexiones.' },
    'data.object': { type: 'object', description: 'El recurso tal como queda después del cambio, con la misma estructura que devuelve la API.' },
    'data.previous': { type: 'object', description: 'Solo en los eventos de actualización y de conversión: los campos que cambiaron, con sus valores anteriores.' },
  }}
/>

Una entrega de `lead.created`, abreviada a los campos que usan la mayoría de las apps:

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

Los nombres de los campos están en `snake_case`, los id son cadenas opacas y un evento solo lleva datos que tus alcances te permiten leer.

## Verifica la firma [#verify-the-signature]

Cualquiera puede enviar una solicitud a tu endpoint. Verifica `Matchai-Signature` en cada entrega antes de confiar en el cuerpo.

El encabezado tiene este aspecto:

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

<Steps>
<Step>

Toma `t` (segundos Unix) y el **cuerpo sin procesar de la solicitud**, exactamente como se recibió, antes de cualquier análisis del JSON.

</Step>
<Step>

Calcula el HMAC-SHA256 de `<t>.<raw body>` con el secreto de firma de tu suscripción (`whsec_…`), en hexadecimal en minúsculas. El texto firmado es la marca de tiempo, un punto y luego el cuerpo.

</Step>
<Step>

Compara el resultado con cada valor `v1` en tiempo constante. Acepta la entrega si alguno de ellos coincide.

</Step>
<Step>

Rechaza la entrega si `t` difiere en más de 5 minutos de tu reloj. Así evitas que alguien reenvíe una solicitud antigua.

</Step>
</Steps>

<Callout title="Usa el cuerpo sin procesar">
Firma y compara los bytes que envió Matchai. Un cuerpo que se analizó y se volvió a escribir puede diferir en los espacios o en el orden de las claves, y entonces ninguna firma coincidirá. En la mayoría de los frameworks web, esto significa leer el cuerpo como texto o como bytes antes de que se ejecute cualquier middleware de JSON.
</Callout>

Usa el secreto exactamente como lo recibiste, incluido el prefijo `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 versión en Python necesita Python 3.10 o posterior.

</Tab>
</Tabs>

### Comprueba tu código con una firma conocida [#check-your-code-against-a-known-signature]

Este ejemplo es inventado. Úsalo para comprobar que tu código calcula la firma igual que Matchai, antes de apuntarlo a una suscripción real.

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

La muestra es antigua, así que pasa su propia marca de tiempo como hora actual. Así, si la comprobación falla, no será por la ventana de 5 minutos.

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

## Recibe una entrega [#receive-a-delivery]

Responde con cualquier estado `2xx` en menos de 10 segundos. Cualquier otra respuesta, o la falta de respuesta a tiempo, cuenta como una entrega fallida y se reintenta. Así que verifica, guarda o pon en cola el evento, responde, y haz el trabajo lento después.

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

Un controlador para cualquier entorno de ejecución que use los objetos estándar `Request` y `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 necesita el cuerpo sin procesar. Registra `express.raw` en esta ruta y mantén `express.json()` fuera de ella.

```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 una app atiende a varias cuentas, dale a cada suscripción su propia dirección, por ejemplo `/hooks/matchai/{your broker id}`. Cada suscripción tiene su propio secreto, y la dirección te indica con qué secreto verificar antes de leer el cuerpo.

## Reintentos y fallos [#retries-and-failures]

Una entrega que no recibe un `2xx` se reintenta con esperas cada vez más largas: aproximadamente 1 minuto, 5 minutos, 30 minutos, 2 horas y 6 horas, y luego cada 12 horas, durante un máximo de 3 días. `Matchai-Delivery-Attempt` te indica qué intento estás viendo.

Si todas las entregas a una suscripción fallan durante 3 días, Matchai desactiva la suscripción. Muestra `disabled_reason: failing`, y se le avisa por correo electrónico al desarrollador de la app.

Reactivarla con `PATCH /webhook-subscriptions/{webhook_subscription_id}` y `{ "status": "enabled" }` reanuda las entregas a partir de ese momento. Los eventos del tiempo en que estuvo desactivada no se vuelven a enviar, así que [ponte al día](#catch-up-after-downtime) con `GET /events`.

Cuando el titular de la cuenta desconecta tu app, sus suscripciones se detienen y muestran `disabled_reason: connection_ended`. Cuando el titular de la cuenta vuelva a conectar tu app, lista tus suscripciones y crea o reactiva las que necesites.

## Orden y duplicados [#order-and-duplicates]

Los eventos pueden llegar desordenados y más de una vez. Escribe tu controlador de modo que eso no importe.

- **Ignora los duplicados con `id`.** Conserva los id que ya procesaste durante al menos los 3 días en que Matchai sigue reintentando.
- **Decide qué es lo actual con `created_at`, o vuelve a preguntar.** Un evento más antiguo del mismo objeto puede llegar después de uno más reciente. Compara `created_at` con lo que tienes, o vuelve a leer el objeto con 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;
}
```

Para todo lo que deba ser exacto, trata el evento como una señal y lee el objeto desde la API. `data.object` también está disponible en `GET /events/{event_id}`.

## Ponte al día después de una interrupción [#catch-up-after-downtime]

`GET /events` conserva 30 días de eventos de la conexión, primero los más recientes, con la misma estructura que las entregas. Úsalo después de una interrupción del servicio, después de reactivar una suscripción o de forma programada como red de seguridad. Necesita `webhooks:manage`.

<TypeTable
  type={{
    type: { type: 'string', description: 'Solo los eventos de este tipo.' },
    since: { type: 'string', description: 'Solo los eventos posteriores a este momento, como marca de tiempo ISO 8601.' },
    limit: { type: 'integer', default: '25', description: 'Eventos por página, de 1 a 100.' },
    cursor: { type: 'string', description: 'El next_cursor de la página anterior.' },
  }}
/>

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

## Prueba tu endpoint [#test-your-endpoint]

`POST /webhook-subscriptions/{webhook_subscription_id}/test` envía de inmediato un evento `webhook.test` al endpoint y devuelve la entrega. El evento tiene `test_mode: true`, y `data.object.message` es un texto fijo. Devuelve un `2xx` para superar la prueba.

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

Otras formas de probar:

- **Usa el modo de prueba.** Una app que está conectada solo a la cuenta de Matchai de su propio desarrollador está en modo de prueba y no se factura. Sus eventos llevan `test_mode: true`, así que puedes crear y comprobar todo el flujo primero con tu propia cuenta.
- **Haz accesible tu máquina.** Tu endpoint debe usar `https`. Mientras desarrollas, usa un túnel que le dé a tu servidor local una dirección `https` pública, y suscribe esa dirección.
- **Reproduce un evento real.** Léelo con `GET /events/{event_id}`, o vuelve a enviar una entrega fallida con `POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry`.

## Rota el secreto de firma [#rotate-the-signing-secret]

Rota un secreto de forma periódica, o de inmediato si pudo haberse filtrado. La rotación nunca hace que se pierda un evento, porque el secreto anterior sigue funcionando durante 24 horas.

<Steps>
<Step>

Llama a `POST /webhook-subscriptions/{webhook_subscription_id}/rotate-secret`. La respuesta es la suscripción con un nuevo `secret`, que se muestra una sola vez.

</Step>
<Step>

Durante las 24 horas siguientes, cada entrega lleva en `Matchai-Signature` un valor `v1` por cada secreto.

</Step>
<Step>

Despliega el nuevo secreto. El código de verificación de arriba acepta una entrega cuando coincide cualquier valor `v1`, así que funciona con el secreto anterior antes del despliegue y con el nuevo después.

</Step>
<Step>

Pasadas las 24 horas, solo el nuevo secreto firma las entregas.

</Step>
</Steps>

## Registros de entrega [#delivery-logs]

`GET /webhook-subscriptions/{webhook_subscription_id}/deliveries` enumera los intentos de entrega recientes de un endpoint, primero los más recientes, y los conserva durante 30 días. Usa `status` (`pending`, `succeeded` o `failed`) para acotar la lista. Necesita `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: 'El id de la entrega.' },
    event_id: { type: 'string', description: 'El evento que se envió.' },
    event_type: { type: 'string', description: 'Su tipo.' },
    attempt: { type: 'integer', description: 'El número de intento, a partir de 1.' },
    status: { type: '"pending" | "succeeded" | "failed"', description: 'El resultado de este intento.' },
    response_status: { type: 'integer | null', description: 'El estado HTTP que devolvió tu endpoint.' },
    error: { type: 'string | null', description: 'Por qué falló cuando no hubo respuesta, por ejemplo "timeout after 10 s".' },
    created_at: { type: 'string', description: 'Cuándo se hizo este intento.' },
    next_attempt_at: { type: 'string | null', description: 'Cuándo toca el próximo reintento.' },
  }}
/>

Para volver a enviar ahora mismo una entrega fallida, llama a `POST /webhook-subscriptions/{webhook_subscription_id}/deliveries/{delivery_id}/retry`. Devuelve una nueva entrega.

## Acciones finales y aprobaciones [#final-actions-and-approvals]

Cuando tu conexión está configurada en **Preguntarme**, una acción final, como enviar un mensaje o publicar un anuncio, devuelve `202 Accepted` con un `approval_id`. No ocurre nada hasta que el titular de la cuenta la aprueba en Matchai.

Luego Matchai envía `approval.resolved` a tu app, tenga los alcances que tenga. `data.object.id` es el `approval_id` del `202`. `status` indica si la acción se aprobó, se rechazó o caducó. Cuando se aprobó, `result.status_code` y `result.body` son lo que devolvió la llamada cuando Matchai la realizó.

```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 no puedes recibir webhooks, consulta periódicamente `GET /approvals/{approval_id}` en su lugar. Más información en [getApproval](https://matchai.co/docs/developers/api-reference/approvals/getApproval).

## Antes de pasar a producción [#before-you-go-live]

- Tu endpoint usa `https` y responde con un `2xx` en menos de 10 segundos.
- Verificas `Matchai-Signature` sobre el cuerpo sin procesar, comparas en tiempo constante y rechazas una marca de tiempo que difiera en más de 5 minutos.
- Ignoras los duplicados por `id` y toleras los eventos desordenados.
- Ignoras los tipos de evento y los campos que no conoces.
- El secreto de firma está en un almacén de secretos, y sabes cómo rotarlo.
- Una llamada programada a `GET /events` cubre cualquier hueco.

<Cards>
  <Card title="Creadores de sitios web" href="https://matchai.co/docs/es/developers/guides/website-builders">
    Usa los webhooks con un feed de anuncios, la recepción de prospectos y las solicitudes de visita.
  </Card>
  <Card title="Conectar con Matchai" href="https://matchai.co/docs/es/developers/authentication/connect-with-matchai">
    Cómo obtiene una app una conexión a la cuenta de un profesional.
  </Card>
  <Card title="Errores" href="https://matchai.co/docs/es/developers/errors">
    Los códigos de error y cómo manejarlos.
  </Card>
  <Card title="Límites de solicitudes" href="https://matchai.co/docs/es/developers/rate-limits">
    Los límites por conexión, y qué hacer ante un 429.
  </Card>
</Cards>

## Sitemap

- [Todas las páginas de la documentación de Matchai](https://matchai.co/docs/sitemap.md)
- [llms.txt](https://matchai.co/docs/es/llms.txt)
