Skip to content

Webhook triggers: fire a workflow from outside

Let your ERP or CRM start a Browserberg workflow through a signed delivery: the exact HMAC recipe, the replay protection and the one identical 401.

Last updated:

One signed delivery

Your sender holds the trigger secret signature = HMAC-SHA256(secret, deliveryId . timestamp . rawBody ) the raw bytes -- not the re-parsed JSON POST /v1/trigger-hooks/:id verifies, then runs Three refusals, one identical answer bad signature · unknown trigger · timestamp outside the 5-minute skew window the delivery id sits UNDER the signature: replaying a captured delivery under a fresh id changes the signed string, so the dedup key only ever deduplicates ids the SENDER chose
The delivery id sits under the signature, so a captured delivery cannot be replayed under a fresh id.

The one public endpoint

POST /v1/trigger-hooks/:id is the only endpoint in the API that accepts requests without an API key — it has to be, because the caller is your ERP or CRM, not your code. When an order lands, a case closes or an invoice posts, the system that knows can start the workflow directly.

What replaces the API key is a per-trigger secret and a signature over every delivery.

Sign and deliver

Requires: api-key kek

BASE="https://browserberg.com"
AUTH="Authorization: Bearer $BROWSERBERG_API_KEY"
JSON="Content-Type: application/json"

WFID=$(curl -s -X POST "$BASE/v1/workflows" -H "$AUTH" -H "$JSON" -d '{
  "title": "Webhook walkthrough",
  "blocks": [ { "blockType": "navigation", "label": "Open_page", "url": "https://example.com" } ]
}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["workflow"]["workflowId"])')

# The secret comes back exactly once, at creation. Store it; it cannot be read
# back, only rotated.
CREATED=$(curl -s -X POST "$BASE/v1/workflows/$WFID/triggers" -H "$AUTH" -H "$JSON" \
  -d '{ "kind": "webhook", "name": "ERP delivery", "session": { "ttlSeconds": 300 } }')
SECRET=$(echo "$CREATED" | python3 -c 'import json,sys; print(json.load(sys.stdin)["secret"])')
TID=$(echo "$CREATED" | python3 -c 'import json,sys; print(json.load(sys.stdin)["trigger"]["id"])')

# Sign a delivery: HMAC-SHA256 over deliveryId.timestamp.rawBody -- the raw
# bytes you send, never a re-serialisation.
BODY='{"inputs":{}}'
DELIVERY="delivery-$(date +%s)"
TS=$(date +%s)
SIG=$(python3 -c "import hmac, hashlib, sys
delivery, ts, body, secret = sys.argv[1:5]
print(hmac.new(secret.encode(), f'{delivery}.{ts}.{body}'.encode(), hashlib.sha256).hexdigest())" \
  "$DELIVERY" "$TS" "$BODY" "$SECRET")

curl -s -X POST "$BASE/v1/trigger-hooks/$TID" -H "$JSON" \
  -H "x-browserberg-delivery: $DELIVERY" \
  -H "x-browserberg-timestamp: $TS" \
  -H "x-browserberg-signature: v1=$SIG" \
  -d "$BODY" \
  | python3 -c 'import json,sys; f = json.load(sys.stdin)["firing"]; print(json.dumps({
      "outcome": f["outcome"]}, indent=2))'

curl -s -X DELETE "$BASE/v1/triggers/$TID" -H "$AUTH" > /dev/null

The recipe, precisely

Compute HMAC-SHA256 over deliveryId.timestamp.rawBody — the raw bytes you send, never a re-serialisation of the JSON — and send it as lowercase hex, with an optional v1= prefix. The timestamp is unix seconds and may deviate at most 300 seconds from the server clock. Content-Type must be application/json; anything else is refused before parsing.

The delivery id is the dedup key, and it sits under the signature on purpose: dedup alone only filters ids the sender chose, so a captured delivery replayed under a fresh id would start a run each time — but changing the id changes the signed string, and the signature dies with it. An unknown trigger and a bad signature receive one identical 401, so probing the endpoint teaches an attacker nothing.

Secrets and rotation

The whs_... secret is shown exactly once, in the creation response; store it in your sender's configuration, because it cannot be fetched again. POST /v1/triggers/:id/secret rotates it with a grace window of up to 7 days during which the old secret is still honoured — long enough to redeploy the sender without a gap in deliveries.

Note · Key provider needed on self-hosted setups

Webhook trigger secrets are sealed with the tenant key. A self-hosted deployment therefore needs OVHcloud KMS or, outside production, a local KEK; missing both, the API replies 503 capacity_unavailable and says which configuration is absent.

More on triggers

  • Schedule triggers The clock-driven counterpart
  • Triggers reference Headers, limits and firing outcomes
  • Errors and retries How the 401 and 202 responses fit the envelope