Webhooks
Recibe publicaciones y leads en tu sistema.
Configura URL y eventos en Configuración → Integraciones. Solo OWNER/ADMIN de la corredora pueden gestionarlos. Copia el secreto al crear o rotar. El listado solo muestra una vista enmascarada. Al rotar, las entregas incluyen firmas del secreto nuevo y del anterior durante 24 horas. Headers: X-TochiHub-Signature: v1=<hex HMAC-SHA256>, X-TochiHub-Timestamp: <epoch segundos>, X-TochiHub-Event y X-TochiHub-Delivery. Firma exactamente timestamp + "." + body crudo en UTF-8. Nunca reserialices JSON antes de verificar. Durante la rotación puede haber varias firmas v1 separadas por coma: basta una válida. Rechaza timestamps fuera de una tolerancia de 300 segundos y compara en tiempo constante. Body: { id, event, livemode, createdAt, data }. id es el identificador estable de entrega; el secreto nunca viaja en el body. Entrega al menos una vez: deduplica por id. Persiste el evento antes de responder 2xx y procesa tu lógica después. Timeout 10 segundos, hasta 5 intentos totales, backoff exponencial desde 30 segundos (30, 60, 120, 240). Un timeout o cualquier respuesta fuera de 2xx cuenta como fallo. Cada intento firma el mismo evento con un timestamp nuevo. El historial de entregas está en el admin y en get_integration_status. Enviar prueba usa datos ficticios y livemode: false; también puedes probar publicaciones completas con una key TEST.
Verificar en Node.js
Ejemplo con Express. Define persistOnce(id, event) en tu almacenamiento: una inserción durable con id único debe ignorar duplicados. Si falla la persistencia, responde 5xx. Configura TOCHIHUB_WEBHOOK_SECRET en tu receptor, sin imprimirlo.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
const secret = process.env.TOCHIHUB_WEBHOOK_SECRET;
if (!secret) throw new Error('Falta TOCHIHUB_WEBHOOK_SECRET');
app.post('/webhooks/tochihub', express.raw({ type: 'application/json' }), async (req, res) => {
const timestampText = req.get('X-TochiHub-Timestamp') ?? '';
const timestamp = Number(timestampText);
const signatures = (req.get('X-TochiHub-Signature') ?? '').split(',');
const fresh = /^\d+$/.test(timestampText) &&
Number.isSafeInteger(timestamp) && Math.abs(Date.now() / 1000 - timestamp) <= 300;
const expected = createHmac('sha256', secret)
.update(timestampText + '.').update(req.body).digest();
const valid = fresh && signatures.some((part) => {
const match = /^v1=([a-f0-9]{64})$/.exec(part.trim());
return match !== null && timingSafeEqual(Buffer.from(match[1], 'hex'), expected);
});
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
await persistOnce(event.id, event);
res.sendStatus(204);
});
app.listen(8080);LEAD_CREATED
Llegó un lead nuevo (desde un portal, el sitio o cargado a mano) asociado a una propiedad.
{
"id": "delivery-ejemplo",
"event": "LEAD_CREATED",
"livemode": false,
"createdAt": "2026-10-05T14:03:00.000Z",
"data": {
"leadId": "cm1lead0000000000000000",
"propertyId": "cm1prop0000000000000000",
"channel": "PORTAL_CONTACT",
"contactName": "Camila Rojas",
"contactPhone": "+56912345678",
"contactEmail": "camila@example.com",
"message": "Hola, ¿sigue disponible el departamento?",
"createdAt": "2026-10-05T14:03:00.000Z"
}
}PROPERTY_CREATED
Se creó una propiedad (en el panel, por WhatsApp o por la API).
{
"id": "delivery-ejemplo",
"event": "PROPERTY_CREATED",
"livemode": false,
"createdAt": "2026-10-05T14:03:00.000Z",
"data": {
"propertyId": "cm1prop0000000000000000",
"externalId": "crm-prop-12345",
"title": "Departamento 2D2B en Providencia",
"operationType": "SALE",
"status": "DRAFT",
"createdAt": "2026-10-05T14:03:00.000Z"
}
}PROPERTY_PUBLICATION_COMPLETED
Terminó una publicación, actualización o baja en un portal (con éxito o con error definitivo; nunca en un reintento).
{
"id": "delivery-ejemplo",
"event": "PROPERTY_PUBLICATION_COMPLETED",
"livemode": false,
"createdAt": "2026-10-05T14:03:00.000Z",
"data": {
"propertyId": "cm1prop0000000000000000",
"externalId": "crm-prop-12345",
"portalCode": "YAPO",
"action": "PUBLISH",
"succeeded": false,
"errorMessage": "Yapo requires the built area (square, m²) on the property.",
"errorDescription": "Falta la superficie construida (m²)."
}
}PROPERTY_UPDATED
Se editó una propiedad o su galería (en el panel o por la API). Trae el estado actual; consulta GET /v1/properties/{id} para el detalle.
{
"id": "delivery-ejemplo",
"event": "PROPERTY_UPDATED",
"livemode": false,
"createdAt": "2026-10-05T14:03:00.000Z",
"data": {
"propertyId": "cm1prop0000000000000000",
"externalId": "crm-prop-12345",
"status": "PUBLISHED",
"updatedAt": "2026-10-05T14:03:00.000Z"
}
}PROPERTY_STATUS_CHANGED
Una propiedad se pausó, se reanudó o se archivó (en el panel o por la API).
{
"id": "delivery-ejemplo",
"event": "PROPERTY_STATUS_CHANGED",
"livemode": false,
"createdAt": "2026-10-05T14:03:00.000Z",
"data": {
"propertyId": "cm1prop0000000000000000",
"externalId": "crm-prop-12345",
"previousStatus": "PUBLISHED",
"status": "PAUSED",
"changedAt": "2026-10-05T14:03:00.000Z"
}
}INBOX_MESSAGE_RECEIVED
Entró un mensaje nuevo a la bandeja de entrada.
{
"id": "delivery-ejemplo",
"event": "INBOX_MESSAGE_RECEIVED",
"livemode": false,
"createdAt": "2026-10-05T14:03:00.000Z",
"data": {
"conversationId": "cm1conv0000000000000000",
"messageId": "cm1msg00000000000000000",
"channel": "WHATSAPP",
"contactName": "Camila Rojas",
"contactPhone": "+56912345678",
"body": "¿Puedo visitar el depto el sábado?"
}
}TICKET_CREATED
Se creó un ticket de seguimiento (manual o porque el asistente derivó la conversación).
{
"id": "delivery-ejemplo",
"event": "TICKET_CREATED",
"livemode": false,
"createdAt": "2026-10-05T14:03:00.000Z",
"data": {
"ticketId": "cm1tick0000000000000000",
"title": "Cliente pide visita el sábado",
"priority": "MEDIUM",
"source": "BOT_FALLBACK",
"conversationId": "cm1conv0000000000000000"
}
}