---
title: Partner integrations guide
description: How a store's product data gets into Leitagu through the partner app (Shopify, a product feed or a CSV upload), how fields are mapped, and what stores can correct.
section: partners
order: 2
updated: 2026-10-17
---

# Partner integrations guide

A store gets its products into Leitagu by adding a data connection (andmeühendus) in the partner app at partner.leitagu.ee: its Shopify store, a product feed at a web address, or a CSV file it uploads. Each sync reads the store's products, describes them in Leitagu's fixed vocabulary, keeps any corrections the store has made, and logs what it could not use. This guide is for store staff setting up a connection and for the Leitagu team supporting them. The connections are built, but no real store has connected yet: every product in the shop today is example data.

## Getting access

There is no open signup. A store joins like this:

1. The Leitagu team creates the store in the admin app (Stores, Poed), or with "Make a partner" in the [CRM](../platform/crm.md#make-a-partner).
2. The team creates an invite link for a contact at the store, usually as owner. Leitagu does not send email yet, so the team passes the link on.
3. The contact opens the link. If their email already has a Leitagu account, they confirm with their password; otherwise they create an account (a password of at least 10 characters). They land on the store's overview.

An invite link is tied to one email address, works once and expires after 14 days. Only a hash of it is stored.

| Role | Estonian | Can do |
| --- | --- | --- |
| Owner | omanik | Everything a member can, plus data connections, the team (invite, change roles, remove) and store settings (name, website) |
| Member | liige | See the overview, products and connections; correct and hide products |
| Leitagu admin | — | Open every store, shown with a banner, with the same rights as an owner |

Owners invite colleagues from the Team page (Meeskond) with the same kind of link. A store always keeps at least one owner. A partner app login lasts 14 days and is separate from a shop login.

**Paused stores.** The Leitagu team can pause a store in the admin app (Stores → Pause store), for example while its data is broken. Its products disappear from the shop until the store is resumed. Nothing is deleted, syncs and corrections keep working, and the partner app shows a notice.

## Choosing a connection

| Connection | Estonian | Good for | When it syncs |
| --- | --- | --- | --- |
| Shopify | Shopify | Stores on Shopify | Every hour, every day (default) or only when asked |
| Product feed | Tootevoog | Stores that already publish a Google Merchant Center feed, or keep a CSV or TSV file at a web address | Every hour, every day (default) or only when asked |
| CSV upload | CSV-fail | A small range, or getting started without a feed | Each time a file is uploaded |

A store can have more than one connection. Only owners (and Leitagu admins) can add, change, test, sync or delete connections.

## Shopify

Leitagu reads products through the Shopify Admin GraphQL API, with read-only access.

1. In Shopify admin, go to Settings → Apps → Develop apps and create an app.
2. Give it the `read_products` scope, and optionally `read_inventory`.
3. Install the app and copy its Admin API access token (it starts with `shpat_`).
4. In the partner app, open Data connections → Shopify. Enter the store's myshopify.com address, for example `mystore.myshopify.com`, and paste the token. The bare store name or a full admin address also work; the store's own domain does not.
5. "Test the connection" asks Shopify for the shop's name. "Sync now" imports the products.

What a sync reads:

- **Which products.** Active products only, 100 per request, up to 5,000 products.
- **Product fields.** Title, description, product type, tags, the online store address (or `https://<shop>/products/<handle>`), the featured image and the total inventory.
- **Price.** The first variant's price and compare-at price. A product with several variants becomes one Leitagu product with the first variant's price.
- **Colour.** The first value of a variant option named Color, Colour, Värv or Toon.
- **Metafields** with the keys below, in any namespace.

| Metafield key | Used as |
| --- | --- |
| `width` or `laius` | Width |
| `depth`, `sügavus` or `length` | Depth |
| `height` or `kõrgus` | Height |
| `dimensions`, `mõõdud` or `size` | Size text, for example "180 x 45 x 80 cm" |
| `material` or `materjal` | Material |
| `color`, `colour` or `värv` | Colour, when there is no colour option |

The API version is set with `SHOPIFY_API_VERSION` (default `2026-07`). Each request times out after 30 seconds. If Shopify answers 401 or 403, the sync fails with "Shopify did not accept the access token, or it lacks the read_products scope."

The token is stored encrypted and never shown again. The form only says that a token is saved, and "Replace token" lets an owner enter a new one.

## Product feed

A product feed is a public web address that the store keeps up to date. The partner app asks for https; plain http also works.

- **Formats.** A Google Merchant Center XML feed (RSS 2.0 or Atom, with or without the `g:` prefix), or a CSV or TSV file. A file that starts with `<?xml`, `<rss` or `<feed` is read as XML; anything else as CSV.
- **Field names.** Leitagu recognises the Google Merchant names and their Estonian equivalents. Names are matched regardless of case, a `g:` prefix is ignored, and spaces count as underscores.

| Field | Names recognised |
| --- | --- |
| Product id | `id`, `sku`, `tootekood`, `kood` |
| Name | `title`, `name`, `nimetus`, `nimi`, `toode` |
| Description | `description`, `kirjeldus` (HTML tags removed, first 4,000 characters) |
| Product link | `link`, `url`, `product_url` |
| Image link | `image_link`, `image`, `image_url`, `pilt` |
| Price | `price`, `hind` |
| Sale price | `sale_price`, `soodushind` |
| Old price | `old_price`, `vana_hind` |
| Availability | `availability`, `saadavus`: "in stock", "in_stock", "laos", "olemas", "saadaval", "yes", "jah", "true" or "1" mean in stock |
| Quantity | `quantity`, `kogus`, `laoseis` |
| Category | `product_type`, `category`, `kategooria`, `google_product_category` |
| Colour | `color`, `colour`, `värv`, `colour_hex` |
| Material | `material`, `materjal` |
| Width | `width`, `width_cm`, `laius`, `product_width` |
| Depth | `depth`, `depth_cm`, `sügavus` |
| Length | `length`, `product_length` |
| Height | `height`, `height_cm`, `kõrgus`, `product_height` |
| Size text | `size`, `mõõdud`, `dimensions` |
| Delivery time (days) | `delivery_days`, `tarneaeg` |
| Cities with a display piece | `display_cities`, `näidis` |
| Stock per city | `stock_tallinn`, `stock_tartu`, `stock_parnu`, `stock_narva` (or `laos_tallinn` and so on) |

- **Sale prices.** When a sale price and a regular price are both given, the sale price becomes the price and the regular price the old price.
- **Length and width.** Feeds use these inconsistently. When a length is given and no depth, the longer of length and width becomes the width and the other the depth.
- **Testing.** "Test the connection" downloads and reads the feed and says how many products it found, without saving anything.

## CSV upload

For a store without a feed, the partner app accepts a CSV file uploaded by hand.

1. Open Data connections → CSV file (CSV-fail) and create the connection.
2. "Download the template" (Laadi alla näidisfail) gives `leitagu-tooted.csv`: the header row and one example row.
3. Fill in one row per product and upload it with "Upload and import" (Laadi üles ja impordi).

The template's columns, exactly:

```
id,name,category,price,old_price,url,image_url,width_cm,depth_cm,height_cm,colour,material,description,stock_tallinn,stock_tartu,stock_parnu,stock_narva,display_cities,delivery_days
```

Its example row:

```
PK-180-OAK,"Puhvetkapp Nordli 180, tamm",puhvetkapp,449,499,https://pood.example.ee/puhvetkapp-nordli,,180,45,80,tamm,täispuit,"Õlitatud tammest puhvetkapp, 4 ust",2,0,0,0,Tallinn,5
```

| Column | What to put in it |
| --- | --- |
| `id` | Your own product code. Keep it the same between uploads: it is how Leitagu recognises the product. |
| `name` | The product name. |
| `category` | The type of furniture, in Estonian or English, for example "puhvetkapp" or "sideboard". |
| `price` | The current price in euros. "449", "449.00" and "1 299,00 €" all work. |
| `old_price` | The price before a sale. Used only when it is higher than `price`. |
| `url` | The product's page in your webshop. |
| `image_url` | A link to a product image. |
| `width_cm`, `depth_cm`, `height_cm` | Size in centimetres. A value written with "mm" or "m" is converted. |
| `colour` | A colour or wood name ("tamm", "valge", "light oak") or a hex code ("#C49A62"). |
| `material` | For example "täispuit", "spoon", "laminaat" or "värvitud MDF". |
| `description` | Free text. Leitagu also reads material, wood, finish, style, legs, fronts and sizes from it. |
| `stock_tallinn`, `stock_tartu`, `stock_parnu`, `stock_narva` | How many are in stock in your store in that city. 0 or empty means none. |
| `display_cities` | Cities where the piece is on display, separated by commas, semicolons or "\|". Write the names as Tallinn, Tartu, Pärnu and Narva. |
| `delivery_days` | Home delivery time in days. |

- **Format.** The first row is the header. The separator can be a comma, a semicolon or a tab; it is detected from the header row. Quoted cells may contain separators, line breaks and doubled quotes.
- **Other names.** Every field name from the [product feed](#product-feed) table also works in an uploaded file.
- **Size.** Up to 10 MB.
- **Upload the whole range every time.** An upload is a full import: products from this connection that are missing from the file are hidden from shoppers.

## Sync schedule

- **Shopify and feeds** sync every hour (Iga tund), every day (Iga päev, the default) or only by hand (Ainult käsitsi). "Sync now" (Sünkrooni kohe) runs one at any time.
- **CSV connections** sync only when a file is uploaded.
- **How the schedule runs.** A scheduler calls the partner app every hour. A connection is due when its last sync is more than 55 minutes old (hourly) or more than 23 hours old (daily), so "every day" means roughly every 23 to 24 hours.
- **Switching off.** A connection that is switched off (Väljas) does not sync on schedule.
- **One at a time.** A connection runs one sync at a time. A sync still marked as running after 30 minutes is treated as stopped.
- **Deleting a connection** keeps the products it imported, but they are no longer updated.

## What a sync does

1. **Read the source.** If the source can't be reached or has no products Leitagu can read, the sync fails and nothing changes.
2. **Map each item** to a Leitagu product, as described in [How fields are mapped](#how-fields-are-mapped). Rows without a product id are ignored. Items without a name, without a price, or in a category Leitagu does not show are skipped and listed as problems.
3. **Save.** New products are created and known ones updated. A product's id in Leitagu is `<store slug>--<your product id>`: lower-cased, with anything other than letters, digits, dots, underscores and hyphens replaced by "-", and at most 80 characters long. Every product the sync saves is made visible again, unless the store hid it in the partner app.
4. **Hide what is gone.** Products from this connection that were not in the source this time are hidden, not deleted, so their statistics and the rooms they were kept in stay. If they come back in a later sync, they are shown again.
5. **Log the run.**

Each run in the sync history (Sünkroonimiste ajalugu) shows a status, counts and problems. The last 20 runs are listed.

| Status | Estonian | Meaning |
| --- | --- | --- |
| Succeeded | Õnnestus | Every product was used. |
| Partly | Osaliselt | Some products were skipped or have no dimensions. |
| Failed | Ebaõnnestus | The source could not be read. The error is shown and nothing changed. |
| Running | Käib | The sync is in progress. |

| Count | Estonian | Meaning |
| --- | --- | --- |
| read | loetud | Products found in the source |
| new | uued | Products created |
| updated | uuendatud | Products that already existed |
| hidden (gone from source) | peidetud (allikast kadunud) | Products hidden because they were missing from the source |
| skipped | vahele jäetud | Products not imported (no name, no price or unrecognised category) |
| without dimensions | mõõtudeta | Products imported but hidden from shoppers until they have a size |

Problems are listed per product with your product id and name: no price, no name, category not recognised, or no dimensions. A colour taken from the product name is listed as well, although the product is shown. A run keeps up to 300 problems; the page shows the first 100 and a count per kind.

Every sync overwrites the synced fields with what the source says now: name, links, price and old price, category, size, colour, material and the other attributes, stock, display cities and delivery time. Corrections made in the partner app are kept separately and are not touched. Changes the Leitagu team makes to a synced product in the admin app are kept as corrections too: size, colour, material and the other attributes, and hiding it. The admin app can't change a synced product's name, price, stock or links, or delete it, because the next sync would bring them back.

## How fields are mapped

The rules live in `packages/core/src/server/integrations/normalize.ts`. They are plain text rules in Estonian and English, with no AI involved.

### Name and price

- **Name:** required; the first 160 characters are kept.
- **Price:** required and above zero. "129.00 EUR", "1 299,00 €" and plain numbers are all read.
- **Old price:** kept only when it is higher than the price. It shows as a sale.

### Category

Leitagu shows eight categories. The category is found from the source's category text, the product name and the tags together:

1. The connection's own category mapping is checked first (see [Settings that fill gaps](#settings-that-fill-gaps)).
2. Then the built-in rules, most specific first:

| Leitagu category | Words recognised (anywhere in the text) |
| --- | --- |
| TV unit | tv-alus, tv kapp, tv laud, TV unit, TV stand, TV bench, TV cabinet, meediakapp, media unit, media console |
| Coffee table | diivanilaud, kohvilaud, sohvalaud, coffee table, sofa table |
| Side table | abilaud, lisalaud, öökapp, nightstand, bedside, side table, lamp table, end table |
| Dining table | söögilaud, köögilaud, dining table, kitchen table |
| Chest of drawers | kummut, sahtlikapp, chest of drawers, dresser |
| Sideboard | puhvet, sideboard, credenza, buffet |
| Shelf or bookcase | riiul, raamaturiiul, bookcase, bookshelf, shelving, shelf |
| Cabinet | vitriin, kapp, cabinet, highboard, display case, cupboard |

Anything else, such as sofas, beds, chairs and desks, is skipped with "Category not recognised", because Leitagu does not show it yet. Words are matched anywhere in the text. Wardrobes (riidekapp, garderoob), kitchen and bathroom units (köögikapp, vannitoakapp, peeglikapp) and fridges (külmkapp) are skipped too, even though their names contain "kapp" or "riiul"; a category mapping in the connection's settings can still bring them in. A product that lands in the wrong category can be corrected or hidden in the partner app.

### Colour, tone and undertone

- **From the colour field** (or the Shopify colour option or metafield). A hex code is used as it is. Otherwise Leitagu looks for a known colour or wood name and uses its colour value. The longest name found wins, so "hele tamm" (light oak) beats "tamm" (oak).
- **Names known**, in Estonian and English: valgendatud tamm (white oak), hele tamm (light oak), sonoma tamm, artisan tamm, tume tamm (dark oak), tamm (oak), pähkel (walnut), kask (birch), mänd (pine), pöök (beech), tiik (teak), akaatsia (acacia), saar (ash), valge (white), must (black), hall (grey), helehall (light grey), tumehall (dark grey), antratsiit (anthracite), beež (beige), kreem (cream), liivakarva (sand), pruun (brown), roheline (green), sinine (blue), punane (red), kollane (yellow), roosa (pink) and terrakota (terracotta).
- **The store's colour name** is kept as written and shown on the product's details.
- **No usable colour.** If the source gives no colour that Leitagu recognises, a colour or wood name after the last comma in the product name is used, as in "Puhvetkapp Nordli, tamm". The product is then flagged "Colour taken from the product name".
- **Tone and undertone** are worked out from the colour value.

### Material and other attributes

These are read from the name, material, colour, description and tags. For each attribute the first matching rule wins, in the order listed.

| Attribute | Value | Words recognised |
| --- | --- | --- |
| Material | Solid wood | täispuit, massiiv, solid wood, solid oak (pine, birch, ash, walnut) |
| | Veneer | spoon, veneer |
| | Painted MDF | värvitud, painted, lakitud MDF |
| | Foil or laminate | laminaat, lamineeritud, kile, melamiin, laminate, melamine, foil, puitlaastplaat, chipboard, particleboard |
| | Metal or glass | metall, klaas, metal, glass, steel, teras |
| Wood | Oak, walnut, birch, pine, beech, teak, acacia, ash | tamm, pähkel, kask, mänd, pöök, tiik, akaatsia, saar, and the English names |
| Finish | Matte, gloss, oiled, lacquered, waxed, satin | matt, läik, õlitatud, lakitud, vahatatud, poolmatt, and the English names |
| Style | Scandinavian | skandinaavia, scandinavian, nordic, põhjamaa |
| | Industrial | tööstuslik, industrial, loft |
| | Rustic | maalähedane, rustikaal, rustic, farmhouse |
| | Mid-century | mid-century, retro, 60ndate |
| | Japandi, boho | japandi, boho |
| | Classic | klassikaline, classic |
| | Modern | modernne, modern, minimalist |
| Legs | Hairpin | juuksenõel, hairpin |
| | Wall-hung | seinale, seinakinnitusega, wall-mounted, wall-hung, floating |
| | Castors | ratastel, rattad, castor, wheels |
| | Plinth | sokkel, plinth |
| | Tapered | koonilised, tapered |
| Fronts | Glass doors | klaasuks, glass door |
| | Sliding doors | lükandus, sliding |
| | Cane | punutud, rotang, cane, rattan, viini punutis |
| | Ribbed | riffel, ribbed, fluted |
| | Open shelving | avatud riiul, open shelving, open storage |
| Flat-pack | Yes | isekokkupandav, ise kokku, flat-pack, assembly required |

A sync never sets handles, handle colour or child safety. Handles and handle colour can be added as corrections; child safety can only be set in the admin app.

### Dimensions

Leitagu uses the width, depth and height fields when the source has them. Otherwise it reads them from the size text, the name or the description:

- **Three numbers:** "180x40x50 cm", "180 × 40 × 50" or "1800*400*500 mm", read as width × depth × height. Units can be cm, mm or m. Three numbers without a unit that are all over 300 are read as millimetres.
- **Labelled values:** "L 180 S 40 K 50", "W180 D40 H50" or "laius: 180 cm, kõrgus 50 cm". L, W, laius, width, pikkus and length mean width; S, D, sügavus and depth mean depth; K, H, kõrgus and height mean height.
- **Limits:** a width over 500 cm, a depth over 400 cm or a height over 300 cm is treated as missing.

### Availability, links and images

- **Stock per city** is used when the source has it, for Tallinn, Tartu, Pärnu and Narva only.
- **Otherwise**, if the connection has a stock city set, a product in stock (a quantity above zero, or availability "in stock") counts as in stock in that city.
- **Display cities** and **delivery time** come from the source, or else from the connection's settings. City names are matched regardless of case and accents ("tallinn" and "PARNU" work); cities Leitagu doesn't cover yet are ignored.
- **Links and images** are kept only if they start with `http://` or `https://`. A product with an image link shows that image on its card, loaded from the store's address; without one, the card shows a 3D rendering.

## Required and optional fields

| Field | Needed? | Without it |
| --- | --- | --- |
| Product id | Required | The row is ignored. |
| Name | Required | Skipped ("No name"). |
| Price | Required | Skipped ("No price"). |
| A category Leitagu shows | Required | Skipped ("Category not recognised"). |
| Width, depth and height | Required to be shown | Imported but hidden from shoppers ("No dimensions"). |
| Colour | Strongly recommended | Matching can't compare colour or tone. Flagged "No colour". |
| Material | Strongly recommended | Matching can't compare material, and wood-look foil can't be flagged. Flagged "No material". |
| Stock, display cities, delivery time | Strongly recommended | Without stock, the product never appears for "take it home today"; without stock or display cities, not for "see it in a store first"; without a delivery time, not for "delivered within 2 weeks". With none of the three it is flagged "No availability". |
| Product link | Recommended | "Open in store" goes to the store's homepage. Flagged "No product link". |
| Image link | Optional | The card shows a 3D rendering. |
| Old price | Optional | No sale badge, and the product never appears for "on sale only". |

The partner app's Products page (Tooted) shows each product's source, its 30-day numbers and its data problems, with filters for hidden products and products with missing data.

## Why products without dimensions are hidden

Leitagu's promise is that a product fits. It checks size limits, works out whether a piece fits the free wall or floor space in the shopper's room, and places it in the 3D room. None of that can be done honestly without a width, depth and height. So search and suggestions skip any product that lacks one of them.

The product is still saved. It shows in the partner app as "Hidden (no dimensions)" (Peidus, mõõdud puudu), and it becomes visible to shoppers as soon as its size arrives, either from the source or as a correction.

## Corrections that survive syncs

On a product's page in the partner app, "Correct the data" (Paranda andmeid) lets owners and members fix what the source got wrong or left out:

- **Fields:** category, width, depth, height, colour, colour name, material, wood, tone, undertone, finish, style, handles, handle colour, legs, fronts, delivery time and flat-pack. Values come from Leitagu's fixed vocabulary.
- **Source and correction side by side.** Each field shows what the source says. "Remove correction" (Eemalda parandus) goes back to the source value.
- **Colour brings tone along.** A new colour also sets the tone and undertone worked out from it, unless those are corrected too.
- **Hiding.** "Hide from shoppers" (Peida ostjate eest) hides the product until someone chooses "Show to shoppers". Hiding is stored as a correction, so a sync does not undo it.
- **Not correctable:** name, price, links, image and stock. These always come from the source.

Corrections are stored separately from the synced data (`products.overrides`) and applied on top whenever the product is read, so the next sync never overwrites them. The page also shows the product's card as shoppers see it, and its numbers for the last 30 days. Every correction is written to the audit log.

## Settings that fill gaps

Each connection has settings for data its source leaves out. They are under "Filling in missing data" (Puuduvate andmete täitmine) and apply from the next sync.

| Setting | Estonian | What it does |
| --- | --- | --- |
| Stock city | Laos olev kaup on kohe saadaval linnas | When the source has no stock per city, products in stock count as "take home today" in this city. |
| Default delivery time | Tarneaeg päevades, kui allikas seda ei ütle | Delivery time in days (0–365) for products whose source gives none. |
| Showroom cities | Näidised väljas linnades | Cities where the products are on display, for products whose source doesn't say. |
| Category mapping | Kategooriate vastavus | Your category name (or part of it) → a Leitagu category, for example "Hoiukapid" → sideboard. Checked before the built-in rules, regardless of case, anywhere in the category, name and tags. |

## How secrets are stored

- **Encryption.** A Shopify access token is encrypted with AES-256-GCM, with a random 12-byte IV and an authentication tag, before it is saved. The key is derived from `INTEGRATION_SECRET_KEY`.
- **Never sent to browsers.** The form only shows that a token is saved, and replacing it writes a new encrypted value.
- **Without a key.** In production the partner app refuses to save or read secrets without `INTEGRATION_SECRET_KEY`. Development uses a fixed key and logs a warning.

## Feed fetch safety

Feed addresses come from store users, so Leitagu must never be tricked into requesting addresses inside its own network:

- **Protocols:** only `http` and `https`. Addresses with a username or password are refused.
- **Public addresses only.** The host name is resolved, and every address must be public. Refused: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24 and 224.0.0.0 upwards; in IPv6 the unspecified and loopback addresses, unique local (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32), Teredo (2001::/32) and discard (100::/64) ranges, and anything that can't be read. IPv4 addresses inside IPv6 (IPv4-mapped, NAT64 64:ff9b::/96 and 6to4 2002::/16) are checked as IPv4.
- **Redirects** are followed one at a time, at most 3, and each new address is checked again.
- **Limits:** 60 seconds per request and 50 MB per file. A declared size over 50 MB is refused before downloading, and the download stops as soon as it passes 50 MB, also when the server doesn't declare a size.
- **Known limit.** The address check and the request resolve the host name separately, so a DNS-rebinding attack is possible in theory. Pinning the resolved address would close it.
- **Shopify** requests go only to `https://<name>.myshopify.com`, with a 30-second limit.

## Related pages

- [Analytics definitions](analytics.md)
- [Why join Leitagu](why-join.md)
- [Platform architecture](../platform/architecture.md#product-data-flow)
- [How matching works](../product/how-it-works.md#how-matching-works)
