Chapter 23Lesson 02~180 minutes

Web API, Webhooks, Automation, Provisioning, and Policy as Code: Guided Hands-On Workflow

Build a disposable local policy-as-code workflow with a scoped user token, idempotent project/webhook reconciliation, pagination/error handling, HMAC-validated local webhooks, and explicit cleanup.

Web APIWebhooksAutomationPolicy as codeGovernance

Learning objectives

  • Create a disposable Community Build automation identity and project without embedding administrator credentials in scripts.
  • Write an idempotent local provisioning client that reads before it creates or updates.
  • Handle paginated read results and preserve HTTP error bodies/statuses.
  • Run a local HMAC-verifying webhook receiver and reconcile a project-level webhook safely.
  • Trigger an analysis, correlate ceTaskId with the webhook task ID, and preserve both paths as evidence.
  • Revoke the automation credential and clean up only objects owned by the lab.

1. Disposable workflow contract

Local/sandbox only. Run this on a disposable Community Build instance. The lab creates an automation user, one private project, one temporary permission-template rule for project creator administration, one project webhook, and synthetic source. Preserve pre-lab state and remove only those named resources during cleanup.
State Lab value
Server Community Build 26.9.0.129388 at http://localhost:9000
Scanner SonarScanner CLI 8.1.0.6389
Automation user ch23-bot with Create Projects plus project-scoped rights inherited by a disposable key-pattern template
Project sq-ch23-policy-lab, private
Webhook receiver 127.0.0.1:9123; actual URL depends on SonarQube runtime network
Secrets User token and webhook HMAC secret generated locally and held only in environment/secret storage

2. One-time local bootstrap: make least privilege possible

A project webhook requires project administration, while project creation requires the global Create Projects permission. Do not give the automation user Administer System merely to make the script easy.

Using the local administrator through the UI, create a temporary permission template named SQ Ch23 Lab Template with project key pattern ^sq-ch23-.*. Grant the project creator the project permissions needed for the lab: Browse, Execute Analysis, and Administer. Grant user ch23-bot the global Create Projects permission. Record these bootstrap mutations for rollback.

Why UI for bootstrap? The automation under test should not contain an administrator token. Chapter 23 is testing a bounded reconciliation identity, not bootstrapping the entire identity system with the same broad credential.

3. Generate a bounded User token and inspect expiration metadata

Sign in as ch23-bot and generate a short-lived User token. The token needs API actions matching the user’s permissions. Keep it out of source, command history, screenshots, and artifacts.

export SONAR_HOST_URL="http://localhost:9000"
export PROJECT_KEY="sq-ch23-policy-lab"
export PROJECT_NAME="SQ Chapter 23 Policy Lab"

read -rsp "ch23-bot user token: " SONAR_API_TOKEN; echo
export SONAR_API_TOKEN

mkdir -p evidence/http
curl --fail-with-body -sS \
  -o evidence/http/validate.json \
  -H "Authorization: Bearer $SONAR_API_TOKEN" \
  "$SONAR_HOST_URL/api/authentication/validate"

# The validate endpoint is one of the documented exceptions that does not return
# token-expiration metadata. Capture the header from a normal authenticated API call instead.
curl --fail-with-body -sS \
  -D evidence/http/inventory.headers \
  -o evidence/http/inventory.json \
  -H "Authorization: Bearer $SONAR_API_TOKEN" \
  "$SONAR_HOST_URL/api/components/search?qualifiers=TRK&p=1&ps=1"

grep -i '^SonarQube-Authentication-Token-Expiration:' \
  evidence/http/inventory.headers || true

4. Build the idempotent project/webhook reconciler

The script owns exactly two things: project existence/visibility and a webhook named ch23-local-receiver. It never deletes or rewrites objects merely because their names are similar.

# reconcile.py
import json, os, sys, urllib.parse, urllib.request, urllib.error

BASE = os.environ["SONAR_HOST_URL"].rstrip("/")
TOKEN = os.environ["SONAR_API_TOKEN"]
PROJECT = os.environ.get("PROJECT_KEY", "sq-ch23-policy-lab")
NAME = os.environ.get("PROJECT_NAME", "SQ Chapter 23 Policy Lab")
HOOK_NAME = "ch23-local-receiver"
HOOK_URL = os.environ["CH23_WEBHOOK_URL"]
HOOK_SECRET = os.environ["CH23_WEBHOOK_SECRET"]


def call(method, path, form=None):
    data = None
    headers = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"}
    if form is not None:
        data = urllib.parse.urlencode(form).encode()
        headers["Content-Type"] = "application/x-www-form-urlencoded"
    req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            body = r.read()
            return r.status, dict(r.headers), json.loads(body or b"{}")
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        raise RuntimeError(f"{method} {path} -> HTTP {e.code}: {body}") from None


def project_exists():
    # Search is paginated; inspect every page and compare the exact key.
    page = 1
    while True:
        q = urllib.parse.urlencode({"qualifiers":"TRK", "q":PROJECT, "p":page, "ps":100})
        _, _, payload = call("GET", "/api/components/search?" + q)
        if any(c.get("key") == PROJECT for c in payload.get("components", [])):
            return True
        pg = payload.get("paging", {})
        size, total = int(pg.get("pageSize", 0)), int(pg.get("total", 0))
        index = int(pg.get("pageIndex", page))
        if size == 0 or index * size >= total:
            return False
        page += 1


def ensure_project():
    if project_exists():
        print("project: already present")
        return
    call("POST", "/api/projects/create", {
        "project": PROJECT, "name": NAME, "visibility": "private"
    })
    if not project_exists():
        raise RuntimeError("project create returned but exact key is not readable")
    print("project: created")


def ensure_webhook():
    q = urllib.parse.urlencode({"project": PROJECT})
    _, _, payload = call("GET", "/api/webhooks/list?" + q)
    matches = [h for h in payload.get("webhooks", []) if h.get("name") == HOOK_NAME]
    if len(matches) > 1:
        raise RuntimeError("ambiguous duplicate webhook names; stop for manual review")
    if not matches:
        call("POST", "/api/webhooks/create", {
            "project": PROJECT, "name": HOOK_NAME,
            "url": HOOK_URL, "secret": HOOK_SECRET
        })
        print("webhook: created")
        return
    hook = matches[0]
    if hook.get("url") == HOOK_URL and str(hook.get("hasSecret", "")).lower() in {"true", "yes"}:
        print("webhook: already compliant")
        return
    call("POST", "/api/webhooks/update", {
        "webhook": hook["key"], "name": HOOK_NAME,
        "url": HOOK_URL, "secret": HOOK_SECRET
    })
    print("webhook: reconciled")


ensure_project()
ensure_webhook()

Run the script twice. The first run should create missing state; the second should report compliant state rather than creating another project or webhook.

5. Build an HMAC-validating local webhook receiver

The receiver stores only validated event metadata. It does not treat the payload as permission to deploy or mutate SonarQube.

# receiver.py
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import hashlib, hmac, json, os, pathlib, time

SECRET = os.environ["CH23_WEBHOOK_SECRET"].encode()
OUT = pathlib.Path("evidence/webhook-events.jsonl")
OUT.parent.mkdir(parents=True, exist_ok=True)

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        n = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(n)
        got = self.headers.get("X-Sonar-Webhook-HMAC-SHA256", "")
        expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(got, expected):
            self.send_response(401); self.end_headers(); return
        payload = json.loads(body)
        record = {
            "receivedAt": int(time.time()),
            "projectKey": payload.get("project", {}).get("key"),
            "taskId": payload.get("taskId") or payload.get("taskID"),
            "taskStatus": payload.get("status"),
            "qualityGate": payload.get("qualityGate", {}).get("status"),
        }
        with OUT.open("a", encoding="utf-8") as f:
            f.write(json.dumps(record, sort_keys=True) + "\n")
        self.send_response(204); self.end_headers()
    def log_message(self, fmt, *args):
        pass

ThreadingHTTPServer(("127.0.0.1", 9123), Handler).serve_forever()
export CH23_WEBHOOK_SECRET="$(python -c 'import secrets; print(secrets.token_hex(32))')"

# If SonarQube runs directly on this host:
export CH23_WEBHOOK_URL="http://127.0.0.1:9123/sonar"

# Docker Desktop alternative when SonarQube is in a container:
# export CH23_WEBHOOK_URL="http://host.docker.internal:9123/sonar"

python receiver.py

Before configuring the webhook, verify the SonarQube runtime can actually route to the receiver. A host browser reaching 127.0.0.1 does not prove a container can reach that same loopback address.

6. Reconcile twice and prove rerun safety

python reconcile.py | tee evidence/reconcile-run-1.txt
python reconcile.py | tee evidence/reconcile-run-2.txt

curl --fail-with-body -sS \
  -H "Authorization: Bearer $SONAR_API_TOKEN" \
  "$SONAR_HOST_URL/api/webhooks/list?project=$PROJECT_KEY" \
  | tee evidence/webhooks-after.json

The second run should produce no duplicate project and no second webhook with the same ownership name. If the script detects duplicate owned webhook names, it stops instead of deleting one by guesswork.

7. Create a tiny source fixture and run an analysis

mkdir -p src
cat > sonar-project.properties <<'EOF'
sonar.projectKey=sq-ch23-policy-lab
sonar.sources=src
sonar.sourceEncoding=UTF-8
sonar.analysis.chapter=23
EOF

cat > src/app.py <<'PY'
def normalize(value: str) -> str:
    return value.strip().lower()
PY

git init
git config user.email "learner@example.invalid"
git config user.name "SonarQube Learner"
git add sonar-project.properties src/app.py
git commit -m "chapter23 automation fixture"
git rev-parse HEAD | tee evidence/revision.txt

# A project-analysis token is preferred for scanning; keep it separate from the API user token.
read -rsp "Project-analysis token: " SONAR_TOKEN; echo
export SONAR_TOKEN
sonar-scanner -X 2>&1 | tee evidence/scanner.log
cp .scannerwork/report-task.txt evidence/report-task.txt
CE_TASK_ID="$(sed -n 's/^ceTaskId=//p' .scannerwork/report-task.txt)"
echo "$CE_TASK_ID" | tee evidence/ce-task-id.txt

8. Correlate Compute Engine and webhook evidence

curl --fail-with-body -sS \
  -H "Authorization: Bearer $SONAR_API_TOKEN" \
  "$SONAR_HOST_URL/api/ce/task?id=$CE_TASK_ID" \
  | tee evidence/ce-task.json

cat evidence/webhook-events.jsonl

After the webhook arrives, compare its task ID with ceTaskId. Matching identity proves the event belongs to the same background task. Then compare task status and Quality Gate status; do not infer one from the other.

9. Force a pagination-aware read path

Set a deliberately small page size when reading issues or rules so the client proves it can follow pages even on a tiny fixture.

for p in 1 2 3; do
  curl --fail-with-body -sS \
    -H "Authorization: Bearer $SONAR_API_TOKEN" \
    "$SONAR_HOST_URL/api/issues/search?componentKeys=$PROJECT_KEY&p=$p&ps=2" \
    > "evidence/issues-page-$p.json"
done

Stop according to the response paging metadata. Do not hard-code “three pages” in real automation; the loop above is only a visible teaching probe.

10. Challenge: choose the correct layer

The webhook receiver returns 401 even though SonarQube shows the delivery was attempted. Which layer should you debug first?

  1. Preserve the delivery metadata and raw request bytes if your receiver safely captures them.
  2. Verify the receiver is using the same webhook secret as the SonarQube webhook configuration.
  3. Verify it computes HMAC-SHA256 over the raw request body and compares against X-Sonar-Webhook-HMAC-SHA256.
  4. Do not change the scanner token, quality gate, source code, or database; none of those repair receiver HMAC verification.

11. Guarded cleanup

  • Revoke the ch23-bot User token and verify a later API request fails authentication.
  • Revoke the project-analysis token separately.
  • Delete the project webhook by its exact webhook key, not by fuzzy name matching.
  • Delete sq-ch23-policy-lab only after exporting evidence and only if the project was created by this lab.
  • Remove the temporary key-pattern permission template and restore any changed global permission state.
  • Unset SONAR_API_TOKEN, SONAR_TOKEN, and CH23_WEBHOOK_SECRET.

Knowledge check

Why use a User token for the provisioning API but a project-analysis token for scanning?

What makes the second reconciliation run valuable evidence?

Why can 127.0.0.1:9123 fail when SonarQube runs in Docker?

What identity links the webhook event to the scanner’s asynchronous result?

Why stop on duplicate owned webhook names instead of deleting one automatically?

Next lesson

Choose automation and policy-as-code patterns deliberately

Lesson 3 compares API generations, reconciliation models, polling/webhook patterns, ownership boundaries, and rollback trade-offs.

Official references and version notes

Version and compatibility note

Rechecked 2026-09-08. Mandatory examples target Community Build 26.9.0.129388 and SonarScanner CLI 8.1.0.6389. Current Community Build documentation states that Web API V2 is gradually replacing the existing Web API endpoint by endpoint; do not infer a mechanical /api/v2 rewrite. Bearer authentication with a User token is recommended for Web API calls, and authenticated responses normally expose SonarQube-Authentication-Token-Expiration for rotation planning. Unless an endpoint documents otherwise, traditional POST Web API calls should use form data / application/x-www-form-urlencoded. Project creation remains documented at POST /api/projects/create, while some newer capabilities already use V2 resources. SonarQube webhooks can be configured at project/global scope and can be protected with HMAC-SHA256 in X-Sonar-Webhook-HMAC-SHA256; validate the raw body before trusting a payload. Webhook payloads include background task and Quality Gate evidence, but delivery success is not equivalent to Compute Engine success or gate pass. Always recheck the exact target instance’s /web_api documentation, endpoint changelog, permission requirement, pagination/error schema, and V2 replacement status before production automation.

Automation evidence rule. Preserve endpoint generation/path/method, sanitized request intent, HTTP status/body, pagination completion, token owner/type/expiration (never the value), before/after policy state, revision, ceTaskId, Compute Engine result, webhook HMAC outcome, task/gate state, controller version, ownership record, and cleanup/revocation evidence separately.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.