Vizen Shop

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.

WhatValue
Placecategory_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
Pathcomputed, 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
Formatchosen by the extension: it sets Content-Type and the escaping of values (§5.5)
Reservedat 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 deletedthe file stays, is listed with orphaned: true, empty path and url, and answers 404; move it (PATCH {category_id}) or delete it
Publicationis_published: false — stored, previewable, 404 on the host
Whothe shop owner or an administrator only (an editor gets SITE_FILES_FORBIDDEN whatever the key's scopes)
Contourproduction only: files have no draft copy; a dev key reads, a write answers PAT_CONTOUR_MISMATCH
Tariffsite_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 pathCost
a Yandex feed (Direct, Webmaster, Yandex Products)template yandex_yml from GET /site-files/templatesnone; Yandex Direct also accepts a Google XML feed
a Google Merchant Center feedtemplate google_xmlthe 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 feedPUT /products/{id}/ad-participation {"mode":"exclude"} (§8)catalog:write; the storefront is not affected
to keep a category out of one feedproducts "exclude_category=slug" in that template (§5.2)per file; other feeds still carry it
a feed of part of the catalogueproducts "category=slug-a,slug-b"a slug that later disappears breaks the feed (§7, §9)
other pictures in ads than on the carda gallery named e.g. Feed on the product, pictures . 5 "Feed" (§6.6)one more gallery per product
to know the template worksPOST /site-files/preview before savingsaving checks syntax only: a wrong category slug or option is found by preview or by the robot
ads.txt or a verification filethe 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 + namethe 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.yml

Verify: 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 .html file: it is then listed in /sitemap.xml (§7.4); other formats are never listed.
  • orphaned: true — the category was deleted: path and url are "" and the file answers 404 until you move or delete it.
  • row_version grows on every change (edit, publish switch, delete); send it back as know_version to edit safely.
  • url is 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).
  • size is the body length in bytes; updated_at equals created_at for a file never edited.

SiteFileListItem — the same without body.

4.2. Endpoints

Method and pathScopeWhat 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/templatessite-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:readone file with its body
POST /site-files/preview {path, body, limit}site-files:readrenders a body without saving (§4.3)
POST /site-files {category_id?, name, body, is_published?, in_sitemap?}site-files:writecreates 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:writepartial 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:writesoft delete: the place is freed at once, the URL answers 404; no body needed; answers {}
GET /products/{id}/ad-participationcatalog:read{"mode": "include"} or "exclude" (§8)
PUT /products/{id}/ad-participation {mode}catalog:writeinclude 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 every range products separately; a limit= 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: true is set; the render itself stops at 4 MB.
  • Errors do not fail the call. A syntax or runtime error answers 200 with errors: [{"line": 3, "message": "function \"picture\" not defined", "code": "SITE_FILE_TEMPLATE_INVALID"}] and output holding what was produced before the error. line is 1-based, 0 when unknown. A render over the preview time limit answers the same way with "code": "SITE_FILE_PREVIEW_TIMEOUT" (line 0). Match on code, never on the English message. 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

CodeHTTPWhen
SITE_FILE_NAME_INVALID400name outside the grammar (§1), empty, or with a slash; the text repeats the rule
SITE_FILE_CATEGORY_NOT_FOUND404category_id is not a live category of this shop (deleted, another shop's, never existed)
SITE_FILE_PATH_RESERVED400a platform name at the root (§1)
SITE_FILE_PATH_TAKEN409a live file with that name exists in the same category (or at the root) — compared case-insensitively (Feed.xml = feed.xml)
SITE_FILE_TEMPLATE_INVALID400the 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_LARGE400body over 65 536 bytes (proto validation usually answers first, with the field name)
SITE_FILE_BODY_INVALID400body is not UTF-8
SITE_FILE_VERSION_CONFLICT409know_version ≠ the current row_version
SITE_FILE_NOT_FOUND404no such file in this shop, or deleted
SITE_FILE_LIMIT_REACHED429the 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_LIMITED429more 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_FORBIDDEN403the key's user is not the owner or an administrator of the shop
PRODUCT_NOT_FOUND404ad participation of a product that does not exist or belongs to another shop
PAT_SCOPE_MISSING403the key lacks the scope in §4.2
PAT_CONTOUR_MISMATCH403a dev-contour key tried to write (create, edit, delete, set ad participation)
DEV_MODE_UNAVAILABLE400the 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`.

FieldValue
.Shop.Nameshop name
.Shop.URLscheme + host the file is served from, no trailing slash (https://myshop.vizen.shop)
.Shop.Currencyshop 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:

OptionMeaning
category=a,bonly 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,bminus these categories and their subcategories (main or extra category)
limit=Nat most N products (positive); never more than 50 000 per range
include_zero_price=truekeep 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

FieldValue
.IDproduct id (number) — stable, use it as offer id
.Nameproduct name
.Descriptionthe description as stored; markup in it comes out escaped, as text
.URLabsolute 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)
.SKUarticle / vendor code, may be ""
.Pricethe storefront listing price for an anonymous visitor, whole units of the currency (§7.3)
.OldPricethe crossed-out price, only when higher than .Price; 0 — none
.Currencycurrency code of the price
.CategoryIDthe 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
.CategoryNamename of that category
.CategoryPathParent > Child names down to that category
.Rating, .RatingCountaverage rating (decimal) and number of ratings
.Kindstandard, 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).

FieldValue
.IDcategory id
.ParentIDthe nearest ancestor that is also in this list; 0 — top level
.Name, .Slugname, slug
.URLabsolute 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

CallReturns
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 Nfirst N characters (not bytes), no ellipsis added; counted before escaping
default FALLBACK VALUEVALUE 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 ss as plain text: HTML tags removed (block tags and <br> leave a space, <script>/<style> go with their content), entities decoded (&amp; → &, &nbsp; → 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 IDthe 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 IDthe same path without the host: /catalog/feed.yml
category_url IDthe 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 IDthe 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.

ExtensionWhat happens to a value
.xml, .yml& < > " ' become entities; characters illegal in XML 1.0 (control characters) are removed
.htmlHTML entities
.csvRFC 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
.jsonthe inside of a JSON string, without the quotes: write "name": "{{ .Name }}"; numbers without quotes: "price": {{ .Price }}
.txtas is

Numbers and booleans are printed as they are in every format. Consequences:

  • literal markup is yours: write &amp; in constant XML text, or let printf build the string — {{ printf "%s?utm_source=yandex&utm_medium=cpc" .URL }} prints …?utm_source=yandex&amp;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, urlquery escape a second time — do not use them;
  • printf verbs %#v, %T and %p are 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

  1. Create a gallery named Feed with 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.
  2. 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.

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.

AnswerWhen
200a published file on a live, published site
304If-None-Match matches the ETag, or If-Modified-Since is not older than the build
404unknown 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 buildthe 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
502a 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
504the 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

HeaderValue
Content-Typeby extension: application/xml (.xml, .yml), text/csv, text/plain, application/json, text/html; all ; charset=utf-8
ETaghash of the body
Last-Modifiedwhen this copy was built
Cache-Controlpublic, max-age=300
X-Robots-Tagnoindex for every format except .html
X-Content-Type-Optionsnosniff — the browser keeps the declared type
Content-Security-Policysandbox (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_url follow) — 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=300 may 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

WrittenWhat happens
a gallery name in pictures that the product does not havethe 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 0not in the feed unless include_zero_price=true
a sold-out productstill 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 plainprinted as escaped text (&lt;p&gt;…), not stripped — wrap it in plain (the reference templates do)
truncatecuts without an ellipsis
a seller redirect rule on the file's path, set to override live pagesthe 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 onestored, never listed
category_id of the home categorystored as 0: the file is at the root
<script> in an .html fileserved, 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 --noout passes; <currencies>, <categories>, <offers> in this order inside <shop>, <collections> after <offers>.
  • [ ] Every offer has <url>, <price>, <currencyId>, one <categoryId> (products with .CategoryID 0 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:link to the product page, g:image_link, g:availability, g:price with currency, g:condition, and g:brand / g:gtin / g:identifier_exists.
  • [ ] g:sale_price only with a higher g: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, products options, 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 nested template calls, range over 10^12, three nested range products over 50 000, printf width 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; products memory by the parsed query and the 20-selection ceiling; refused printf verbs; the CSV formula guard; one disk copy per file, streaming without loading the body, cancel when the last waiter leaves, last good build bound to row_version and 7 days; the per-shop render gate.
  • go test ./internal/api/seohttp/ -run 'TestSiteFile|TestSitemap_' — headers (incl. nosniff and CSP sandbox on 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, .html files in the sitemap; ?id= — the same gates and cache as the path, Link rel=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.

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

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