Webhooks
Reciba notificaciones cuando ocurran cosas en Crisphive — en lugar de sondear, hacemos POST de los eventos a su URL.
Cómo funciona
Un miembro del equipo registra su endpoint HTTPS en el panel de Crisphive y elige qué eventos recibir (o todos). Obtiene un secreto de firma (whsec_…) una sola vez — guárdelo. Aquí también elige la duración del secreto — 30 días de forma predeterminada, de 1 hasta 365 (vea Duración y rotación del secreto).
El registro envía de inmediato un ping de verificación firmado. Responda con 2xx y el endpoint nace active; de lo contrario permanece en pending_verification (no recibe nada) hasta que pase la Verificación desde el panel. Cambiar la URL requiere volver a verificar.
Cuando ocurre un evento, Crisphive envía un POST a su URL con un cuerpo JSON y un encabezado Crisphive-Signature.
Su endpoint verifica la firma, devuelve 2xx rápidamente y procesa de forma asíncrona.
Respuesta distinta de 2xx / tiempo de espera ⇒ reintentamos con backoff (1m, 5m, 30m, 2h, 6h). Tras 5 fallos consecutivos (cualquier éxito reinicia el contador) el endpoint se desactiva automáticamente y se envía un correo a los Propietarios/Administradores del negocio. Reactivarlo requiere una Verificación exitosa, que también reinicia el contador.
Cada entrega queda registrada — el panel expone el historial de entregas completo (estado, intentos, último código HTTP/error) por endpoint.
id del evento (upsert, nunca agregue a ciegas).Duración y rotación del secreto
Los secretos de firma de webhook expiran, y se rotan en el mismo lugar. Usted elige la duración del secreto al registrar el endpoint — 30 días de forma predeterminada, de 1 hasta 365. A diferencia de una clave de API, un endpoint de webhook es una única URL registrada y no puede duplicarse en paralelo (registrar la misma URL dos veces le entregaría cada evento dos veces). Así que, en lugar de un segundo endpoint, se rota el secreto:
Developers → Webhooks → Rotate secret emite un nuevo whsec_ y sigue firmando con el secreto nuevo y el anterior durante 24 horas. Rotar también reinicia la duración — usted elige una nueva al rotar (30 días de forma predeterminada, de 1 hasta 365) — así que es además la vía de renovación.
Los Propietarios y Administradores del negocio reciben un correo 7 días antes de que un secreto de firma expire, y de nuevo una vez que ha expirado. Los endpoints registrados antes de que existieran las duraciones de secreto no llevan secret_expires_at y siguen firmando como antes — su primera rotación los pasa a la duración estándar.
409 WEBHOOK_SECRET_EXPIRED en lugar de rearmar el endpoint.Catálogo de eventos
| Tipo de evento | Cuándo se activa |
|---|---|
job_request.created | Se creó una solicitud de trabajo (reserva) — vía la API, la página de reservas pública o el panel. |
job_request.confirmed | El cliente confirmó una franja horaria; el trabajo queda agendado (y se asigna un técnico/equipo). |
job_request.assigned | Se asignó o reasignó un técnico/equipo al trabajo. |
job_request.completed | El trabajo alcanzó su estado terminal de completado. |
job_request.archived | El trabajo se archivó (cancelado / cerrado). |
job_request.status_changed | Cualquier otra transición de estado del flujo (p. ej. on_the_way, arrived, personalizado). Incluye previous_status. |
job_request.priority_changed | Cambió la prioridad del trabajo. |
job_request.rescheduled | El trabajo se movió a otra hora o a otro técnico/equipo. |
customer.created | Se creó un registro de cliente. |
customer.updated | Cambió el perfil, el contacto, el nivel o el estado de un cliente. |
customer.deleted | Se eliminó un registro de cliente. |
technician.created | Un técnico se incorporó al equipo (incluida la readmisión de un miembro eliminado anteriormente). |
technician.updated | Cambió el perfil, el grupo de rol, el nivel o el estado de un técnico. Las escrituras que solo afectan relaciones (buddies/vehículos/habilidades/zonas de servicio) no disparan eventos. |
technician.deleted | Un técnico fue eliminado del equipo. |
Un cambio de estado dispara exactamente un evento (su hito específico si lo tiene, o si no status_changed). El catálogo es de solo ampliación — pueden agregarse nuevos tipos de evento; los existentes nunca cambian de significado.
Payload
El cuerpo de cada entrega es este sobre. data.object es el recurso con la misma forma que el GET correspondiente de /v1 (consulte /v1 para obtener el detalle completo/actualizado si lo necesita).
{
"id": "evt_2b9c…", // unique event id — dedupe on this
"type": "job_request.completed",
"created_at": "2026-06-30T10:00:00Z",
"business_id": "b1f0…",
"environment": "live", // or "sandbox"
"data": { "object": { "id": "job_…", "short_code": "REQ-…", "status": "completed" } },
"previous_status": "arrived" // only on job_request.status_changed
}Verificar la firma
Formato del encabezado: Crisphive-Signature: t=<unix>,v1=<hex> donde v1 = HMAC_SHA256(secret, t + "." + rawBody). Calcúlelo sobre el cuerpo de la solicitud sin procesar, compare en tiempo constante y rechace si t es demasiado antiguo (p. ej. > 5 min) para frenar los replays.
Verifique contra una lista de firmas, no contra un único valor. Durante la ventana de gracia de rotación de 24 horas el encabezado lleva dos entradas v1:
Crisphive-Signature: t=1753857711,v1=<signature-new>,v1=<signature-old>
v1 es la versión del algoritmo de firma, no un identificador de secreto. Calcule el HMAC con su secreto almacenado y acepte la entrega si cualquier entrada v1 coincide. Un receptor codificado para leer solo la primera v1 empezará a rechazar eventos a mitad de una rotación.
Node.js
const crypto = require('crypto'); function verify(rawBody, header, secret) { const parts = header.split(',').map(kv => kv.split('=')); const t = parts.find(([k]) => k === 't')[1]; // two v1 entries during the 24h rotation window — accept any match const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v); const expected = crypto.createHmac('sha256', secret) .update(t + '.' + rawBody).digest('hex'); return sigs.some(v1 => v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))); }
Python
import hmac, hashlib def verify(raw_body: bytes, header: str, secret: str) -> bool: pairs = [kv.split('=', 1) for kv in header.split(',')] t = next(v for k, v in pairs if k == 't') # two v1 entries during the 24h rotation window — accept any match sigs = [v for k, v in pairs if k == 'v1'] expected = hmac.new(secret.encode(), t.encode() + b'.' + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, v1) for v1 in sigs)
Go
func Verify(rawBody []byte, header, secret string) bool { var t string var sigs []string // two v1 entries during the 24h rotation window for _, kv := range strings.Split(header, ",") { kvp := strings.SplitN(kv, "=", 2) switch kvp[0] { case "t": t = kvp[1] case "v1": sigs = append(sigs, kvp[1]) } } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) for _, v1 := range sigs { if hmac.Equal([]byte(expected), []byte(v1)) { return true } } return false }
Java
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.security.MessageDigest; import java.util.*; public static boolean verify(byte[] rawBody, String header, String secret) throws Exception { String t = null; List<String> sigs = new ArrayList<>(); // two v1 entries during the 24h rotation window for (String kv : header.split(",")) { String[] x = kv.split("=", 2); if (x[0].equals("t")) t = x[1]; if (x[0].equals("v1")) sigs.add(x[1]); } Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); mac.update((t + ".").getBytes()); byte[] sig = mac.doFinal(rawBody); StringBuilder hex = new StringBuilder(); for (byte b : sig) hex.append(String.format("%02x", b)); byte[] expected = hex.toString().getBytes(); for (String v1 : sigs) { if (MessageDigest.isEqual(expected, v1.getBytes())) return true; } return false; }
C# / .NET
using System;
using System.Security.Cryptography;
using System.Collections.Generic;
using System.Text;
public static bool Verify(byte[] rawBody, string header, string secret) {
string t = null;
var sigs = new List<string>(); // two v1 entries during the 24h rotation window
foreach (var kv in header.Split(',')) {
var x = kv.Split('=', 2);
if (x[0] == "t") t = x[1];
if (x[0] == "v1") sigs.Add(x[1]);
}
var prefix = Encoding.UTF8.GetBytes(t + ".");
var input = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, input, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, input, prefix.Length, rawBody.Length);
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Encoding.UTF8.GetBytes(
Convert.ToHexString(mac.ComputeHash(input)).ToLowerInvariant());
foreach (var v1 in sigs) {
if (CryptographicOperations.FixedTimeEquals(
expected, Encoding.UTF8.GetBytes(v1))) return true;
}
return false;
}PHP
<?php function verify(string $rawBody, string $header, string $secret): bool { $t = null; $sigs = []; // two v1 entries during the 24h rotation window foreach (explode(',', $header) as $kv) { [$k, $v] = explode('=', $kv, 2); if ($k === 't') { $t = $v; } if ($k === 'v1') { $sigs[] = $v; } } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sigs as $v1) { if (hash_equals($expected, $v1)) { return true; } } return false; }
Ruby
require 'openssl' def verify(raw_body, header, secret) pairs = header.split(',').map { |kv| kv.split('=', 2) } t = pairs.find { |k, _| k == 't' }[1] # two v1 entries during the 24h rotation window — accept any match sigs = pairs.select { |k, _| k == 'v1' }.map { |_, v| v } expected = OpenSSL::HMAC.hexdigest('SHA256', secret, t + '.' + raw_body) # constant-time compare (Rack::Utils.secure_compare / ActiveSupport::SecurityUtils.secure_compare) sigs.any? { |v1| Rack::Utils.secure_compare(expected, v1) } end
Pruebas
Use Enviar prueba en su endpoint desde el panel para recibir un evento ping firmado y confirmar que su receptor y el manejo de la firma funcionan de extremo a extremo (solo diagnóstico — el estado nunca cambia). Use Verificar para activar un endpoint en pending_verification o desactivado: el mismo ping firmado, pero un 2xx lo cambia a active y reinicia el contador de fallos. Si el secreto de firma caducó, Verificar se niega con WEBHOOK_SECRET_EXPIRED — rote el secreto primero.