Webhooks, in and out
Send an event to a flow the moment something happens in your system. Receive signed messages from SentraBridge about platform status and your activity log. Both ways are signed with HMAC-SHA256.
Sending an event into a flow
The message is never queued or stored: it is used and dropped.
- 01Your system callsIt POSTs JSON to the flow's private address.
- 02SentraBridge checksSignature, calling address and that the flow is on.
- 03The flow runsInside the request. The message is then dropped.
- 04You get the resultA run ID and counts in the answer.
The address and the call
Give a flow the start step When a system calls in. Its private address looks like https://app.sentrabridge.com/hooks/… and is shown on the step. Treat it like a password.
POST a JSON object. The step says where the list of records is inside it, and which field is the record ID. The flow must be turned on.
The answer arrives when the flow has finished, with the run ID and counts. The limit is 120 calls a minute per calling address.
BODY='{"id":"REQ-1001","status":"open"}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" | sed 's/^.* //')
curl -s -X POST "$SENTRABRIDGE_URL/hooks/$PRIVATE_ADDRESS" \
-H "Content-Type: application/json" \
-H "X-Signature: sha256=$SIGNATURE" \
-d "$BODY"{
"run_id": "01m769bjym26wdm4ej2rw2k7wn",
"status": "ok",
"records": 1,
"sent": 0
}Signature checks a flow can use
Each flow chooses how it checks the sender, on its start step. The signing secret is saved encrypted and never shown again.
Sign the exact bytes you send. Schemes with a timestamp are refused when it is more than 5 minutes off, which stops replays. Keep your server clock in sync.
A flow can also list the only addresses allowed to call it. Refused calls are written to the activity log.
| Choice on the step | Header | How it is checked |
|---|---|---|
| Don't check (the private address is the only secret) | None | No check. The private address is the only secret. |
| Another system: HMAC-SHA256 of the body in a header | X-Signature (or the header you name) | HMAC-SHA256 of the raw body with the shared secret, as hex or base64, with or without a "sha256=" prefix. |
| Another system: a shared token in a header | X-Webhook-Token (or the header you name) | The shared secret itself, compared in constant time. |
| Sentra 311 or Sentra Scheduler (X-Civic311-Signature) | X-Civic311-Signature + X-Civic311-Timestamp | "sha256=" + hex HMAC-SHA256 of "{timestamp}.{body}". Refused when the timestamp is more than 5 minutes off. |
| SentraQueue (X-SentraQueue-Signature) | X-SentraQueue-Signature | "t={unix},v1={hex}" where v1 is the HMAC-SHA256 of "{t}.{body}". Refused when t is more than 5 minutes off. |
| CivicCA (X-CivicCA-Signature) | X-CivicCA-Signature | "t={unix},v1={hex}" where v1 is the HMAC-SHA256 of "{t}.{body}". Refused when t is more than 5 minutes off. |
| SentraDeed (X-SentraDeed-Signature) | X-SentraDeed-Signature | "t={unix},v1={hex}" where v1 is the HMAC-SHA256 of "{t}.{body}". Refused when t is more than 5 minutes off. |
| Sentra CMS (X-SentraCMS-Signature) | X-SentraCMS-Signature | "sha256=" + hex HMAC-SHA256 of the raw body. |
Answers
Retry on 429 and 5xx. A 502 means the flow ran and failed; the run ID in the body leads to the reason in run history. Other codes won't succeed on retry.
import crypto from 'node:crypto';
const body = JSON.stringify({ id: 'REQ-1001', status: 'open' });
const signature = crypto.createHmac('sha256', process.env.SIGNING_SECRET).update(body).digest('hex');
const res = await fetch(process.env.FLOW_ADDRESS, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Signature': `sha256=${signature}` },
body,
});
console.log(res.status, await res.json());| Code | What it means |
|---|---|
200 | The run that handled the message, with counts. |
401 | The signature check failed. |
403 | The calling address is not on the flow's list. |
404 | Unknown address. |
409 | The flow is turned off. |
422 | The body was not JSON, or was empty. |
429 | More than 120 calls in a minute. See the Retry-After header. |
502 | The flow ran and failed. The body still carries the run ID. |
Receiving SentraBridge's webhooks
Status updates for anyone; activity-log forwarding for workspace admins.
- 01Something happensAn incident is posted, or the activity log gets entries.
- 02SentraBridge signsHMAC-SHA256 over the timestamp and body.
- 03Your endpointChecks the signature and the timestamp.
- 04Answer 2xxWithin 10 seconds for status, 15 for log entries.
Verifying a signature
Every signed message has X-SentraBridge-Timestamp (Unix seconds) and X-SentraBridge-Signature. The signature is sha256= plus the hex HMAC-SHA256 of the timestamp, a dot and the raw body.
Compute it over the bytes you received, before parsing the JSON. Compare in constant time, and refuse timestamps more than five minutes old.
| Input | Value |
|---|---|
| Secret | whsec_test |
| Timestamp | 1768496400 |
| Body | {"type":"subscription.verify"} |
| Signature | sha256=bc1904fb176ca5096ac52d6ca0b4ac0e50083adb8b26c9c2bf12656a8cfcdac4 |
import hashlib, hmac, time
def verified(secret: str, headers, raw_body: bytes) -> bool:
timestamp = headers.get('X-SentraBridge-Timestamp', '')
sent = headers.get('X-SentraBridge-Signature', '')
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False # too old: possible replay
expected = 'sha256=' + hmac.new(secret.encode(), timestamp.encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sent)import crypto from 'node:crypto';
export function verified(secret, headers, rawBody) {
const timestamp = headers['x-sentrabridge-timestamp'] ?? '';
const sent = headers['x-sentrabridge-signature'] ?? '';
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return expected.length === sent.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sent));
}Status updates
Register a public https address. SentraBridge first sends a signed subscription.verify message; answer 2xx with the challenge in the body to prove the address is yours.
The answer to your registration has the signing secret, shown once. After that you get a POST for each of: incident.posted, incident.updated, maintenance.posted, maintenance.updated, maintenance.reminder. The type is also in X-SentraBridge-Event.
Answer within 10 seconds. After 20 failed deliveries the subscription pauses until you register again. To stop, POST to the unsubscribe_url in any message.
curl -s -X POST "$SENTRABRIDGE_URL/public/status/subscribe" \
-H "Content-Type: application/json" \
-d '{"channel": "webhook", "url": "https://ops.example.gov/hooks/sentrabridge-status", "components": ["api", "schedules"]}'{
"type": "incident.posted",
"sent_at": "2026-01-15T17:00:00+00:00",
"status_page": "https://sentrabridge.example/status/",
"incident": {
"id": "01jexampleincident0000000000",
"title": "Scheduled runs are delayed",
"kind": "incident",
"impact": "degraded",
"status": "investigating",
"components": [
"schedules"
],
"starts_at": "2026-01-15T17:00:00+00:00",
"ends_at": null,
"resolved_at": null
},
"update": {
"at": "2026-01-15T17:00:00+00:00",
"status": "investigating",
"text": "Scheduled flows are starting late. We are looking into it."
},
"unsubscribe_url": "https://app.sentrabridge.example/public/status/unsubscribe?token=EXAMPLE"
}Activity log forwarding
Workspace admins can forward the activity log to a webhook under Settings → Records & audit (syslog and OpenTelemetry are there too). Entries arrive in batches, oldest first.
Headers: X-SentraBridge-Timestamp, X-SentraBridge-Sink, X-SentraBridge-Signature. The signature is sent when a signing secret is saved on the destination, so save one.
Each entry carries its tamper-check hashes (hash, prev_hash), so your log store can prove nothing was removed. Entries describe actions and settings, never record contents.
{
"source": "sentrabridge",
"sent_at": "2026-01-15T17:00:00+00:00",
"events": [
{
"id": 4821,
"chain": "01jexampleworkspace00000000",
"workspace_id": null,
"workspace": null,
"at": "2026-01-15T17:00:00+00:00",
"action": "flow.toggled",
"summary": "Turned on Nightly permit export",
"actor": {
"id": null,
"name": "System",
"email": null
},
"subject": {
"type": "flow",
"id": "01jexampleflow0000000000000"
},
"ip": "203.0.113.10",
"hash": "ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12",
"prev_hash": "cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34",
"meta": null
}
]
}