API reference

Redirects and 404 log

Redirects send dead URLs of a website to existing ones. They only take effect where the 404 page would otherwise appear – i.e. for paths without a page and for unknown subpaths of a catch-all page (not_found()). Existing pages always take precedence.

  • Paths are normalized: decoded, lowercased, without query and without a trailing slash. A pasted URL (https://alt.de/Seite/?x=1) becomes /seite. Query parameters of the request are appended to the target if the target has none of its own.
  • Response: 301 (with Cache-Control: max-age=3600, so that changed redirects don’t stick in the browser forever) or 302 (no-store).
  • Automatic: If a published page is renamed (slug) or moved within its website, 301 redirects are created for it and its published subpages (auto: true). Redirects that pointed to the old path then point directly to the new one; a redirect from a path that is in use again is removed.
  • 404 log: Requests to dead URLs without a redirect (GET only) are counted per website – with first/last request and the most recently seen origin (Referer), at most 1,000 entries per website. Creating a redirect removes the path from the log.
  • Bundles contain the redirects, not the 404 log.

Redirect object

{
  "id": 4, "root_id": 1,
  "source": "/blog/123",
  "target": "/blog/mein-artikel",
  "status": 301, "auto": false,
  "hits": 12, "last_hit_at": 1791220210, "created_at": 1791220100,
  "target_missing": false
}
Field Type Description
root_id int Website (root page); cannot be changed when updating
source string Old address (path or URL, normalized). Not /, not under /cms-admin, /api, /media, /_assets, /sitemap.xml, /healthz, /favicon.ico, /robots.txt and not the path of an existing page (400). Unique per website (409)
target string Path (/neu) or http(s) URL. No loops via other redirects (400)
status int 301 (default) or 302
auto bool Read-only: created automatically on rename/move (becomes false when edited)
hits, last_hit_at int Read-only: usage
target_missing bool Only in the list: the target is a path without a page and without a further redirect

GET /redirects?root_id={id} 🔑

All redirects of the website, sorted by path.

POST /redirects ✏️ · PUT /redirects/{id} ✏️ · DELETE /redirects/{id} ✏️

{ "root_id": 1, "source": "/blog/123", "target": "/blog/mein-artikel", "status": 301 }

POST → 201, PUT (merge, source/target/status) → 200, DELETE → 204.

GET /notfound?root_id={id} 🔑 · DELETE /notfound?root_id={id}[&path=…] ✏️

404 log of the website, most frequent first: [{ "path": "/angebote/2019", "hits": 7, "first_at": …, "last_at": …, "referer": "https://www.example.org/links" }]. Every entry carries ignored (true = hidden; it keeps counting but is left out of the CSV export). PUT with body { "ignored": true|false } hides or unhides one entry (path); without path it applies to all open entries (true) or all ignored ones (false) – 204, the entry is kept with its counter. DELETE permanently removes one entry (path) or the entire log. The log holds at most 1000 open and 5000 ignored addresses per website (those not seen for the longest time drop out).

GET /redirects/export?root_id={id} 🔑

CSV (UTF-8 with BOM, semicolon delimiter) with the columns Von;Nach;Status;Typ;Treffer;Zuletzt;Herkunft: first the open addresses from the 404 log (most frequent first, Nach and Status empty, Typ = 404), then all redirects (Typ = manuell or auto). Zuletzt as JJJJ-MM-TT HH:MM (server time).

POST /redirects/import?root_id={id}[&dry_run=1][&delete=1] ✏️

Multipart field file: CSV (UTF-8, optionally with BOM; comma, semicolon or tab delimiter is detected). Columns are detected via the header row: Von (also source, from, alt, pfad …) and Nach (also target, to, ziel …) are required, Status (301/302, empty = previous status or 301) is optional, all others are ignored. Matching is done via the normalized path in Von:

  • Row with a target → new redirect or change of target/status (then counts as manual); unchanged ones count as unchanged. New ones remove their path from the 404 log.
  • Row without a target → stays open (open); an existing redirect remains unchanged.
  • delete=1: redirects that are missing from the file or have no target there are deleted.

Validation is the same as for POST /redirects – loops even across several rows of the file – plus duplicate paths. All or nothing: on errors 422 (with error, code csv.errors and params.count), nothing is written. dry_run=1 only returns the preview (200, even on errors):

{ "delimiter": ";", "rows": 4, "created": 1, "updated": 1, "deleted": 0, "unchanged": 1, "open": 1,
  "changes": [{ "action": "create", "source": "/kaputt", "target": "/neu", "status": 301 },
              { "action": "update", "source": "/weg", "target": "https://example.com/", "status": 301, "old_target": "/neu", "old_status": 302 }],
  "errors": [{ "row": 3, "message": "this redirect would create a loop", "code": "redirect.loop" }], "error_count": 1, "imported": false }

row counts the header row as row 1; errors contains at most 50 entries.