webhooks - BaryoDev/barakoCMS GitHub Wiki
A Webhook workflow action POSTs a JSON body to the URL it is configured with. With a secret set,
every delivery is signed so the receiver can tell a genuine delivery from anyone who learned the
URL. With or without one, every delivery leaves a row in the delivery log.
Retry is not here. Delivery is retried by the workflow runner (up to five attempts with backoff),
and a transient failure is tried again inside one attempt first. Both are in docs/workflow-runs.md.
A receiver can see the same delivery more than once and should dedupe on Idempotency-Key, or on
X-Barako-Delivery for tries inside one attempt.
{
"event": "Published",
"tenant": "acme",
"contentId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"contentType": "post",
"status": "Published",
"data": { "Title": "Hello" },
"createdAt": "2026-10-01T08:00:00Z",
"updatedAt": "2026-10-02T09:30:00Z"
}event is the trigger that fired the workflow (Created, Updated, Published, Unpublished,
Deleted or transition:<Name>), so one URL behind several events can tell them apart. A webhook
inside a Conditional action carries the same event as the run it belongs to. data
holds the fields the content type marks Public, and is empty for an entry that is itself Sensitive
or Hidden.
tenant is the tenant the workflow fired in, spelled as the handle it resolves by (the value
GET /api/tenants/by-host/{host} answers with and X-Tenant selects). The default tenant is
default. by-host never answers default: it answers 404 for a host that names no tenant, so a
receiver that resolved no tenant compares against default. It is read from the tenant the run belongs to, not from the action's parameters, so a
workflow cannot name another tenant, and a webhook inside a Conditional carries its run's tenant.
A Deleted delivery is about an entry that no longer exists, so its body is event, tenant,
contentId and contentType only. There is no status, data, createdAt or updatedAt to
send.
A receiver that serves more than one tenant behind the same secret has to check tenant. The
signature proves a body came from this API, not which tenant's URL it was posted to, so without the
check a delivery captured for one tenant verifies when replayed at another tenant's URL. After the
signature verifies, compare the body's tenant with the tenant the receiver resolved for the
request, and refuse a body that names a different one.
The check stops a captured delivery being replayed against another tenant. It does not stop a party that holds the secret, who can sign a body naming any tenant. Where tenants write their own workflows, give each tenant its own secret.
The X-Barako-Tenant header carries the same value so a delivery can be routed before its body is
read. It is not part of the signed string. Route on it, and decide on the body's tenant.
Add a Secret parameter to the action:
{
"type": "Webhook",
"parameters": {
"Url": "https://hooks.example.com/barako",
"Secret": "whsec_a_long_random_value"
}
}It is encrypted with the deployment's Secrets:Key (falling back to JWT:Key) when the workflow
is saved. No read returns it: GET /api/workflows shows secretSet: true on the action and leaves
the parameter out. The runs, the execution log and the delivery log hold no copy either. Only the
action decrypts it, at the moment of sending.
The same applies to any action parameter whose name reads as a credential (Password, Token,
ApiKey, AccessKey, PrivateKey, Credential and similar, matched as a substring). Those are
encrypted on save too, and the runner decrypts them just before the action runs, so a custom action
reads them as it always did. Only Secret reaches the action still encrypted. The child actions a
Conditional carries in ThenActions and ElseActions are treated the same way, at any depth: their
credentials are encrypted on save, left out of what the API returns (each child carries a
SecretSet flag instead), and decrypted just before the child runs. A branch that is not a JSON
array of actions the Conditional can run (each an object, parameter values as text, no repeated
property name) is stored as it was sent, is not run and is not returned; the action's
unreadableBranches names it, and the startup pass logs a warning naming it. Credential
parameters on a workflow's own actions that were stored in clear before 4.2 are encrypted in place
when the API starts. With Tenancy:DatabaseEnforcement on, that pass visits registered tenants and
the default partition only (see tenancy-at-the-database.md).
If Secrets:Key is rotated, stored secrets can no longer be decrypted. The action then refuses to
send rather than sending unsigned, marks the attempt as a permanent failure with that reason, and
the fix is to enter the secret again on the workflow.
A Webhook with a Secret must use an https URL. Over http the body and the signature travel
in cleartext, and whoever reads them can replay the delivery inside the receiver's tolerance window.
Creating or validating a workflow with a Secret and an http:// URL fails with an error on
actions[n].parameters.Url that names this rule. A delivery that reaches the action anyway (a
definition saved before the rule) is recorded as a permanent failure with the same reason and
nothing is sent. Webhooks without a secret are unaffected.
{
"Webhooks": {
"AllowInsecureSignedUrls": true
}
}Webhooks:AllowInsecureSignedUrls (default false) turns the check off at create and at delivery,
for a lab talking to a loopback receiver. WebhookDeliveryTests verifies the signing recipe over a
loopback http listener, which is the case the setting exists for.
Every delivery carries:
| Header | Value |
|---|---|
X-Barako-Delivery |
The id of the delivery log row, so your log and ours can be joined. |
X-Barako-Timestamp |
Unix seconds when the request was signed. |
X-Barako-Signature |
sha256=<hex>, only when a secret is set. |
X-Barako-Tenant |
The body's tenant, for routing. Not signed, and left out when the name holds a space or a character outside printable ASCII. |
Idempotency-Key |
Stable across retries of the same action, so a retry can be recognised. |
The signature is HMAC-SHA256, keyed with the secret, over the string "<timestamp>.<body>", where
<body> is the exact bytes of the request body. Including the timestamp is what lets a receiver
refuse a captured delivery replayed later. The body holds tenant, so the signature covers it and
a body edited to name another tenant does not verify.
Read the raw body before parsing it. A body re-serialised by your framework is a different string and will not verify.
import hmac, hashlib, time
def verify(secret: str, headers, raw_body: bytes, tolerance=300) -> bool:
ts = headers["X-Barako-Timestamp"]
if abs(time.time() - int(ts)) > tolerance:
return False
material = ts.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), material, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers["X-Barako-Signature"])WebhookDeliveryTests runs this recipe, written out in C#, against the bytes a real delivery put on
the wire and checks it fails with a different secret.
GET /api/webhook-deliveries lists deliveries newest first, paginated, gated on view_workflow_runs
(the capability that reads workflow runs). It lists webhooks only: what a Request action sent
through a connector is stored as the same document and listed by GET /api/connector-deliveries
(see connectors.md). Filters:
-
workflowId: one workflow's deliveries. -
status: a class, one of2xx,3xx,4xx,5xx, orfailedfor a delivery that got no response at all (connection refused, timeout, a URL the outbound guard refused). An unknown value is a 400, not an empty list.
Each row holds the workflow id, the run id when the runner made the delivery, the URL cut to scheme, host and port, the trigger event, the request headers minus the signature, the response status, the response body, when that body was cleared by retention, the duration, the error text when nothing answered, the attempt number and when it happened.
The response body is kept because "what did they say" is the next question after "did it fire", and
it is the one field on the row that answers to a narrower reader and a shorter clock than the rest.
Some providers echo a credential in a 401 body, the same reason Features/WorkflowRuns/Endpoints.cs
and Infrastructure/Connectors/ConnectorSender.cs carry no response body at all. Reading it needs
view_webhook_response_bodies on top of view_workflow_runs; a caller holding only the latter sees
every row, with the status and everything else intact, and responseBody: null. docs/access-control.md
covers the split and who loses access to what on upgrade. See issue #607.
{
"Webhooks": {
"DeliveryLogRetentionDays": 30,
"ResponseBodyRetentionHours": 24
}
}Two windows, on the same sweep, for the reason WorkflowRunRetentionService runs two: the row and
the response body are worth keeping for different lengths of time. Thirty days is the default for the
row: "did this workflow's hook fire, and when" is worth answering long after the fact, and this is
still not an audit trail, the same caveat docs/workflow-runs.md makes. Twenty-four hours is the
default for the body: debugging a webhook a provider is rejecting happens in the same shift it broke
in, and a body that can carry a credential should not sit in the log any longer than that debugging
window needs.
The sweep runs hourly, two minutes after start, on every tenant holding deliveries. It clears expired
bodies before it deletes expired rows, so a row due for deletion next sweep rather than this one
never sits with an expired body in the meantime. A cleared body leaves the row in place with
responseBody: null and responseBodyClearedAt set to when the sweep cleared it, which is what
tells it apart from a row whose body was empty to begin with (nothing answered, or the body has not
expired yet): both read responseBody: null, and only a cleared one sets the timestamp.
Zero or less keeps a window forever, the same reading Workflows:Retention uses, because "0" reads
as "delete immediately" just as naturally and keeping is the direction a mistake can be recovered
from. The two windows are independent: a deployment can keep rows forever while still clearing
bodies hourly, or the other way round.
Generated from docs/webhooks.md by scripts/wiki-sync.sh. Edit the doc in the repository, not this page.