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-Signature: t=1785460233,v1=8a7d...
Content-Type: application/json
User-Agent: Mati-Webhook/1.0

Verificar la firma

La firma es HMAC-SHA256 del body raw concatenado con el timestamp. 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

Una nueva reserva fue creada desde el widget o desde la API.

{
  "type": "booking.created",
  "eventId": "evt_abc123",
  "workspaceId": "ws_xyz",
  "data": {
    "id": "bk_abc123",
    "status": "PENDING",
    "service": {
      "name": "Consulta General",
      "duration": 60
    },
    "customer": {
      "name": "María García",
      "email": "[email protected]"
    },
    "startAt": "2026-08-15T10:00:00Z"
  }
}
booking.confirmed

La reserva pasó a estado CONFIRMED (pago recibido o admin manual).

{
  "type": "booking.confirmed",
  "eventId": "evt_def456",
  "data": {
    "id": "bk_abc123",
    "status": "CONFIRMED"
  }
}
booking.cancelled

La reserva fue cancelada por el cliente o por el profesional.

{
  "type": "booking.cancelled",
  "eventId": "evt_ghi789",
  "data": {
    "id": "bk_abc123",
    "status": "CANCELLED",
    "reason": "customer_request"
  }
}
payment.received

Stripe confirmó el pago de una reserva.

{
  "type": "payment.received",
  "eventId": "evt_pay001",
  "data": {
    "bookingId": "bk_abc123",
    "amount": 5000,
    "currency": "usd"
  }
}

Política de reintentos

Si tu endpoint no responde 2xx en menos de 10 segundos, Mati reintenta con backoff exponencial:

Intento 1: inmediato
Intento 2: +15 segundos
Intento 3: +1 minuto
Intento 4: +5 minutos
Intento 5: +30 minutos
Intento 6: +1 hora (final)

Después de 6 intentos fallidos, el webhook se marca como fallido y puedes reenviarlo manualmente desde el dashboard.

¿Listo para probar? Crea un endpoint de prueba con webhook.site y registra la URL en tu dashboard.