Saltar al artículo

Recibir webhooks

Hacemos un POST a tu URL cuando algo pasa aquí, firmado para que sepas que fuimos nosotros. Los webhooks y Slack están en los planes Pro y Team.

O solo Slack

Si lo que quieres es enterarte, y no construir algo, conecta Slack: Ajustes, luego Slack. Pegas una URL de incoming webhook de Slack (Apps → Incoming Webhooks → Add to Slack, donde eliges el canal), y llega un mensaje a ese canal cuando alguien responde un formulario, cuando alguien comenta o cuando un sitio se publica.

En un formulario, el mensaje dice qué campos llenó la persona, nunca lo que escribió. En un comentario, lo que se dijo y el nombre de quien comentó, con un enlace a la conversación. Un canal llega a más gente que tu bandeja de entrada, y no nos corresponde difundir ahí el correo de nadie.

Agregar un webhook

Ajustes, luego Webhooks. Das una URL https y eliges de qué quieres enterarte. Nosotros te damos un secreto de firma, una sola vez. Lo guardamos para firmar, no para mostrarlo, así que no hay forma de volver a verlo, solo de reemplazar el webhook.

Qué enviamos

Un cuerpo JSON con un campo event, y dos encabezados:solta-event y solta-signature. Los eventos son site.published, version.created, submission.received, site.deleted, comment.created y comment.resolved.

Un comentario, o una respuesta, llega como comment.created. El autor es un nombre, nunca un correo, y url abre la conversación en la versión donde se dejó:

{
  "event": "comment.created",
  "siteId": "6f1c…",
  "slug": "quote-builder",
  "title": "Quote builder",
  "version": 3,
  "versionState": "draft",
  "comment": {
    "id": "a41e…",
    "threadId": "a41e…",
    "parentId": null,
    "author": "Ana",
    "body": "The total should include VAT.",
    "createdAt": "2026-10-12T09:41:07.000Z"
  },
  "url": "https://solta.now/s/quote-builder/v/3?panel=comments&thread=a41e…"
}

parentId es el comentario al que se respondió, o null en una conversación nueva, cuyo threadId es su propio id. comment.resolved tiene los mismos campos de sitio y de versión, y un comment con su id, threadId y resolvedAt.

Verificar la firma

El encabezado se ve así: t=1757155200,v1=9f86d0…. El digest es un HMAC-SHA256 del timestamp, un punto y el cuerpo exacto que enviamos. Por eso, verifica los bytes sin procesar, antes de que cualquier parsing de JSON reordene algo.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=")),
  );
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!(age < 300)) return false;            // demasiado viejo para ser legítimo

  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  return parts.v1.length === expected.length &&
    timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}

El timestamp va dentro de lo que se firma, no al lado. Eso es lo que impide que alguien reenvíe hoy, contra tu reloj, una entrega que capturó la semana pasada.

Cuando la entrega falla

Todo lo que no sea un 2xx se reintenta cinco veces, con un intervalo cada vez mayor. Un 4xx también se reintenta: desde aquí, un rechazo definitivo y una ruta mal configurada se ven idénticos, y rendirse en el primer 404 haría perder eventos mientras corriges una ruta.

No leemos el cuerpo de tu respuesta. El tiempo de espera es de diez segundos: responde primero y trabaja después.