Saltar al contenido principal

Webhooks

Con webhooks, Cortecloud avisa a su sistema cuando cambia el estado de un servicio de la central: en cada cambio, hace un POST firmado a una URL de su sistema, el destino. Así su integración no necesita llamar a GET /services repetidamente para descubrir cambios.

Cuándo usarlo​

Sin webhook, la forma de detectar cambios es listar los servicios periódicamente. GET /services acepta 1 solicitud cada 5 segundos (ver Errores, límites y paginación), y la mayor parte de esas llamadas no trae nada nuevo, pero consume la cuota de la ruta.

Use webhook cuando su sistema necesite reaccionar a cambios de estado, por ejemplo para importar en el ERP un servicio que acaba de ser aprobado. El evento indica qué servicio cambió y a qué estado; el contenido completo sigue viniendo de GET /services/{id}.

El webhook no reemplaza del todo la consulta a la API: las entregas pueden fallar después de agotar los reintentos, y no todo cambio genera un evento propio (ver Duplicados, orden e idempotencia). Mantenga una conciliación esporádica con GET /services, filtrando por date_start, para cubrir lo que no llegó.

Cómo activarlo​

No existe una ruta de la API para registrar destinos: el registro lo hace Serrabits. Escriba a suporte@serrabits.com.br e informe:

Qué informarDetalles
URL del destinoDirección de su sistema que recibirá los POST. Debe usar https.
CentralEl código de la central, el mismo que su integración usa en x-company-internal-code. Si son varias centrales, indíquelas todas.
Tipo de eventoHoy solo existe service.status_changed (ver Tipos de evento).
AmbienteHomologación o producción. Cada ambiente tiene sus propios registros y credenciales.
Clave propia (opcional)Un secreto elegido por usted, como segundo factor de verificación (ver Clave propia). Envíela por un canal seguro.

Cada combinación de central, tipo de evento y URL es un destino. Para cada destino, Serrabits entrega un par de credenciales:

CredencialUso
api key del destino (sb_wk_…)Viene en el encabezado Authorization de cada entrega e indica qué secret usar en la verificación.
secret del destino (sb_ws_…)Serrabits la usa para firmar las entregas, y usted para verificar la firma. Se entrega una sola vez, en el registro: guárdela al recibirla.

Estas credenciales son distintas de la api key y la secret key con las que su integración llama a la API (ver Autenticación). Quien integra varias centrales recibe un par por central, aunque use la misma URL: guarde las secrets indexadas por la api key del destino. La secret del destino requiere los mismos cuidados que la secret key de la API: debe quedar solo en su backend.

Reglas de la URL​

  • El esquema debe ser https. Serrabits no registra URL http.
  • Se aceptan un puerto distinto del predeterminado (https://erp.example.com:8443/...) y una query string (?central=01), y ambos entran en la firma.
  • La URL se llama exactamente como fue registrada. Registre la URL final y no responda con redirección (3xx): la firma vale para la URL registrada, y la solicitud redirigida puede llegar sin cuerpo o sin el encabezado Authorization.
  • La autenticidad de la entrega la garantiza la firma, no el origen de red. No dependa de la IP de origen para aceptar o rechazar una entrega.

Cambios en el registro​

Para cambiar la URL, desactivar un destino o cambiar el secret, comuníquese con soporte (suporte@serrabits.com.br). Comportamientos que usted observará:

  • los eventos que ya estaban en la cola antes de un cambio de URL todavía se entregan en la URL anterior;
  • después de que un destino es revocado, las entregas pendientes para él dejan de enviarse.

Tipos de evento​

Tipo (event_type)Cuándo se dispara
service.status_changedEl código de estado de un servicio de la central cambió.

Un destino recibe solo los eventos del tipo y de la central para los que fue registrado.

El evento no se envía en el instante del cambio: el primer intento de entrega ocurre al menos 30 segundos después del cambio de estado, y puede tardar más. No hay un plazo máximo garantizado.

Formato de la solicitud​

Cada entrega es un POST a la URL del destino:

POST /webhooks/cortecloud HTTP/1.1
Host: erp.example.com
Authorization: SB1-HMAC-SHA256 api-key="<api key del destino>", signed-headers="host", signature="<firma>"
Content-Type: application/json
X-Webhook-Event: service.status_changed
X-Webhook-Id: <id del evento>
X-Webhook-Delivery: <id de la entrega>
X-Webhook-Client-Token: sha256=<hash de la clave propia>
EncabezadoContenido
AuthorizationFirma de la entrega, en el mismo esquema SB1-HMAC-SHA256 de la API. api-key es la api key del destino. Ver Verificar la autenticidad.
Content-TypeSiempre application/json. El cuerpo es JSON en UTF-8, en una sola línea.
X-Webhook-EventEl mismo valor de event_type del cuerpo.
X-Webhook-IdEl mismo valor de id del cuerpo.
X-Webhook-DeliveryIdentificador numérico de la entrega (un evento para un destino). Es igual en todos los intentos de la misma entrega. Útil para el log y para hablar con soporte.
X-Webhook-Client-TokenSolo para destinos registrados con clave propia. Ver Clave propia.

X-Webhook-Event, X-Webhook-Id y X-Webhook-Delivery no están firmados: sirven para enrutamiento y log. Para cualquier decisión, use los valores del cuerpo, que está cubierto por la firma.

Cuerpo de un service.status_changed (formateado aquí para facilitar la lectura; el cuerpo real llega en una sola línea):

{
"id": "3f3584f349fc00fc06ad324d89bf976fac5dc0df043c3fe43868ad5b175aed98",
"spec_version": "1",
"event_type": "service.status_changed",
"company_id": 123,
"company_internal_code": "CENTRAL-01",
"occurred_at": "2026-08-04T12:00:00.000Z",
"data": {
"service_id": 456,
"service_internal_code": "PED-789",
"status": 6,
"old_status": 4
}
}
CampoContenido
idIdentificador del evento: texto opaco de hasta 64 caracteres. Es el mismo en todo intento y en todo destino que recibe el evento. Es el campo para deduplicar.
spec_versionVersión del formato del cuerpo. Hoy, "1".
event_typeTipo del evento.
company_idIdentificador numérico de la central en Cortecloud.
company_internal_codeCódigo de la central, el mismo que su integración usa en x-company-internal-code.
occurred_atMomento del cambio, en ISO 8601 UTC. La precisión es de segundos (los milisegundos vienen en cero).
data.service_idid del servicio, el mismo de GET /services y GET /services/{id}.
data.service_internal_codeinternal_code del servicio (el número del pedido en su ERP), o null si el servicio todavía no fue asociado.
data.statusCódigo del nuevo estado.
data.old_statusCódigo del estado anterior.

Ignore los campos que su integración no conoce, para que un campo nuevo en el cuerpo no rompa su procesamiento.

Verificar la autenticidad​

Cualquiera que conozca la URL del destino puede enviarle un POST. Verifique la firma de cada entrega antes de procesarla y descarte las que no coincidan.

Qué se firma​

La firma usa el mismo texto firmado (canonical request) descrito en Cómo calcular la firma, con estas diferencias:

  • quien firma es Serrabits, con la secret del destino (no la secret key de su integración en la API); la api-key del encabezado Authorization indica qué secret usar;
  • el método es siempre POST;
  • la ruta, la query string y el host son los de la URL registrada: el host incluye :puerto solo cuando el puerto no es el predeterminado de https (443);
  • el hash del cuerpo se calcula sobre los bytes recibidos, antes de cualquier parse de JSON;
  • no hay encabezado x-company-internal-code: la central viene en el cuerpo.

Arme el texto a partir de la URL registrada (una constante de su configuración), y no del encabezado Host ni de la ruta que llegan a su servidor: proxies, balanceadores y frameworks pueden reescribir esos valores, y la firma deja de coincidir.

La firma cubre el cuerpo completo, incluidos id y occurred_at. No tiene una marca de tiempo propia: para rechazar reenvíos maliciosos de una entrega capturada, deduplique por id (ver Duplicados, orden e idempotencia).

Ejemplo para validar​

Destino registrado con la URL https://erp.example.com/webhooks/cortecloud, que recibe este cuerpo (los bytes exactos, en una línea):

{"id":"3f3584f349fc00fc06ad324d89bf976fac5dc0df043c3fe43868ad5b175aed98","spec_version":"1","event_type":"service.status_changed","company_id":123,"company_internal_code":"CENTRAL-01","occurred_at":"2026-08-04T12:00:00.000Z","data":{"service_id":456,"service_internal_code":"PED-789","status":6,"old_status":4}}

Texto firmado:

POST
/webhooks/cortecloud

host:erp.example.com

host
56cb1342b58572a917c94c09174ccbc794300b7fc3bd26c78b8950ce39b5631a

Con la secret de prueba sb_ws_exemplo, la firma es b184a379ea64a2af1210c4dd04e703b16f44e5227bc6c51f01b452f0c98020c6, y el encabezado recibido es:

Authorization: SB1-HMAC-SHA256 api-key="sb_wk_exemplo", signed-headers="host", signature="b184a379ea64a2af1210c4dd04e703b16f44e5227bc6c51f01b452f0c98020c6"

Si su implementación llega al mismo valor, verifica las entregas de la misma forma en que Serrabits las firma.

Código de referencia​

Los dos ejemplos reciben los encabezados (con nombres en minúsculas) y el cuerpo sin procesar, y devuelven el evento cuando la firma coincide. La comparación se hace en tiempo constante, para no revelar por el tiempo de respuesta cuántos caracteres de la firma son correctos.

Node.js​

const crypto = require('crypto');

// URL del destino, exactamente como fue registrada.
const WEBHOOK_URL = new URL('https://erp.example.com/webhooks/cortecloud');

// Secret de cada destino, indexada por la api key del destino.
const WEBHOOK_SECRETS = {
'<api key del destino>': '<secret del destino>',
};

function canonicalQueryString(url) {
const entries = [...url.searchParams.entries()].sort(([ak, av], [bk, bv]) =>
ak === bk ? av.localeCompare(bv) : ak.localeCompare(bk),
);
return entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
}

function buildCanonicalRequest(url, rawBody) {
const payloadHash = crypto.createHash('sha256').update(rawBody).digest('hex');
return [
'POST',
url.pathname || '/',
canonicalQueryString(url),
`host:${url.host}\n`,
'host',
payloadHash,
].join('\n');
}

function parseAuthorization(header) {
if (typeof header !== 'string' || !header.startsWith('SB1-HMAC-SHA256 ')) {
return null;
}
const params = {};
for (const [, name, value] of header.matchAll(/([a-z-]+)="([^"]*)"/g)) {
params[name] = value;
}
return params;
}

function safeEqual(a, b) {
const left = Buffer.from(a, 'utf-8');
const right = Buffer.from(b, 'utf-8');
return left.length === right.length && crypto.timingSafeEqual(left, right);
}

function verifyWebhook(headers, rawBody) {
const auth = parseAuthorization(headers['authorization']);
if (!auth || auth['signed-headers'] !== 'host') {
return null;
}
const secret = WEBHOOK_SECRETS[auth['api-key']];
if (!secret) {
return null;
}
const expected = crypto
.createHmac('sha256', secret)
.update(buildCanonicalRequest(WEBHOOK_URL, rawBody))
.digest('hex');
if (!safeEqual(expected, auth.signature ?? '')) {
return null;
}
return JSON.parse(rawBody.toString('utf-8'));
}

rawBody es el Buffer del cuerpo tal como llegó. En Express, por ejemplo, léalo con express.raw({ type: 'application/json' }) en la ruta del webhook: un express.json() antes de la verificación entrega el objeto ya interpretado, y volver a serializarlo no reproduce los bytes firmados.

Python​

import hashlib
import hmac
import json
import re
import urllib.parse

# URL del destino, exactamente como fue registrada.
WEBHOOK_URL = "https://erp.example.com/webhooks/cortecloud"

# Secret de cada destino, indexada por la api key del destino.
WEBHOOK_SECRETS = {
"<api key del destino>": "<secret del destino>",
}


def encode(value):
# safe="!*'()" reproduce el conjunto de caracteres que el
# encodeURIComponent de JavaScript deja sin escapar.
return urllib.parse.quote(str(value), safe="!*'()")


def canonical_query_string(query):
pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
return "&".join(
f"{encode(k)}={encode(v)}"
for k, v in sorted(pairs, key=lambda kv: (kv[0], kv[1]))
)


def build_canonical_request(url, raw_body):
parts = urllib.parse.urlsplit(url)
return "\n".join([
"POST",
parts.path or "/",
canonical_query_string(parts.query),
f"host:{parts.netloc}\n",
"host",
hashlib.sha256(raw_body).hexdigest(),
])


def parse_authorization(header):
if not header or not header.startswith("SB1-HMAC-SHA256 "):
return None
return dict(re.findall(r'([a-z-]+)="([^"]*)"', header))


def verify_webhook(headers, raw_body):
auth = parse_authorization(headers.get("authorization"))
if not auth or auth.get("signed-headers") != "host":
return None
secret = WEBHOOK_SECRETS.get(auth.get("api-key"))
if not secret:
return None
expected = hmac.new(
secret.encode(),
build_canonical_request(WEBHOOK_URL, raw_body).encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, auth.get("signature", "")):
return None
return json.loads(raw_body)

raw_body son los bytes del cuerpo tal como llegaron (en Flask, request.get_data(); en Django, request.body). En Python, escriba la URL registrada con el host en minúsculas y sin el puerto 443, tal como aparece en el texto firmado.

Clave propia​

Si usted informó una clave propia en el registro, cada entrega trae también:

X-Webhook-Client-Token: sha256=<HMAC-SHA256 en hexadecimal del id del evento, con la clave propia>

La clave nunca viaja: el encabezado es el HMAC-SHA256 del id del cuerpo, calculado con la clave como secreto. Es el mismo en todos los intentos del mismo evento. Verifíquelo después de la firma, usando el id del cuerpo ya verificado:

function verifyClientToken(headers, event, clientToken) {
const expected = 'sha256=' + crypto.createHmac('sha256', clientToken).update(event.id).digest('hex');
return safeEqual(expected, headers['x-webhook-client-token'] ?? '');
}
def verify_client_token(headers, event, client_token):
expected = "sha256=" + hmac.new(
client_token.encode(), event["id"].encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, headers.get("x-webhook-client-token", ""))

Con la clave token-do-cliente y el evento del ejemplo, el encabezado es sha256=9608c5ca6278ceba4f977d55908b150b3d940e2cb6beb9945b41d00f3de4c962.

La clave propia complementa la firma, no la reemplaza: la firma cubre el cuerpo completo, y el encabezado cubre solo el id.

Responder a la entrega​

Respuesta del destinoResultado
Cualquier 2xx en hasta 10 segundosEntrega concluida. El cuerpo de la respuesta se ignora.
4xx o 5xxFalla: la entrega se reintenta.
Sin respuesta en 10 segundos, error de conexión o de TLSFalla: la entrega se reintenta.

Como un 4xx también genera reintento, responda 2xx a los eventos duplicados y a los eventos que su integración decide ignorar. Ante una firma que no coincide, responda con un error (por ejemplo, 401) y no procese el cuerpo.

Responda rápido: valide la firma, guarde el evento y devuelva 2xx; haga el procesamiento (consultar el servicio, registrar en el ERP) después, fuera de la solicitud. Una respuesta que supera los 10 segundos cuenta como falla aunque su sistema haya procesado el evento, y este volverá a llegar.

Reintentos​

Cada entrega tiene hasta 6 intentos. Después de una falla, el siguiente intento espera:

Después de la fallaEspera hasta el siguiente intento
1.ª15 a 30 segundos
2.ª30 a 60 segundos
3.ª1 a 2 minutos
4.ª2 a 4 minutos
5.ª4 a 8 minutos
6.ªNo hay un nuevo intento.

La espera de cada intervalo se sortea dentro del rango, y los intentos salen en ciclos de aproximadamente 10 segundos, por lo que los horarios reales varían un poco. Sin Retry-After, los 6 intentos se agotan entre unos 8 y 16 minutos después del primero.

Si la respuesta de falla trae Retry-After (en segundos o como fecha HTTP), el siguiente intento espera el mayor entre ese valor y la espera de la tabla, con un límite de 1 hora. Un Retry-After menor que la espera de la tabla no adelanta el intento. Toda respuesta de falla cuenta como intento, incluso 429.

Después de la 6.ª falla, la entrega se cierra y no se reenvía automáticamente. Para recuperar lo que no llegó, use la conciliación con GET /services descrita en Cuándo usarlo. Para investigar una entrega que falló, comuníquese con soporte (suporte@serrabits.com.br) indicando el X-Webhook-Delivery y el id del evento.

Duplicados, orden e idempotencia​

La entrega es al menos una vez: el mismo evento puede llegar más de una vez, por ejemplo cuando su respuesta superó el tiempo límite o cuando la conexión se cayó después de que su sistema procesó el evento. Toda repetición trae el mismo id.

  • Deduplique por id. Guarde los id ya procesados y, al recibir uno repetido, responda 2xx sin procesarlo de nuevo.
  • El orden no está garantizado. Dos cambios del mismo servicio pueden llegar fuera de orden, por ejemplo cuando la primera entrega falló y se reintentó después de la segunda. Use occurred_at para descartar un evento anterior al último que usted aplicó para el mismo servicio.
  • No todo cambio genera un evento propio. Dos cambios de estado del mismo servicio en el mismo segundo producen el mismo id, y solo se entrega el primero. Cuando su integración necesite el estado actual, y no solo la transición, consulte el servicio en GET /services/{id} al recibir el evento.

Concurrencia​

Cada ciclo de entrega envía como máximo 5 solicitudes simultáneas al mismo destino, pero puede haber más de un ciclo en curso al mismo tiempo, por lo que no cuente con un tope fijo de solicitudes simultáneas. Su endpoint debe aceptar entregas en paralelo, incluso de eventos del mismo servicio, y la deduplicación por id debe funcionar entre solicitudes concurrentes (por ejemplo, con una restricción de unicidad en la base de datos).

Estado en el evento​

data.status y data.old_status son códigos de estado del servicio, los mismos de la tabla de estados. El evento se dispara en cualquier cambio de estado, por lo que puede traer códigos que no están en la tabla (estados internos de Cortecloud, como cancelación o archivo). Trate los códigos que le interesan a su integración y responda 2xx a los demás.

Buenas prácticas​

  • Verifique la firma antes que cualquier otra cosa, con el cuerpo sin procesar y la URL registrada.
  • Responda 2xx rápido y procese después, fuera de la solicitud.
  • Deduplique por id, de forma segura entre solicitudes concurrentes.
  • Compare occurred_at antes de aplicar un cambio, para no hacer retroceder un servicio con un evento atrasado.
  • Use el evento como aviso y consulte el servicio en la API cuando necesite el estado actual o el contenido completo.
  • Registre X-Webhook-Delivery e id en su log: es lo que soporte necesita para investigar una entrega.
  • Mantenga una conciliación esporádica con GET /services para cubrir las entregas que fallaron definitivamente.