Recibir webhooks
Un webhook es la alternativa a preguntar. Consultar un mercado cada cinco minutos para pillar un cambio que ocurre dos veces al día es la fuente más común de cuota desperdiciada, y una entrega no cuesta cuota en absoluto.
Crear un endpoint
curl -sS -X POST "https://api.skautik.com/v1/api/webhooks" \
-H "Authorization: Bearer $SKAUTIK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/skautik",
"events": ["property.created", "property.updated", "property.withdrawn"]
}'La respuesta lleva el secreto de firma una sola vez:
{
"data": {
"webhook": { "id": "whk_1b7e6c19a1", "url": "…", "active": true },
"secret": "whsec_uEw7ugUA3saVyNo087Sg3XvhBnWI0txICtik-ySKD2k"
}
}Guárdalo antes de cerrar la respuesta. No se muestra nunca más, y la única recuperación es rotarlo, lo que invalida el anterior.
Eventos
| Evento | Cuándo |
|---|---|
property.created | Se publicó un inmueble, por ti, por una importación o por una agencia. |
property.updated | Cambió cualquier campo, incluido un cambio de precio. |
property.withdrawn | Un inmueble salió del mercado. No es un borrado: la ficha sigue siendo legible. |
inquiry.created | Alguien consultó por uno de tus inmuebles. |
Suscríbete a lo que vayas a atender. Un endpoint suscrito a todo que
filtra en código sigue pagando el coste de recibirlo todo, y una importación con mucho
movimiento convierte property.updated en el evento más ruidoso de la plataforma.
Verificar la firma
Haz esto antes de leer el cuerpo. Un endpoint de webhook sin verificar es una API pública que escribe en tu base de datos a petición de cualquiera que adivine la URL.
Cada entrega lleva tres cabeceras:
Skautik-Signature: t=1786680348,v1=8f2a41c9d0b7e6c19a139404cb173d23fcb3331c4e…
Skautik-Event: property.updated
Skautik-Delivery: dlv_9c4e1f2308La firma es HMAC-SHA256(secret, timestamp + "." + body), en hexadecimal. La
marca de tiempo va dentro de la firma y no solo al lado, que es la parte
que importa: si solo se firmara el cuerpo, una entrega capturada podría reproducirse
contra tu endpoint eternamente y la firma seguiría coincidiendo.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((piece) => piece.split("=") as [string, string]),
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) {
return false;
}
// Rechaza cualquier cosa de más de cinco minutos. Sin esto la firma es
// válida para siempre y una petición capturada nunca deja de funcionar.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) {
return false;
}
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// En tiempo constante. Un === normal filtra la posición del primer byte
// equivocado, que basta para falsificar una firma con suficientes intentos.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(piece.split("=", 1) for piece in header.split(","))
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)Firma los bytes en bruto, no un objeto vuelto a serializar. Parsear el JSON y volver a volcarlo cambia el orden de las claves y los espacios, y la firma no coincidirá nunca. A la mayoría de los frameworks hay que decirles que conserven el cuerpo en bruto.
Contestar
Contesta 2xx en cuanto hayas verificado la firma y escrito el contenido
en algún sitio duradero. Haz el trabajo de verdad después, en una cola.
Un endpoint que geocodifica una dirección, escribe en tres tablas y manda un correo antes de contestar es un endpoint que expira bajo carga, lo que convierte una entrega en seis cuando reintentamos, lo que empeora la carga.
Cualquier cosa que no sea un 2xx es un fallo y se reintentará.
Reintentos
Una entrega fallida se reintenta hasta seis intentos con espera exponencial.
next_retry_at en la ficha de entrega dice cuándo toca el siguiente.
Las entregas son al menos una vez, no exactamente una vez. Una expiración por tu lado después
de que hubieras confirmado sigue contando como fallo para nosotros, y volverás a ver el evento.
Haz idempotente el tratamiento: usa como clave Skautik-Delivery, o el id de la ficha y su
updated_at, y trata una repetición como algo que no hace nada.
El orden tampoco está garantizado. Dos actualizaciones de un inmueble pueden llegar al
revés, así que compara updated_at antes de sobrescribir en lugar de fiarte del
orden de llegada.
Cuando algo va mal
GET /v1/api/webhooks/{webhook_id}/deliveries enumera lo que intentamos, con el
código de estado que devolviste y cuántos intentos hicieron falta. Es el primer sitio donde
mirar cuando los datos han dejado de llegar, y suele responder la pregunta antes de que
abras tus propios registros.
POST /v1/api/webhooks/{webhook_id}/test envía una entrega sintética, que es
la forma rápida de comprobar que un endpoint nuevo es accesible y verifica correctamente
antes de que dependas de él.
Una lista de comprobación
- Verifica la firma antes de leer el cuerpo.
- Rechaza las entregas de más de unos minutos.
- Compara firmas en tiempo constante.
- Contesta rápido; trabaja después.
- Gestiona repeticiones y llegadas desordenadas.
- Suscríbete solo a los eventos que atiendes.
- Mantén el secreto fuera de tu repositorio, como cualquier otra credencial.