Chapter 29Lesson 02~350 minutes

REST API, GraphQL API, Webhooks, System Hooks, Pagination, Rate Limits, and Automation: Guided Hands-On Workflow and Core Operations

Use a disposable GitLab Free project to inspect REST and GraphQL state, paginate safely, verify a synthetic signed webhook locally, and perform one idempotent label mutation.

Hands-onFree pathglab apiHMACDesired state

Learning objectives

  • Measure current project/API state before changing anything.
  • Retrieve complete REST and GraphQL result sets with documented pagination.
  • Verify and deduplicate a current-format signed webhook using only a local receiver.
  • Implement a harmless label as an idempotent desired-state mutation.
  • Capture sanitized evidence and clean up the disposable mutation.
Availability baseline — verified 2026-08-22 against GitLab 19.3. REST, GraphQL, glab api, and project webhooks have Free-compatible paths across GitLab.com, Self-Managed, and Dedicated. Group webhooks require Premium/Ultimate. System hooks are instance-wide administrator controls documented for Self-Managed and Dedicated, while the current System Hooks REST API reference is Self-Managed-specific. New webhooks should prefer the GitLab 19.1+ HMAC-SHA256 signing-token mechanism. The mandatory chapter path uses a GitLab Free disposable project, a local synthetic receiver, and one harmless label mutation; it does not require a public webhook endpoint, paid tier, administrator access, or production token.

1. Disposable scenario and preflight

Use a disposable GitLab Free project such as training/api-automation-lab. The lab assumes glab, jq, Git, and Python 3. The mandatory webhook exercise stays on 127.0.0.1; it does not create a real GitLab webhook or public tunnel. Authenticate glab interactively or with a properly stored credential—never paste a token into the repository.

glab auth status
git remote -v
glab api projects/:fullpath   | jq '{id,path_with_namespace,default_branch,visibility}'
mkdir -p ch29-lab/evidence && cd ch29-lab
Trust boundary: this lesson mutates only one disposable project label. It does not create tokens, hooks, branches, environments, deployments, packages, images, or runners.

2. Read-only REST: prove host, project, status, and pagination metadata

Start with an explicit resource. glab api -i can include headers so you can preserve status/request/pagination evidence without printing the credential.

glab api -i "projects/:fullpath/labels?per_page=2"
# Do not assume the first JSON array is complete.

glab api projects/:fullpath/labels --paginate --output ndjson   | jq '{id,name,color}'   | tee evidence/labels-before.ndjson

If you write a raw HTTP client instead of using --paginate, parse and follow Link entries whose relation is next. Stop when no next relation exists. Do not require x-total to be present.

3. GraphQL with variables and cursor pagination

The current glab api graphql --paginate helper requires a query that accepts $endCursor: String and returns pageInfo { hasNextPage endCursor }. Keep the query small and request only fields the client needs.

glab api graphql --paginate --output ndjson   -f fullPath='training/api-automation-lab'   -f query='query($fullPath: ID!, $endCursor: String) {
    project(fullPath: $fullPath) {
      issues(first: 2, after: $endCursor) {
        nodes { iid title state }
        pageInfo { hasNextPage endCursor }
      }
    }
  }' | tee evidence/issues-graphql.ndjson

After every GraphQL request, inspect for top-level errors. If you build mutations later, inspect the mutation payload errors too. Never equate “HTTP 200” with “all requested fields were authorized and resolved.”

4. Build a private local signed-webhook receiver

This receiver validates the current HMAC signing token before parsing JSON. It enforces a five-minute timestamp window and remembers webhook-id values to make replay a no-op. The in-memory set is appropriate only for this lab; production receivers need durable deduplication shared across replicas.

from http.server import BaseHTTPRequestHandler, HTTPServer
import base64, hashlib, hmac, json, os, time

TOKEN = os.environ["WEBHOOK_SIGNING_TOKEN"]
if not TOKEN.startswith("whsec_"):
    raise SystemExit("expected whsec_ signing token")
KEY = base64.b64decode(TOKEN.removeprefix("whsec_"))
seen = set()

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
        wid = self.headers.get("webhook-id", "")
        ts = self.headers.get("webhook-timestamp", "")
        sig = self.headers.get("webhook-signature", "")
        try:
            age = abs(int(time.time()) - int(ts))
        except ValueError:
            return self.reply(401, {"error":"bad timestamp"})
        if age > 300:
            return self.reply(401, {"error":"stale delivery"})
        signed = f"{wid}.{ts}.".encode() + body
        expected = "v1," + base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest()).decode()
        signatures = sig.split()
        if not any(hmac.compare_digest(candidate, expected) for candidate in signatures):
            return self.reply(401, {"error":"signature mismatch"})
        duplicate = wid in seen
        seen.add(wid)
        event = json.loads(body)
        # Log selected safe fields, not secrets or an unbounded raw payload.
        print(json.dumps({"webhook_id":wid,"duplicate":duplicate,"object_kind":event.get("object_kind")}))
        return self.reply(200, {"accepted":True,"duplicate":duplicate})

    def reply(self, status, obj):
        out = json.dumps(obj).encode()
        self.send_response(status)
        self.send_header("Content-Type","application/json")
        self.send_header("Content-Length",str(len(out)))
        self.end_headers(); self.wfile.write(out)

HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

Save it as receiver.py. Next create an ephemeral lab signing token in the shell; the token never enters Git history or the evidence directory.

export WEBHOOK_SIGNING_TOKEN="$(python - <<'PYCODE'
import base64, os
print('whsec_' + base64.b64encode(os.urandom(32)).decode())
PYCODE
)"
python receiver.py

5. Send and replay one synthetic delivery

In a second terminal, use the same environment variable and save the following as send_webhook.py. This script constructs the HMAC from exact body bytes rather than serializing the body a second time during verification.

import base64, hashlib, hmac, json, os, time, urllib.request
key = base64.b64decode(os.environ["WEBHOOK_SIGNING_TOKEN"].removeprefix("whsec_"))
wid = os.environ.get("WEBHOOK_ID", "ch29-demo-event-001")
ts = str(int(time.time()))
body = json.dumps({"object_kind":"push","project":{"path_with_namespace":"training/api-automation-lab"}}, separators=(",",":")).encode()
signed = f"{wid}.{ts}.".encode() + body
sig = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
req = urllib.request.Request("http://127.0.0.1:8765/", data=body, method="POST", headers={
    "Content-Type":"application/json",
    "webhook-id":wid,
    "webhook-timestamp":ts,
    "webhook-signature":sig,
})
print(urllib.request.urlopen(req).read().decode())
export WEBHOOK_ID='ch29-demo-event-001'
python send_webhook.py
python send_webhook.py   # same ID: receiver returns duplicate=true

The second delivery is not an error. It proves the receiver can acknowledge a redelivery without repeating downstream work. A production receiver would persist the event ID, enqueue once, and return 2xx quickly.

6. Idempotent desired-state mutation: one disposable label

Creating the label with blind POST retries would be the wrong lesson. Instead observe first, create only if absent, update only if materially different, and verify with a second read. Current label APIs support mutation by numeric label ID; avoid relying on deprecated name-in-body update/delete patterns.

#!/usr/bin/env bash
set -euo pipefail
LABEL="automation-managed"
DESC="Chapter 29 disposable automation marker"
COLOR="#428BCA"

current="$(glab api projects/:fullpath/labels --paginate --output ndjson   | jq -s --arg n "$LABEL" '[.[] | select(.name == $n)] | first')"

if [ "$current" = "null" ]; then
  echo "create: label absent"
  glab api --method POST projects/:fullpath/labels     -f name="$LABEL" -f color="$COLOR" -f description="$DESC" >/dev/null
else
  id="$(jq -r '.id' <<<"$current")"
  actual="$(jq -r '.description // ""' <<<"$current")"
  if [ "$actual" != "$DESC" ]; then
    echo "update: label exists but differs"
    glab api --method PUT "projects/:fullpath/labels/$id"       -f description="$DESC" -f color="$COLOR" >/dev/null
  else
    echo "no-op: desired state already present"
  fi
fi

glab api projects/:fullpath/labels --paginate --output ndjson   | jq --arg n "$LABEL" 'select(.name == $n) | {id,name,color,description}'

Save as ensure-label.sh, make it executable, and run it twice. Expected behavior: first run creates or converges the label; second run prints no-op. There should still be exactly one label named automation-managed.

chmod +x ensure-label.sh
./ensure-label.sh
./ensure-label.sh

glab api projects/:fullpath/labels --paginate --output ndjson   | jq -s '[.[] | select(.name == "automation-managed")] | {count:length,items:.}'   | tee evidence/label-after.json

7. Simulate 429 logic instead of hammering GitLab

Do not deliberately exceed a real service rate limit. Use this fixture to practice the decision:

HTTP_STATUS=429
RETRY_AFTER=4
if [ "$HTTP_STATUS" -eq 429 ]; then
  printf 'rate limited; wait at least %ss, add jitter, then re-read state before any write retry
' "$RETRY_AFTER"
fi

A read can generally be retried with bounded backoff. A mutation must re-read current state after the wait; if the first request actually succeeded, the desired-state check prevents a duplicate.

8. Challenge: choose the surface, not the syntax

You need to synchronize 4,000 issue titles into an internal index and then react quickly to changes. Choose an architecture and justify it. A strong answer uses a complete paginated REST/GraphQL baseline plus project webhook events for low-latency updates, while periodically reconciling current state because webhook delivery can be duplicated, delayed, or reordered.

9. Verification and cleanup

Delete the disposable label by its current numeric ID only after preserving sanitized evidence. Then prove it is absent. Remove local webhook files and unset the signing-token variable.

id="$(glab api projects/:fullpath/labels --paginate --output ndjson   | jq -r 'select(.name == "automation-managed") | .id' | head -n1)"
[ -n "$id" ] && glab api --method DELETE "projects/:fullpath/labels/$id" >/dev/null

if glab api projects/:fullpath/labels --paginate --output ndjson   | jq -e 'select(.name == "automation-managed")' >/dev/null; then
  echo 'cleanup failed: label still exists' >&2; exit 1
else
  echo 'verified: lab label absent'
fi
unset WEBHOOK_SIGNING_TOKEN WEBHOOK_ID
rm -f receiver.py send_webhook.py ensure-label.sh
Do not “clean up” by deleting a real project access token or webhook unless you created it specifically for this disposable lab and have recorded its identifier.

Knowledge check

Why does the lab send the same webhook ID twice?

Why is the label script read-before-write?

What makes the GraphQL --paginate example valid for glab?

Why is the webhook receiver local only?

What should be kept as evidence?

10. Summary and next bridge

You have now exercised the full safe loop: read, paginate, verify events, converge a harmless object, replay, verify, and clean up. Lesson 3 turns those mechanics into architecture choices for production clients.

Primary sources and version notes

These lessons were finalized against current official GitLab documentation on 2026-08-22. API fields, rate limits, webhook event schemas, CLI flags, and tier/offering availability can change, so production clients should pin/document assumptions and re-check the API/CLI documentation for the deployed GitLab version.

Next lesson

Configuration, Design Choices, and Tradeoffs

Choose REST/GraphQL, glab/custom clients, event scope, polling, identities, and idempotency deliberately.

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.