Storefront redirects: full reference
/docs/redirects-referencedraftEN· проверено 2026-09-25
Резюме по-русски. Полный справочник области «Редиректы»: правила «откуда → куда» (точное, маска, шаблон), условие по параметру, переход «не найдено → ближайший раздел», порядок разбора адреса витриной, обмен CSV, проверка адреса, петли, жизненный цикл адреса при переносе, переименовании и удалении, коды отказов, машинный блок лимитов и тариф (§11), журнал 404 (§12). Короткий вход —/docs/redirects; раздел отсюда берётся отдельно:GET /docs/redirects-reference?section=<slug>.
Status: draft · Verified: 2026-09-25 against the code of branch production (stages A, B and C of the redirects plan): unit tests of the matcher and the shared Go/TS vectors, integration tests on PostgreSQL 17 (TestSFR*, incl. TestSFRStageB*, TestSFRStageC*), a run on a local copy of a live shop · Owner: redirects line Serves: GET /docs/redirects-reference
The short entry — what redirects are, the lifecycle of an address and a quick start — is GET /docs/redirects. Read this reference one section at a time with ?section=<slug> (a wrong slug answers 404 with the list of slugs), e.g. GET /docs/redirects-reference?section=7-url-lifecycle.
1. What you can do here
Keep the traffic of an address that stopped existing. A rule says «this path of my storefront → that path (or an external URL)». You list, create, edit, delete, reorder and import rules, switch a rule off without deleting it, ask what the storefront will do with a given address, see how often each rule fired, and turn the shop-wide «missing address → nearest section» fallback on or off. The 404 log lists the addresses visitors still miss, ready to become rules (§12). When you move a category, POST /categories/move tells you which addresses change and can pin them with rules in the same call.
Scope: only the shop's own storefront (<slug>.vizen.shop and its custom domain). The token needs redirects:read / redirects:write; the person behind it must be the shop's owner or admin (an editor gets REDIRECTS_FORBIDDEN). Rules live only in the production contour: a draft key reads them, a draft key's writes are refused.
2. Decide first (forks)
| If you want… | Do this | Note |
|---|---|---|
| nothing — a product or category was renamed or moved | nothing | the storefront follows the slug journal and the last URL segment itself (§7) |
| one old address → one new address | exact rule /old → /new | cheapest: matched by key |
one old address, but only with ?ID=5 | exact rule + query_match: "ID=5" | a rule with a condition wins over the same path without one |
| a family of old addresses with a shape | mask (kind: "pattern") /old/*.html → /c/$1 | masks are limited by the plan (§11) |
| a whole section, keep the tail | prefix /shop/* → /catalog/* | first matching prefix in sort_order wins |
| a whole section to one landing page | prefix /shop/* → /catalog | the section page itself still opens |
| to override a page that still exists | override_live: true | applied before rendering; deliberate |
| deleted pages to land somewhere sensible | keep miss_fallback on (default) | nearest live section, never the home page |
| a moved category to stay pinned forever | POST /categories/move with pin_rules: true | ask the owner first (§7.1) |
| to see which addresses visitors still miss | the 404 log GET /storefront-redirects/misses | then a rule per address (§12) |
3. Quick start
# 1. the set, its version and the fallback switch
curl -s "$API/storefront-redirects" -H "$T"
# 2. one rule (enabled must be sent: proto3 drops a false bool)
curl -s -X POST "$API/storefront-redirects" -H "$T" -H "$J" \
-d '{"item":{"from_path":"/old-page","to_target":"/catalog/sofas","code":301,"enabled":true}}'
# 3. what the storefront will do with the address
curl -s "$API/storefront-redirects/check?url=/old-page" -H "$T"Step 3 answers {"result":{"matched":true,"rule_id":"12","code":301, "location":"/catalog/sofas","chain":["/old-page","/catalog/sofas"], "steps":[{"kind":"rule","from":"/old-page","to":"/catalog/sofas","rule_id":"12","code":301}]}}.
4. Requests
4.1. Objects
A rule (StorefrontRedirect):
| Field | Who writes | Meaning |
|---|---|---|
id | server | 64-bit, a JSON string |
kind | you or server | exact, pattern or prefix; empty = derived from from_path (no * → exact; one trailing /* → prefix, unless the target uses $1; any other * → pattern). Send it only for /goods/*, which can be both |
from_path | you | a path of your storefront, as typed. No query string: use query_match |
to_target | you | a path of your shop, or an absolute http(s) URL with external: true |
external | you | required for an off-shop target (REDIRECT_EXTERNAL_NOT_ALLOWED otherwise) |
code | you | 301 or 302; 0 means 301 |
enabled | you | defaults to false — send true |
override_live | you | false (default): the rule acts only where no page exists; true: before rendering, over a live page |
drop_query | you | false (default): the visitor's query goes to the target; true: dropped, ad tags excepted |
query_match | you | exact rules only: key=value (a leading ? is stripped), compared byte for byte |
sort_order | server | order of masks and prefixes; change with PUT /storefront-redirects/order |
note | you | ≤ 500 chars. Rules pinned by a category move carry category move |
hits, last_hit_at | server | how often and when it last fired; list only, approximate |
The comparison key is derived, not sent: percent-decoded, repeated / collapsed, trailing / dropped, ASCII-lowercased — the storefront computes the request key the same way.
4.2. Endpoints
| Method and path | Scope | What |
|---|---|---|
GET /storefront-redirects | redirects:read | the set in matching order, version, miss_fallback, the plan limits rules_limit / patterns_limit (0 = the plan does not limit) and the ceilings rules_ceiling / patterns_ceiling (§11) |
GET /storefront-redirects/check?url= | redirects:read | the trace of an address (§4.4) |
POST /storefront-redirects | redirects:write | {"item": {...}} → the saved rule |
PUT /storefront-redirects/{id} | redirects:write | {"item": {...}} — the whole input again |
DELETE /storefront-redirects | redirects:write | {"ids": ["12","13"]} → {"deleted": 2} |
PUT /storefront-redirects/order | redirects:write | {"ids": [...]} — every live rule exactly once |
POST /storefront-redirects/import | redirects:write | CSV text, dry_run (§4.3) |
PUT /storefront-redirects/settings | redirects:write | {"miss_fallback": false} (§5) |
GET /storefront-redirects/misses | redirects:read | the 404 log, paged (§12) |
POST /storefront-redirects/misses/hide | redirects:write | {"paths": [...], "unhide": false} (§12) |
POST /categories/move | catalog:write (+ redirects:write for pin_rules) | move with a summary (§7.1) |
GET /slug-redirects/resolve?company_id=&entity_type=&slug= | public | the slug journal: product, category, article (§7.3) |
GET /storefront/redirects?slug= | public | the compiled set the storefront applies (§4.6) |
Every mutation bumps the set's version in the same transaction and purges the storefront cache of the shop.
4.3. CSV import and export
POST /storefront-redirects/import with {"text": "...", "dry_run": true}. One rule per line, eight columns, only the first two required:
# from;to;code;kind;params;condition;always;note
/old-page;/catalog/sofas;301
/bitrix/*.php;/b/$1;301;pattern;drop
/catalog.php;/catalog;302;exact;keep;SECTION_ID=7;1;moved from Bitrix| Column | Values | Empty means |
|---|---|---|
from | a storefront path | — |
to | a path or an http(s) URL | — |
code | 301 / 302 | 301 |
kind | exact / prefix / pattern | derived from from |
params | keep / drop | keep |
condition | key=value (exact only) | none |
always | 1 / true / yes = override_live | only where no page exists |
note | the rule's note, ≤ 500 chars; takes the rest of the line, separators included | empty note — but a line with no `note` column keeps the stored note |
The note column is optional and backward-compatible: a line of two to seven columns (an older export, a Shopify file) never touches the note of an existing rule, so re-importing such a file keeps every note — the category move mark included — and an otherwise identical rule reports UNCHANGED. A present note column, even an empty one (…;always;), sets the note; an empty one clears it.
Write the column names as a # comment, not as a header line: only a Shopify header is recognised, any other becomes an error row. The separator is the first of tab, →, ->, ;, , found outside quotes; a field may be double-quoted CSV-style (a quote inside doubled). Blank lines, lines starting with # and a Shopify header Redirect from,Redirect to are skipped; a two- or three-column file (a Shopify export) reads as before. Imported rules are enabled. A key repeated in the file keeps its first line; a key that exists updates the rule. Each report row carries status (created|updated|skipped|error) and a machine reason: LINE_MALFORMED, CODE_INVALID, DUPLICATE_IN_FILE, UNCHANGED, RESERVED_PATH, LOOP, CHAIN_TOO_LONG, EXTERNAL_NOT_ALLOWED, INVALID_SOURCE, INVALID_TARGET, LIMIT_REACHED, KIND_UNKNOWN, OPTION_INVALID, QUERY_INVALID, NOTE_INVALID, PATTERN_LIMIT_REACHED. Run with dry_run: true, show the report, repeat without it; a second identical run reports zeros and leaves version alone.
Export has no endpoint: write the same eight columns from GET /storefront-redirects (from_path, to_target, code, kind, drop_query → drop/keep, query_match, override_live → 1/empty, note). Quote a field that holds a separator, a quote or a space. enabled is not exchanged: imported rules are enabled.
4.4. Check an address
GET /storefront-redirects/check?url=/old/sofa.html?ID=5 models what the storefront does and returns every step: steps[] of {kind, from, to, rule_id, code, sent_code, override_live} where kind is rule (a seller rule), html (the built-in …/x.html → …/x, 308) or fallback (nearest section, 308). matched, rule_id, code, sent_code, location, chain, fallback summarise it; reserved: true means a service path no rule can take; too_long: true means the chain runs past 10 hops (chain and steps show the first 10; such a rule cannot be saved, §8); reason is Russian prose for a person. The check cannot know whether a page exists at an address — a rule without override_live is reported as matched with that caveat in reason.
`code` vs `sent_code`. code is the code stored in the first matching rule (301/302). sent_code is what the storefront actually sends for it (§5): the same 301/302 for a rule with override_live or a rule on a .html address; 308 for a 301 and 307 for a 302 of a rule without override_live on any other address. On steps, sent_code is the code of that hop (308 for the built-in ones). sent_code is 0 when no rule matched. Both fields are additive: older clients that read only code keep working.
4.5. Settings
PUT /storefront-redirects/settings {"miss_fallback": false} turns the nearest section fallback off for the whole shop; true turns it back on. It does not bump version; the storefront gets it with the host resolve. There is no «leave it 404» per address: whoever wants 404s switches the fallback off.
4.6. The public set and hit counters
GET /storefront/redirects?slug=<shop> (or ?domain=) is anonymous and returns the enabled rules split by kind: {"result":{"version","exact":[{k,id,to,code, ext,ovr,dq,q}],"pattern":[…],"prefix":[…]}}. A closed (PIN) site answers 404. The storefront counts hits in memory and flushes them to the platform together with the 404 log (§12); neither ever bumps version.
5. What the storefront does with an address
In this order, first match wins:
- Middleware, before the page: rules with
override_live: true— exact (with a condition first), then masks, then prefixes insort_order. Answers with the rule's own 301/302. - Built-in:
…/x.html→…/x(308); canonical host (308). Exception — a rule on the `.html` address itself (exact/page.htmlor a mask such as/old/*.html) answers here, in middleware, with its own 301/302, even withoutoverride_live: it must beat the built-in.htmlhop, and no live page ever sits on a.htmladdress. The loop gate and the address check model the same order. - The page resolves: a live category, product or article renders. An address whose chain broke but whose last segment is a live slug answers 308 to that entity's canonical address.
- Slug journal: an old slug of a product, category or article → 308 to the current address.
- Rules without `override_live` (the default), same order as in step 1. Here Next.js can only answer 308 (for 301) or 307 (for 302) — the pair search engines treat the same.
- `miss_fallback` (on by default): the address itself is a live section typed in another Latin case (
/CATALOG/BEDS) → that section; a deleted product → its last section if alive; otherwise the nearest live section by the address chain (longest matching prefix). Never/. 308, the query string carried. A file address — the last segment ends in.xml,.csv,.txt,.jsonor.yml(/catalog/feed.yml) — never falls back: a missing feed answers 404, not the section page..htmlis a page address and falls back as usual. - 404 — and the address goes to the 404 log (§12).
Query strings: kept by default; drop_query drops them except ad tags — utm_*, gclid, gbraid, wbraid, dclid, yclid, ysclid, _openstat, fbclid, msclkid, ttclid always travel.
6. Mask grammar
*— a piece inside one segment, at least one character, never crosses/; at most one*per segment:/old/*.html,/p/*-sale.**— the whole last segment: a tail of zero or more segments./shop/**matches/shopand/shop/a/b.- In the target
$1…$9are the stars left to right; a capture is path-escaped ($1in a query part is query-escaped). Up to 9 stars. - Matching is linear, case-insensitive for ASCII, no regex.
query_matchis not allowed on a mask (REDIRECT_INVALID_QUERY).
Examples: /catalog/*/*.html → /c/$1/$2; /old/** → /new/$1; /index.php + query_match: "ELEMENT_ID=5" → /sofa.
7. URL lifecycle
7.1. Moving a category
The category's address, every subcategory's and every product's whose primary category is in the moved branch change. Old addresses keep working without any rule: the storefront finds the entity by the last segment (§5 step 3). Always:
POST /categories/movewith"dry_run": true— nothing changes, the same answer as a real move:
{"from_id": "41", "to_id": "7", "type": "inside", "dry_run": true}
→ {"categories_affected": 3, "products_affected": 120, "rules_created": 0,
"url_changes": [{"old_path": "/sofas/corner", "new_path": "/living/sofas/corner",
"kind": "category", "covered_by": "auto"}, …]}- Show the summary to the user and ask: move (old addresses follow automatically) · move and pin with rules · cancel.
- Repeat without
dry_run, with"pin_rules": trueif asked.
url_changes holds at most 20 examples, categories first; the counters and the pinning cover every changed address. covered_by: auto — the storefront follows it (§5 step 3); rule — a rule (existing, or created by this move); fallback — the entity is unpublished, the nearest section takes the address; none — 404.
pin_rules: true creates, in the same transaction as the move, an exact 301 rule old → new (override_live: false, note category move) for every changed address not already covered by an enabled rule and whose key is not taken; a rule that would close a loop (the branch moved back where a pinned rule points from) is skipped. rules_created says how many. If they do not fit the rule limit, the whole call is refused with REDIRECT_LIMIT_REACHED and nothing is moved. It needs the owner/admin role and, for a token, redirects:write (PAT_SCOPE_MISSING (required_scope: redirects:write) otherwise); dry_run needs only catalog:write. A draft key cannot move categories, dry_run included: 403 PAT_CONTOUR_MISMATCH (the passport lists MoveCategory in live_operations_denied); a cabinet session in the draft contour gets DEV_MODE_UNAVAILABLE.
7.2. Moving a product to another category
Changing category_id changes the product's address; the old one follows by the last segment. To pin it, create an exact rule old → new yourself (POST /storefront-redirects).
7.3. Renaming a slug
A new slug of a product, category or article written in the live contour is recorded in the slug journal; the old address answers 308 to the current one (chains A → B → C collapse). A draft slug reaches the journal on publish. GET /slug-redirects/resolve?company_id=11&entity_type=article&slug=old → {"entity_id":"5","current_slug":"new","entity_type":"article"}; a slug held by a live entity again is not a redirect (404). To keep an old address even if the slug is later reused, add an exact rule.
7.4. Deleting
A deleted product's address goes to its last section (the journal answers fallback_category_id), a deleted category's addresses to the nearest live section — both through miss_fallback. To send them elsewhere create an exact rule to the chosen page before or after deleting.
8. Loops
Every mutation checks the set it would leave behind inside its own transaction: exact rules, masks (on probe addresses), prefixes with tails, the built-in .html hop and the nearest-section fallback.
- A chain that returns to a visited address (or keeps growing — a tail substituted into itself) is refused with
REDIRECT_LOOP. - A finite chain is allowed up to 10 hops inclusive; the 11th hop is refused with
REDIRECT_CHAIN_TOO_LONG. Every hop counts: rules, the built-in.htmlhop and the fallback. Fix it by pointing the first rule straight at the final address. The length is counted whichever end of the chain the new rule extends.
Both are checked on create, edit, import (row reasons LOOP / CHAIN_TOO_LONG), reorder, delete and a category move. A set that failed before the change is not locked: you can always delete or fix the rule at fault.
9. Refusal codes
| Code | gRPC / HTTP | When |
|---|---|---|
REDIRECTS_FORBIDDEN | PermissionDenied / 403 | not owner or admin |
PAT_SCOPE_MISSING (required_scope: …) | 403 | the token lacks the scope |
REDIRECT_LIMIT_REACHED | ResourceExhausted / 429 | the plan's redirects_max (§11), incl. pin_rules; the body carries "limit_code": "redirects_max" — only another plan helps |
REDIRECT_PATTERN_LIMIT_REACHED | ResourceExhausted / 429 | the plan's redirect_masks_max; "limit_code": "redirect_masks_max" |
REDIRECT_CEILING_REACHED / REDIRECT_PATTERN_CEILING_REACHED | ResourceExhausted / 429 | the hard ceiling (20000 rules / 200 masks) whatever the plan; no limit_code |
REDIRECT_LOOP | InvalidArgument / 400 | the chain returns to itself (§8) |
REDIRECT_CHAIN_TOO_LONG | InvalidArgument / 400 | the chain is finite but longer than 10 hops (§8) |
REDIRECT_RESERVED_PATH | 400 | a service path (/cart, /api, /robots.txt, /_next…) |
REDIRECT_INVALID_SOURCE / REDIRECT_INVALID_TARGET | 400 | not a path; query in the source; bad $n |
REDIRECT_EXTERNAL_NOT_ALLOWED | 400 | absolute URL without external |
REDIRECT_INVALID_QUERY | 400 | condition not key=value, or on a non-exact rule |
REDIRECT_DUPLICATE | AlreadyExists / 409 | same kind, key and condition |
REDIRECT_IMPORT_TOO_LARGE | 400 | more than 1000 lines |
REDIRECT_NOT_FOUND | NotFound / 404 | unknown or foreign id; a reorder list that does not name every live rule once; journal miss |
PAT_CONTOUR_MISMATCH | PermissionDenied / 403 | a draft key tried to write (rules, settings, the 404 journal, a category move) |
DEV_MODE_UNAVAILABLE | FailedPrecondition | the same write from a cabinet session in the draft contour |
10. Silently ignored
enabledleft out → a switched-off rule, 200, never fires.sort_orderin the input — there is none; a new prefix is placed above a prefix that would shadow it, everything else goes to the tail.kindthat contradictsfrom_pathis refused, not ignored; but an emptykindon/goods/*makes a prefix.drop_querynever drops ad tags (§5).url_changesbeyond 20 are not listed; the counters and pinning still count them.
11. Limits
{
"rules_ceiling": 20000,
"patterns_ceiling": 200,
"tariff_limits": ["redirects_max", "redirect_masks_max"],
"import_rows_max": 1000,
"from_max_chars": 1024,
"to_max_chars": 2048,
"note_max_chars": 500,
"query_match_max_chars": 256,
"chain_hops_max": 10,
"pattern_stars_max": 9,
"move_url_samples_max": 20,
"codes": [301, 302],
"kinds": ["exact", "pattern", "prefix"],
"covered_by": ["auto", "rule", "fallback", "none"],
"miss_key_max_chars": 512,
"miss_new_paths_per_day": 1000,
"miss_rare_retention_days": 90,
"miss_retention_days": 365,
"miss_page_max": 200,
"miss_hide_paths_max": 500
}Plan limits. Rules and masks are limited by the shop's plan: redirects_max (seeded Free 200, Pro 2000) and redirect_masks_max (Free 20, Pro 50); the platform owner may change them. A plan without the row (Corporate) is limited only by the ceilings. The shop's current values come with GET /storefront-redirects (rules_limit, patterns_limit; 0 = the plan does not limit). Only adding is refused: editing, deleting, reordering and an import of updates work above the limit (after a downgrade).
12. 404 log
The storefront records every address that ended in «not found» after all of §5 — the live page, the slug journal, the rules and the fallback — so the shop can turn it into a rule.
- Kept: per address and day — hits, last seen, the host of the last referrer (no path, no query), a hidden flag. No visitor IP, no user agent. The address is the comparison key of §4.1 (decoded, ASCII lower-case, no trailing
/): a rule withfrom_path=pathmatches it exactly. - Not kept: bot junk — script, config, dump, backup and archive extensions (
.php,.asp,.jsp,.env,.sql,.bak,.zip…), scanner folders (wp-admin,wp-content,xmlrpc,cgi-bin,phpmyadmin…), hidden files and folders (/.env,/.git,/.well-known),apple-touch-icon*, keys longer than 512 bytes or with invisible characters; service paths (§9REDIRECT_RESERVED_PATH), the home page, draft hosts, closed sites, embedded fragments. Oldindex.php?ID=addresses of a previous CMS are migrated with CSV (§4.3), not from the log. - Timing and caps: sent in batches every few seconds with the hit counters, so an address appears within ~10 s; at most 60 new addresses per shop per minute from one storefront process and 1000 new addresses per shop per day (known ones keep counting). Day rows with one hit expire after 90 days, any row after a year.
- Read —
GET /storefront-redirects/misses(redirects:read):q(substring, case-insensitive),sorthits(default) |last_seen,include_hidden,limit(≤ 200, default 50),offset. Answer:{"result":{"items":[{"path":"/old/page","hits":"9","last_seen":"…", "referrer":"yandex.ru","hidden":false,"rule_id":"0"}],"total":2,"hidden":1}}—hitssummed over the kept days;rule_idis the rule that would catch the address now (0 = none; past hits stay after you add a rule);total— addresses under the filter;hidden— hidden addresses matchingq. - Hide —
POST /storefront-redirects/misses/hide(redirects:write){"paths": ["/old/page"], "unhide": false}→{"updated": 1}; ≤ 500 paths, normalised like the log; a hidden address stays hidden when hit again. - Rows into rules: one address —
POST /storefront-redirectswithfrom_path=path; many addresses to one page — one import, a line per addresspath;target;301;exact(dry_runfirst). The plan limit applies.
13. How this was verified
go test ./internal/core/domain/— matcher, masks, loop gate, the shared Go/TS vectors (testdata/storefront_redirect_vectors.json), the move planner (TestPlanCategoryMove*).go test -tags=integration ./internal/api/catalog/ -run TestSFRon PostgreSQL 17: CRUD, role and tenant gates, limits, import, check, the public set, the fallback setting, dry-run move (nothing changes), pinning every address and the atomic limit refusal, the pin gate for editors and tokens, the article slug journal; stage C: the tariff seed and refusals (REDIRECT_LIMIT_REACHED, masks, the ceiling on a plan without the row), the 404 log (merge by address and day, junk and unknown shops dropped,versionuntouched, search, sort, the rule mark, hide and unhide, the daily cap, the purge).- Prod test 2026-09-25 (ВЕЗУ-ВЕЗУ):
TestStorefrontRedirectChainHopLimit,TestTraceStorefrontRedirectSentCode(domain), vectors row 99 (10 hops ok, 11 →chain_too_long),TestSFRProd*(integration: the 10/11-hop gate from both ends,sent_codein the check, notes kept on re-import). internal/core/domain/testdata/storefront_miss_vectors.json— the junk filter and the referrer host, shared by Go and the storefront.go test ./internal/core/services/ -run TestRedirectScope— scope, contour and role registries;redirects:readcannot write.