Skip to main content

Webhooks

With webhooks, Cortecloud notifies your system when the status of one of the service center's services 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​

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

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). Keep an occasional reconciliation with GET /services, filtering by date_start, to cover whatever did not arrive.

How to enable​

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

What to sendDetails
Destination URLThe address in your system that will receive the POST requests. It must use https.
Service centerThe service center code, the same one your integration sends in x-company-internal-code. For several service centers, list all of them.
Event typeToday only service.status_changed exists (see Event types).
EnvironmentStaging 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). 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:

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

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

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​

Type (event_type)When it fires
service.status_changedThe 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​

Each delivery is a POST to the destination URL:

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>
HeaderContent
AuthorizationDelivery signature, in the same SB1-HMAC-SHA256 scheme as the API. api-key is the destination api key. See Verify authenticity.
Content-TypeAlways application/json. The body is UTF-8 JSON on a single line.
X-Webhook-EventSame value as event_type in the body.
X-Webhook-IdSame value as id in the body.
X-Webhook-DeliveryNumeric 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-TokenOnly for destinations registered with an own key. See Own key.

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

{
"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
}
}
FieldContent
idEvent 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_versionVersion of the body format. Currently "1".
event_typeEvent type.
company_idNumeric identifier of the service center in Cortecloud.
company_internal_codeService center code, the same one your integration sends in x-company-internal-code.
occurred_atWhen the change happened, in ISO 8601 UTC. Precision is one second (milliseconds are always zero).
data.service_idThe service id, the same as in GET /services and GET /services/{id}.
data.service_internal_codeThe service internal_code (the order number in your ERP), or null if the service has not been linked yet.
data.statusCode of the new status.
data.old_statusCode 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​

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​

The signature uses the same signed text (canonical request) described in How to compute the signature, 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).

Example to validate against​

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

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

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:

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​

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​

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​

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​

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

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:

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

With the key token-do-cliente and the event from the example, 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​

Destination responseResult
Any 2xx within 10 secondsDelivery completed. The response body is ignored.
4xx or 5xxFailure: the delivery is retried.
No response within 10 seconds, connection or TLS errorFailure: 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​

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

After failureWait until the next attempt
1st15 to 30 seconds
2nd30 to 60 seconds
3rd1 to 2 minutes
4th2 to 4 minutes
5th4 to 8 minutes
6thNo 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. 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​

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​

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​

data.status and data.old_status are service status codes, the same as in the status table. 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​

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