# Webhooks

> Como receber um POST assinado a cada mudança de status de um serviço, verificar a assinatura e tratar retentativas e duplicatas.

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

Com webhooks, o Cortecloud avisa o seu sistema quando o status de um [serviço](https://apis.cortecloud.com.br/docs/comecando/conceitos.md#servico) da central muda: a cada mudança, ele faz um `POST` assinado numa URL do seu sistema, o **destino**. Assim a sua integração não precisa chamar `GET /services` repetidamente para descobrir mudanças.

## Quando usar {#quando-usar}

Sem webhook, a forma de detectar mudanças é listar os serviços periodicamente. `GET /services` aceita 1 requisição a cada 5 segundos (ver [Erros, limites e paginação](https://apis.cortecloud.com.br/docs/comecando/erros-limites-paginacao.md)), e a maior parte dessas chamadas não traz nada novo, mas consome a cota da rota.

Use webhook quando o seu sistema precisa reagir a mudanças de status, por exemplo para importar no ERP um serviço que acabou de ser aprovado. O evento diz qual serviço mudou e para qual status; o conteúdo completo continua vindo de [`GET /services/{id}`](https://apis.cortecloud.com.br/docs/guias/integracao-erp/obter-servicos.md#consultar-um-servico).

O webhook não substitui de todo a consulta à API: entregas podem falhar depois de esgotar as retentativas, e nem toda mudança gera um evento próprio (ver [Duplicatas, ordem e idempotência](https://apis.cortecloud.com.br/docs/guias/webhooks.md#duplicatas-ordem-e-idempotencia)). Mantenha uma reconciliação esporádica com `GET /services`, filtrando por `date_start`, para cobrir o que não chegou.

## Como ativar {#como-ativar}

Não há rota da API para cadastrar destinos: o cadastro é feito pela Serrabits. Escreva para suporte@serrabits.com.br informando:

| O que informar | Detalhes |
| --- | --- |
| **URL do destino** | Endereço do seu sistema que vai receber os `POST`. Precisa usar `https`. |
| **Central** | O [código da central](https://apis.cortecloud.com.br/docs/comecando/conceitos.md#codigo-da-central), o mesmo que a sua integração usa em `x-company-internal-code`. Para várias centrais, liste todas. |
| **Tipo de evento** | Hoje só existe `service.status_changed` (ver [Tipos de evento](https://apis.cortecloud.com.br/docs/guias/webhooks.md#tipos-de-evento)). |
| **Ambiente** | Homologação ou produção. Cada ambiente tem os seus próprios cadastros e credenciais. |
| **Chave própria** (opcional) | Um segredo escolhido por você, para um segundo fator de verificação (ver [Chave própria](https://apis.cortecloud.com.br/docs/guias/webhooks.md#chave-propria)). Envie por canal seguro. |

Cada combinação de central, tipo de evento e URL é um destino. Para cada destino, a Serrabits devolve um par de credenciais:

| Credencial | Uso |
| --- | --- |
| **api key do destino** (`sb_wk_…`) | Vem no cabeçalho `Authorization` de cada entrega e indica qual secret usar na verificação. |
| **secret do destino** (`sb_ws_…`) | Usada pela Serrabits para assinar as entregas, e por você para verificar a assinatura. É entregue uma única vez, no cadastro: guarde-a ao receber. |

Essas credenciais são diferentes da api key e da secret key com que a sua integração chama a API (ver [Autenticação](https://apis.cortecloud.com.br/docs/comecando/autenticacao.md)). Quem integra várias centrais recebe um par por central, mesmo usando a mesma URL: guarde as secrets indexadas pela api key do destino. A secret do destino tem os mesmos cuidados da secret key da API: fica só no seu backend.

### Regras da URL {#regras-da-url}

- O esquema precisa ser `https`. A Serrabits não cadastra URL `http`.
- Porta fora da padrão (`https://erp.example.com:8443/...`) e query string (`?central=01`) são aceitas, e as duas entram na assinatura.
- A URL é chamada exatamente como cadastrada. Cadastre a URL final e não responda com redirecionamento (`3xx`): a assinatura vale para a URL cadastrada, e a requisição redirecionada pode chegar sem corpo ou sem o cabeçalho `Authorization`.
- A autenticidade da entrega é garantida pela assinatura, não pela origem de rede. Não dependa do IP de origem para aceitar ou recusar uma entrega.

### Alterações no cadastro {#alteracoes-no-cadastro}

Para trocar a URL, desativar um destino ou trocar a secret, fale com o suporte (suporte@serrabits.com.br). Comportamentos que você vai observar:

- eventos que já estavam na fila antes de uma troca de URL ainda são entregues na URL anterior;
- depois que um destino é revogado, as entregas que estavam pendentes para ele deixam de ser enviadas.

## Tipos de evento {#tipos-de-evento}

| Tipo (`event_type`) | Quando dispara |
| --- | --- |
| `service.status_changed` | O código de status de um serviço da central mudou. |

Um destino recebe só os eventos do tipo e da central para os quais foi cadastrado.

O evento não é enviado no instante da mudança: a primeira tentativa de entrega acontece pelo menos 30 segundos depois da mudança de status, e pode levar mais. Não há prazo máximo garantido.

## Formato da requisição {#formato-da-requisicao}

Cada entrega é um `POST` na URL do destino:

```text
POST /webhooks/cortecloud HTTP/1.1
Host: erp.example.com
Authorization: SB1-HMAC-SHA256 api-key="<api key do destino>", signed-headers="host", signature="<assinatura>"
Content-Type: application/json
X-Webhook-Event: service.status_changed
X-Webhook-Id: <id do evento>
X-Webhook-Delivery: <id da entrega>
X-Webhook-Client-Token: sha256=<hash da chave própria>
```

| Cabeçalho | Conteúdo |
| --- | --- |
| `Authorization` | Assinatura da entrega, no mesmo esquema `SB1-HMAC-SHA256` da API. `api-key` é a api key do destino. Ver [Verificar a autenticidade](https://apis.cortecloud.com.br/docs/guias/webhooks.md#verificar-a-autenticidade). |
| `Content-Type` | Sempre `application/json`. O corpo é JSON em UTF-8, numa linha só. |
| `X-Webhook-Event` | O mesmo valor de `event_type` do corpo. |
| `X-Webhook-Id` | O mesmo valor de `id` do corpo. |
| `X-Webhook-Delivery` | Identificador numérico da entrega (um evento para um destino). Igual em todas as tentativas da mesma entrega. Útil para log e para falar com o suporte. |
| `X-Webhook-Client-Token` | Só para destinos cadastrados com chave própria. Ver [Chave própria](https://apis.cortecloud.com.br/docs/guias/webhooks.md#chave-propria). |

`X-Webhook-Event`, `X-Webhook-Id` e `X-Webhook-Delivery` **não são assinados**: servem para roteamento e log. Para qualquer decisão, use os valores do corpo, que é coberto pela assinatura.

Corpo de um `service.status_changed` (formatado aqui para leitura; o corpo real vem numa linha só):

```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 | Conteúdo |
| --- | --- |
| `id` | Identificador do evento: texto opaco de até 64 caracteres. O mesmo em toda tentativa e em todo destino que recebe o evento. É o campo para deduplicar. |
| `spec_version` | Versão do formato do corpo. Hoje, `"1"`. |
| `event_type` | Tipo do evento. |
| `company_id` | Identificador numérico da central no Cortecloud. |
| `company_internal_code` | Código da central, o mesmo que a sua integração usa em `x-company-internal-code`. |
| `occurred_at` | Momento da mudança, em ISO 8601 UTC. A precisão é de segundo (os milissegundos vêm zerados). |
| `data.service_id` | `id` do serviço, o mesmo de `GET /services` e `GET /services/{id}`. |
| `data.service_internal_code` | `internal_code` do serviço (o número do pedido no seu ERP), ou `null` se o serviço ainda não foi associado. |
| `data.status` | Código do novo status. |
| `data.old_status` | Código do status anterior. |

Ignore campos que a sua integração não conhece, para que um campo novo no corpo não quebre o seu processamento.

## Verificar a autenticidade {#verificar-a-autenticidade}

Qualquer um que conheça a URL do destino pode enviar um `POST` para ela. Verifique a assinatura de toda entrega antes de processá-la e descarte as que não conferem.

### O que é assinado {#o-que-e-assinado}

A assinatura usa o mesmo texto assinado (canonical request) descrito em [Como calcular a assinatura](https://apis.cortecloud.com.br/docs/comecando/autenticacao.md#como-calcular-a-assinatura), com estas diferenças:

- quem assina é a Serrabits, com a **secret do destino** (não a secret key da sua integração na API); a `api-key` do cabeçalho `Authorization` diz qual secret usar;
- o método é sempre `POST`;
- caminho, query string e host são os da **URL cadastrada**: o host inclui `:porta` só quando a porta não é a padrão do `https` (443);
- o hash do corpo é calculado sobre os **bytes recebidos**, antes de qualquer parse de JSON;
- não há cabeçalho `x-company-internal-code`: a central vem no corpo.

Monte o texto a partir da URL cadastrada (uma constante da sua configuração), e não do cabeçalho `Host` ou do caminho que chegam ao seu servidor: proxies, balanceadores e frameworks podem reescrever esses valores, e a assinatura deixa de conferir.

A assinatura cobre o corpo inteiro, incluindo `id` e `occurred_at`. Ela não tem carimbo de tempo próprio: para recusar reenvios maliciosos de uma entrega capturada, deduplique por `id` (ver [Duplicatas, ordem e idempotência](https://apis.cortecloud.com.br/docs/guias/webhooks.md#duplicatas-ordem-e-idempotencia)).

### Exemplo para validar {#exemplo-para-validar}

Destino cadastrado com a URL `https://erp.example.com/webhooks/cortecloud`, recebendo este corpo (os bytes exatos, numa linha):

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

```text
POST
/webhooks/cortecloud

host:erp.example.com

host
56cb1342b58572a917c94c09174ccbc794300b7fc3bd26c78b8950ce39b5631a
```

Com a secret de teste `sb_ws_exemplo`, a assinatura é `b184a379ea64a2af1210c4dd04e703b16f44e5227bc6c51f01b452f0c98020c6`, e o cabeçalho recebido é:

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

Se a sua implementação chega no mesmo valor, ela verifica as entregas do jeito que a Serrabits assina.

### Código de referência {#codigo-de-referencia}

Os dois exemplos recebem os cabeçalhos (com nomes em minúsculas) e o corpo bruto, e devolvem o evento quando a assinatura confere. A comparação é feita em tempo constante, para não vazar por tempo de resposta quantos caracteres da assinatura estão certos.

#### Node.js {#nodejs}

```js
const crypto = require('crypto');

// URL do destino, exatamente como cadastrada.
const WEBHOOK_URL = new URL('https://erp.example.com/webhooks/cortecloud');

// Secret de cada destino, indexada pela api key do destino.
const WEBHOOK_SECRETS = {
  '<api key do destino>': '<secret do 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` é o `Buffer` do corpo como chegou. No Express, por exemplo, leia-o com `express.raw({ type: 'application/json' })` na rota do webhook: um `express.json()` antes da verificação entrega o objeto já interpretado, e reserializá-lo não reproduz os bytes assinados.

#### Python {#python}

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

# URL do destino, exatamente como cadastrada.
WEBHOOK_URL = "https://erp.example.com/webhooks/cortecloud"

# Secret de cada destino, indexada pela api key do destino.
WEBHOOK_SECRETS = {
    "<api key do destino>": "<secret do destino>",
}


def encode(value):
    # safe="!*'()" reproduz o conjunto de caracteres que o
    # encodeURIComponent do JavaScript deixa sem 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` são os bytes do corpo como chegaram (no Flask, `request.get_data()`; no Django, `request.body`). Em Python, escreva a URL cadastrada com o host em minúsculas e sem a porta `443`, como ela aparece no texto assinado.

### Chave própria {#chave-propria}

Se você informou uma chave própria no cadastro, toda entrega traz também:

```text
X-Webhook-Client-Token: sha256=<HMAC-SHA256 em hexadecimal do id do evento, com a chave própria>
```

A chave nunca trafega: o cabeçalho é o HMAC-SHA256 do `id` do corpo, calculado com a chave como segredo. Ele é o mesmo em toda tentativa do mesmo evento. Verifique depois da assinatura, usando o `id` do corpo já 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", ""))
```

Com a chave `token-do-cliente` e o evento do [exemplo](https://apis.cortecloud.com.br/docs/guias/webhooks.md#exemplo-para-validar), o cabeçalho é `sha256=9608c5ca6278ceba4f977d55908b150b3d940e2cb6beb9945b41d00f3de4c962`.

A chave própria complementa a assinatura, não a substitui: a assinatura cobre o corpo inteiro, e o cabeçalho cobre só o `id`.

## Responder à entrega {#responder-a-entrega}

| Resposta do destino | Resultado |
| --- | --- |
| Qualquer `2xx` em até 10 segundos | Entrega concluída. O corpo da resposta é ignorado. |
| `4xx` ou `5xx` | Falha: a entrega é tentada de novo. |
| Sem resposta em 10 segundos, erro de conexão ou de TLS | Falha: a entrega é tentada de novo. |

Como `4xx` também gera retentativa, responda `2xx` para eventos duplicados e para eventos que a sua integração decide ignorar. Para uma assinatura que não confere, responda com um erro (por exemplo, `401`) e não processe o corpo.

Responda rápido: valide a assinatura, grave o evento e devolva `2xx`; faça o processamento (consultar o serviço, gravar no ERP) depois, fora da requisição. Uma resposta que passa de 10 segundos conta como falha mesmo que o seu sistema tenha processado o evento, e ele vai chegar de novo.

## Retentativas {#retentativas}

Cada entrega tem até **6 tentativas**. Depois de uma falha, a próxima tentativa espera:

| Depois da falha | Espera até a próxima tentativa |
| --- | --- |
| 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ª | Não há nova tentativa. |

A espera de cada intervalo é sorteada dentro da faixa, e as tentativas saem em ciclos de cerca de 10 segundos, então os horários reais variam um pouco. Sem `Retry-After`, as 6 tentativas se esgotam entre cerca de 8 e 16 minutos depois da primeira.

Se a resposta de falha trouxer `Retry-After` (em segundos ou como data HTTP), a próxima tentativa espera o maior entre esse valor e a espera da tabela, limitado a 1 hora. Um `Retry-After` menor que a espera da tabela não antecipa a tentativa. Toda resposta de falha conta como tentativa, inclusive `429`.

Depois da 6ª falha, a entrega é encerrada e não é reenviada automaticamente. Para recuperar o que não chegou, use a reconciliação com `GET /services` descrita em [Quando usar](https://apis.cortecloud.com.br/docs/guias/webhooks.md#quando-usar). Para investigar uma entrega que falhou, fale com o suporte (suporte@serrabits.com.br) informando o `X-Webhook-Delivery` e o `id` do evento.

## Duplicatas, ordem e idempotência {#duplicatas-ordem-e-idempotencia}

A entrega é **pelo menos uma vez**: o mesmo evento pode chegar mais de uma vez, por exemplo quando a sua resposta passou do tempo limite ou quando a conexão caiu depois de o seu sistema processar o evento. Toda repetição traz o mesmo `id`.

- **Deduplique por `id`.** Guarde os `id` já processados e, ao receber um repetido, responda `2xx` sem processar de novo.
- **A ordem não é garantida.** Duas mudanças do mesmo serviço podem chegar fora de ordem, por exemplo quando a primeira entrega falhou e foi tentada de novo depois da segunda. Use `occurred_at` para descartar um evento anterior ao último que você aplicou para o mesmo serviço.
- **Nem toda mudança gera um evento próprio.** Duas mudanças de status do mesmo serviço no mesmo segundo produzem o mesmo `id`, e só a primeira é entregue. Quando a sua integração precisa do status atual, e não só da transição, consulte o serviço em `GET /services/{id}` ao receber o evento.

## Concorrência {#concorrencia}

Cada ciclo de entrega envia no máximo 5 requisições simultâneas para o mesmo destino, mas mais de um ciclo pode estar em andamento ao mesmo tempo, então não conte com um teto fixo de requisições simultâneas. O seu endpoint deve aceitar entregas em paralelo, inclusive de eventos do mesmo serviço, e a deduplicação por `id` deve funcionar entre requisições concorrentes (por exemplo, com uma restrição de unicidade no banco).

## Status no evento {#status-no-evento}

`data.status` e `data.old_status` são códigos de status do serviço, os mesmos da [tabela de status](https://apis.cortecloud.com.br/docs/comecando/conceitos.md#status-do-servico). O evento é disparado em qualquer mudança de status, e por isso pode trazer códigos que não estão na tabela (status internos do Cortecloud, como cancelamento ou arquivamento). Trate os códigos que interessam à sua integração e responda `2xx` aos demais.

## Boas práticas {#boas-praticas}

- Verifique a assinatura antes de qualquer outra coisa, com o corpo bruto e a URL cadastrada.
- Responda `2xx` rápido e processe depois, fora da requisição.
- Deduplique por `id`, de forma segura entre requisições concorrentes.
- Compare `occurred_at` antes de aplicar uma mudança, para não regredir um serviço com um evento atrasado.
- Use o evento como aviso e consulte o serviço na API quando precisar do estado atual ou do conteúdo completo.
- Registre `X-Webhook-Delivery` e `id` no seu log: são o que o suporte precisa para investigar uma entrega.
- Mantenha uma reconciliação esporádica com `GET /services` para cobrir entregas que falharam de vez.
