Pular para o conteúdo principal

Webhooks

Com webhooks, o Cortecloud avisa o seu sistema quando o status de um serviço 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​

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

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). Mantenha uma reconciliação esporádica com GET /services, filtrando por date_start, para cobrir o que não chegou.

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 informarDetalhes
URL do destinoEndereço do seu sistema que vai receber os POST. Precisa usar https.
CentralO código da central, o mesmo que a sua integração usa em x-company-internal-code. Para várias centrais, liste todas.
Tipo de eventoHoje só existe service.status_changed (ver Tipos de evento).
AmbienteHomologaçã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). 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:

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

  • 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​

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​

Tipo (event_type)Quando dispara
service.status_changedO 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​

Cada entrega é um POST na URL do destino:

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çalhoConteúdo
AuthorizationAssinatura da entrega, no mesmo esquema SB1-HMAC-SHA256 da API. api-key é a api key do destino. Ver Verificar a autenticidade.
Content-TypeSempre application/json. O corpo é JSON em UTF-8, numa linha só.
X-Webhook-EventO mesmo valor de event_type do corpo.
X-Webhook-IdO mesmo valor de id do corpo.
X-Webhook-DeliveryIdentificador 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-TokenSó para destinos cadastrados com chave própria. Ver Chave própria.

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

{
"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
}
}
CampoConteúdo
idIdentificador 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_versionVersão do formato do corpo. Hoje, "1".
event_typeTipo do evento.
company_idIdentificador numérico da central no Cortecloud.
company_internal_codeCódigo da central, o mesmo que a sua integração usa em x-company-internal-code.
occurred_atMomento da mudança, em ISO 8601 UTC. A precisão é de segundo (os milissegundos vêm zerados).
data.service_idid do serviço, o mesmo de GET /services e GET /services/{id}.
data.service_internal_codeinternal_code do serviço (o número do pedido no seu ERP), ou null se o serviço ainda não foi associado.
data.statusCódigo do novo status.
data.old_statusCó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​

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​

A assinatura usa o mesmo texto assinado (canonical request) descrito em 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).

Exemplo para validar​

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

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

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 é:

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​

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​

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​

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​

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

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:

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", ""))

Com a chave token-do-cliente e o evento do exemplo, 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​

Resposta do destinoResultado
Qualquer 2xx em até 10 segundosEntrega concluída. O corpo da resposta é ignorado.
4xx ou 5xxFalha: a entrega é tentada de novo.
Sem resposta em 10 segundos, erro de conexão ou de TLSFalha: 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​

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

Depois da falhaEspera 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. 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​

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​

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​

data.status e data.old_status são códigos de status do serviço, os mesmos da tabela de status. 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​

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