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.
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.
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
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
Knowledge check
Why does the lab send the same webhook ID twice?
To prove at-least-once delivery safety: a receiver can authenticate both deliveries but perform the downstream action only once.
Why is the label script read-before-write?
A timeout does not tell you whether a write committed. Re-reading desired state prevents a blind retry from duplicating or conflicting with the first mutation.
What makes the GraphQL --paginate example valid for glab?
The query accepts an $endCursor variable and returns pageInfo with hasNextPage and endCursor.
Why is the webhook receiver local only?
It proves verification and deduplication without exposing a lab endpoint or payloads to a public tunnel/service.
What should be kept as evidence?
Selected status, IDs, request/pagination metadata, sanitized fields, before/after state, and deduplication results—not credentials or full sensitive payloads.
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.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.