Vizen Shop

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 thisNote
nothing — a product or category was renamed or movednothingthe storefront follows the slug journal and the last URL segment itself (§7)
one old address → one new addressexact rule /old → /newcheapest: matched by key
one old address, but only with ?ID=5exact rule + query_match: "ID=5"a rule with a condition wins over the same path without one
a family of old addresses with a shapemask (kind: "pattern") /old/*.html → /c/$1masks are limited by the plan (§11)
a whole section, keep the tailprefix /shop/* → /catalog/*first matching prefix in sort_order wins
a whole section to one landing pageprefix /shop/* → /catalogthe section page itself still opens
to override a page that still existsoverride_live: trueapplied before rendering; deliberate
deleted pages to land somewhere sensiblekeep miss_fallback on (default)nearest live section, never the home page
a moved category to stay pinned foreverPOST /categories/move with pin_rules: trueask the owner first (§7.1)
to see which addresses visitors still missthe 404 log GET /storefront-redirects/missesthen 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):

FieldWho writesMeaning
idserver64-bit, a JSON string
kindyou or serverexact, 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_pathyoua path of your storefront, as typed. No query string: use query_match
to_targetyoua path of your shop, or an absolute http(s) URL with external: true
externalyourequired for an off-shop target (REDIRECT_EXTERNAL_NOT_ALLOWED otherwise)
codeyou301 or 302; 0 means 301
enabledyoudefaults to false — send true
override_liveyoufalse (default): the rule acts only where no page exists; true: before rendering, over a live page
drop_queryyoufalse (default): the visitor's query goes to the target; true: dropped, ad tags excepted
query_matchyouexact rules only: key=value (a leading ? is stripped), compared byte for byte
sort_orderserverorder of masks and prefixes; change with PUT /storefront-redirects/order
noteyou≤ 500 chars. Rules pinned by a category move carry category move
hits, last_hit_atserverhow 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 pathScopeWhat
GET /storefront-redirectsredirects:readthe 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:readthe trace of an address (§4.4)
POST /storefront-redirectsredirects:write{"item": {...}} → the saved rule
PUT /storefront-redirects/{id}redirects:write{"item": {...}} — the whole input again
DELETE /storefront-redirectsredirects:write{"ids": ["12","13"]} → {"deleted": 2}
PUT /storefront-redirects/orderredirects:write{"ids": [...]} — every live rule exactly once
POST /storefront-redirects/importredirects:writeCSV text, dry_run (§4.3)
PUT /storefront-redirects/settingsredirects:write{"miss_fallback": false} (§5)
GET /storefront-redirects/missesredirects:readthe 404 log, paged (§12)
POST /storefront-redirects/misses/hideredirects:write{"paths": [...], "unhide": false} (§12)
POST /categories/movecatalog:write (+ redirects:write for pin_rules)move with a summary (§7.1)
GET /slug-redirects/resolve?company_id=&entity_type=&slug=publicthe slug journal: product, category, article (§7.3)
GET /storefront/redirects?slug=publicthe 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
ColumnValuesEmpty means
froma storefront path—
toa path or an http(s) URL—
code301 / 302301
kindexact / prefix / patternderived from from
paramskeep / dropkeep
conditionkey=value (exact only)none
always1 / true / yes = override_liveonly where no page exists
notethe rule's note, ≤ 500 chars; takes the rest of the line, separators includedempty 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:

  1. Middleware, before the page: rules with override_live: true — exact (with a condition first), then masks, then prefixes in sort_order. Answers with the rule's own 301/302.
  2. Built-in: …/x.html → …/x (308); canonical host (308). Exception — a rule on the `.html` address itself (exact /page.html or a mask such as /old/*.html) answers here, in middleware, with its own 301/302, even without override_live: it must beat the built-in .html hop, and no live page ever sits on a .html address. The loop gate and the address check model the same order.
  3. 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.
  4. Slug journal: an old slug of a product, category or article → 308 to the current address.
  5. 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.
  6. `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, .json or .yml (/catalog/feed.yml) — never falls back: a missing feed answers 404, not the section page. .html is a page address and falls back as usual.
  7. 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 /shop and /shop/a/b.
  • In the target $1…$9 are the stars left to right; a capture is path-escaped ($1 in a query part is query-escaped). Up to 9 stars.
  • Matching is linear, case-insensitive for ASCII, no regex.
  • query_match is 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:

  1. POST /categories/move with "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"}, …]}
  1. Show the summary to the user and ask: move (old addresses follow automatically) · move and pin with rules · cancel.
  2. Repeat without dry_run, with "pin_rules": true if 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 .html hop 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

CodegRPC / HTTPWhen
REDIRECTS_FORBIDDENPermissionDenied / 403not owner or admin
PAT_SCOPE_MISSING (required_scope: …)403the token lacks the scope
REDIRECT_LIMIT_REACHEDResourceExhausted / 429the plan's redirects_max (§11), incl. pin_rules; the body carries "limit_code": "redirects_max" — only another plan helps
REDIRECT_PATTERN_LIMIT_REACHEDResourceExhausted / 429the plan's redirect_masks_max; "limit_code": "redirect_masks_max"
REDIRECT_CEILING_REACHED / REDIRECT_PATTERN_CEILING_REACHEDResourceExhausted / 429the hard ceiling (20000 rules / 200 masks) whatever the plan; no limit_code
REDIRECT_LOOPInvalidArgument / 400the chain returns to itself (§8)
REDIRECT_CHAIN_TOO_LONGInvalidArgument / 400the chain is finite but longer than 10 hops (§8)
REDIRECT_RESERVED_PATH400a service path (/cart, /api, /robots.txt, /_next…)
REDIRECT_INVALID_SOURCE / REDIRECT_INVALID_TARGET400not a path; query in the source; bad $n
REDIRECT_EXTERNAL_NOT_ALLOWED400absolute URL without external
REDIRECT_INVALID_QUERY400condition not key=value, or on a non-exact rule
REDIRECT_DUPLICATEAlreadyExists / 409same kind, key and condition
REDIRECT_IMPORT_TOO_LARGE400more than 1000 lines
REDIRECT_NOT_FOUNDNotFound / 404unknown or foreign id; a reorder list that does not name every live rule once; journal miss
PAT_CONTOUR_MISMATCHPermissionDenied / 403a draft key tried to write (rules, settings, the 404 journal, a category move)
DEV_MODE_UNAVAILABLEFailedPreconditionthe same write from a cabinet session in the draft contour

10. Silently ignored

  • enabled left out → a switched-off rule, 200, never fires.
  • sort_order in the input — there is none; a new prefix is placed above a prefix that would shadow it, everything else goes to the tail.
  • kind that contradicts from_path is refused, not ignored; but an empty kind on /goods/* makes a prefix.
  • drop_query never drops ad tags (§5).
  • url_changes beyond 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 with from_path = path matches 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 (§9 REDIRECT_RESERVED_PATH), the home page, draft hosts, closed sites, embedded fragments. Old index.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), sort hits (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}} — hits summed over the kept days; rule_id is 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 matching q.
  • 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-redirects with from_path = path; many addresses to one page — one import, a line per address path;target;301;exact (dry_run first). 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 TestSFR on 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, version untouched, 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_code in 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:read cannot write.

Исходник: https://api.vizen.shop/docs/redirects-reference

Работает в демо-режиме