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/pushis unchanged as a URL and still accepts everything it used to, so existing Gitea/GitHub/SVN hooks keep working. Two things did change: the202body, and the fact that ingestion now needs the built-intasks.declared:entry to be present.POST /ops/webhook/gitea-pr(thegitea_prplugin's private endpoint) is removed — point that hook at/ops/webhook/giteainstead.
The four sources
{source} | Use it for | Emits |
|---|---|---|
gitea | A Gitea webhook of any type | repo.push, scm.pull_request, scm.review_requested, scm.comment |
github | A GitHub webhook of any type | the same set |
push | A 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 |
generic | Anything else you want to trigger on: CI results, monitoring, a ticket system | webhook.<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
- Your Git host sends an event to
POST /ops/webhook/{source}. - Codumentor verifies the request against the configured shared secrets. Unsigned or badly signed →
401, and nothing is emitted. - The
{source}normalizer parses the body. Forrepo.andscm.events, the repository is matched against your configuredrepos:; a repository not in your config is rejected with404— a webhook can never name a repository you have not opted into. - 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:
debounce_seconds: 10— pushes arriving within ten seconds of each other collapse into a single ingestion run. Raise it on a busy monorepo. Collapsing is per repository: this entry has noscope:, so it watches every repo, and pushes to different repos inside the same window are kept apart and all ingested.on_overlap: queue+max_in_flight: 2— one run in flight plus one waiting. A push that lands mid-ingest is not lost, and a storm cannot pile up more than one follow-up.max_fires_per_hour: 120— the per-task storm cap. Three times this in an hour pauses the task with a notice.access_profile— mandatory on any task that fires on a webhook event. An ingestion run has no agent and no tools, so the narrowest built-in profile costs it nothing.
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:
- Push matches the repo's configured branch (or the repo has no branch restriction) → full ingestion: clone/pull the repo and update the vector DB.
- Push is on a different branch → fetch-only refresh:
git fetch --all --tags --prune(orsvn update) against the existing clone soorigin/<branch>stays current. No re-embedding, but the branch-switching plugin can check that branch out against the latest state. - More than one push to that repo collapsed into this run → full ingestion, whatever the branches were. A collapsed window keeps only the last branch per repo, so concluding "off-branch" from it could silently leave the indexed branch stale. Re-embedding costs work; not re-embedding is silent.
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"
| Field | Required | Description |
|---|---|---|
webhooks.enabled | yes | Must be true for the endpoint to respond. When false, the endpoint returns 404. |
webhooks.secrets | yes | List 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:
- Settings → Webhooks → Add Webhook → Gitea.
- Target URL:
https://codumentor.example.com/ops/webhook/gitea - HTTP Method:
POST - POST Content Type:
application/json - Secret: the same value you put in
webhooks.secrets. - 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
204for anything it has nothing to say about. - Save.
Gitea will sign each request with X-Gitea-Signature: <hex HMAC-SHA256 of the body>.
GitHub setup
In your GitHub repository:
- Settings → Webhooks → Add webhook.
- Payload URL:
https://codumentor.example.com/ops/webhook/github - Content type:
application/json - Secret: the same value you put in
webhooks.secrets. - Which events would you like to trigger this webhook? → Just the
pushevent for ingestion; addpull_request/issue_commentif you want tasks on those too. - 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:
- The repo must be configured with
scm: svn(see the SVN plugin docs). The SVN plugin handles the actualsvn checkout/svn updateduring ingestion. - Add the webhook secret to
webhooks.secretsas 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:
| Scenario | Action |
|---|---|
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 branch | Fetch-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:
repository.clone_urlrepository.ssh_urlrepository.html_url(fallback whenclone_urlis absent)repository.full_namerepository.name
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
| Code | Meaning |
|---|---|
202 | Accepted 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. |
204 | Event recognised and deliberately ignored (a ping, a release, a non-push body on the push source). |
400 | Malformed JSON, or a body that names no repository. |
401 | Missing, invalid, or unmatched signature / token. Nothing is emitted. |
404 | Webhooks disabled, unknown {source}, or the repository is not in config.repos. |
503 | Webhooks 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:
Webhook rejected: repo not in config (…)— the404, with enough detail to fix config.Webhook event 'repo.push' is a duplicate of <id>; routed nothing— a redelivery.Starting ingestion for repo '<name>'/Ingestion complete for '<name>' (files_processed=…, total_chunks=…)— full-ingestion lifecycle.Starting git fetch for repo '<name>'/Starting svn fetch for repo '<name>'— fetch-only lifecycle.Ingestion skipped for '<name>': …— the cross-process file lock was held by a CLI run. The next push re-triggers.
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
- There is no daemon. If the API server is down when a push happens, that push is simply not received; if it restarts during a debounce window, the pending fire is persisted on the task row and the next process fires it. Hosts that retry (Gitea does) will redeliver, and the dedupe index makes that safe. A manual
codumentor ingestalways works. - A
202means "delivered", not "ingested". If nothing seems to happen, check that theingest-on-pushentry is still in yourtasks:config and that the task is not paused. - Unknown repositories are rejected, not auto-registered. Add the repo to
config.reposand reload before the first push will be accepted. - The endpoint has no host allowlist: anything in
config.reposis acceptable, and anything outside it is not. Don't add repos you wouldn't want cloned. - Webhook ingestion shares the same cross-process
IngestionLockas CLI and/ops/ingest. If one is running, the other defers.