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;
localhosty las direcciones privadas se rechazan. - Responde un
GETsin redirección. Cualquier otra respuesta pasa, así que la ruta solo necesita aceptarPOST.
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
| Tipo | Cuándo | data.object |
|---|---|---|
estimate.completed | Una estimación asíncrona terminó con una respuesta. | La estimación |
estimate.failed | Una estimación asíncrona no pudo terminar. No se cobró nada. | La estimación, con error |
credits.low | Los 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.exhausted | Los créditos de producción llegaron a cero. | El saldo |
api_key.rotated | Se rotó una llave. data.previous dice cuándo deja de funcionar la anterior. | La llave nueva |
api_key.expiring | Una llave con fecha de caducidad deja de funcionar en menos de 14 días. | La llave |
api_key.suspicious_activity | Una llave se comportó distinto de lo habitual. data.anomaly dice qué regla saltó y si la llave quedó en pausa. | La llave |
webhook.test | Enviaste 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>.tes 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:
- Separa la cabecera por comas. Lee
ty cadav1. - Arma el texto firmado:
t, un punto y luego el cuerpo tal como llegó. - Calcula el HMAC-SHA256 de ese texto con tu secreto.
- Compáralo con cada
v1en tiempo constante, y rechaza unta 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.
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);
});
}
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)
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:
| Reintento | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Espera antes | 1 min | 5 min | 30 min | 2 h | 6 h | 12 h | 24 h | 24 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.