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 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, 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). |
| 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). 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). 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 registerhttpURLs. - 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 theAuthorizationheader. - 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_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
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>
| 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. |
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. |
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
}
}
| 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
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-keyin theAuthorizationheader 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
:portonly when the port is not thehttpsdefault (443); - the body hash is computed over the bytes received, before any JSON parsing;
- there is no
x-company-internal-codeheader: 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 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
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. 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 theidvalues already processed and, when a repeated one arrives, respond2xxwithout 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_atto 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 withGET /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
2xxquickly and process afterwards, outside the request. - Deduplicate by
id, safely across concurrent requests. - Compare
occurred_atbefore 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-Deliveryandid: that is what support needs to investigate a delivery. - Keep an occasional reconciliation with
GET /servicesto cover deliveries that failed for good.