# Storefront redirects: old addresses that keep their traffic

> **Резюме по-русски.** Правила «откуда → куда» для витрины магазина: точное,
> маска, шаблон, условие по параметру; переход «не найдено → ближайший
> раздел»; журнал 404. Здесь — короткий вход и жизненный цикл адреса; полный справочник —
> `/docs/redirects-reference?section=<slug>`. Перед переносом раздела —
> `dry_run` и вопрос владельцу.

**Status:** draft · **Verified:** 2026-09-25 against branch `production`
(integration `TestSFR*` on PostgreSQL 17) · **Owner:** redirects line
**Serves:** `GET /docs/redirects` · full reference `GET /docs/redirects-reference`

## What redirects are

A rule `{from_path, to_target, code, enabled}` sends a path of the shop's own
storefront to another path (or, with `external: true`, to an `http(s)` URL)
under `redirects:read` / `redirects:write`, for the shop's **owner or admin**
only. Three kinds: **exact** `/old` (optionally with `query_match`
`ELEMENT_ID=5`), **pattern** — a mask, `*` inside one segment and `**` as the
tail, `$1…$9` in the target (`/old/*.html` → `/c/$1`), and
**prefix** `/shop/*` → `/catalog/*` (the tail carried). A rule acts only where
no page exists unless `override_live: true`. Rules live in production only; a
draft key reads them and cannot write.

## How the storefront answers an address

1. rules with `override_live` (exact → pattern → prefix) or on a `.html`
   address — their own 301/302;
2. the live page — or, when the chain broke, the entity named by the **last
   segment** (308 to its canonical address);
3. the slug journal: an old slug of a product, category or article (308);
4. rules without `override_live` — answered as **308/307** (Next.js);
5. `miss_fallback` (on by default): a live section typed in another Latin
   case → that section; a deleted product → its last section, otherwise the
   nearest live section of the address; never `/`; 308. A file address
   (`…/feed.yml` — `.xml`, `.csv`, `.txt`, `.json`, `.yml`) never falls back;
6. 404, and the address goes to the 404 log.

## URL lifecycle

- **Moving a category** changes the addresses of the category, its
  subcategories and the products whose primary category is in the branch.
  They keep working by rule 2 above. **Before moving, call
  `POST /categories/move` with `"dry_run": true`, show the summary to the user
  and ask:** move · move and pin with rules · cancel. Then repeat without
  `dry_run`, with `"pin_rules": true` if asked. The answer:
  `categories_affected`, `products_affected`, `rules_created`,
  `url_changes[]` (≤ 20 examples of `old_path`, `new_path`, `kind`,
  `covered_by`: `auto` · `rule` · `fallback` · `none`). `pin_rules` creates an
  exact 301 rule for **every** changed address not already covered, in the
  same transaction; over the limit the whole move is refused
  (`REDIRECT_LIMIT_REACHED`) and nothing moves. Needs `redirects:write`.
- **Moving a product** to another category: the old address follows by the
  last segment; pin it with an exact rule if asked.
- **Renaming a slug** (product, category, article): the journal answers 308
  from the old slug; `GET /slug-redirects/resolve?entity_type=article&…`.
- **Deleting** a product or category: `miss_fallback` sends its addresses to
  the nearest live section; for another target create an exact rule.

## Quick start

```bash
curl -s "$API/storefront-redirects" -H "$T"                      # set, version, miss_fallback
curl -s -X POST "$API/storefront-redirects" -H "$T" -H "$J" \
  -d '{"item":{"from_path":"/old","to_target":"/new","enabled":true}}'
curl -s "$API/storefront-redirects/check?url=/old" -H "$T"       # every step
```

## Key rules

- **`enabled: true` must be sent** — a rule without it is saved switched off.
- **CSV exchange** (`POST /storefront-redirects/import`, `dry_run` first):
  `from;to;code;kind;params;condition;always;note` — `always` `1` =
  `override_live`; no `note` column keeps the stored note.
- **Ad tags always travel** (`utm_*`, `gclid`, `yclid`…), even with
  `drop_query`.
- **Loops are refused** (`REDIRECT_LOOP`), counting masks, `.html` and the
  fallback; 10 hops are allowed, the 11th is `REDIRECT_CHAIN_TOO_LONG`.
- Check: `code` is the rule's, `sent_code` the one sent (308/307).
- **Limits** are the plan's: `redirects_max` rules and `redirect_masks_max`
  masks (Free 200/20, Pro 2000/50), refused with `REDIRECT_LIMIT_REACHED` /
  `REDIRECT_PATTERN_LIMIT_REACHED` + `limit_code`; ceiling 20000/200
  whatever the plan; 1000 lines per import. `?section=11-limits`.
- Turn the fallback off with `PUT /storefront-redirects/settings
  {"miss_fallback": false}`.

## 404 log

`GET /storefront-redirects/misses?q=&sort=hits|last_seen&include_hidden=true`
— addresses that still answered 404 after all of the above: `path`, `hits`,
`last_seen`, the referrer host, `rule_id` (the rule that catches it now; 0 —
none). Bot junk (`.php`, `wp-admin`, `.env`…) is not logged; one-hit days
expire after 90 days. Hide: `POST /storefront-redirects/misses/hide
{"paths":[…]}`. A rule from a row: `from_path` = `path`.

## Reference: sections

`GET /docs/redirects-reference?section=<slug>`:

| Slug | What |
|---|---|
| `1-what-you-can-do-here` · `2-decide-first-forks` · `3-quick-start` | scope, which rule to take, three requests |
| `4-requests` (`4-2-endpoints`, `4-3-csv-import-and-export`, `4-4-check-an-address`) | objects, endpoints with JSON, CSV, check, settings, public set |
| `5-what-the-storefront-does-with-an-address` · `6-mask-grammar` | matching order, 301/302 vs 308/307, masks |
| `7-url-lifecycle` (`7-1-moving-a-category`, `7-3-renaming-a-slug`) | move, rename, delete in detail |
| `8-loops` · `9-refusal-codes` · `10-silently-ignored` | loop rules, error codes, silent traps |
| `11-limits` · `12-404-log` · `13-how-this-was-verified` | limits and plans, the 404 log, tests |
