Site files: full reference
/docs/feeds-referencedraftEN· проверено 2026-09-24
Резюме по-русски. Полный справочник области «Файлы сайта»: объекты и ручки, язык шаблона и функции, рецепты (YML, Google XML, CSV,ads.txt, верификация, страницы, ссылки по id), отдача и кэш, участие товара в рекламе, молча игнорируемое, чек-листы, машинный блок лимитов (§11). Короткий вход в область —/docs/feeds; раздел отсюда берётся отдельно:GET /docs/feeds-reference?section=<slug>.
Status: draft · Verified: 2026-09-24 against the code of branch feature/site-files-feeds (incl. nesting under categories, sitemap flag and security headers): unit tests of the engine and the raw route, integration tests on PostgreSQL (TestSiteFile*, TestAdParticipation*, golden YML and Google XML); no external run on a live shop yet · Owner: site files line (ФИДЫ-РЕКЛАМЫ) Serves: GET /docs/feeds-reference
The short entry to this area — what site files are, the rules and a quick start — is GET /docs/feeds. This document is the whole reference; read one section at a time with ?section=<slug> (a wrong slug answers 404 with the list of slugs), e.g. GET /docs/feeds-reference?section=5-4-functions.
1. What you can do here
Keep files that live on the shop's storefront host — on its root or under any category of the site tree (product, page or news category) — and are fetched by robots (and, for a simple .html page, by people):
- a product feed for Yandex Direct / Webmaster / Yandex Products (
/yandex-feed.yml), for Google Merchant Center (/google-feed.xml), a CSV or JSON export for a partner; - `/ads.txt`, a webmaster verification file (
/yandex_1a2b3c.html,/google1a2b3c.html), any small.txt; - a simple `.html` page under a section (
/sofas/delivery-terms.html), optionally listed in/sitemap.xml(in_sitemap). Scripts in site files never run (§7.2): a page that needs JavaScript is an HTML widget, not a site file.
A file is {category_id, name, body, is_published, in_sitemap}: where it lives is the category (0 — the host root) and the file name; its full path is computed by the platform. The body is a Go text/template (§5): plain text without {{ }} is served as it is, a template is rendered on the live catalogue at the moment a robot asks for it. Nothing is exported or uploaded anywhere — you give the ad system the URL.
| What | Value |
|---|---|
| Place | category_id — 0 (or absent) for the host root, otherwise a live category of this shop of any type; name — the file name, the last path segment |
| Name | [A-Za-z0-9._-]{1,120} + .xml, .yml, .csv, .txt, .json or .html (extension lowercase), no slashes |
| Path | computed, read-only: the category's canonical path (the same chain of slugs as its page URL and the sitemap) + / + name; at the root / + name. The home category is the root: a file placed under it is stored with category_id 0 |
| Format | chosen by the extension: it sets Content-Type and the escaping of values (§5.5) |
| Reserved | at the root only: robots.txt, sitemap.xml, yandex-token.html, favicon.*, icon*, the platform's IndexNow key file — the platform serves them itself. /ads.txt and verification files are allowed; under a category every valid name is allowed |
| Category deleted | the file stays, is listed with orphaned: true, empty path and url, and answers 404; move it (PATCH {category_id}) or delete it |
| Publication | is_published: false — stored, previewable, 404 on the host |
| Who | the shop owner or an administrator only (an editor gets SITE_FILES_FORBIDDEN whatever the key's scopes) |
| Contour | production only: files have no draft copy; a dev key reads, a write answers PAT_CONTOUR_MISMATCH |
| Tariff | site_files_max — live files per shop: Free 5, Pro 50; a plan without the limit is unlimited |
The OpenAPI of this area alone is GET /openapi.json?scope=site-files; capabilities.site_files_read / site_files_write in GET /v1/account/token say what this key may call.
2. Decide first (forks)
| If you want… | Take this path | Cost |
|---|---|---|
| a Yandex feed (Direct, Webmaster, Yandex Products) | template yandex_yml from GET /site-files/templates | none; Yandex Direct also accepts a Google XML feed |
| a Google Merchant Center feed | template google_xml | the format works wherever Merchant Center is available to the advertiser (Google Ads is paused for Russia-based advertisers since 2022) |
| to keep one product out of every feed | PUT /products/{id}/ad-participation {"mode":"exclude"} (§8) | catalog:write; the storefront is not affected |
| to keep a category out of one feed | products "exclude_category=slug" in that template (§5.2) | per file; other feeds still carry it |
| a feed of part of the catalogue | products "category=slug-a,slug-b" | a slug that later disappears breaks the feed (§7, §9) |
| other pictures in ads than on the card | a gallery named e.g. Feed on the product, pictures . 5 "Feed" (§6.6) | one more gallery per product |
| to know the template works | POST /site-files/preview before saving | saving checks syntax only: a wrong category slug or option is found by preview or by the robot |
ads.txt or a verification file | the same object at the root, body without {{ }} (§6.7, §6.8) | the .txt is served as is; publish the file or it answers 404 |
a feed or file at a section's address (/sofas/feed.yml) | category_id of that section + name | the address follows the section's slugs: renaming a slug moves the file's URL |
| a simple page (terms, a landing text) | an .html file, in_sitemap: true to list it (§6.9) | no scripts (CSP sandbox); interactive pages are HTML widgets |
3. Quick start: a Yandex feed in three requests
API=https://api.vizen.shop
T="Authorization: Bearer vz_pat_…" # owner/admin key with site-files:read + site-files:write
J="Content-Type: application/json"
# 1. The reference template (the same body the cabinet offers)
BODY=$(curl -s "$API/site-files/templates" -H "$T" | jq -r '.items[] | select(.key=="yandex_yml") | .body')
# optional, and worth it: render it on 20 products of your live catalogue
jq -n --arg b "$BODY" '{path:"/yandex-feed.yml", body:$b, limit:20}' \
| curl -s -X POST "$API/site-files/preview" -H "$T" -H "$J" -d @- | jq '{items_count, truncated, errors}'
# → {"items_count": 20, "truncated": false, "errors": []}
# 2. Create it published (at the root; add "category_id" to put it under a section)
jq -n --arg b "$BODY" '{name:"yandex-feed.yml", body:$b, is_published:true}' \
| curl -s -X POST "$API/site-files" -H "$T" -H "$J" -d @- | jq '{id, path, url, row_version}'
# → {"id": "12", "path": "/yandex-feed.yml", "url": "https://myshop.vizen.shop/yandex-feed.yml", "row_version": "1"}
# 3. Fetch it the way Yandex will
curl -s -D - "https://myshop.vizen.shop/yandex-feed.yml" -o feed.yml
# → 200, Content-Type: application/xml; charset=utf-8, ETag, X-Robots-Tag: noindex
xmllint --noout feed.yml && grep -c '<offer ' feed.ymlVerify: the offer count equals the number of published, priced products you expect (§5.2 lists what is always left out); open two <url>s and compare the price on the page with <price>. Then give the URL to Yandex Direct (Feeds → Add feed → by link). The site must be published (not closed by a PIN) or the URL answers 404 (§7.1).
4. Requests
All paths are on the API base, no /v1 prefix. Bodies are JSON with snake_case names; the answer is the object itself (no result wrapper), every field present. 64-bit numbers are JSON strings on output (id, row_version, size); input accepts 12 and "12". Timestamps are RFC 3339 strings in UTC. An unknown body field is refused with 400 invalid request body: <field>.
Errors come as {"error": "rpc error: code = <Code> desc = <CODE>[: detail]"} — match on the CODE, not on the HTTP status.
4.1. Objects
SiteFile — GET /site-files/{id}, create and edit answers:
{
"id": "12",
"category_id": "57",
"name": "yandex-feed.yml",
"path": "/furniture/sofas/yandex-feed.yml",
"content_type": "application/xml; charset=utf-8",
"body": "{{- /*\nYandex Market / Yandex Direct product feed (YML).…",
"is_published": true,
"in_sitemap": false,
"orphaned": false,
"row_version": "3",
"url": "https://myshop.vizen.shop/furniture/sofas/yandex-feed.yml",
"created_at": "2026-09-24T10:00:00Z",
"updated_at": "2026-09-24T10:05:12Z",
"size": "1843"
}category_id—"0"for a file at the host root;name— the file name;path— computed from them (§1), read-only. A path you cannot compute yourself is not needed: the answer always carries it.in_sitemap— only meaningful for a published.htmlfile: it is then listed in/sitemap.xml(§7.4); other formats are never listed.orphaned: true— the category was deleted:pathandurlare""and the file answers 404 until you move or delete it.
row_versiongrows on every change (edit, publish switch, delete); send it back asknow_versionto edit safely.urlis the address on the shop's active custom domain, otherwise on its<slug>host;""when the host is not known or the file is orphaned. Both hosts serve the file (§7.1).sizeis the body length in bytes;updated_atequalscreated_atfor a file never edited.
SiteFileListItem — the same without body.
4.2. Endpoints
| Method and path | Scope | What it does |
|---|---|---|
GET /site-files ?category_id= &q= | site-files:read | {"items": [SiteFileListItem…]}, ordered by category, then name (case-insensitive); deleted files are not listed. category_id=0 — root files only, category_id=N — files of category N only (not of its subcategories), absent — all files. q (≤ 200 characters) — case-insensitive substring of the name or the full path (q=feed, q=/sofas/), combined with category_id; empty — no filter |
GET /site-files/templates | site-files:read | {"items": [{key, name, path, body}]} — yandex_yml (/yandex-feed.yml), google_xml (/google-feed.xml), empty (/file.txt, empty body) |
GET /site-files/{id} | site-files:read | one file with its body |
POST /site-files/preview {path, body, limit} | site-files:read | renders a body without saving (§4.3) |
POST /site-files {category_id?, name, body, is_published?, in_sitemap?} | site-files:write | creates a file; category_id absent or 0 — the root; is_published and in_sitemap default to false. The place is given only as category_id + name; path exists only in answers (computed) |
PATCH /site-files/{id} {category_id?, name?, body?, is_published?, in_sitemap?, know_version?} | site-files:write | partial edit: a field you do not send is left alone. Moving is category_id (0 — to the root); renaming is name; both are checked against the final place (reserved names at the root, a taken name in the target category) |
DELETE /site-files/{id} | site-files:write | soft delete: the place is freed at once, the URL answers 404; no body needed; answers {} |
GET /products/{id}/ad-participation | catalog:read | {"mode": "include"} or "exclude" (§8) |
PUT /products/{id}/ad-participation {mode} | catalog:write | include or exclude the product from every feed |
Every call is private to the key's shop: another shop's file answers like a missing one. Ad participation uses the same owner/admin gate as files.
Safe edit (`know_version`). Read the file, keep row_version, send it back:
curl -s -X PATCH "$API/site-files/12" -H "$T" -H "$J" \
-d '{"body":"google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0\n","know_version":"3"}'If someone saved in between, the answer is 409 SITE_FILE_VERSION_CONFLICT and nothing is written: read again, merge, retry. know_version absent or 0 writes without the check. Renaming is {"name": "new-name.yml"}, moving is {"category_id": "57"} or {"category_id": "0"} for the root — the old URL answers 404 from that moment.
4.3. Preview
POST /site-files/preview {"path":"/yandex-feed.yml","body":"…","limit":20} renders exactly as the host would — the live production catalogue, seen as an anonymous buyer, same prices, same escaping — with three differences:
- `limit` (0..50,
0→ 20) caps the products of everyrange productsseparately; alimit=option in the template larger than that is cut to it.limit> 50 →400. - `output` is cut to 200 KB (on a UTF-8 character boundary) and
truncated: trueis set; the render itself stops at 4 MB. - Errors do not fail the call. A syntax or runtime error answers
200witherrors: [{"line": 3, "message": "function \"picture\" not defined", "code": "SITE_FILE_TEMPLATE_INVALID"}]andoutputholding what was produced before the error.lineis 1-based,0when unknown. A render over the preview time limit answers the same way with"code": "SITE_FILE_PREVIEW_TIMEOUT"(line0). Match oncode, never on the Englishmessage. Only a transport or database failure fails the request.
{"output": "<?xml version=\"1.0\"…", "truncated": false, "items_count": 20, "errors": []}items_count is the number of distinct products the template received. path sets the format — a full path (/sofas/feed.yml) or just a name (feed.yml); its last segment must match the name grammar (a reserved name is fine here); nothing is stored, the file need not exist. Preview has its own budget per shop — 30 calls a minute — and stops a render after 10 s. A shop renders at most 2 files at a time (previews and builds for robots together); a preview that cannot get a slot within 3 s is refused the same way. Both refusals are 429 SITE_FILE_PREVIEW_RATE_LIMITED with a Retry-After header and {"code": "SITE_FILE_PREVIEW_RATE_LIMITED", "retry_after": N} in the body. If the client disconnects, the render stops.
4.4. Refusals
| Code | HTTP | When |
|---|---|---|
SITE_FILE_NAME_INVALID | 400 | name outside the grammar (§1), empty, or with a slash; the text repeats the rule |
SITE_FILE_CATEGORY_NOT_FOUND | 404 | category_id is not a live category of this shop (deleted, another shop's, never existed) |
SITE_FILE_PATH_RESERVED | 400 | a platform name at the root (§1) |
SITE_FILE_PATH_TAKEN | 409 | a live file with that name exists in the same category (or at the root) — compared case-insensitively (Feed.xml = feed.xml) |
SITE_FILE_TEMPLATE_INVALID | 400 | the body does not parse: SITE_FILE_TEMPLATE_INVALID: line 12: function "picture" not defined. Unknown function names are caught here; unknown fields and option values only at render time |
SITE_FILE_BODY_TOO_LARGE | 400 | body over 65 536 bytes (proto validation usually answers first, with the field name) |
SITE_FILE_BODY_INVALID | 400 | body is not UTF-8 |
SITE_FILE_VERSION_CONFLICT | 409 | know_version ≠ the current row_version |
SITE_FILE_NOT_FOUND | 404 | no such file in this shop, or deleted |
SITE_FILE_LIMIT_REACHED | 429 | the plan's site_files_max is used up (counted in the same locked transaction as the insert, so parallel creates cannot overshoot); no Retry-After — retrying does not help, delete a file or change the plan; the body carries "code": "SITE_FILE_LIMIT_REACHED" and "limit_code": "site_files_max" (every tariff refusal names its limit this way) |
SITE_FILE_PREVIEW_RATE_LIMITED | 429 | more than 30 previews a minute for the shop, or the shop's render slots are busy (§4.3); Retry-After and retry_after in the body are set |
SITE_FILES_FORBIDDEN | 403 | the key's user is not the owner or an administrator of the shop |
PRODUCT_NOT_FOUND | 404 | ad participation of a product that does not exist or belongs to another shop |
PAT_SCOPE_MISSING | 403 | the key lacks the scope in §4.2 |
PAT_CONTOUR_MISMATCH | 403 | a dev-contour key tried to write (create, edit, delete, set ad participation) |
DEV_MODE_UNAVAILABLE | 400 | the same write from a cabinet session switched to the dev contour |
Proto validation (name longer than 130 characters, a negative category_id, mode not include|exclude, limit > 50) answers 400 with the field name.
5. The template language
Go text/template ({{ }} actions, if, range, with, else, define, variables, eq/ne/lt/gt, and/or/not, len, index, printf) plus eight platform functions. No file system, no network, no raw output.
Work is bounded, whatever the template does: a {{ template }} that calls itself, directly or through other defines, is refused at save (SITE_FILE_TEMPLATE_INVALID: … calls itself (recursion is not allowed)); a render stops after 30 s, 50 MB of output or 20 000 000 steps (a step is every range iteration, define call and if/with branch — the template does too much work); a value built by printf, print, println, html, js or urlquery may not exceed 1 MB, and a printf width or precision above 1000 or * is an error.
5.1. Data
The root holds one object: `.Shop`.
| Field | Value |
|---|---|
.Shop.Name | shop name |
.Shop.URL | scheme + host the file is served from, no trailing slash (https://myshop.vizen.shop) |
.Shop.Currency | shop currency code (RUB when not set) |
Inside range the dot is the element: reach the shop as $.Shop.Name.
5.2. products — the offers
{{ range products }} … {{ end }}
{{ range products "category=sofas,armchairs" "exclude_category=outlet" "limit=500" }} … {{ end }}Options are separate strings name=value:
| Option | Meaning |
|---|---|
category=a,b | only these categories and their subcategories, by slug (case-insensitive) or id; a product counts when its main or an extra category matches — the storefront's own rule |
exclude_category=a,b | minus these categories and their subcategories (main or extra category) |
limit=N | at most N products (positive); never more than 50 000 per range |
include_zero_price=true | keep products whose price is 0 (skipped by default) |
An unknown option, a malformed value or a category that is not in the shop is an error at render time with the line number — a typo never silently returns the whole catalogue or an empty feed.
Always left out, whatever the options: unpublished products, products hidden from the catalogue (catalog_hidden), products an anonymous visitor may not see (access policies, closed sections), products that the storefront listing hides itself (items shown only inside a set), products excluded from ads (§8), and products with price 0 (unless include_zero_price=true). The list is exactly what the storefront grid shows an anonymous visitor.
Order: by creation time, oldest first — new products are appended, the feed stays stable between builds. With category=a,b the categories are walked in the given order and a product appears once. The same selection is evaluated once per render: calls that differ only in letter case, spaces, option order, repeated categories or a smaller limit reuse the list already loaded. A render may make at most 20 different selections — the 21st is an error with its line (products: more than 20 different product selections…); load once into a variable ({{ $all := products }}) and reuse it.
Product fields
| Field | Value |
|---|---|
.ID | product id (number) — stable, use it as offer id |
.Name | product name |
.Description | the description as stored; markup in it comes out escaped, as text |
.URL | absolute canonical product page URL on the served host; /product/{id} when the product has no clean path (the storefront redirects it to the canonical one) |
.SKU | article / vendor code, may be "" |
.Price | the storefront listing price for an anonymous visitor, whole units of the currency (§7.3) |
.OldPrice | the crossed-out price, only when higher than .Price; 0 — none |
.Currency | currency code of the price |
.CategoryID | the product's main category — or, if that category is not in categories (unpublished, the home page, not a product category), its nearest ancestor that is; 0 when there is none |
.CategoryName | name of that category |
.CategoryPath | Parent > Child names down to that category |
.Rating, .RatingCount | average rating (decimal) and number of ratings |
.Kind | standard, combo or multi |
5.3. categories
{{ range categories }} — the shop's categories a feed may name: published, product-type, not the home page, visible to an anonymous visitor, in tree order (a parent always before its children).
| Field | Value |
|---|---|
.ID | category id |
.ParentID | the nearest ancestor that is also in this list; 0 — top level |
.Name, .Slug | name, slug |
.URL | absolute URL of the category page |
Because of the fallback in .CategoryID, every non-zero .CategoryID of a product is an .ID in this list — <categoryId> never points to a category the feed does not declare.
5.4. Functions
| Call | Returns | ||
|---|---|---|---|
attr . "code" | the value of the attribute with that code, product first, code second. Only attributes visible to buyers in the catalogue list come back; hidden, internal and restricted ones return "". Several values are joined with ", ". Missing — "": wrap in with | ||
pictures . N ["Gallery"] ["w=800"] | up to N absolute picture URLs of the product. The optional strings go in any order: a gallery name (case-insensitive) and w= width 1..2560. Without a name — the product's first gallery; a name no gallery has — also the first gallery, silently; no galleries — the listing preview picture. Without w= — the original file (CDN URL); with w= — the storefront resizer /w/<width>/webp/… on the served host, i.e. WebP (files outside the shop's storage come back unchanged) | ||
now "layout" | current time in UTC, Go layout ("2006-01-02 15:04"); "" — RFC 3339 | ||
truncate s N | first N characters (not bytes), no ellipsis added; counted before escaping | ||
default FALLBACK VALUE | VALUE unless it is empty ("", 0, false, empty list), else FALLBACK. Sprig order, so it works in a pipeline: `{{ .SKU \ | default "n/a" }}, {{ attr . "brand" \ | default $.Shop.Name }}` |
plain s | s as plain text: HTML tags removed (block tags and <br> leave a space, <script>/<style> go with their content), entities decoded (& → &, → space), runs of whitespace and line breaks collapsed to one space. The result is then escaped for the file's format like any value. Use it for descriptions: Google wants plain text, and Yandex accepts HTML only inside CDATA. Combine: {{ truncate (plain .Description) 3000 }}, {{ default .Name (plain .Description) }} | ||
file_url ID | the full URL of the shop's site file ID on the served host: https://<host>/<its current path>. The path is taken at render time from the file's current category and name — move or rename the target and the next build follows it (§7.2) | ||
file_path ID | the same path without the host: /catalog/feed.yml | ||
category_url ID | the canonical absolute URL of a published category (the same as .URL in categories and in /sitemap.xml); the home category — the host root | ||
product_url ID | the canonical absolute URL of a product an anonymous visitor sees in the catalogue (the same as .URL in range products) | ||
products …, categories | §5.2, §5.3 |
attr and pictures need a product as the first argument (. inside range products); anything else is a render error.
The link functions take an id as a number or a string of digits (15, "15", .ID). A target that does not exist, belongs to another shop, is deleted or unpublished — a file whose category was deleted or closed by an access policy, too; a hidden or unpublished category; a product an anonymous visitor cannot see — is a render error with the line (file_url: file 15 not found or not published), not an empty string: a broken link must show in preview, not in the ad system. One build asks for at most 1000 different link targets (repeats are free); inside range products use .URL.
5.5. Escaping is automatic — write only the constant markup
Every value that leaves an action {{ … }} is escaped for the file's format — fields, function results, printf output, variables, even string constants. Literal text of the template is never touched. There is no way to print a value raw.
| Extension | What happens to a value |
|---|---|
.xml, .yml | & < > " ' become entities; characters illegal in XML 1.0 (control characters) are removed |
.html | HTML entities |
.csv | RFC 4180: a value with , ; " a line break, a tab or an edge space is put in quotes, inner quotes doubled. Do not write quotes around `{{ }}` yourself — they would be doubled. Formula guard: a text value starting with = + - @, a tab or a carriage return gets a leading ' (=SUM(A1) → '=SUM(A1)), so Excel and Google Sheets show it as text instead of running it; numbers (-5) are printed as they are |
.json | the inside of a JSON string, without the quotes: write "name": "{{ .Name }}"; numbers without quotes: "price": {{ .Price }} |
.txt | as is |
Numbers and booleans are printed as they are in every format. Consequences:
- literal markup is yours: write
&in constant XML text, or letprintfbuild the string —{{ printf "%s?utm_source=yandex&utm_medium=cpc" .URL }}prints…?utm_source=yandex&utm_medium=cpc; - no CDATA around values: the escaped text inside
<![CDATA[ ]]>would reach the parser with the entities undecoded. Escaped text is what any XML parser reads as the original string; - the built-ins
html,js,urlqueryescape a second time — do not use them; printfverbs%#v,%Tand%pare refused (render error with the line): they print internal type names and memory addresses. Print a field (.Name) instead.
5.6. Errors
At save, the body must parse (syntax, known function names) — SITE_FILE_TEMPLATE_INVALID with the line. At render (preview, or a robot fetching the URL) the rest is checked: fields (can't evaluate field Nope), option values, categories, argument types, link targets (file_url and friends). In preview they come back in errors[] with the line; on the host the URL answers an error (§7.1). So: preview after every edit, and again after renaming or deleting a category your template names.
6. Recipes
Each recipe is a body for POST /site-files (or PATCH). Verify each with preview first (errors: []), then fetch the URL.
6.1. Yandex YML with UTM tags, parameters and collections
<collections> are category pages as ad landing pages (Direct's combined format); <collectionId> links an offer to its page. The data has no category picture — add <picture> to a collection only if you have one.
<?xml version="1.0" encoding="UTF-8"?>
<yml_catalog date="{{ now "2006-01-02 15:04" }}">
<shop>
<name>{{ .Shop.Name }}</name>
<company>{{ .Shop.Name }}</company>
<url>{{ .Shop.URL }}</url>
<currencies><currency id="{{ .Shop.Currency }}" rate="1"/></currencies>
<categories>
{{- range categories }}
<category id="{{ .ID }}"{{ if .ParentID }} parentId="{{ .ParentID }}"{{ end }}>{{ .Name }}</category>
{{- end }}
</categories>
<offers>
{{- range products }}
<offer id="{{ .ID }}" available="true">
<name>{{ .Name }}</name>
<url>{{ printf "%s?utm_source=yandex&utm_medium=cpc&utm_campaign=feed" .URL }}</url>
<price>{{ .Price }}</price>
{{- if .OldPrice }}
<oldprice>{{ .OldPrice }}</oldprice>
{{- end }}
<currencyId>{{ .Currency }}</currencyId>
{{- if .CategoryID }}
<categoryId>{{ .CategoryID }}</categoryId>
<collectionId>{{ .CategoryID }}</collectionId>
{{- end }}
{{- range pictures . 5 }}
<picture>{{ . }}</picture>
{{- end }}
{{- with .SKU }}
<vendorCode>{{ . }}</vendorCode>
{{- end }}
<description>{{ truncate (plain .Description) 3000 }}</description>
{{- with attr . "color" }}
<param name="Color">{{ . }}</param>
{{- end }}
{{- with attr . "material" }}
<param name="Material">{{ . }}</param>
{{- end }}
</offer>
{{- end }}
</offers>
<collections>
{{- range categories }}
<collection id="{{ .ID }}">
<url>{{ .URL }}</url>
<name>{{ .Name }}</name>
</collection>
{{- end }}
</collections>
</shop>
</yml_catalog>Replace color / material with your attribute codes (GET /attributes, /docs/catalogue); Yandex takes up to 10 <param> per offer. Direct can also strip UTM tags itself (an option when adding the feed).
6.2. Google Merchant Center XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
<channel>
<title>{{ .Shop.Name }}</title>
<link>{{ .Shop.URL }}</link>
<description>Products of {{ .Shop.Name }}</description>
{{- range products }}
<item>
<g:id>{{ .ID }}</g:id>
<g:title>{{ truncate .Name 150 }}</g:title>
<g:description>{{ truncate (default .Name (plain .Description)) 5000 }}</g:description>
<g:link>{{ .URL }}</g:link>
{{- range $i, $p := pictures . 11 }}
{{- if eq $i 0 }}
<g:image_link>{{ $p }}</g:image_link>
{{- else }}
<g:additional_image_link>{{ $p }}</g:additional_image_link>
{{- end }}
{{- end }}
<g:availability>in_stock</g:availability>
<g:condition>new</g:condition>
{{- if .OldPrice }}
<g:price>{{ .OldPrice }} {{ .Currency }}</g:price>
<g:sale_price>{{ .Price }} {{ .Currency }}</g:sale_price>
{{- else }}
<g:price>{{ .Price }} {{ .Currency }}</g:price>
{{- end }}
<g:brand>{{ truncate (attr . "brand" | default $.Shop.Name) 70 }}</g:brand>
<g:identifier_exists>no</g:identifier_exists>
{{- with .CategoryPath }}
<g:product_type>{{ . }}</g:product_type>
{{- end }}
</item>
{{- end }}
</channel>
</rss>With a crossed-out price, g:price is the old price and g:sale_price the current one — Google's own rule. If your products carry GTINs as an attribute, output <g:gtin> from attr and drop identifier_exists.
6.3. CSV for a partner
Path /partner-prices.csv. No quotes in the template — values that need them get them.
id,name,price,old_price,url,picture,category
{{ range products }}{{ .ID }},{{ .Name }},{{ .Price }},{{ .OldPrice }},{{ .URL }},{{ range pictures . 1 }}{{ . }}{{ end }},{{ .CategoryPath }}
{{ end }}A name Sofa "Oslo", grey comes out as "Sofa ""Oslo"", grey". Put the header and every row on its own line; {{ end }} right after the line break keeps one row per product.
6.4. A/B feeds with different UTM tags
Two files, the same body except the tag — each is its own URL in Direct:
/yandex-a.yml <url>{{ printf "%s?utm_source=yandex&utm_content=a" .URL }}</url>
/yandex-b.yml <url>{{ printf "%s?utm_source=yandex&utm_content=b" .URL }}</url>Both count toward site_files_max. Offer ids stay the product ids in both — that is what Yandex expects of one product across feeds.
6.5. A feed per category
{{- range products "category=sofas" }} … {{ end }}categories still lists the whole tree — that is valid YML. Name categories by slug for readability or by id to survive a slug change; renaming a slug the template uses breaks the feed until you edit it (preview shows … "sofas" is not a category of this shop).
6.6. Pictures for ads that differ from the card
- Create a gallery named
Feedwith the ad pictures (POST /galleries,/docs/catalogue§7) and link it to the product after its storefront gallery:PUT /products/{id} {"item":{"gallery_ids":["<main>","<feed>"], "gallery_ids_replace":true}}— the first gallery stays the card's. - In the template:
{{ range pictures . 5 "Feed" }}<picture>{{ . }}</picture>{{ end }}.
A product without a Feed gallery falls back to its first gallery — the feed never loses its pictures. Add "w=1200" for resized WebP copies; the originals (no w=) are safest when you do not control their size (Yandex wants ≥ 450 px on the short side).
6.7. ads.txt
Path /ads.txt, body — the lines your ad network gave you, as they are:
google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0.txt is not escaped. Publish it; verify curl https://<host>/ads.txt.
6.8. Webmaster verification file
Verification files live at the root (category_id 0) — that is where Yandex and Google look.
Yandex Webmaster offers an HTML file: path /yandex_<code>.html, body exactly what it shows, e.g.
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></head><body>Verification: <code></body></html>Google Search Console: path /google<code>.html, body google-site-verification: google<code>.html. Literal text is served unchanged, and .html files carry no X-Robots-Tag — the verifying robot reads them. Publish, fetch the URL (200, body as you wrote it), then press "Verify". Unpublished, the address answers 404.
6.9. A simple page under a section
POST /site-files {"category_id": "57", "name": "delivery-terms.html", "is_published": true, "in_sitemap": true, "body": "…"} — a static HTML page at /furniture/sofas/delivery-terms.html, listed in /sitemap.xml. Values from {{ }} are HTML-escaped. The page is served with Content-Security-Policy: sandbox: scripts, forms and pop-ups do not work and the page has no access to the shop's cookies. Anything interactive — a calculator, a form, a slider — belongs in an HTML widget (/docs/webcoding), not in a site file.
6.10. Links between files: an index file
A sitemap-like list of your feeds for a partner, or a page that links to another file. Link by id, never by a written path — the link survives moving and renaming the target:
feeds.xml at the root — an index of the shop's feeds (files 12 and 14):
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>{{ file_url 12 }}</loc></sitemap>
<sitemap><loc>{{ file_url 14 }}</loc></sitemap>
</sitemapindex>In an .html file: <a href="{{ file_path 14 }}">Price list (CSV)</a>, <a href="{{ category_url 57 }}">Sofas</a>. The ids are the id of GET /site-files (and of categories and products).
When file 14 moves to another category, the index is rebuilt with the new address within about 15 seconds (§7.2). When file 14 is unpublished or deleted, the index fails to build and the host keeps serving its last good copy (§7.1) — preview shows the error; remove the line.
Outside site files (HTML widgets, components, HTML blocks, a menu item) link to a file by its permanent address /_file/<id> (§7.1) — it too survives moves.
7. How serving works
7.1. The URL and its answers
https://<host>/<path> — <path> is the file's computed path: the canonical path of its category (the same slugs as the category page) and the name. The shop's <slug> host and its active custom domain both serve the file; links inside (.Shop.URL, .URL, category and resized picture URLs) use the host that was asked. Point the ad system at the domain it knows the shop by.
| Answer | When |
|---|---|
| 200 | a published file on a live, published site |
| 304 | If-None-Match matches the ETag, or If-Modified-Since is not older than the build |
| 404 | unknown host; the dev (draft) host; the site is closed (unpublished / PIN); a reserved name at the root or an invalid path; the category part of the path is not the canonical path of a live category an anonymous visitor may see (deleted, closed by an access policy, a non-canonical chain) — an unpublished or noindex category does not hide its files, they have their own is_published; no such file, deleted or unpublished. On such a miss the storefront keeps its old behaviour: an .html path that is not a verification file (yandex_*, google*, …) redirects (308) to the path without .html, the IndexNow key file is served, a seller "on miss" redirect rule applies, otherwise 404 noindex |
| 200, the last good build | the render failed (a category it names was deleted, a render over 30 s or 50 MB, the shop's render slots busy for 10 s) while the file itself was not edited since that build (same row_version) and the build is not older than 7 days — the robot gets that last good copy with its old ETag and Last-Modified (a 304 on a conditional request), so an ad system does not drop your products over a catalogue change. The error is only in the server log; preview shows it with its line — check preview after every edit, the served feed does not tell you |
| 502 | a render failure with no usable last good build: a new file, an edited template that fails (the old copy of a different template is not served — preview protects you), a last build older than 7 days, or the first build after a core restart without the disk cache. The core answers 500 (503 with Retry-After when the render slots are busy); the storefront passes both on as 502. Details are only in the server log |
| 504 | the core did not answer within 120 s |
A seller redirect rule on the same path that is set to override live pages wins over the file (those rules run first); an "on miss" rule applies only when the file is absent or unpublished.
Permanent address by id. https://<host>/_file/<id> (anything after the id, /_file/<id>/whatever.yml, is ignored) serves the same file wherever it lives now — link to it from HTML widgets, components, HTML blocks and menus: the link survives moving and renaming the file. The answers, headers, caching and 304s are exactly those of the file's own path; a file that is unpublished, deleted, of another shop or under a deleted / closed category answers 404. The answer carries Link: <https://<host>/<current path>>; rel="canonical", so a robot indexes the real path. Behind the storefront it is GET /seo/site-file?host=<host>&id=<id> on the core (id wins over path).
7.2. Headers and caching
| Header | Value |
|---|---|
Content-Type | by extension: application/xml (.xml, .yml), text/csv, text/plain, application/json, text/html; all ; charset=utf-8 |
ETag | hash of the body |
Last-Modified | when this copy was built |
Cache-Control | public, max-age=300 |
X-Robots-Tag | noindex for every format except .html |
X-Content-Type-Options | nosniff — the browser keeps the declared type |
Content-Security-Policy | sandbox (no allow-scripts) — on every answer, .html included: scripts, forms and pop-ups in site files never run. Pages with scripts are HTML widgets |
Link | <https://<host>/<current path>>; rel="canonical" — the file's current address, on its path and on /_file/<id> alike |
A built file is cached on disk, one copy per file and host (shop, host, file), stamped with the file's row_version and the catalogue revision; the body is streamed from disk and a 304 is decided before it is read. Each shop may take at most a fifth of the cache (never less than two 50 MB files), so one shop's big feeds do not push out the others'. The copy is rebuilt when:
- editing the file changes
row_version— the next request rebuilds it; - a catalogue change — products, categories, promotions and price rules (including their start and end moments), combos, galleries, attributes and their visibility, ad participation, shop name, currency and access, and any site file of the shop (created, edited, moved, renamed, unpublished, deleted — so files that link to it with
file_urlfollow) — changes the revision, noticed within about 15 seconds; - otherwise a copy lives 60 minutes at most;
- a failed build is remembered for 60 seconds per (file version, catalogue revision): within that window requests get the last good copy (or 502) without a new render; after it, or as soon as the file or the catalogue changes, the next request tries again. The last good copy is the same cached copy, not a second one; it is served for at most 7 days after its build and a copy nobody asked for in 7 days is removed;
- one build per file version at a time: robots arriving during a build wait for it; if every waiting client disconnects, the build is cancelled;
- a path with no published file answers 404 without a database read: the set of the shop's published files is remembered for 30 seconds and forgotten at once when a file is created, edited or deleted on the same core;
- a client or CDN honouring
max-age=300may keep its copy up to 5 minutes; saving a file purges the storefront host's cache.
Robots re-fetching with If-None-Match get a 304 while nothing changed.
7.3. Price parity with the storefront
.Price is taken from the same listing call that draws the storefront grid, as an anonymous visitor: active promotions, combos priced by their composition, price rules — byte for byte what the category page shows. A feed therefore does not disagree with the site, which is the first thing Yandex and Google moderation checks. Prices of signed-in buyers (personal or group rules) are not in feeds.
7.4. Sitemap
A published .html file with in_sitemap: true is listed in the host's /sitemap.xml by its full path, lastmod — its last edit. Feeds, .txt, .csv, .json are never listed, whatever the flag. A file of a category an anonymous visitor cannot see (closed, deleted) is not listed either. The sitemap is cached up to 15 minutes.
8. Ad participation
curl -s "$API/products/808/ad-participation" -H "$T"
# → {"mode": "include"}
curl -s -X PUT "$API/products/808/ad-participation" -H "$T" -H "$J" -d '{"mode":"exclude"}'
# → {"mode": "exclude"}exclude removes the product from every file of the shop at the next build (≤ ~15 s); include (the default for every product) brings it back. The storefront, search and sitemap are not affected. Scopes catalog:read / catalog:write, owner or administrator, production only (a dev key writing gets PAT_CONTOUR_MISMATCH). The cabinet shows it as "Participates in ad feeds" on the product. To exclude a whole category from one feed use exclude_category= in that template instead.
9. Silently ignored
| Written | What happens |
|---|---|
a gallery name in pictures that the product does not have | the first gallery is used, no error |
attr . "code" with an unknown, hidden or empty attribute | ""; the line still prints unless wrapped in with |
limit= above 50 000 (or above the preview limit) | cut to the ceiling |
| a product with price 0 | not in the feed unless include_zero_price=true |
| a sold-out product | still in the feed: the data has no stock field, and the reference templates write available="true" / in_stock. Exclude it (§8) or unpublish it |
markup in .Description without plain | printed as escaped text (<p>…), not stripped — wrap it in plain (the reference templates do) |
truncate | cuts without an ellipsis |
| a seller redirect rule on the file's path, set to override live pages | the redirect wins, the file is never served |
the same path with other letter case (/Feed.xml) | served — file names are case-insensitive; the category part is compared case-insensitively too |
in_sitemap: true on a non-.html file or an unpublished one | stored, never listed |
category_id of the home category | stored as 0: the file is at the root |
<script> in an .html file | served, never executed (CSP sandbox) |
{{ html . }}, {{ js . }}, {{ urlquery . }} | escaped twice |
a path written by hand in the body (/catalog/feed.yml) | printed as is — it breaks when the target moves; link by id (file_url, §6.10) |
10. Checklists before submitting
Yandex Direct / Webmaster / Yandex Products (YML)
- [ ] The site is published and the URL answers 200 from outside (
curl -I), on the domain the ad account knows. - [ ]
xmllint --nooutpasses;<currencies>,<categories>,<offers>in this order inside<shop>,<collections>after<offers>. - [ ] Every offer has
<url>,<price>,<currencyId>, one<categoryId>(products with.CategoryID0 have none — give them a published category),<name>and at least one<picture>(≥ 450 px on the short side, JPG/PNG/WebP/GIF). - [ ] Prices on three random product pages equal
<price>; sold-out products are excluded (§9). - [ ] Up to 10
<param>; attribute codes return values in preview. - [ ] Nothing from restricted categories; shop legal details are published on the site (Yandex Products moderation).
- [ ] The feed is under 512 MB (Direct by link); ours stops at 50 MB anyway.
Google Merchant Center (RSS 2.0 + `g:`)
- [ ] Available only where Merchant Center serves the advertiser's country; the feed domain matches the verified website (use §6.8 to verify).
- [ ] Every item has
g:id,g:title(≤ 150),g:description(≤ 5000, plain text),g:linkto the product page,g:image_link,g:availability,g:pricewith currency,g:condition, andg:brand/g:gtin/g:identifier_exists. - [ ]
g:sale_priceonly with a higherg:price; prices match the site. - [ ] Consider
g:google_product_category(a Google taxonomy id) — the template can print it from an attribute.
11. Limits
{
"template_max_bytes": 65536,
"name_pattern": "^[A-Za-z0-9._-]{1,120}\\.(xml|csv|txt|json|yml|html)$",
"name_base_max": 120,
"extensions": ["xml", "csv", "txt", "json", "yml", "html"],
"root_reserved": ["/robots.txt", "/sitemap.xml", "/yandex-token.html"],
"root_reserved_prefixes": ["/favicon.", "/icon"],
"id_zone": "/_file",
"render_steps_max": 20000000,
"value_max_bytes": 1048576,
"link_lookups_max": 1000,
"preview_timeout_seconds": 10,
"preview_calls_per_minute": 30,
"product_selections_max": 20,
"renders_per_shop_max": 2,
"preview_queue_wait_seconds": 3,
"last_good_max_age_days": 7,
"missing_path_memory_seconds": 30,
"output_max_bytes": 52428800,
"render_timeout_seconds": 30,
"products_per_range_max": 50000,
"cache_ttl_minutes": 60,
"render_failure_ttl_seconds": 60,
"catalogue_change_seconds": 15,
"cache_control_max_age_seconds": 300,
"preview_limit_default": 20,
"preview_limit_max": 50,
"preview_output_max_bytes": 204800,
"pictures_width_max": 2560,
"functions": ["attr", "categories", "category_url", "default", "file_path", "file_url", "now", "pictures", "plain", "product_url", "products", "truncate"],
"tariff_limit": "site_files_max"
}TestFeedsAreaLimitsMatchCode checks these numbers, the function list, the name grammar (name_pattern, extensions), the reserved root names and the /_file zone against the code — this block is the machine-readable source the storefront and the cabinet check their own copies against. The root also reserves /{API_INDEXNOW_KEY}.txt when that key is configured.
12. How this was verified
go test ./internal/api/sitefiles/— escaping per format and that no path around it exists (printf, variables,define,block), function semantics,productsoptions, line numbers of runtime errors, output and time limits,plain, cache TTL, singleflight, disk cache, last good build on a failed render, a failed build remembered per key; hostile templates (recursion, 2^60 nestedtemplatecalls,rangeover 10^12, three nestedrange productsover 50 000,printfwidth bombs, a value doubled in a loop) all stop within the time limit; link functions: escaping, errors with lines, memory per build, the 1000-target ceiling;productsmemory by the parsed query and the 20-selection ceiling; refusedprintfverbs; the CSV formula guard; one disk copy per file, streaming without loading the body, cancel when the last waiter leaves, last good build bound torow_versionand 7 days; the per-shop render gate.go test ./internal/api/seohttp/ -run 'TestSiteFile|TestSitemap_'— headers (incl.nosniffand CSPsandboxon 200 and 304), 404 gates (dev host, closed site, reserved and unpublished paths, unknown or non-canonical category), nested files, the last good build with its ETag when a render fails, neutral 500 without one,.htmlfiles in the sitemap;?id=— the same gates and cache as the path,Linkrel=canonical following a move.go test -tags=integration ./internal/api/catalog/ -run 'TestSiteFile|TestAdParticipation'on PostgreSQL — CRUD, case-insensitive uniqueness,know_version, tariff limit, reference templates rendered to valid YML and RSS, feed price = listing price with a promotion and a combo, excluded / hidden / unpublished / zero-price products absent, hidden attribute not leaking, gallery by name, category fallback, preview, and the raw route (200, 304, new ETag after a product edit); nesting — the same name in two categories, moving, the root-only reserve, the home category as the root, orphaned files, the list filter, a closed and a deleted category answering 404, the tariff limit under parallel creates.- Storefront side (
vizen-market,test:site-file): rewrite of root paths, fallback on 404, 502/504.