# Webhooks

> Cómo recibir un POST firmado en cada cambio de estado de un servicio, verificar la firma y tratar reintentos y duplicados.

URL canónica: https://apis.cortecloud.com.br/docs/es/guias/webhooks/

Con webhooks, Cortecloud avisa a su sistema cuando cambia el estado de un [servicio](https://apis.cortecloud.com.br/docs/es/comecando/conceitos.md#servico) 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 {#quando-usar}

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](https://apis.cortecloud.com.br/docs/es/comecando/erros-limites-paginacao.md)), 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}`](https://apis.cortecloud.com.br/docs/es/guias/integracao-erp/obter-servicos.md#consultar-um-servico).

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](https://apis.cortecloud.com.br/docs/es/guias/webhooks.md#duplicatas-ordem-e-idempotencia)). Mantenga una conciliación esporádica con `GET /services`, filtrando por `date_start`, para cubrir lo que no llegó.

## Cómo activarlo {#como-ativar}

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

| Qué informar | Detalles |
| --- | --- |
| **URL del destino** | Dirección de su sistema que recibirá los `POST`. Debe usar `https`. |
| **Central** | El [código de la central](https://apis.cortecloud.com.br/docs/es/comecando/conceitos.md#codigo-da-central), el mismo que su integración usa en `x-company-internal-code`. Si son varias centrales, indíquelas todas. |
| **Tipo de evento** | Hoy solo existe `service.status_changed` (ver [Tipos de evento](https://apis.cortecloud.com.br/docs/es/guias/webhooks.md#tipos-de-evento)). |
| **Ambiente** | Homologació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](https://apis.cortecloud.com.br/docs/es/guias/webhooks.md#chave-propria)). 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:

| Credencial | Uso |
| --- | --- |
| **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](https://apis.cortecloud.com.br/docs/es/comecando/autenticacao.md)). 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 {#regras-da-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 {#alteracoes-no-cadastro}

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 {#tipos-de-evento}

| Tipo (`event_type`) | Cuándo se dispara |
| --- | --- |
| `service.status_changed` | El 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 {#formato-da-requisicao}

Cada entrega es un `POST` a la URL del destino:

```text
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>
```

| Encabezado | Contenido |
| --- | --- |
| `Authorization` | Firma 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](https://apis.cortecloud.com.br/docs/es/guias/webhooks.md#verificar-a-autenticidade). |
| `Content-Type` | Siempre `application/json`. El cuerpo es JSON en UTF-8, en una sola línea. |
| `X-Webhook-Event` | El mismo valor de `event_type` del cuerpo. |
| `X-Webhook-Id` | El mismo valor de `id` del cuerpo. |
| `X-Webhook-Delivery` | Identificador 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-Token` | Solo para destinos registrados con clave propia. Ver [Clave propia](https://apis.cortecloud.com.br/docs/es/guias/webhooks.md#chave-propria). |

`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):

```json
{
  "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
  }
}
```

| Campo | Contenido |
| --- | --- |
| `id` | Identificador 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_version` | Versión del formato del cuerpo. Hoy, `"1"`. |
| `event_type` | Tipo del evento. |
| `company_id` | Identificador numérico de la central en Cortecloud. |
| `company_internal_code` | Código de la central, el mismo que su integración usa en `x-company-internal-code`. |
| `occurred_at` | Momento del cambio, en ISO 8601 UTC. La precisión es de segundos (los milisegundos vienen en cero). |
| `data.service_id` | `id` del servicio, el mismo de `GET /services` y `GET /services/{id}`. |
| `data.service_internal_code` | `internal_code` del servicio (el número del pedido en su ERP), o `null` si el servicio todavía no fue asociado. |
| `data.status` | Código del nuevo estado. |
| `data.old_status` | Có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 {#verificar-a-autenticidade}

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 {#o-que-e-assinado}

La firma usa el mismo texto firmado (canonical request) descrito en [Cómo calcular la firma](https://apis.cortecloud.com.br/docs/es/comecando/autenticacao.md#como-calcular-a-assinatura), 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](https://apis.cortecloud.com.br/docs/es/guias/webhooks.md#duplicatas-ordem-e-idempotencia)).

### Ejemplo para validar {#exemplo-para-validar}

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

```text
{"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:

```text
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:

```text
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 {#codigo-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 {#nodejs}

```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 {#python}

```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 {#chave-propria}

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

```text
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:

```js
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'] ?? '');
}
```

```python
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](https://apis.cortecloud.com.br/docs/es/guias/webhooks.md#exemplo-para-validar), 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 {#responder-a-entrega}

| Respuesta del destino | Resultado |
| --- | --- |
| Cualquier `2xx` en hasta 10 segundos | Entrega concluida. El cuerpo de la respuesta se ignora. |
| `4xx` o `5xx` | Falla: la entrega se reintenta. |
| Sin respuesta en 10 segundos, error de conexión o de TLS | Falla: 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 {#retentativas}

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

| Después de la falla | Espera 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](https://apis.cortecloud.com.br/docs/es/guias/webhooks.md#quando-usar). 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 {#duplicatas-ordem-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 {#concorrencia}

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 {#status-no-evento}

`data.status` y `data.old_status` son códigos de estado del servicio, los mismos de la [tabla de estados](https://apis.cortecloud.com.br/docs/es/comecando/conceitos.md#status-do-servico). 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 {#boas-praticas}

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