Volver al portal
Mati API v1
WEBHOOKS

Webhooks salientes

Recibe notificaciones HTTP en tiempo real cuando ocurren eventos en tu workspace. Firma HMAC-SHA256 para garantizar autenticidad.

Cómo funciona

  1. Registras una URL en /dashboard/[slug]/settings/webhooks
  2. Recibes un secret autogenerado
  3. Cuando ocurre un evento, Mati hace POST a tu URL
  4. Verificas la firma HMAC con tu secret
  5. Respondes 2xx para confirmar recepción

Headers enviados

X-Webhook-Event: booking.created
X-Webhook-Event-ID: evt_abc123
X-Webhook-Timestamp: 1785460233
X-Webhook-Signature: t=1785460233,v1=8a7d...
Content-Type: application/json
User-Agent: Mati-Webhook/1.0

Verificar la firma

La firma es HMAC-SHA256 con tu secret sobre el texto <timestamp>.<body raw>, en hexadecimal. Cada intento se firma de nuevo con su propio timestamp, así que puedes rechazar firmas de hace más de 5 minutos sin perder reintentos legítimos. Formato del header:t=<ts>,v1=<hex>.

Node.js — Express middleware
import crypto from "crypto";

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-webhook-signature"] || "";
  const secret = process.env.MATI_WEBHOOK_SECRET;

  // 1. Parse "t=...,v1=..." format
  const parts = Object.fromEntries(sig.split(",").map((p) => p.split("=")));
  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return res.status(401).send("Invalid signature");

  // 2. Compute expected HMAC
  const body = req.body.toString(); // raw body, NOT parsed JSON
  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + "." + body, "utf8")
    .digest("hex");

  // 3. Constant-time comparison
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(401).send("Invalid signature");
  }

  // 4. Process the event
  const event = JSON.parse(body);
  console.log("Received:", event.type);

  res.json({ received: true });
});
Ver en Python (Flask), PHP y Ruby
Python — Flask
import hmac, hashlib
from flask import Flask, request, abort

app = Flask(__name__)

@app.route("/webhook", methods=["POST"])
def webhook():
    sig = request.headers.get("X-Webhook-Signature", "")
    parts = dict(p.split("=", 1) for p in sig.split(","))
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        abort(401)

    body = request.get_data(as_text=True)
    expected = hmac.new(
        key=app.config["MATI_WEBHOOK_SECRET"].encode(),
        msg=f"{timestamp}.{body}".encode(),
        digestmod=hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        abort(401)

    event = request.get_json()
    print("Received:", event["type"])
    return {"received": True}
PHP — Slim
<?php
$app->post("/webhook", function ($req, $res) {
    $sigHeader = $req->getHeaderLine("X-Webhook-Signature");
    $parts = [];
    foreach (explode(",", $sigHeader) as $p) {
        [$k, $v] = explode("=", $p, 2);
        $parts[$k] = $v;
    }
    $timestamp = $parts["t"] ?? null;
    $signature = $parts["v1"] ?? null;
    if (!$timestamp || !$signature) {
        return $res->withStatus(401);
    }

    $body = (string) $req->getBody();
    $expected = hash_hmac("sha256", "{$timestamp}.{$body}", getenv("MATI_WEBHOOK_SECRET"));
    if (!hash_equals($expected, $signature)) {
        return $res->withStatus(401);
    }

    $event = json_decode($body, true);
    error_log("Received: " . $event["type"]);
    return $res->withJson(["received" => true]);
});
Ruby — Sinatra
require "sinatra"
require "openssl"
require "json"

post "/webhook" do
  sig = env["HTTP_X_WEBHOOK_SIGNATURE"] || ""
  parts = sig.split(",").each_with_object({}) { |p, h| k, v = p.split("=", 2); h[k] = v }
  timestamp = parts["t"]
  signature = parts["v1"]
  halt 401 unless timestamp && signature

  body = request.body.read
  expected = OpenSSL::HMAC.hexdigest("sha256", ENV["MATI_WEBHOOK_SECRET"], "#{timestamp}.#{body}")

  halt 401 unless Rack::Utils.secure_compare(expected, signature)

  event = JSON.parse(body)
  puts "Received: #{event["type"]}"
  { received: true }.to_json
end

Catálogo de eventos

booking.created

Se creó una reserva: desde el widget, el carrito, la API v1, el panel del negocio, el panel de WordPress o una serie recurrente.

{
  "type": "booking.created",
  "eventId": "evt_abc123",
  "workspaceId": "ws_xyz",
  "data": {
    "id": "cmabc123",
    "status": "PENDING",
    "startAt": "2026-08-15T10:00:00.000Z",
    "endAt": "2026-08-15T11:00:00.000Z",
    "amount": 50,
    "currency": "USD",
    "paid": false,
    "cartId": null,
    "service": {
      "id": "svc_1",
      "name": "Consulta General",
      "duration": 60
    },
    "customer": {
      "name": "María García",
      "email": "[email protected]"
    },
    "provider": {
      "name": "Dra. López"
    },
    "source": "widget"
  },
  "sentAt": "2026-08-14T09:00:00.000Z"
}
booking.confirmed

La reserva pasó a CONFIRMED: pago recibido, confirmación manual desde el panel o membresía. Salvo cuando viene de un pago de Stripe, `data` es la reserva completa; en ese caso solo trae `{ id, status, reason: "payment_received" }`.

{
  "type": "booking.confirmed",
  "eventId": "evt_def456",
  "workspaceId": "ws_xyz",
  "data": {
    "id": "cmabc123",
    "status": "CONFIRMED",
    "reason": "payment_received"
  },
  "sentAt": "2026-08-14T09:01:00.000Z"
}
booking.cancelled

La reserva pasó a CANCELLED: la canceló el cliente, el negocio o la API. Una cancelación repetida NO vuelve a emitir.

{
  "type": "booking.cancelled",
  "eventId": "evt_ghi789",
  "workspaceId": "ws_xyz",
  "data": {
    "id": "cmabc123",
    "status": "CANCELLED",
    "startAt": "2026-08-15T10:00:00.000Z",
    "endAt": "2026-08-15T11:00:00.000Z",
    "amount": 50,
    "currency": "USD",
    "paid": false,
    "cartId": null,
    "service": {
      "id": "svc_1",
      "name": "Consulta General",
      "duration": 60
    },
    "customer": {
      "name": "María García",
      "email": "[email protected]"
    },
    "provider": {
      "name": "Dra. López"
    },
    "source": "widget",
    "reason": "customer_request"
  },
  "sentAt": "2026-08-14T10:00:00.000Z"
}
booking.completed

La reserva pasó a COMPLETED (cita atendida), marcada desde el panel del negocio o el panel de WordPress.

{
  "type": "booking.completed",
  "eventId": "evt_comp_001",
  "workspaceId": "ws_xyz",
  "data": {
    "id": "cmabc123",
    "status": "COMPLETED",
    "startAt": "2026-08-15T10:00:00.000Z",
    "endAt": "2026-08-15T11:00:00.000Z",
    "amount": 50,
    "currency": "USD",
    "paid": false,
    "cartId": null,
    "service": {
      "id": "svc_1",
      "name": "Consulta General",
      "duration": 60
    },
    "customer": {
      "name": "María García",
      "email": "[email protected]"
    },
    "provider": {
      "name": "Dra. López"
    },
    "source": "widget"
  },
  "sentAt": "2026-08-15T11:05:00.000Z"
}
booking.rescheduled

La reserva cambió de hora (la movió el cliente, el negocio, la API o la sesión a la que pertenece). `data.startAt`/`endAt` son los NUEVOS; los anteriores van en `previousStartAt`/`previousEndAt`. Si la mueve el cliente desde su portal, la reserva vuelve a PENDING (el negocio tiene que confirmarla otra vez), `data.status` lo refleja y `data.source` es `customer_portal`.

{
  "type": "booking.rescheduled",
  "eventId": "evt_resch_001",
  "workspaceId": "ws_xyz",
  "data": {
    "id": "cmabc123",
    "status": "CONFIRMED",
    "startAt": "2026-08-16T10:00:00.000Z",
    "endAt": "2026-08-16T11:00:00.000Z",
    "amount": 50,
    "currency": "USD",
    "paid": false,
    "cartId": null,
    "service": {
      "id": "svc_1",
      "name": "Consulta General",
      "duration": 60
    },
    "customer": {
      "name": "María García",
      "email": "[email protected]"
    },
    "provider": {
      "name": "Dra. López"
    },
    "source": "api_v1",
    "previousStartAt": "2026-08-15T10:00:00.000Z",
    "previousEndAt": "2026-08-15T11:00:00.000Z"
  },
  "sentAt": "2026-08-14T12:00:00.000Z"
}
payment.received

Un CLIENTE del negocio pagó: dinero que ENTRA. `data` trae `bookingId` (una cita), `cartId` + `bookingIds` (varias citas de una misma visita) o `membershipId` (una membresía), y siempre `amount`, `currency` y `stripePaymentIntentId`. La factura de la suscripción del negocio a Mati NO viaja por aquí: eso es `subscription.invoice_paid`.

{
  "type": "payment.received",
  "eventId": "evt_pay_001",
  "workspaceId": "ws_xyz",
  "data": {
    "bookingId": "cmabc123",
    "amount": 50,
    "currency": "USD",
    "stripePaymentIntentId": "pi_xxx"
  },
  "sentAt": "2026-08-14T09:01:00.000Z"
}
subscription.invoice_paid

Mati cobró la factura de la suscripción del negocio a Mati: dinero que SALE. No es un cobro a un cliente final (eso es `payment.received`) y el importe es lo que el negocio paga por su plan y sus módulos, no una venta suya.

{
  "type": "subscription.invoice_paid",
  "eventId": "evt_sub_001",
  "workspaceId": "ws_xyz",
  "data": {
    "invoiceId": "in_1OaBcDeFgHiJkL",
    "subscriptionId": "sub_1OaBcDeFgHiJkL",
    "amount": 27,
    "currency": "USD",
    "hostedInvoiceUrl": "https://invoice.stripe.com/i/acct_1/live_abc123"
  },
  "sentAt": "2026-08-14T09:01:00.000Z"
}
ping

Evento de prueba enviado desde el dashboard para verificar la firma.

{
  "type": "ping",
  "eventId": "evt_ping_001",
  "data": {
    "sentAt": "2026-07-31T12:00:00Z",
    "message": "Hello from Mati"
  }
}

Qué podrías hacer con cada evento

No es una API aparte: es la misma descripción del catálogo, en el formato que usarías para enrutar cada evento en tu propio código.

Ejemplo — tabla de acciones por evento
const ACCIONES_POR_EVENTO = [
  { evento: "booking.created", cuando: "Se creó una reserva: desde el widget, el carrito, la API v1, el panel del negocio, el panel de WordPress o una serie recurrente." },
  { evento: "booking.confirmed", cuando: "La reserva pasó a CONFIRMED: pago recibido, confirmación manual desde el panel o membresía. Salvo cuando viene de un pago de Stripe, `data` es la reserva completa; en ese caso solo trae `{ id, status, reason: \"payment_received\" }`." },
  { evento: "booking.cancelled", cuando: "La reserva pasó a CANCELLED: la canceló el cliente, el negocio o la API. Una cancelación repetida NO vuelve a emitir." },
  { evento: "booking.completed", cuando: "La reserva pasó a COMPLETED (cita atendida), marcada desde el panel del negocio o el panel de WordPress." },
  { evento: "booking.rescheduled", cuando: "La reserva cambió de hora (la movió el cliente, el negocio, la API o la sesión a la que pertenece). `data.startAt`/`endAt` son los NUEVOS; los anteriores van en `previousStartAt`/`previousEndAt`. Si la mueve el cliente desde su portal, la reserva vuelve a PENDING (el negocio tiene que confirmarla otra vez), `data.status` lo refleja y `data.source` es `customer_portal`." },
  { evento: "payment.received", cuando: "Un CLIENTE del negocio pagó: dinero que ENTRA. `data` trae `bookingId` (una cita), `cartId` + `bookingIds` (varias citas de una misma visita) o `membershipId` (una membresía), y siempre `amount`, `currency` y `stripePaymentIntentId`. La factura de la suscripción del negocio a Mati NO viaja por aquí: eso es `subscription.invoice_paid`." },
  { evento: "subscription.invoice_paid", cuando: "Mati cobró la factura de la suscripción del negocio a Mati: dinero que SALE. No es un cobro a un cliente final (eso es `payment.received`) y el importe es lo que el negocio paga por su plan y sus módulos, no una venta suya." },
];

Política de reintentos

Si tu endpoint no responde 2xx en menos de 30 segundos, Mati lo considera fallido y reintenta con backoff exponencial. Son 5 intentos en total.

Intento 1: inmediato
Intento 2: +15 segundos
Intento 3: +1 minuto
Intento 4: +4 minutos
Intento 5: +16 minutos

Entre intento e intento esperamos al menos 15 segundos, luego al menos 1 minuto, luego al menos 4 minutos, luego al menos 16 minutos. Si el último también falla, el webhook se marca como fallido en el panel.

Entrega, duplicados y orden

Los reintentos los recoge un proceso que corre cada 10 minutos: las esperas de arriba son mínimas, y cada reintento sale en el primer ciclo posterior a su espera.

Mati entrega cada evento al menos una vez. Si tu endpoint tarda en responder, o el intento en segundo plano coincide con un reintento, puedes recibir el mismo evento dos veces: guarda el X-Webhook-Event-ID (es el mismo eventId del cuerpo) de lo que ya procesaste y descarta los repetidos.

Tampoco hay garantía de orden: un reintento puede llegar después de un evento más nuevo. Ante la duda, consulta el estado actual con GET /bookings/{id} en vez de fiarte del orden de llegada.

Campos de data en los eventos booking.*

Las fechas (startAt, endAt, previousStartAt) son instantes UTC en ISO 8601. amount y currency son los del servicio, y paid dice si ya se cobró. reason aparece solo cuando hay un motivo (por ejemplo, el que mandaste al cancelar por la API). source dice por dónde entró el cambio:

  • widget — el widget embebido en la web del negocio
  • cart — el carrito del widget (varias citas de una misma visita)
  • api_v1 — esta API: POST /bookings, PATCH /bookings/{id} o POST .../cancel
  • api_legacy — la cancelación que hace el cliente desde el widget
  • dashboard — el panel del negocio
  • customer_portal — el portal del cliente (al reprogramar, la reserva vuelve a PENDING)
  • wp_panel — el panel de WordPress
  • manual — una reserva anotada a mano por el negocio
  • series — una serie recurrente
  • session — una sesión programada (taller, clase)
¿Listo para probar? Crea un endpoint de prueba con webhook.site y registra la URL en tu dashboard.