Codumentor logo Codumentor

Inbound Webhooks

Codumentor accepts push and pull-request notifications from your Git host on one endpoint:

POST /ops/webhook/{source}

{source} picks the parser: gitea, github, generic, or push. There is one authentication path and one secret list for all of them.

The endpoint's whole job is authenticate → normalize → emit a trigger event → 202. It does not ingest anything itself. Ingestion is a task — a built-in entry in tasks.declared: that fires on repo.push — which means an operator can see it, retune it, or turn it off (see Ingestion is a task below). The same is true of anything else you want a push or a PR to start.

Upgrading? POST /ops/webhook/push is unchanged as a URL and still accepts everything it used to, so existing Gitea/GitHub/SVN hooks keep working. Two things did change: the 202 body, and the fact that ingestion now needs the built-in tasks.declared: entry to be present. POST /ops/webhook/gitea-pr (the gitea_pr plugin's private endpoint) is removed — point that hook at /ops/webhook/gitea instead.

The four sources

{source}Use it forEmits
giteaA Gitea webhook of any typerepo.push, scm.pull_request, scm.review_requested, scm.comment
githubA GitHub webhook of any typethe same set
pushA bare push notification with no forge behind it — an SVN post-commit hook, a CI step, a shell script. Also the URL the old dedicated push endpoint used.repo.push
genericAnything else you want to trigger on: CI results, monitoring, a ticket systemwebhook.<your type>

A delivery the source recognises but has nothing to say about — Gitea's ping, a release event — answers 204 and emits nothing, so leaving "send all events" on in the forge is fine.

Plugins can register further sources; a source nothing is registered for answers 404 and names the ones that exist.

How it works

  1. Your Git host sends an event to POST /ops/webhook/{source}.
  2. Codumentor verifies the request against the configured shared secrets. Unsigned or badly signed → 401, and nothing is emitted.
  3. The {source} normalizer parses the body. For repo. and scm. events, the repository is matched against your configured repos:; a repository not in your config is rejected with 404 — a webhook can never name a repository you have not opted into.
  4. The normalized event is emitted, matched against your event tasks, and the endpoint answers 202. It never blocks on the work a task starts.

Ingestion is a task

The default config ships this entry under tasks::

tasks:
  declared:
    - name: ingest-on-push
      "on": repo.push
      job_type: repo_ingest
      target_queue: repo_ingest
      mode: coalesce
      debounce_seconds: 10
      on_overlap: queue
      max_in_flight: 2
      max_fires_per_hour: 120
      access_profile: restricted_fallback
      run:
        prompt: Ingest the repository this push landed on.

Note the quotes on "on": YAML reads a bare on: as the boolean true, and the entry is then refused with "Field required: on".

What the dials do:

Deleting this entry stops push-driven ingestion. That is the point of having it.

Fetch vs full is not configurable here, deliberately. The ingestion job decides it from the repository's configured branch:

The pushed payload has no say in it. Whoever can push must not be able to choose whether their push triggers a full re-embed.

Ingestion runs are serialized within the server process so two repos never write to the vector DB at once. A codumentor ingest running as a separate process (CLI) still claims the cross-process lock; a run that lands during that window is skipped and re-triggers on the next push.

Triggering something else

repo.push is one event of several. To have a push, a pull request or a /review comment start an agent turn, add your own entry to tasks.declared: — see doc/core-dev/design/triggers.md §13.1 for the full field list, and use list_event_types to see what each event carries.

Configuration

Add a webhooks block to your codumentor.yaml:

webhooks:
  enabled: true
  secrets:
    - "${CODUMENTOR_WEBHOOK_SECRET}"
    - "${CODUMENTOR_WEBHOOK_SECRET_PREVIOUS}"   # optional, for rotation

repos:
  - url: "https://gitea.example.com/org/my-repo"
    branch: "main"
  - "https://github.com/other-org/their-lib@release"
FieldRequiredDescription
webhooks.enabledyesMust be true for the endpoint to respond. When false, the endpoint returns 404.
webhooks.secretsyesList of accepted shared secrets. The request is accepted if any secret matches. Use environment substitution (${VAR_NAME}) to keep secrets out of the file.

Leaving secrets empty while enabled: true returns 503 — an empty list is treated as a misconfiguration, not as "accept anything".

Rotation. To rotate a secret without downtime, add the new secret to the list first, update the Git host to send the new secret, then remove the old secret on the next config change.

Gitea setup

In your Gitea repository:

  1. Settings → Webhooks → Add Webhook → Gitea.
  2. Target URL: https://codumentor.example.com/ops/webhook/gitea
  3. HTTP Method: POST
  4. POST Content Type: application/json
  5. Secret: the same value you put in webhooks.secrets.
  6. Trigger On: Push Events is enough for ingestion. Leave "all events" on if you also want pull-request and comment events available to your own tasks — Codumentor answers 204 for anything it has nothing to say about.
  7. Save.

Gitea will sign each request with X-Gitea-Signature: <hex HMAC-SHA256 of the body>.

GitHub setup

In your GitHub repository:

  1. Settings → Webhooks → Add webhook.
  2. Payload URL: https://codumentor.example.com/ops/webhook/github
  3. Content type: application/json
  4. Secret: the same value you put in webhooks.secrets.
  5. Which events would you like to trigger this webhook? → Just the push event for ingestion; add pull_request / issue_comment if you want tasks on those too.
  6. Save.

GitHub sends X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body>. Codumentor accepts both header styles.

Calling from SVN (post-commit hook)

Subversion has no native webhook system, but the endpoint accepts a minimal generic JSON payload that an SVN post-commit hook can produce in a few lines of shell.

Prerequisites:

  1. The repo must be configured with scm: svn (see the SVN plugin docs). The SVN plugin handles the actual svn checkout / svn update during ingestion.
  2. Add the webhook secret to webhooks.secrets as usual.

Example hooks/post-commit on the SVN server:

#!/bin/bash
SECRET='your-shared-secret'
BODY='{"repo":"myproject","branch":"trunk","event":"push"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -fsS -X POST https://codumentor.example.com/ops/webhook/push \
  -H 'Content-Type: application/json' \
  -H "X-Webhook-Signature: $SIG" \
  --data "$BODY" >/dev/null

The repo field may be the configured repo's name, its url, or a local path / basename — whatever is easier to produce from the hook. branch is optional; when present it is compared against the configured repo's branch the same way as for git. Omit branch to always do a full ingestion.

What Codumentor does for SVN:

ScenarioAction
branch matches configured branch (or no branch set)Full ingestion. The SVN plugin runs svn update (or svn checkout on first run), then re-embeds changed files.
branch differs from configured branchFetch-only. Runs svn update on the working copy without re-embedding, so the agent's file/shell tools see latest content.

Credentials configured on the SVN repo entry (options.username, options.password) are used automatically for the fetch-only svn update.

Calling from other systems

Any client that can compute HMAC-SHA256 over the raw request body will work. Use either the Gitea-shaped push payload or the generic shape:

# Gitea / GitHub shape (git)
SECRET='your-shared-secret'
BODY='{"ref":"refs/heads/main","repository":{"clone_url":"https://gitea.example.com/org/my-repo.git","ssh_url":"git@gitea.example.com:org/my-repo.git","full_name":"org/my-repo","name":"my-repo"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -X POST https://codumentor.example.com/ops/webhook/gitea \
  -H 'Content-Type: application/json' \
  -H 'X-Gitea-Event: push' \
  -H "X-Gitea-Signature: $SIG" \
  --data "$BODY"

# Bare push shape — any repo identifier plus an optional branch
BODY='{"repo":"my-repo","branch":"main"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -X POST https://codumentor.example.com/ops/webhook/push \
  -H 'Content-Type: application/json' \
  -H "X-Webhook-Signature: $SIG" \
  --data "$BODY"

HMAC headers are interchangeable: X-Gitea-Signature, X-Hub-Signature-256 (accepts the sha256= prefix), and X-Webhook-Signature are all validated the same way.

If you cannot compute an HMAC (e.g., a simple monitoring script), Codumentor also accepts a bearer-token fallback against the same secret list:

curl -X POST https://codumentor.example.com/ops/webhook/gitea \
  -H 'Content-Type: application/json' \
  -H 'X-Gitea-Event: push' \
  -H "Authorization: Bearer $SECRET" \
  --data "$BODY"

The X-Webhook-Token: $SECRET header is accepted as an equivalent alternative.

Security note. The HMAC path authenticates both the caller and the body contents. The bearer-token path only authenticates the caller. Prefer HMAC when the Git host supports it.

Accepted payload shapes

Gitea / GitHub push (git-only). The endpoint reads repository identification from, in order of preference:

Branch detection reads ref (refs/heads/<branch>). Tag pushes and other refs are ignored.

Bare push — the push source; works for git, SVN, or anything else:

{"repo": "<name | url | path>", "branch": "<branch>", "event": "push"}

Only repo is required. branch is optional (omit to always do a full ingest). event is optional — any non-push value answers 204, so the same URL can be wired into a hook that fires for multiple event types.

The repo field is matched against your configured repos by URL, name, local path, or path basename — whichever makes your hook simplest.

A payload missing repository identification (no repository block and no repo field) returns 400.

Generic — the generic source, for anything that is not a repository event:

{"type": "ci_finished", "scope": "my-repo", "payload": {"build": 41, "status": "green"}}

This emits webhook.ci_finished with payload as its body. There is no repository allowlist behind it, which is exactly why a task on a webhook.* event has to be operator-declared. scope is optional and is the cheap narrowing an event task can index on; dedupe_key is optional and makes a redelivery a no-op.

Response codes

CodeMeaning
202Accepted and emitted. Body is {"status": "accepted", "events": [{"id", "type", "duplicate", "matched"}]} — one entry per emitted event. matched is how many tasks the event matched; 0 is normal and not an error. A 202 says the event was delivered, not that a task ran.
204Event recognised and deliberately ignored (a ping, a release, a non-push body on the push source).
400Malformed JSON, or a body that names no repository.
401Missing, invalid, or unmatched signature / token. Nothing is emitted.
404Webhooks disabled, unknown {source}, or the repository is not in config.repos.
503Webhooks enabled but secrets is empty — a misconfiguration, never "accept anything".

A redelivery (Gitea retries, or the same push arriving through two proxies) is 202 with "duplicate": true; the event is recorded once and routed once.

Observability

The door logs under codumentor.api.webhook; ingestion under codumentor.ingestion.repo_ingest. Useful messages:

Bump logging.logger_levels.codumentor.api.webhook to DEBUG for per-request tracing.

Every event is on the ledger, whether or not a task matched. That is what answers "did the webhook arrive?" separately from "did anything run?" — use the list_events tool, or list_task_runs for what a matched task actually did.

Operational caveats