es

Webhooks

Reciba notificaciones cuando ocurran cosas en Crisphive — en lugar de sondear, hacemos POST de los eventos a su URL.

Cómo funciona

1
Registre su endpoint

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).

2
Ping de verificación

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.

3
Hacemos POST de los eventos

Cuando ocurre un evento, Crisphive envía un POST a su URL con un cuerpo JSON y un encabezado Crisphive-Signature.

4
Verifique y confirme

Su endpoint verifica la firma, devuelve 2xx rápidamente y procesa de forma asíncrona.

5
Reintentos y desactivación automática

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.

6
Historial de entregas

Cada entrega queda registrada — el panel expone el historial de entregas completo (estado, intentos, último código HTTP/error) por endpoint.

Entrega al menos una vez. Una entrega puede repetirse — deduplique por el 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.

Si se deja caducar un secreto, el endpoint se desactiva y deja de recibir entregas. Rote primero y luego ejecute Verificar para reanudar las entregas — Verificar sobre un secreto caducado devuelve 409 WEBHOOK_SECRET_EXPIRED en lugar de rearmar el endpoint.

Catálogo de eventos

Tipo de eventoCuándo se activa
job_request.createdSe creó una solicitud de trabajo (reserva) — vía la API, la página de reservas pública o el panel.
job_request.confirmedEl cliente confirmó una franja horaria; el trabajo queda agendado (y se asigna un técnico/equipo).
job_request.assignedSe asignó o reasignó un técnico/equipo al trabajo.
job_request.completedEl trabajo alcanzó su estado terminal de completado.
job_request.archivedEl trabajo se archivó (cancelado / cerrado).
job_request.status_changedCualquier otra transición de estado del flujo (p. ej. on_the_way, arrived, personalizado). Incluye previous_status.
job_request.priority_changedCambió la prioridad del trabajo.
job_request.rescheduledEl trabajo se movió a otra hora o a otro técnico/equipo.
customer.createdSe creó un registro de cliente.
customer.updatedCambió el perfil, el contacto, el nivel o el estado de un cliente.
customer.deletedSe eliminó un registro de cliente.
technician.createdUn técnico se incorporó al equipo (incluida la readmisión de un miembro eliminado anteriormente).
technician.updatedCambió 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.deletedUn 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.