#+title: gitbay API and webhooks * The JSON API One endpoint fronts the entire command registry — everything the SSH control plane can do, current and future, with identical semantics. Disabled by default; the instance must set: #+begin_src toml [api] enabled = true #+end_src ** Tokens Tokens are minted wherever the registry is reached: over SSH, on the API, anywhere. A full-scope token can mint another, which is what full scope means; a read-scoped one cannot, because minting is a write. The controls here are scope, TTL and revocation, not which door a request arrived through (#234). Give a token the narrowest scope and shortest TTL that does its job, and revoke it when the job is over. #+begin_src sh gitbay auth token create --name ci [--scope full|read] [--ttl 30d] gitbay auth token list gitbay auth token revoke ci #+end_src The token (prefix =gb_=, shown exactly once) is presented as =Authorization: Bearer gb_...=. Only a hash is stored server-side. Scope =read= permits list/show/log/diff-style commands and refuses anything that modifies state. =--ttl= takes Go durations or a day suffix (=30d=); expired, revoked, and unknown tokens all answer the same 401. ** POST /api/v1/cmd Request body: #+begin_src json {"argv": ["issue", "create", "you/project", "--title", "from CI"], "stdin": "optional body for --file - style input"} #+end_src =argv= is real argv — no shell, no quoting rules. The response is the command's own JSON envelope with =exit_code= added, plus =stderr= when the command wrote diagnostics: #+begin_src json {"protocol_version": 1, "exit_code": 0, "data": {"number": 7}} #+end_src HTTP status maps the exit code: 0→200, 2→400, 3→404, 4→403, else 500. Commands that emit raw text rather than an envelope (=help=, =mr diff=) come wrapped as ={"output": "..."}=. Git transport commands and the token commands are refused by name. #+begin_src sh curl -s -H "Authorization: Bearer $TOKEN" \ -d '{"argv":["whoami"]}' https://gitbay.org/api/v1/cmd printf '{"argv":["issue","create","you/project","--title","t","--file","-"],"stdin":"body\n"}' | curl -s -H "Authorization: Bearer $TOKEN" -d @- https://gitbay.org/api/v1/cmd #+end_src ** GET /api/v1/read The conditional half: the same registry over GET, admitting only commands the registry marks read-only, so a GET structurally cannot mutate. Responses carry an =ETag=; =If-None-Match= answers 304. #+begin_src sh curl -s -H "Authorization: Bearer $TOKEN" \ "https://gitbay.org/api/v1/read?argv=repo&argv=show&argv=you/project" #+end_src ** The dashboard read =dashboard= returns the account aggregate — pinned repositories, open merge requests, assigned issues, recent builds — in one call: #+begin_src sh curl -s -H "Authorization: Bearer $TOKEN" \ "https://gitbay.org/api/v1/read?argv=dashboard" #+end_src For an instance admin the response also carries ={"server": {"commit": ""}}=, the build the daemon is running, so the deployed commit is readable without the journal. The key is absent for everyone else: the exact build a host runs narrows down which known issues apply to it. ** Cursor pagination =issue list=, =mr list=, =repo list=, and =feed= accept =--limit = (1–200) and =--cursor =. With either flag present the =data= envelope becomes ={"items": [...], "next": "..."}=; =next= is an opaque cursor for the following page, absent on the last one. Without the flags the response stays the complete bare array; a bare =feed= returns the newest 50 events. * Commit statuses (CI reporting) CI reports results through the same command surface (over SSH or the JSON API with a full-scope token; reporting requires write access): #+begin_src sh gitbay status set --context build --state pending gitbay status set --context build --state success --url https://ci.example/run/1 gitbay status list --json # {"combined": "...", "statuses": [...]} #+end_src One row per (commit, context): re-reporting updates in place. States: =pending=, =success=, =failure=, =error=; the combined state is the worst of them. Statuses appear on commit pages, MR pages, and =mr show=, each with =updated_at=; a =ci/= status also carries =duration=, read from the build behind it, once that build has finished. With =repo settings require-checks on=, every status on the MR head must be green, and a head something was going to report on must carry some: a =.gitbay/ci.yml= with a job a push runs, or a repository that has recorded a status before, which is what reporting from outside through =status set= looks like. A repository where nothing has ever reported merges. Each report also emits a =status= event to webhooks. * Webhooks Per-repository outbound POSTs for repository events. Managed by repo admins: #+begin_src sh gitbay webhook add --secret s3cret [--events push,issue.created] # default * gitbay webhook list gitbay webhook deliveries [--limit 50] # status, attempts, last error gitbay webhook redeliver # requeue, including dead letters gitbay webhook remove #+end_src ** Events Every event this forge records, and so every name =--events= may take. The server holds the same list as =control.EventKinds=, and a test fails if the code emits something not listed or lists something it never emits — so this is the whole set, not a sample. =webhook add= refuses a name that is not one of them, since a subscription to a typo would silently never fire. - repository: =push= (ref, old, new, forced, deleted), =repo.archived=, =repo.unarchived=, =repo.imported= (from) - issues: =issue.created=, =issue.edited=, =issue.commented=, =issue.closed=, =issue.open=, =issue.labeled= (labels), =issue.assigned= (assignees), =issue.milestoned= (milestone) - merge requests: =mr.created=, =mr.edited=, =mr.commented=, =mr.reviewed= (verdict), =mr.draft= (draft), =mr.retargeted= (from, to), =mr.labeled= (labels), =mr.milestoned= (milestone), =mr.merged= (number, sha), =mr.closed= - releases: =release.created= (tag), =release.deleted= (tag) - CI: =status=, =build.success=, =build.failure=, =build.cancelled= Every payload carries =number= where it names an issue or merge request. There is deliberately no =repo.deleted=. Both =events.repo_id= and =webhooks.repo_id= cascade from =repos=, so recording one would delete it — and every webhook that could have received it — in the same statement. A repository's deletion is visible in the audit log. ** Delivery Each event POSTs one JSON body: #+begin_src json {"event": "push", "repo": "you/project", "actor": "alice", "created_at": "2026-08-24T01:00:00.000Z", "data": {"ref": "refs/heads/main", "old": "...", "new": "...", "forced": false, "deleted": false}} #+end_src Headers: =X-Gitbay-Event=, =X-Gitbay-Delivery= (id), and — when the hook has a secret — =X-Gitbay-Signature-256: sha256== over the raw body. Verify before trusting: #+begin_src python import hmac, hashlib expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest() ok = hmac.compare_digest(expected, request.headers["X-Gitbay-Signature-256"]) #+end_src A 2xx within 10 seconds is success. Anything else retries with exponential backoff (30s base, doubling) and dead-letters after five attempts; =webhook deliveries= shows the trail and =redeliver= revives a dead letter. Redirects are never followed, and targets resolving to loopback/private/link-local addresses are refused both at registration and again at connect time, unless the instance sets =[webhooks] allow_local=.