HTTP API
Document metadata
- Type
- Reference
- Status
- stable
- Description
- The five API routes. Parameters, authentication, response bodies and every status code for sync, webhooks, search, status and the image proxy.
- Sources
- src/app/api/sync/route.ts
- packages/server/src/sync/manual-sync.ts
- packages/server/src/webhooks/endpoint.ts
- packages/server/src/search-api.ts
- packages/core/src/palette.ts
- packages/server/src/status-api.ts
- packages/server/src/assets/serve.ts
- Generated
- Verified
- Stale after
- (in 972 years)
HTTP API
The portal pages are server-rendered and have no public JSON contract. Five routes are meant to be called by other programs.
| Route | Methods | Authentication | Purpose |
|---|---|---|---|
POST /api/sync |
POST |
Authorization: Bearer <SYNC_SECRET> |
Start a sync |
POST /api/webhooks/github |
POST |
X-Hub-Signature-256 HMAC |
Receive GitHub App events |
GET /api/search |
GET |
session cookie | Live results for the palette |
GET /api/status |
GET |
session cookie | Sync status |
GET /api/health |
GET |
none | Health check, verdict only |
GET /api/asset/{owner}/{repo}/{path} |
GET, HEAD |
session cookie | Repository images |
Every other method on a route answers 405 with an Allow header, without reading the request. Responses of the JSON routes carry Cache-Control: no-store. Put a rate limit in front of /api/search, /api/sync and /api/status: the app only caps how much work runs at once.
POST /api/sync
Starts a manual sync. The secret is checked before the body is read or anything else is touched. The body is optional JSON of at most 4096 bytes.
| Body | Effect |
|---|---|
empty, or {} |
Full sync of every selected repository. |
{"repo": "name"} |
Sync one repository by name. |
{"repo": "owner/name"} |
Sync one repository by owner and name. |
{"githubId": 123456789} |
Sync one repository by GitHub id. |
Unknown keys, and repo together with githubId, are rejected.
curl -X POST -H "Authorization: Bearer $SYNC_SECRET" http://127.0.0.1:3000/api/sync
curl -X POST -H "Authorization: Bearer $SYNC_SECRET" -d '{"repo":"handbook"}' http://127.0.0.1:3000/api/sync
A successful answer lists one entry per repository:
{
"ok": true,
"scope": "all",
"run": { "id": 42, "status": "success", "durationMs": 812, "stats": { "reposTotal": 1 } },
"repositories": [
{
"repo": "acme/handbook",
"githubId": 101,
"status": "ok",
"error": null,
"errorKind": null,
"retryable": false,
"retryAfter": null,
"added": 2,
"updated": 0,
"identical": 0,
"removed": 0,
"skipped": 0,
"failedFilesTotal": 0,
"warningsTotal": 0
}
]
}
run.stats is shortened here; it also holds the repository and document counters (reposOk, reposFailed, docsAdded, docsRemoved and others).
| Status | Meaning |
|---|---|
200 |
The run finished with status success or partial. |
202 |
Coalesced: another sync holds the repository, the request is recorded and that sync runs again to include it. Body has "status": "coalesced". |
400 |
invalid_request: the body is not valid JSON or not one of the shapes above. |
401 |
unauthorized: missing or wrong secret. Carries WWW-Authenticate: Bearer. |
404 |
repository_not_found (the App cannot see it) or repository_not_configured (excluded, archived or not listed; with a reason). |
405 |
Not POST. Allow: POST. |
409 |
repository_busy: the repository has never been synced and its first sync is running elsewhere. Nothing was done, retry shortly. |
413 |
payload_too_large: body over 4096 bytes. |
500 |
misconfigured (the sync secret is missing, shorter than 16 characters, or the config is invalid) or internal_error. |
502 |
The run failed and a retry will not help by itself, for example rejected credentials. |
503 |
The run failed or could not start, and a retry may work. Retry-After is set when the retry time is known. |
Only Authorization: Bearer is accepted, not a query string or a body field. The app has no brute-force counter. For the semantics of a run, see the sync engine.
POST /api/webhooks/github
The endpoint of the GitHub App webhook. The request must be application/json with a valid X-Hub-Signature-256. The event name comes from X-GitHub-Event. Which events do what is described in Webhooks.
| Status | Meaning |
|---|---|
200 |
ping. Body {"ok":true,"event":"ping"}. |
202 |
Accepted: dispatched, or still running after 5 seconds and left to finish in the background. Body {"ok":true}. |
400 |
invalid_request: bad X-GitHub-Event or X-GitHub-Delivery header, or the body is not a JSON object. Only after the signature passed. |
401 |
unauthorized: signature missing, malformed or wrong. The same fixed answer in every case. |
405 |
Not POST. Allow: POST. |
413 |
payload_too_large: body over 25 MiB. |
415 |
unsupported_media_type: not application/json. Form-encoded deliveries are refused. |
500 |
misconfigured (the webhook secret is missing or shorter than 16 characters) or internal_error (the router failed within the wait, so GitHub can redeliver). |
GET /api/search
Live results for the command palette. Reads Postgres only, through the visibility filter.
| Parameter | Meaning |
|---|---|
q |
The query, in web search syntax. Cut to 256 characters. |
limit |
1 to 10, default 8. |
repo |
owner/name, repeatable. Narrows the results. |
Type, tag, status and paging parameters are ignored here; use the /search page for those. An empty q answers at once with no hits and does not touch the database.
{
"q": "sync engine",
"total": 1,
"hits": [
{
"key": "12:docs/architecture/sync-engine.md",
"title": "Sync engine",
"href": "/acme/doc-my-repos/docs/architecture/sync-engine",
"repoLabel": "acme/doc-my-repos",
"path": "docs/architecture/sync-engine.md",
"section": null,
"snippet": "How a repository is copied into Postgres.",
"isPrivate": false
}
],
"seeAll": "/search?q=sync%20engine"
}
A snippet is plain text. Matched words are wrapped in two private-use characters, U+E000 before and U+E001 after, which a client turns into highlights. Never insert a snippet as HTML.
| Status | Meaning |
|---|---|
200 |
Results, possibly empty. |
405 |
Not GET (HEAD included). Allow: GET. |
503 |
{"error":"unavailable"}: the database or configuration is unusable, or more than 16 searches are already running (then with Retry-After: 1). |
GET /api/status
The sync status as JSON, for operators, scripts and health checks. It shows what the portal pages already show, through the same visibility filter, and nothing else: no secrets, run ids, SHAs or error stacks. Query parameters are ignored.
{
"generatedAt": "2026-10-10T12:00:00.000Z",
"running": null,
"lastRun": {
"trigger": "webhook",
"scope": "acme/handbook",
"status": "success",
"startedAt": "2026-10-10T11:59:59.000Z",
"finishedAt": "2026-10-10T12:00:00.000Z",
"durationMs": 812,
"error": null,
"counts": { "repos": 1, "ok": 1, "failed": 0, "docsAdded": 2 }
},
"summary": { "repos": 1, "ok": 1, "error": 0, "pending": 0, "withWarnings": 0 },
"repos": [
{
"owner": "acme",
"name": "handbook",
"visibility": "private",
"syncStatus": "ok",
"syncError": null,
"lastSyncedAt": "2026-10-10T12:00:00.000Z",
"warnings": [],
"skippedFiles": 0
}
],
"truncated": false
}
lastRun.counts is shortened here. repos lists at most 500 repositories by owner then name, and truncated is true when there are more; summary always counts all of them.
| Status | Meaning |
|---|---|
200 |
The app and its database answer. A failed repository sync does not change this: read summary.error or lastRun.status. |
405 |
Not GET (HEAD included). Allow: GET. |
503 |
{"error":"unavailable"}: the database or the configuration is unusable, or more than 4 reads are already running (then with Retry-After: 1). |
Use GET for container and load balancer health checks. The Docker image's HEALTHCHECK calls this route.
GET /api/asset/{owner}/{repo}/{path}
The image proxy, and the only route that reads GitHub while serving a request. Rendered documents point their repository images here, so images of private repositories load without a credential reaching the browser. {path} is the repository-relative file path; the proxy always serves the default branch.
Allowed extensions: png, jpg, jpeg, gif, webp, avif, bmp, ico, svg. The bytes must match the type the extension claims.
| Status | Meaning |
|---|---|
200 |
The image, with Content-Type set from the verified type. |
304 |
If-None-Match matched the strong ETag. |
400 |
The path is invalid: traversal, empty or dot-only segments, encoded slashes, too long. |
404 |
Unknown, not visible or no longer selected repository, or no such file. The same answer for all. |
405 |
Not GET or HEAD. Allow: GET, HEAD. |
413 |
The image is larger than 10 MiB. |
415 |
The extension is not an image type, or the bytes do not match it. |
500 |
The asset service is not available (configuration). |
502 |
GitHub could not deliver the file. |
503 |
GitHub rate-limited the request, or the proxy is saturated. Retry-After is set. |
504 |
Fetching took longer than 15 seconds. |
Caching: public repositories get Cache-Control: public, max-age=300, stale-while-revalidate=600, private ones private, max-age=300, so no shared cache stores a private image. Errors are no-store. A bounded in-memory cache (32 MiB, five minutes) spares GitHub; at most 6 upstream fetches run at once with 50 more queued, and concurrent requests for the same image share one fetch. Every response carries X-Content-Type-Options: nosniff, Cross-Origin-Resource-Policy: same-origin, Referrer-Policy: no-referrer and a sandboxing Content-Security-Policy. The reasoning is in the security model.