Saltar al contenido principal

Webhooks

Un endpoint de webhook es una URL tuya a la que Hometown llama con POST cuando pasa algo en tu cuenta. Por ejemplo, terminó una estimación asíncrona, quedan pocos créditos o se rotó una llave. Cada llamada va firmada, así puedes comprobar que viene de Hometown.

Agrega un endpoint​

Agrega endpoints en el panel, o con una llave que tenga el permiso webhooks:manage:

curl https://api.wfskills.com/v1/webhook_endpoints \
-H "Authorization: Bearer $HOMETOWN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/hometown", "description": "production", "events": ["estimate.completed", "estimate.failed", "credits.low"]}'

La respuesta trae el secreto de firma del endpoint en secret, que empieza con hwsec_. Solo se muestra esta vez, y esta llamada no recibe Idempotency-Key, así que no se guarda ninguna copia del secreto para repetirla. Guárdalo junto a tus otros secretos.

Hometown revisa la URL antes de guardarla:

  • Usa HTTPS, en el puerto 443 o en uno del 1024 al 65535.
  • No lleva usuario, contraseña ni fragmento.
  • Su host resuelve solo a direcciones públicas; localhost y las direcciones privadas se rechazan.
  • Responde un GET sin redirección. Cualquier otra respuesta pasa, así que la ruta solo necesita aceptar POST.

Si la URL no pasa una revisión, la petición responde 422 con el código webhook_url_rejected, y detail dice qué regla no cumple. Para probar en tu propia máquina, publícala con un túnel HTTPS.

Un entorno tiene como máximo 16 endpoints activos; uno más responde 422 con el código webhook_endpoint_limit. Los endpoints pertenecen a un entorno. Una llave de prueba administra los endpoints de prueba, y los eventos que causan las llaves de prueba, entradas del sandbox incluidas, van a ellos. Las llaves y los endpoints de producción funcionan igual.

Tipos de evento​

TipoCuándodata.object
estimate.completedUna estimación asíncrona terminó con una respuesta.La estimación
estimate.failedUna estimación asíncrona no pudo terminar. No se cobró nada.La estimación, con error
credits.lowLos créditos de producción bajaron al 20 % o menos de lo que tenían al empezar tus lotes vigentes. Se revisa cada hora y se envía una vez hasta que se suman créditos.El saldo
credits.exhaustedLos créditos de producción llegaron a cero.El saldo
api_key.rotatedSe rotó una llave. data.previous dice cuándo deja de funcionar la anterior.La llave nueva
api_key.expiringUna llave con fecha de caducidad deja de funcionar en menos de 14 días.La llave
api_key.suspicious_activityUna llave se comportó distinto de lo habitual. data.anomaly dice qué regla saltó y si la llave quedó en pausa.La llave
webhook.testEnviaste un evento de prueba al endpoint.El endpoint

Pueden agregarse tipos nuevos. Ignora un tipo que no manejes.

Todos los eventos tienen la misma forma. data.object es el recurso tal como lo devuelve la API, en la versión de la API de tu cuenta:

{
"id": "evt_01J9Z6X1M4QK2V7H8T3R5N0BCD",
"object": "event",
"type": "estimate.completed",
"api_version": "2026-10-05",
"environment": "live",
"created_at": "2026-10-09T15:04:05+00:00",
"data": { "object": { "id": "est_01J9Z6X1M4QK2V7H8T3R5N0BCE", "object": "estimate", "status": "priced" } }
}

Revisa la firma​

Cada llamada es un POST con Content-Type: application/json, User-Agent: Hometown-Webhooks/1.0 y tres cabeceras propias:

  • Hometown-Signature: t=<segundos unix>,v1=<firma>. t es la hora de este intento, así que cada reintento trae uno nuevo.
  • Hometown-Event-Id: el id del evento.
  • Hometown-Version: la versión de la API del cuerpo.

La firma es el HMAC-SHA256 en hexadecimal de la marca de tiempo, un punto y el cuerpo sin procesar. El HMAC usa el secreto completo del endpoint, con el prefijo hwsec_. Revísala en cuatro pasos:

  1. Separa la cabecera por comas. Lee t y cada v1.
  2. Arma el texto firmado: t, un punto y luego el cuerpo tal como llegó.
  3. Calcula el HMAC-SHA256 de ese texto con tu secreto.
  4. Compáralo con cada v1 en tiempo constante, y rechaza un t a más de 5 minutos de tu reloj.

Usa el cuerpo sin procesar. Si lo interpretas y lo vuelves a serializar, cambian los espacios o el orden de los campos, y la firma ya no coincide.

Node.js
import crypto from 'node:crypto';

export function verifyHometownSignature(rawBody, header, secret, toleranceSeconds = 300) {
if (typeof header !== 'string') return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const [key, value = ''] = part.trim().split('=', 2);
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === 'v1' && value !== '') signatures.push(value);
}
if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'));
return signatures.some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
Python
import hashlib
import hmac
import time


def verify_hometown_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isascii() and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or abs(time.time() - timestamp) > tolerance:
return False

expected = hmac.new(secret.encode(), str(timestamp).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), signature.encode()) for signature in signatures)
PHP
function verifyHometownSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($timestamp === null || abs(time() - $timestamp) > $tolerance) {
return false;
}

$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}

return false;
}

En Express, lee el cuerpo sin procesar con express.raw({ type: 'application/json' }) en la ruta del webhook. En Laravel, usa $request->getContent(). En Flask, usa request.get_data(). Si la revisión falla, responde 400 e ignora el evento.

Responde rápido​

Responde con cualquier estado 2xx en menos de 10 segundos. Haz el trabajo después de responder, en una cola tuya. Hometown no sigue redirecciones: un 3xx cuenta como fallo.

Reintentos​

Una entrega que no recibe un 2xx se intenta 8 veces más, a lo largo de unos tres días:

Reintento12345678
Espera antes1 min5 min30 min2 h6 h12 h24 h24 h

El último reintento también puede fallar. Si el endpoint no respondió ningún otro evento en esos tres días, se deshabilita y el dueño de la cuenta recibe un correo. Mientras está deshabilitado no recibe eventos y los reintentos pendientes se detienen. Vuelve a habilitarlo en el panel, o con PATCH /v1/webhook_endpoints/{id} y {"status": "active"}, cuando ya responda. Después reenvía los eventos que te faltaron con POST /v1/events/{id}/redeliver.

Por los reintentos, un evento puede llegar más de una vez, y los eventos pueden llegar en otro orden. Guarda el Hometown-Event-Id de los eventos que procesaste y omite uno que ya tengas. Para actuar sobre el estado más reciente, vuelve a leer el recurso con la API.

Prueba un endpoint​

POST /v1/webhook_endpoints/{id}/test, o Send a test event en el panel, envía webhook.test solo a ese endpoint, firmado como cualquier otro evento. Llega incluso a un endpoint deshabilitado, para que compruebes un arreglo antes de habilitarlo, y se intenta una sola vez, sin reintentos. GET /v1/events/{id} muestra lo que respondió tu endpoint.

Rota el secreto​

POST /v1/webhook_endpoints/{id}/rotate_secret devuelve un secreto nuevo. Durante las siguientes 24 horas cada entrega trae dos valores v1, uno por secreto, así un receptor que todavía tiene el secreto anterior sigue funcionando. Pon el secreto nuevo en tus servidores antes de que pasen 24 horas. Si rotas otra vez en esas 24 horas, el secreto más antiguo deja de firmar de inmediato.

Lista y reenvía eventos​

GET /v1/events lista los eventos de los últimos 30 días, del más reciente al más antiguo, y type los filtra. GET /v1/events/{id} agrega cada intento de entrega con el estado que respondió tu endpoint.

POST /v1/events/{id}/redeliver envía un evento otra vez con el mismo cuerpo y el mismo Hometown-Event-Id. Por defecto va a cada endpoint activo que escucha su tipo. Con {"endpoint": "whe_..."} va solo a ese endpoint, si está activo. Un evento de más de 30 días responde 422 con el código event_not_redeliverable, o 404 con not_found cuando Hometown ya lo borró.

Permite la dirección de origen​

Las entregas salen de 35.209.19.212. Si tu firewall solo deja entrar direcciones conocidas, agrega esa. Revisar la firma sigue siendo necesario: una dirección por sí sola no prueba quién envió la llamada.