# Webhooks

> How to receive a signed POST on every service status change, verify the signature and handle retries and duplicates.

Canonical URL: https://apis.cortecloud.com.br/docs/en/guias/webhooks/

With webhooks, Cortecloud notifies your system when the status of one of the service center's [services](https://apis.cortecloud.com.br/docs/en/comecando/conceitos.md#servico) changes: on each change, it sends a signed `POST` to a URL in your system, the **destination**. Your integration does not need to call `GET /services` over and over to discover changes.

## When to use {#quando-usar}

Without webhooks, the way to detect changes is to list services periodically. `GET /services` accepts 1 request every 5 seconds (see [Errors, limits and pagination](https://apis.cortecloud.com.br/docs/en/comecando/erros-limites-paginacao.md)), and most of those calls bring nothing new but still use up the route's quota.

Use webhooks when your system needs to react to status changes, for example to import into the ERP a service that has just been approved. The event tells you which service changed and to which status; the full content still comes from [`GET /services/{id}`](https://apis.cortecloud.com.br/docs/en/guias/integracao-erp/obter-servicos.md#consultar-um-servico).

Webhooks do not fully replace querying the API: deliveries can fail after the retries run out, and not every change produces its own event (see [Duplicates, ordering and idempotency](https://apis.cortecloud.com.br/docs/en/guias/webhooks.md#duplicatas-ordem-e-idempotencia)). Keep an occasional reconciliation with `GET /services`, filtering by `date_start`, to cover whatever did not arrive.

## How to enable {#como-ativar}

There is no API route to register destinations: registration is done by Serrabits. Write to suporte@serrabits.com.br with:

| What to send | Details |
| --- | --- |
| **Destination URL** | The address in your system that will receive the `POST` requests. It must use `https`. |
| **Service center** | The [service center code](https://apis.cortecloud.com.br/docs/en/comecando/conceitos.md#codigo-da-central), the same one your integration sends in `x-company-internal-code`. For several service centers, list all of them. |
| **Event type** | Today only `service.status_changed` exists (see [Event types](https://apis.cortecloud.com.br/docs/en/guias/webhooks.md#tipos-de-evento)). |
| **Environment** | Staging or production. Each environment has its own registrations and credentials. |
| **Own key** (optional) | A secret of your choice, as a second verification factor (see [Own key](https://apis.cortecloud.com.br/docs/en/guias/webhooks.md#chave-propria)). Send it over a secure channel. |

Each combination of service center, event type and URL is one destination. For each destination, Serrabits gives you a pair of credentials:

| Credential | Use |
| --- | --- |
| **destination api key** (`sb_wk_…`) | Comes in the `Authorization` header of every delivery and tells you which secret to use for verification. |
| **destination secret** (`sb_ws_…`) | Used by Serrabits to sign deliveries, and by you to verify the signature. It is handed over only once, at registration: store it when you receive it. |

These credentials are different from the api key and secret key your integration uses to call the API (see [Authentication](https://apis.cortecloud.com.br/docs/en/comecando/autenticacao.md)). If you integrate several service centers, you get one pair per service center, even with the same URL: store the secrets keyed by the destination api key. Treat the destination secret like the API secret key: keep it only in your backend.

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

- The scheme must be `https`. Serrabits does not register `http` URLs.
- A non-default port (`https://erp.example.com:8443/...`) and a query string (`?central=01`) are accepted, and both are part of the signature.
- The URL is called exactly as registered. Register the final URL and do not respond with a redirect (`3xx`): the signature is valid for the registered URL, and the redirected request may arrive without a body or without the `Authorization` header.
- Delivery authenticity is guaranteed by the signature, not by network origin. Do not rely on the source IP to accept or reject a delivery.

### Registration changes {#alteracoes-no-cadastro}

To change the URL, deactivate a destination or change the secret, contact support (suporte@serrabits.com.br). Behavior you will observe:

- events that were already queued before a URL change are still delivered to the previous URL;
- once a destination is revoked, deliveries that were pending for it are no longer sent.

## Event types {#tipos-de-evento}

| Type (`event_type`) | When it fires |
| --- | --- |
| `service.status_changed` | The status code of one of the service center's services changed. |

A destination receives only events of the type and service center it was registered for.

The event is not sent at the moment of the change: the first delivery attempt happens at least 30 seconds after the status change, and may take longer. There is no guaranteed maximum delay.

## Request format {#formato-da-requisicao}

Each delivery is a `POST` to the destination URL:

```text
POST /webhooks/cortecloud HTTP/1.1
Host: erp.example.com
Authorization: SB1-HMAC-SHA256 api-key="<destination api key>", signed-headers="host", signature="<signature>"
Content-Type: application/json
X-Webhook-Event: service.status_changed
X-Webhook-Id: <event id>
X-Webhook-Delivery: <delivery id>
X-Webhook-Client-Token: sha256=<own key hash>
```

| Header | Content |
| --- | --- |
| `Authorization` | Delivery signature, in the same `SB1-HMAC-SHA256` scheme as the API. `api-key` is the destination api key. See [Verify authenticity](https://apis.cortecloud.com.br/docs/en/guias/webhooks.md#verificar-a-autenticidade). |
| `Content-Type` | Always `application/json`. The body is UTF-8 JSON on a single line. |
| `X-Webhook-Event` | Same value as `event_type` in the body. |
| `X-Webhook-Id` | Same value as `id` in the body. |
| `X-Webhook-Delivery` | Numeric identifier of the delivery (one event to one destination). The same on every attempt of that delivery. Useful for logging and when talking to support. |
| `X-Webhook-Client-Token` | Only for destinations registered with an own key. See [Own key](https://apis.cortecloud.com.br/docs/en/guias/webhooks.md#chave-propria). |

`X-Webhook-Event`, `X-Webhook-Id` and `X-Webhook-Delivery` are **not signed**: they are for routing and logging. For any decision, use the values in the body, which is covered by the signature.

Body of a `service.status_changed` event (formatted here for readability; the actual body comes on a single line):

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

| Field | Content |
| --- | --- |
| `id` | Event identifier: an opaque string of up to 64 characters. The same on every attempt and for every destination that receives the event. This is the field to deduplicate on. |
| `spec_version` | Version of the body format. Currently `"1"`. |
| `event_type` | Event type. |
| `company_id` | Numeric identifier of the service center in Cortecloud. |
| `company_internal_code` | Service center code, the same one your integration sends in `x-company-internal-code`. |
| `occurred_at` | When the change happened, in ISO 8601 UTC. Precision is one second (milliseconds are always zero). |
| `data.service_id` | The service `id`, the same as in `GET /services` and `GET /services/{id}`. |
| `data.service_internal_code` | The service `internal_code` (the order number in your ERP), or `null` if the service has not been linked yet. |
| `data.status` | Code of the new status. |
| `data.old_status` | Code of the previous status. |

Ignore fields your integration does not know, so that a new field in the body does not break your processing.

## Verify authenticity {#verificar-a-autenticidade}

Anyone who knows the destination URL can send a `POST` to it. Verify the signature of every delivery before processing it, and discard the ones that do not match.

### What is signed {#o-que-e-assinado}

The signature uses the same signed text (canonical request) described in [How to compute the signature](https://apis.cortecloud.com.br/docs/en/comecando/autenticacao.md#como-calcular-a-assinatura), with these differences:

- Serrabits signs, with the **destination secret** (not your integration's API secret key); the `api-key` in the `Authorization` header tells you which secret to use;
- the method is always `POST`;
- path, query string and host are those of the **registered URL**: the host includes `:port` only when the port is not the `https` default (443);
- the body hash is computed over the **bytes received**, before any JSON parsing;
- there is no `x-company-internal-code` header: the service center comes in the body.

Build the text from the registered URL (a constant in your configuration), not from the `Host` header or the path that reach your server: proxies, load balancers and frameworks may rewrite those values, and the signature will no longer match.

The signature covers the entire body, including `id` and `occurred_at`. It has no timestamp of its own: to reject malicious replays of a captured delivery, deduplicate by `id` (see [Duplicates, ordering and idempotency](https://apis.cortecloud.com.br/docs/en/guias/webhooks.md#duplicatas-ordem-e-idempotencia)).

### Example to validate against {#exemplo-para-validar}

A destination registered with the URL `https://erp.example.com/webhooks/cortecloud` receives this body (the exact bytes, on one line):

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

Signed text:

```text
POST
/webhooks/cortecloud

host:erp.example.com

host
56cb1342b58572a917c94c09174ccbc794300b7fc3bd26c78b8950ce39b5631a
```

With the test secret `sb_ws_exemplo`, the signature is `b184a379ea64a2af1210c4dd04e703b16f44e5227bc6c51f01b452f0c98020c6`, and the header received is:

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

If your implementation arrives at the same value, it verifies deliveries the same way Serrabits signs them.

### Reference code {#codigo-de-referencia}

Both examples take the headers (with lowercase names) and the raw body, and return the event when the signature matches. The comparison runs in constant time, so response timing does not reveal how many characters of the signature are correct.

#### Node.js {#nodejs}

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

// Destination URL, exactly as registered.
const WEBHOOK_URL = new URL('https://erp.example.com/webhooks/cortecloud');

// Secret of each destination, keyed by the destination api key.
const WEBHOOK_SECRETS = {
  '<destination api key>': '<destination secret>',
};

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` is the body `Buffer` as received. In Express, for example, read it with `express.raw({ type: 'application/json' })` on the webhook route: an `express.json()` before verification hands you the already-parsed object, and re-serializing it does not reproduce the signed bytes.

#### Python {#python}

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

# Destination URL, exactly as registered.
WEBHOOK_URL = "https://erp.example.com/webhooks/cortecloud"

# Secret of each destination, keyed by the destination api key.
WEBHOOK_SECRETS = {
    "<destination api key>": "<destination secret>",
}


def encode(value):
    # safe="!*'()" reproduces the set of characters that JavaScript's
    # encodeURIComponent leaves unescaped.
    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` is the body bytes as received (in Flask, `request.get_data()`; in Django, `request.body`). In Python, write the registered URL with a lowercase host and without port `443`, as it appears in the signed text.

### Own key {#chave-propria}

If you provided an own key at registration, every delivery also carries:

```text
X-Webhook-Client-Token: sha256=<hex HMAC-SHA256 of the event id, keyed with your own key>
```

The key itself never travels: the header is the HMAC-SHA256 of the body's `id`, computed with the key as the secret. It is the same on every attempt of the same event. Check it after the signature, using the `id` from the already-verified body:

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

With the key `token-do-cliente` and the event from the [example](https://apis.cortecloud.com.br/docs/en/guias/webhooks.md#exemplo-para-validar), the header is `sha256=9608c5ca6278ceba4f977d55908b150b3d940e2cb6beb9945b41d00f3de4c962`.

The own key complements the signature; it does not replace it: the signature covers the entire body, while the header covers only the `id`.

## Responding to a delivery {#responder-a-entrega}

| Destination response | Result |
| --- | --- |
| Any `2xx` within 10 seconds | Delivery completed. The response body is ignored. |
| `4xx` or `5xx` | Failure: the delivery is retried. |
| No response within 10 seconds, connection or TLS error | Failure: the delivery is retried. |

Since a `4xx` also triggers a retry, respond `2xx` to duplicate events and to events your integration chooses to ignore. For a signature that does not match, respond with an error (for example, `401`) and do not process the body.

Respond quickly: verify the signature, store the event and return `2xx`; do the processing (fetching the service, writing to the ERP) afterwards, outside the request. A response that takes longer than 10 seconds counts as a failure even if your system processed the event, and the event will arrive again.

## Retries {#retentativas}

Each delivery gets up to **6 attempts**. After a failure, the next attempt waits:

| After failure | Wait until the next attempt |
| --- | --- |
| 1st | 15 to 30 seconds |
| 2nd | 30 to 60 seconds |
| 3rd | 1 to 2 minutes |
| 4th | 2 to 4 minutes |
| 5th | 4 to 8 minutes |
| 6th | No further attempt. |

Each wait is picked at random within its range, and attempts go out in cycles of about 10 seconds, so actual times vary slightly. Without `Retry-After`, the 6 attempts are used up roughly 8 to 16 minutes after the first one.

If the failure response carries `Retry-After` (in seconds or as an HTTP date), the next attempt waits the longer of that value and the wait in the table, capped at 1 hour. A `Retry-After` shorter than the wait in the table does not bring the attempt forward. Every failure response counts as an attempt, including `429`.

After the 6th failure, the delivery is closed and is not resent automatically. To recover what did not arrive, use the reconciliation with `GET /services` described in [When to use](https://apis.cortecloud.com.br/docs/en/guias/webhooks.md#quando-usar). To investigate a failed delivery, contact support (suporte@serrabits.com.br) with the `X-Webhook-Delivery` value and the event `id`.

## Duplicates, ordering and idempotency {#duplicatas-ordem-e-idempotencia}

Delivery is **at least once**: the same event may arrive more than once, for example when your response exceeded the time limit or when the connection dropped after your system processed the event. Every repetition carries the same `id`.

- **Deduplicate by `id`.** Store the `id` values already processed and, when a repeated one arrives, respond `2xx` without processing it again.
- **Ordering is not guaranteed.** Two changes to the same service may arrive out of order, for example when the first delivery failed and was retried after the second one. Use `occurred_at` to discard an event older than the last one you applied for the same service.
- **Not every change produces its own event.** Two status changes to the same service within the same second produce the same `id`, and only the first one is delivered. When your integration needs the current status, and not just the transition, fetch the service with `GET /services/{id}` when the event arrives.

## Concurrency {#concorrencia}

Each delivery cycle sends at most 5 simultaneous requests to the same destination, but more than one cycle may be running at the same time, so do not count on a fixed cap on simultaneous requests. Your endpoint must accept deliveries in parallel, including events for the same service, and deduplication by `id` must work across concurrent requests (for example, with a uniqueness constraint in the database).

## Status in the event {#status-no-evento}

`data.status` and `data.old_status` are service status codes, the same as in the [status table](https://apis.cortecloud.com.br/docs/en/comecando/conceitos.md#status-do-servico). The event fires on any status change, so it may carry codes that are not in the table (internal Cortecloud statuses, such as cancelled or archived). Handle the codes your integration cares about and respond `2xx` to the others.

## Good practices {#boas-praticas}

- Verify the signature before anything else, using the raw body and the registered URL.
- Respond `2xx` quickly and process afterwards, outside the request.
- Deduplicate by `id`, safely across concurrent requests.
- Compare `occurred_at` before applying a change, so a late event does not move a service backwards.
- Use the event as a notification and fetch the service from the API when you need its current state or full content.
- Log `X-Webhook-Delivery` and `id`: that is what support needs to investigate a delivery.
- Keep an occasional reconciliation with `GET /services` to cover deliveries that failed for good.
