---
name: atlas-api
description: Opprett og les produkter, merker, produktgrupper og egenskaper i Atlas (PIM-et til Flow Retail) via tenant-API-et. Bruk denne når du skal legge inn produkter i Atlas, importere en katalog, opprette registeroppføringer, eller slå opp hva som allerede finnes. Dekker beløpsformat, identitetsregler og hvordan varianter opprettes.
---

# Atlas tenant-API

Atlas er et PIM/DAM for Flow Retail: produkter blir laget og beriket her, og synkes derfra ut til
salgskanaler (Flow Retail, Shopware). Dette API-et skriver INN i Atlas. Det pusher ingenting ut.

**Alt under `https://<din-atlas>/api/v1`. Autentisering:**

```
Authorization: Bearer atlas_live_…
```

Nøkler lages av en administrator under **Innstillinger → API-nøkler**. En nøkkel handler _som_ et
medlem og arver rollen og rettighetene til det medlemmet — den kan aldri mer enn personen.

---

## 1. Beløp: alltid streng, aldri tall

Dette er det ene stedet en feil koster mest, fordi ingenting fanger den etterpå: en faktor-100-bom
gjør 199 kr til 1,99 kr, og produktet går ut i butikken til den prisen.

```jsonc
"sale_price_incl_vat": "199.90"   // ✅ streng, punktum eller komma
"sale_price_incl_vat": "199,90"   // ✅ begge går
"sale_price_incl_vat": 199.90     // ❌ 400 — et JSON-tall avvises, ikke tolkes
"sale_price_incl_vat": 19990      // ❌ 400 — øre er INTERNT, ikke kontrakten
"sale_price_incl_vat": "1.299,00" // ❌ 400 — tusenskilletegn er tvetydig
"sale_price_incl_vat": "199.905"  // ❌ 400 — maks to desimaler
```

**Reglene:**

- Streng, maks **to desimaler**, ingen tusenskilletegn, ingen mellomrom, ingen valuta.
- **Beløpet er INKLUSIVE mva.** Feltnavnet sier det: `sale_price_incl_vat`. Sender du netto, blir
  produktet 25 % for billig, og ingenting vil klage.
- `vat` er **prosent** (`25`), ikke en faktor (`0.25`).
- Svaret gir deg **begge**: `"sale_price_incl_vat": "199.90"` og `"sale_price_minor": 19990`.
  Sjekk den andre — den er den billigste kontrollen på at du traff riktig størrelsesorden.

Andre enheter:

- **Dato** er `"YYYY-MM-DD"`, aldri et tidsstempel.
- **`updated_since`** ved lesing er derimot ISO 8601 med tid: `2026-09-01T00:00:00Z`.

---

## 2. Slå opp FØR du skriver. API-et oppretter aldri et merke for deg

Et ukjent merke, en ukjent produktgruppe eller en ukjent egenskap gir **422**, med forslag:

```json
{
  "ok": false,
  "errors": [
    {
      "path": "products[0].brand",
      "code": "unknown_brand",
      "message": "Merket «Harío» finnes ikke i registeret.",
      "hint": "Mente du «Hario»? Opprett merket først med POST /api/v1/brands, og send så produktet på nytt."
    }
  ]
}
```

Det finnes **ingen bryter** som slår dette av. Grunnen er at auto-opprettelse gjør enhver skrivefeil
til en permanent registeroppføring — «Harío» blir et ekte merke ingen ba om, og det oppdages måneder
senere.

**Riktig rekkefølge:**

```
GET  /api/v1/brands            → finnes merket?
POST /api/v1/brands            → hvis integrasjonen din skal opprette det
POST /api/v1/products          → med et navn som finnes
```

Hvert register-POST tar en **liste** og er «finn-eller-opprett», så steg 2 er ett kall uansett hvor
mange nye navn du har — og det er trygt å gjenta:

```json
POST /api/v1/brands
{ "items": [{ "name": "Hario" }, { "name": "Bodum" }] }

→ { "ok": true, "data": { "brands": [
      { "name": "Hario", "id": "…", "created": true },
      { "name": "Bodum", "id": "…", "created": false }   ← fantes fra før
   ] } }
```

---

## 3. Tørrkjør først

```json
{ "dry_run": true, "products": [ … ] }
```

Alt kjøres — validering, oppslag, kollisjonssjekk — og rulles så tilbake. Svaret er det fulle
resultatet: hva som _ville_ blitt opprettet, hvilke rader som ville blitt hoppet over, hvilke
GTIN-er som ville blitt tømt. Ingenting skrives, og ingen varekoder brennes.

**En tørrkjøring gir ingen `id`** (`"id": null`), fordi raden ble rullet tilbake — det finnes ikke
noe produkt å peke på. Varekoden vises for å svare på «hva ville jeg fått», men den er ikke
reservert: `"sku_reserved": false` sier det, og et ekte kall etterpå kan gi et annet tall.

**Gjør dette på alt større enn noen få rader.** Det er den ene tingen som skiller en import du kan
stå for fra en du må rydde opp i.

---

## 4. Tre unikhetsregler, tre ULIKE utfall

Dette er ikke tre varianter av «duplikat». Utfallene er forskjellige med vilje:

| Felt                         | Ved duplikat                                            | Hvorfor                                                             |
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------- |
| **slug** (utledes av navnet) | Raden **BLOKKERES**                                     | Ingenting kan fikse den etterpå                                     |
| **`sku`** (varekode)         | Raden **HOPPES OVER** og rapporteres                    | Varekoden ER produktets identitet                                   |
| **`gtin`**                   | Raden **OPPRETTES med feltet tomt**, og det rapporteres | En GTIN er ikke identitet — en ekte katalog er full av delte EAN-er |

Sjekk `warnings` i svaret. En hoppet rad og en tømt GTIN er aldri stille, men de er heller ikke
feil — batchen går gjennom.

---

## 5. Sett alltid `product_group`

Et produkt uten produktgruppe **kan ikke pushes til Flow Retail i det hele tatt**. Operatøren møter
den avvisningen dager senere, fra en innkjøpsordre som ikke lar seg opprette — langt fra importen
som lot feltet stå tomt.

Gruppen sendes som **hele stien fra roten**, fordi to blader kan hete det samme under ulike foreldre:

```json
"product_group": ["Kaffe", "Utstyr"]
```

`GET /api/v1/product-groups` gir deg treet med `path` på hver node.

**Kategorier er noe ANNET** — webshopens visningstre, ikke produktgruppen. Et produkt kan stå i
FLERE kategorier samtidig, og settes kun via `PATCH` (§8), ikke ved opprettelse. Samme sti-form,
eget endepunkt: `GET`/`POST /api/v1/categories`. Kategorier har **ingen bladregel** — i motsetning
til produktgrupper kan en hvilken som helst node i treet brukes, ikke bare de ytterste.

---

## 6. Varianter er AKSE-drevne

En variant skilles fra søsknene sine av **variantakser** — egenskaper merket
`is_variant_axis: true`. Et produkt med `variants` blir automatisk en «configurable».

Hele forløpet:

```jsonc
// 1) egenskapen må finnes OG være en variantakse
POST /api/v1/attributes
{ "items": [{ "name": "Farge", "type": "dropdown", "is_variant_axis": true }] }

// 2) produktet med variantene sine
POST /api/v1/products
{ "products": [{
    "name": "T-skjorte",
    "sku": "TS-1",
    "product_group": ["Klær", "Overdeler"],
    "sale_price_incl_vat": "299.00",
    "variants": [
      { "sku": "TS-ROD-M", "axis": { "Farge": "Rød", "Størrelse": "M" } },
      { "sku": "TS-BLA-M", "axis": { "Farge": "Blå", "Størrelse": "M" },
        "sale_price_incl_vat": "319.00" }
    ]
}] }
```

- En akseVERDI som ikke finnes fra før (`"Rød"`) opprettes automatisk på egenskapen. Det er
  bevisst — verdiene er delt vokabular, ikke identitet.
- En variant **uten** egen `sale_price_incl_vat` arver forelderens. Utelat feltet framfor å gjenta
  tallet.
- **Alle produkter i ETT kall må bruke samme aksesett.** Sender du en T-skjorte (Farge + Størrelse)
  og et krus (bare Farge) sammen, får du `mixed_variant_axes`. Del dem i hvert sitt kall.
- En egenskap som ikke er variantakse hører under `attributes` på produktet, ikke under `axis`.
- **`gtin` på SELVE produktet ignoreres når det har `variants`** — en GTIN identifiserer en
  salgbar enhet, og det er varianten, ikke familien. Sett den per variant i stedet
  (`variants[].gtin`). Du får en advarsel (`gtin_ignored_on_configurable`), aldri en stille
  forkasting.
- **`description`, `long_description`, `image_urls`, `attributes` og `sources` gjelder like fullt et
  produkt MED `variants`** — de settes på forelderen (familiens egen tekst/bilder/egenskaper/kilder),
  ikke på hver variant.

**`GET`/`PATCH` gir aksene tilbake, samme `{navn: verdi}`-form som du sender inn.** Hovedproduktet
får `"variant_axes": ["Farge", "Størrelse"]` (familiens deklarerte akser, i rekkefølge); hver
variant får sin egen `"axis": {"Farge": "Rød", "Størrelse": "M"}`. Sammenlign de to for å oppdage
et **ufullstendig sett** — en variant hvis `axis` mangler et navn fra søskens `variant_axes` er
nøyaktig formen en kanal-push (Shopware/Flow Retail) avviser HELE familien for, og den var usynlig
via API-et før dette feltet fantes. Rett det med `PATCH` (§8) sitt `axis`-felt — en SAMMENSLÅING,
ikke en erstatning, så du trenger bare sende den manglende aksen.

### `color_swatch` — en FARGE på egenskapsverdien, ikke et bilde

`POST /api/v1/attributes/{id}/values` tar et valgfritt `color_swatch` per verdi:

```json
{ "items": [{ "label": "Mørk brun", "color_swatch": "#4a3728" }] }
```

Verdien rendres som en liten fargeprikk ved siden av etiketten. **Bare hex** — `#rgb`, `#rrggbb`
eller variantene med alfa. Alt annet gir **400 `invalid_color_swatch`**; en URL eller et filnavn
ville ellers blitt lagret og vist seg som en usynlig prikk.

Tre tilstander, og skillet er med vilje:

| Du sender                   | Hva som skjer                          |
| --------------------------- | -------------------------------------- |
| ingenting                   | en eksisterende farge står urørt       |
| `"color_swatch": "#4a3728"` | settes (normalisert til små bokstaver) |
| `"color_swatch": null`      | tømmes                                 |

Endepunktet er finn-eller-opprett, og **fargen skrives også når verdien fantes fra før** — du kan
altså fylle inn farger på et eksisterende sett verdier ved å sende dem på nytt. Svaret og
`GET /api/v1/attributes/{id}/values` gir begge `color_swatch` tilbake, så du kan kontrollere at det
faktisk ble lagret.

**Vil du ha et STOFFPRØVE-BILDE, er dette feil felt.** Egenskapsverdien er delt vokabular på tvers
av alle produkter — «Rød» ser forskjellig ut på en ullgenser og en lakkert stol. Et bilde hører til
på VARIANTEN: `POST /api/v1/products/{variant-id}/images` med `{"role": "swatch"}` (§9).

---

## 7. Feilene forteller deg hva du skal gjøre

Hver feil har en `path` inn i din egen JSON, en stabil `code`, en norsk `message` og et **`hint` som
er det korrigerte kallet** — ikke en forklaring.

```json
{
  "ok": false,
  "errors": [
    {
      "path": "products[2].price",
      "code": "unknown_field",
      "message": "«price» er ikke et felt på et produkt.",
      "hint": "Mente du \"sale_price_incl_vat\"? Gyldige felt: name, sku, gtin, brand, product_group, sale_price_incl_vat, vat, description, long_description, image_urls, attributes, sources, variants."
    }
  ]
}
```

**Et ukjent feltnavn er en FEIL, ikke noe som ignoreres.** Skriver du `price` i stedet for
`sale_price_incl_vat`, får du 400 — ikke et produkt uten pris.

Statuskodene: `400` formen er gal · `401` nøkkelen · `402` bildekvoten er brukt opp (kun
`.../image-generate`, §12) · `403` rettighet eller scope · `404` finnes ikke · `409` versjonen er
utdatert (kun `PATCH`) · `422` velformet, men peker på noe som ikke finnes · `502`
AI-leverandøren feilet (kun `.../image-generate`) · `503` bildelagring eller den valgte
bildemodellen er ikke satt opp (`.../images` og `.../image-generate`) · `500` vår feil, den er
logget.

---

## 8. Oppdatere et produkt eller en variant (`PATCH`)

```
PATCH /api/v1/products/{id}
```

`{id}` er ENTEN en forelder ELLER én bestemt variant — begge er rader i samme register, og
`GET /api/v1/products/{id}` gir deg id-en uansett hvilken du henter. `PATCH` retter feltene på DEN
raden; den legger aldri en variant TIL et produkt (det er fortsatt `POST /products`).

```jsonc
{
  "version": 0, // PÅKREVD — fra samme GET, ellers 409
  "name": "Myren 3101 skallstol, farget finér",
  "sku": "3101-EIK", // varekoden; unik per tenant på aktive produkter
  "description": "...", // → shortDescription (kort metabeskrivelse), ORDRETT, ingen AI
  "long_description": "...", // selve produktteksten, ORDRETT, ingen AI
  "usps": "...", // fritekst, ORDRETT
  "specifications": "...",
  "sale_price_incl_vat": "4299.00",
  "rrp_incl_vat": "4799.00", // veil. pris, samme beløpsformat
  "is_free": false, // bekrefter at 0 kr er en EKTE pris — se under
  "vat": 25,
  "gtin": "7048740228110",
  "release_date": "2026-09-17", // «nyhetsdato», ren kalenderdato
  "public_url": "https://butikk.no/p/3101", // QR-målet — Shopware MASTRER feltet
  "brand": "Fritz Hansen", // slå opp, samme regel som POST
  "product_group": ["Møbler", "Stoler"],
  "categories": [["Interiør", "Dekorasjon"]], // ERSTATTER hele settet, mange per produkt
  "attributes": { "Materiale": "Eik", "Bruksområde": "Innendørs, Kontor" }, // ikke-akse
  "sources": [{ "url": "https://produsent.no/produkt/123", "label": "Produsent" }], // ERSTATTER hele settet
}
```

**Seks av feltene over kom til 2026-09-17** (`sku`, `rrp_incl_vat`, `is_free`, `release_date`,
`public_url`, `attributes`). De var skrivbare fra Atlas' eget produktpanel hele tiden, men ikke
herfra: de to skrivestiene hadde drevet fra hverandre, og ingen test så det. De går nå gjennom den
SAMME kjernen som panelet, så forskjellen kan ikke oppstå igjen.

**`attributes`** er ikke-akse-egenskaper, samme `{navn: verdi}`-form som på `POST /products`.
Verdien tolkes etter egenskapens TYPE: flervalg tar `"Rød, Blå"`, tall tar `"72"`, ja/nei tar
`"Ja"`/`"Nei"`, dato tar `"ÅÅÅÅ-MM-DD"`. `null` eller `""` tømmer egenskapen. En egenskap merket som
**variantakse avvises her** — den settes med `axis`, fordi akseskriving er en full erstatning av
variantens verdisett og de to må ikke kunne blandes.

**`is_free`** er ikke pynt: den er den eksplisitte bekreftelsen på at 0 kr er en ekte pris. Uten den
UTELATES en 0-pris fra kanal-payloaden i stedet for å sendes — en placeholder-null skal ikke kunne
gå live som en ekte pris.

**`public_url` mastres av Shopware** når produktet publiseres dit: verdien du setter her overskrives
ved neste push. Feltet finnes fordi plakat-QR-koder trenger et mål også for produkter som ikke ligger
i en Shopware-butikk.

**`version` er påkrevd, alltid.** Uten den optimistiske sjekken ville et samtidig annet kall
(et annet skript, en operatør i Atlas) blitt overskrevet stille. Feil versjon → `409
version_conflict` — les produktet på nytt og prøv igjen med den nye versjonen.

**Bare feltene du sender, endres.** Utelatt felt = urørt. `null` der feltet er nullbart (`sku`,
`gtin`, `description`, `long_description`, `usps`, `specifications`, `rrp_incl_vat`,
`release_date`, `public_url`) = tømt.

**`name` regenererer slug-en** (samme kollisjonshåndtering som `POST`). **`categories` og `sources`
erstatter hele settet hver** — send hele lista du vil ha, ikke en diff; utelat feltet for å la det
stå urørt, eller send `[]` for å tømme det helt.
Slå opp kategori-stiene først med `GET /api/v1/categories` (§5) — en ukjent sti gir samme
422-med-forslag som et ukjent merke eller en ukjent produktgruppe.

**`sources`** er eksterne URL-er knyttet til produktet — samme felt som Admin-UI-ets kildeliste på
produktsiden (typisk en produsent- eller leverandørs egen produktside). Hver rad er `{"url": "...",
"label": "..."}`; `label` er valgfri fritekst (maks 120 tegn), `url` må starte med `http://` eller
`https://` (maks 2048 tegn). Maks 20 rader. API-et laster ALDRI ned eller leser disse sidene selv —
det er bare lenker Atlas husker; innholds-/bildehenting fra en kilde er en Admin-UI-funksjon, ikke
noe dette endepunktet trigger. **Erstatningen er på URL-SETTET, ikke på raden**: sender du en URL
som allerede lå der, beholder den sin interne hente-status urørt — praktisk for en integrasjon som
PATCH-er den samme lista hver natt; kun `label` og rekkefølgen oppdateres på en slik rad.

**`axis`** setter en variants egen variantakseverdi — kun gyldig når `{id}` ER en variant (ikke
hovedproduktet), og kun for en egenskap som allerede er en av DENNE familiens deklarerte
variantakser. `{"axis": {"Farge": "Rød"}}` — samme `{Navn: verdi}`-form som `variants[].axis` på
`POST` (§7), og samme oppslag: en ukjent verdi OPPRETTES (som på `POST`), en ukjent eller ikke-akse
egenskap gir 422 med forslag. **I MOTSETNING til `categories`/`sources` er dette en SAMMENSLÅING,
ikke en full erstatning** — kun de aksene du nevner endres, resten av variantens akseverdier står
urørt. Bruk dette til å ETTERFYLLE en akse en tidligere `POST` glemte på noen varianter i familien:
en konfigurerbar der bare NOEN varianter fikk en verdi på en akse, får hele familien avvist av
`POST /api/v1/products/{id}/... push`-relaterte kanalsynker (Shopware krever verdi på ALLE varianter
for hver deklarert akse) — `PATCH` med `axis` er veien til å rette opp de resterende variantene uten
å røre de som allerede er riktige.

**`variant_axes`** setter DERIMOT hvilke akser familien HAR — kun gyldig når `{id}` ER
hovedproduktet (`kind: "configurable"`), aldri en variant. `{"variant_axes": ["Farge",
"Dimensjon"]}` er en full ERSTATNING av aksesettet (i motsetning til `axis` over) —
rekkefølgen i lista er visningsrekkefølgen. Tom liste (`[]`) er lovlig og fjerner alle akser.
Bruk dette til å FJERNE en akse som ikke lenger skal skille familiens varianter — typisk sammen
med `DELETE` (under) på variantene som bare fantes for den aksens skyld. Samme oppslag som
`axis`: en egenskap som ikke finnes eller ikke er merket variantakse gir 422 med forslag.

Svaret er samme form som `GET /api/v1/products/{id}`s enkeltprodukt: `{"product": {...}}`.

### Slette et produkt eller en variant (`DELETE`)

```
DELETE /api/v1/products/{id}
{ "version": 0 }
```

Arkiverer raden — samme handling som «Slett produkt» i Admin-UI-et, og samme `version`-sjekk som
`PATCH` (409 `version_conflict` på en gjenbrukt/utdatert versjon). **Er `{id}` en `configurable`,
kaskaderer arkiveringen til alle dens varianter** — akkurat som UI-knappen. Svaret er
`{"id": "...", "archived": true}`. Atlas sletter aldri en rad fysisk; en arkivert rad forsvinner
fra `GET`/`POST`/`PATCH` men kan gjenopprettes i Admin-UI-et.

**`GET /api/v1/products` og `GET /api/v1/products/{id}` gir `long_description`, `usps`,
`specifications`, `categories` og `sources` tilbake** — `categories` i samme sti-array-form som du
sender inn til `PATCH` (`[["Interiør", "Dekorasjon"]]`), `sources` som `[{"id", "url", "label"}]`
(med den lagrede radens `id`, til orientering — den sendes ikke inn igjen), tom liste når produktet
ikke har noen av delen. Det er den eneste måten å bekrefte at en skriving faktisk landet: et
`200 ok` fra `PATCH` sier at kallet lyktes, ikke hva som står der nå. En variant har normalt
`usps`/`specifications`/`categories`/`sources` tomme — de er forelder-only ETTER KONVENSJON (samme
som «Nyhetsdato»), ikke en sperre `PATCH` håndhever, så send dem på forelderen, ikke på varianten.

---

## 9. Legge bilder på et produkt eller en variant

```
POST /api/v1/products/{id}/images
{ "image_urls": ["https://...", "https://..."], "role": "gallery" }
```

`{id}` er samme regel som `PATCH` (§8) — forelder ELLER én bestemt variant. En liste, alltid, også
for ett bilde: tak på **20 URL-er per kall**.

**Gjelder fotosettet en FARGE på et konfigurerbart produkt? Hopp til `axis-images` under.** Ett
kall dekker da alle størrelsene i den fargen, i stedet for 18 kall med de samme seks bildene.

**Ligger bildene på en disk og ikke på en URL? Last dem opp først** — se «Bilder som ikke ligger på
en URL» under. Et produktbilde skal aldri måtte legges ut på et midlertidig nettsted for å komme
inn i Atlas.

**`role` er valgfri og er `"gallery"` når den utelates** — sender du ingenting, oppfører kallet seg
nøyaktig som før.

**`asset_ids` ved siden av `image_urls`** fester bilder som ALLEREDE ligger i Atlas (fra
`POST /api/v1/assets`): ingen nedlasting, ingen ny asset-rad. Du kan sende bare `asset_ids`, bare
`image_urls`, eller begge. De festede ligger i `data.assets`, de nedlastede i `data.images`. Et
bilde som allerede henger på produktet svarer `already_attached` i stedet for å felle kallet, så
et gjentatt kall er trygt. `cover` og `swatch` teller URL-er og asset-id-er UNDER ETT — to bilder
til én forside er fortsatt 400.

```json
{
  "ok": true,
  "data": {
    "product_id": "…",
    "images": [
      {
        "url": "https://…/a.jpg",
        "status": "attached",
        "asset_id": "…",
        "position": 0,
        "is_cover": true
      },
      {
        "url": "https://…/b.jpg",
        "status": "already_attached",
        "asset_id": "…",
        "position": null,
        "is_cover": null
      },
      {
        "url": "https://…/c.jpg",
        "status": "failed",
        "asset_id": null,
        "position": null,
        "is_cover": null
      }
    ]
  },
  "warnings": [
    {
      "path": "image_urls[2]",
      "code": "download_failed",
      "message": "Kilden svarte 404 da bildet skulle hentes."
    }
  ]
}
```

**Statusen er alltid 200 — les hvert bildes egen `status`, ikke HTTP-statusen.** Ett bilde som
feiler stopper aldri de andre: en 404 på URL 3 av 10 leverer likevel de ni andre. Et bilde som
feiler havner i `warnings` med samme `path`/`code`/`message`-form som resten av API-et.

- **`attached`** — nedlastet og festet. Første bilde på et produkt uten bilder fra før blir
  `is_cover: true` automatisk; det er ikke noe du velger her.
- **`already_attached`** — samme URL var allerede festet til DETTE produktet (fra et tidligere
  kall). Ingen ny nedlasting, ingen ny asset-rad — send den samme lista trygt på nytt.
- **`failed`** — kilden svarte ikke, svarte med noe som ikke er et bilde, eller var større enn
  25 MB. `message` sier hvilket.

Bare **http(s)-URL-er som peker direkte til bildefilen** (JPEG/PNG/WebP/AVIF) — en side-URL som må
skrapes for bilder er en annen jobb, ikke dette endepunktet.

### `role` — hva bildet skal BLI

| `role`    | Hva som skjer                                              | Antall URL-er |
| --------- | ---------------------------------------------------------- | ------------- |
| `gallery` | Legges i produktets galleri (standard)                     | inntil 20     |
| `cover`   | Legges i galleriet **og** settes som forsidebilde          | nøyaktig 1    |
| `swatch`  | Settes som variantens **swatch-bilde** — aldri i galleriet | nøyaktig 1    |

`cover` og `swatch` er enkeltverdier, ikke lister: sender du flere URL-er, får du **400
`role_requires_one_url`**. Kallet gjetter aldri hvilken av dem du mente.

**`role: "cover"`** virker også på et bilde som ALLEREDE lå i galleriet (`already_attached`) — du ba
om at nettopp dette bildet skal være forsiden, og at det tilfeldigvis var importert fra før gjør
ikke ønsket oppfylt.

**`role: "swatch"`** er bildet nettbutikkens variantvelger viser når kunden velger denne variantens
farge/stoff. Det er et ANNET felt enn produktbildet, i Atlas og i Shopware, og det havner **aldri i
galleriet** — i noen sammenheng.

- Det krever en **variant-id**. Mot en forelder eller et enkelt produkt får du **400
  `not_a_variant`** — aldri en stille ignorering.
- Ligger URL-en allerede på produktet (f.eks. i galleriet), gjenbrukes den samme asset-raden:
  pekeren flyttes, ingenting lastes opp på nytt.
- Er den allerede variantens swatch, er svaret `already_attached`.
- Bildet hører til produktfamilien, ikke til egenskapsverdien: «Rød» ser forskjellig ut på ulike
  produkter og materialer, så verdien selv bærer aldri bildet.

```jsonc
POST /api/v1/products/<variant-id>/images
{ "image_urls": ["https://cdn.example/stoff-rod.jpg"], "role": "swatch" }
```

`GET /api/v1/products/{id}` gir feltet tilbake på hver variant:

```json
"swatch_image": { "asset_id": "…", "url": "https://ik.…/stoff-rod.jpg" }
```

`null` betyr «ikke satt» — og står også på et produkt som ikke KAN ha ett, så du slipper å gjette på
et felt som mangler.

### Bilder som ikke ligger på en URL (`POST /api/v1/assets`)

```bash
curl -H "Authorization: Bearer atlas_live_…" \
     -F "file=@/sti/til/rider-rod-1.jpg" \
     https://<din-atlas>/api/v1/assets
```

`multipart/form-data`, feltet heter **`file`**, **én fil per kall**, maks **25 MB**. Svaret er
bildets `asset_id`:

```json
{
  "ok": true,
  "data": {
    "asset_id": "…",
    "url": "https://ik.…/rider-rod-1.jpg",
    "filename": "rider-rod-1.jpg",
    "mime": "image/jpeg",
    "bytes": 184320,
    "next": "Fest bildet med POST /api/v1/products/{id}/images (asset_ids) eller POST /api/v1/products/{id}/axis-images (asset_ids)."
  }
}
```

**Opplastingen fester ingenting.** Bildet ligger i tenantens bildebibliotek til du sender
`asset_id`-en videre — enten til `POST /products/{id}/images` (ett produkt eller én variant) eller
til `POST /products/{id}/axis-images` (en farge på en konfigurerbar familie). Begge tar
`asset_ids` ved siden av `image_urls`, og en asset-id lastes aldri ned på nytt.

**Innholdet avgjør, ikke navnet.** Bytene leses: JPEG, PNG, WebP eller AVIF. En `.jpg` som
egentlig er HTML avvises med **400 `unsupported_image`**, og filnavnets endelse rettes til det
bildet FAKTISK er — ellers følger en løgn som `bilde.ashx` med helt fram til Shopware, som
validerer endelsen og nekter hele pushen.

| Kode                     | Hva som er galt                                          |
| ------------------------ | -------------------------------------------------------- |
| `missing_file`           | Feltet `file` mangler i skjemaet                         |
| `empty_file`             | Filen er tom                                             |
| `file_too_large`         | Over 25 MB — det er proxyens grense, ikke en innstilling |
| `unsupported_image`      | Bytene er ikke et bilde vi tar imot                      |
| `storage_not_configured` | Bildelagring er ikke satt opp for kontoen (503)          |

**Fra en MCP-klient:** `atlas_upload_image` tar `file_paths` og gjør nøyaktig dette — men bare når
serveren kjører på maskinen med filene (Claude Code / Claude Desktop). En tilkobling mot Atlas'
egen `/api/v1/mcp` kan ikke se disken din, og sier det med rene ord i stedet for å feile rart.

### Ett fotosett for hele FARGEN (`axis-images`)

```
POST /api/v1/products/{parent-id}/axis-images
{ "axis_value": "Rød", "image_urls": ["https://...", "https://..."] }
```

Bildene hører til fargen, ikke til størrelsen. En familie med 8 farger × 20 størrelser har 160
varianter, og det samme fotosettet gjelder alle 20 størrelsene i én farge. Dette endepunktet
knytter bildene til AKSEVERDIEN én gang — Atlas projiserer dem ned på hver variant med den
verdien, også varianter som opprettes SENERE.

**`{id}` er HOVEDPRODUKTETS id** (`kind: "configurable"`), aldri variantens. Sender du en variant-id
får du **400 `not_configurable`** med hvor id-en hentes fra.

**Ikke legg dem på hovedproduktet i stedet.** Det ser riktig ut i Atlas, men pushen sender hver
variants EGNE bilder og resolver aldri arv: varianten når butikken UTEN bilder.

| Felt                 | Hva det gjør                                                                            |
| -------------------- | --------------------------------------------------------------------------------------- |
| `axis_value`         | Verdien bildene gjelder, slik den står i `axis` på variantene — f.eks. `"Rød"`          |
| `attribute`          | Aksens navn (`"Farge"`). Bare nødvendig når samme etikett finnes på flere akser         |
| `attribute_value_id` | Eksakt id fra `GET .../axis-images`. Alternativ til `axis_value`                        |
| `image_urls`         | Inntil 20 per kall, samme regler som §9 (http(s), direkte til bildefilen)               |
| `asset_ids`          | Bilder som allerede finnes i Atlas — ingen ny nedlasting                                |
| `mode`               | `"append"` (standard) legger til; `"replace"` setter gruppen til NØYAKTIG det du sender |

**Etiketten gjettes aldri.** Finnes «Natur» på både Farge og Størrelse, svarer kallet **400
`ambiguous_axis_value`** og lister aksene — send `attribute` i tillegg. Finnes verdien ikke på
familien i det hele tatt, svarer det **400 `axis_value_not_found`** og lister verdiene variantene
faktisk bruker.

**`mode: "replace"` FJERNER bildene som ikke står i lista** — fra hver variant med verdien, og
dermed fra kanalen ved neste sync. `{"mode": "replace", "image_urls": []}` tømmer gruppen. Bruk
`replace` også når du vil endre REKKEFØLGEN: posisjon 0 er gruppens første bilde, og rekkefølgen er
`asset_ids` først, så `image_urls`.

Svaret sier hva som faktisk skjedde nedstrøms:

```json
{
  "ok": true,
  "data": {
    "attribute_value_id": "…",
    "attribute_value": "Rød",
    "attribute": "Farge",
    "variant_count": 18,
    "images": [{ "asset_id": "…", "url": "https://ik.…/roed-1.jpg", "position": 0 }],
    "imported": [{ "url": "https://…/roed-1.jpg", "status": "attached", "asset_id": "…" }],
    "variants_touched": 18,
    "links_adopted": 0,
    "links_archived": 0
  }
}
```

`variants_touched` er hvor mange varianter som fikk en skriving, `links_adopted` er bilder som lå
der fra før og nå eies av gruppen, og `links_archived` er lenker som ble fjernet. En URL som feiler
havner i `warnings` og stopper ikke de andre — samme form som §9.

Gjentatt trygt: en URL gruppen allerede har svarer `already_attached` uten å laste ned noe.

```
GET /api/v1/products/{parent-id}/axis-images
```

gir aksene, verdiene variantene bruker (med `variant_count` og `color_swatch`), og bildene i hver
gruppe med `asset_id`. Verdier UTEN bilder er med — det er nettopp der du skal laste opp.

---

## 10. Gjenta trygt med `Idempotency-Key`

`Idempotency-Key` gjelder `POST /products` — en opprettelse uten varekode ville dupliseres på en
retry. `PATCH` trenger den ikke: `version` gjør en retry trygg av seg selv — lyktes den første
gangen, 409-er den andre. `POST .../images` (§9) trenger den heller ikke: dedupe på URL gjør en
gjentatt liste trygg av seg selv.

```
Idempotency-Key: <noe unikt per operasjon>
```

Et produkt **med** `sku` er trygt å gjenta av seg selv — varekoden er identitet, så en retry lander i
`skipped`. Et produkt **uten** `sku` får en ny varekode hver gang og ville blitt duplisert. Send
headeren, så får du det opprinnelige svaret tilbake i 24 timer.

Et gjentatt svar har `Idempotent-Replay: true`. **Statusen er fortsatt 201 — det er ikke en ny
opprettelse.** Sjekk headeren, ikke statusen.

---

## 11. Hva dette API-et IKKE gjør

Uttalt, så du ikke leter etter et endepunkt som ikke finnes:

- **Ingen push eller sync ut** til Flow Retail eller Shopware. Å skrive i Atlas er reversibelt; å
  pushe feil produkt til en levende butikk er det ikke. Det gjøres av et menneske i Atlas.
- **Ingen sletting av BILDER, merker, produktgrupper, kategorier eller egenskaper** — heller ikke
  av et bilde du nettopp la på. Produkter og varianter KAN arkiveres, med
  `DELETE /api/v1/products/{id}` (§over). Denne linja sa til 17. september 2026 «ingen sletting
  eller arkivering, av noe», stikk i strid med DELETE-avsnittet i samme dokument — den var
  fra før endepunktet fantes.
- **`PATCH` legger aldri en variant TIL et eksisterende produkt** — bare felter på en rad som
  allerede finnes (parent ELLER variant). Nye varianter er fortsatt `POST /products` med `variants`.
- **`image_urls` på `POST`/`PATCH /products` henter fortsatt ikke bildene** — produktet
  opprettes/oppdateres og du får en advarsel som peker deg til `POST
/api/v1/products/{id}/images` (§9) — eller til `POST /api/v1/products/{id}/axis-images` når
  settet hører til en farge. De to er de eneste stedene bilder faktisk lastes ned; ligger filen på
  en disk, går den inn via `POST /api/v1/assets` i stedet.
- **Ingen AI-generert tekst.** `PATCH` skriver `description`/`long_description`/`usps` ORDRETT —
  samme regel som `POST`. Tone of voice er «Forbedre»-knappen i Atlas selv, ikke noe API-et trigger.
- **Ingen prisendring fra en leverandørnøkkel.** Hennes pris leses som veil. utsalgspris ved
  opprettelse, og avvises ved endring. Det rapporteres, aldri stille.

---

## 12. Generere AI-bilder UTEN noe produkt (`POST /api/v1/image-generate`)

```
POST /api/v1/image-generate
{
  "source_image_urls": ["https://..."],
  "name": "Stue S – vegghengt, 3 hyller og vitrineskap",
  "brand": "String Furniture",
  "category": "Hylle",
  "hint": "Vis møbelet i en lys, skandinavisk stue.",
  "count": 1,
  "aspect": "4:3",
  "model": "gemini"
}
```

Dette er det ENE endepunktet som ikke tar en `{id}` og ikke rører produktregisteret i det hele
tatt — laget for kataloger som ALDRI blir et Atlas-produkt (f.eks. String Furniture sine
ferdigbygde pakker hos Huset by Lone, som går rett i Flow Retail/Shopware). Har du et produkt i
Atlas fra før, bruk bildegenerering INNE i Atlas selv i stedet — den henter produktets egen
merke/gruppe/tone automatisk. Dette endepunktet er for det motsatte: du har bare et bilde og en
tekstbeskrivelse.

**Eget scope: `images:generate`.** Ikke `products:write` — dette koster ekte penger per bilde
(samme takst som bildegenerering inne i Atlas), og en nøkkel som bare skal skrive produkter skal
ikke plutselig begynne å fakturere fordi den fikk et for vidt scope.

- **`source_image_urls`** — 1–3 referansefoto av DET FYSISKE produktet (samme http(s)-bilde-URL-krav
  som §9). Alle må la seg hente, ellers avvises HELE kallet — du ba om N vinkler av samme produkt av
  en grunn, og å stille generere fra færre av dem ville vært et annet bilde enn du ba om.
- **`name`** — påkrevd, fri tekst. **`brand`/`category`** — valgfrie, fri tekst (ikke slått opp mot
  merke-/produktgruppe-registeret — dette produktet finnes jo ikke der). `category` styrer hvilken
  type omgivelse modellen velger (en sofa iscenesatt i en stue, en lampe på et sidebord).
- **`hint`** — fri instruks til modellen (maks 500 tegn), f.eks. et rom- eller stilforslag. Følges,
  ikke bare et ønske.
- Tenantens EGEN tone-of-voice og «om oss»-tekst (samme som Innstillinger → Merkevare) blandes
  automatisk inn i iscenesettelsen — samme oppførsel som bildegenerering inne i Atlas.
- **`count`** (1–5, standard 1) og **`aspect`** (`"1:1"`, `"3:4"` eller `"4:3"`, standard `"4:3"`).
- **`model`** — hvilken bildemodell som skal brukes. Valgfritt; utelater du feltet, brukes den
  modellen virksomheten har valgt under _Innstillinger → Bilder_ (standard: **Gemini**). Feltet
  gjelder bare dette ene kallet og endrer ikke innstillingen.

### Modellvalg

| `model`    | Modell                 | Oppløsning ut                                          |
| ---------- | ---------------------- | ------------------------------------------------------ |
| `"gemini"` | Google Nano Banana Pro | 2K                                                     |
| `"openai"` | OpenAI gpt-image-2     | 1536×1024 (eller 1024×1536 / 1024×1024 etter `aspect`) |
| `"grok"`   | xAI Grok Imagine       | 2K                                                     |

**Prisen er den samme uansett modell** — 10 kr per bilde etter den inkluderte kvoten. Du velger
altså på uttrykk og oppløsning, ikke på pris, og du kan bytte modell mellom to kall uten at det får
noen fakturamessig konsekvens.

`aspect` virker på alle tre, men når fram på to ulike måter: Gemini og Grok får et ekte
sideforhold, mens OpenAI ikke har noe slikt begrep og i stedet får en fast pikselstørrelse. Samme
`aspect` kan derfor gi litt ulike pikseldimensjoner mellom modellene — forholdet er likt, tallene
ikke nødvendigvis.

### Slik får du brukbare bilder

To ting avgjør resultatet mer enn modellvalget:

- **Si i `hint` hvordan produktet skal OPPTRE, ikke bare hvor det står.** Et møbel klarer seg med
  «i en lys stue». Et klesplagg fotografert flatt på hvit bakgrunn blir liggende flatt også i det
  nye bildet — det limes oppå møbelet i stedet for å bæres. Be eksplisitt om at plagget bæres av
  en person, med vekt og fall, så endres resultatet fullstendig. Samme kildebilde, samme modell,
  samme pris.
- **Velg `aspect` etter produktets fasong.** Et bredt møbel i `"3:4"` blir mest tom vegg; det
  samme produktet i `"1:1"` fyller flaten.

Modellene gjengir ikke tekst pålitelig. Et logotrykk med skrift kommer sjelden korrekt ut, uansett
modell — regn med det når produktet har tekst i trykket.

Modellene tolker den samme prompten ulikt: samme produktfoto og samme `hint` gir tre forskjellige
bilder. Er du usikker, generer ett med hver og se hvilken som kler sortimentet ditt — det er
billigere enn å gjette, og alle tre teller likt.

Svaret sier hvilken modell som FAKTISK kjørte. Utelot du feltet, er det den eneste måten å vite hva
standarden var — og siden oppløsningen varierer, kommer `width`/`height` alltid med.

```json
{
  "ok": true,
  "data": {
    "model": "gemini",
    "images": [
      { "asset_id": "…", "url": "https://ik.…/generert.jpg", "width": 1600, "height": 1200 }
    ]
  }
}
```

Bildet er ALLEREDE lagret når svaret kommer — ingen egen «lagre»-runde, siden en API-kaller ikke
har noen forhåndsvisning i Atlas å godkjenne fra. Vil du forkaste et resultat, la det bare ligge
ubrukt i bildebiblioteket — akkurat som en opplasting du aldri festet til noe.

**Kvote og pris** følger samme regler som bildegenerering inne i Atlas: et lite antall bilder er
inkludert per tenant (uansett hvor kallet kommer fra), deretter måles bruken og faktureres —
**samme takst for alle tre modellene**. Overskredet kvote gir **402** med koden `quota_exceeded`,
aldri en stille avvisning.

Andre feil: **400 `invalid_model`** (ukjent verdi i `model`; hintet lister de gyldige);
**400 `source_download_failed`** (en kilde-URL svarte ikke) med `path` som peker på hvilken;
**502 `generation_failed`** hvis AI-leverandøren feilet — prøv igjen, dette er ikke din feil;
**503 `model_unavailable`** hvis modellen du ba om ikke er satt opp på denne installasjonen (den
feilen går IKKE over av seg selv — utelat `model` for standardmodellen, eller be en administrator
sette den opp); **503 `nothing_saved`** hvis bildelageret ikke er satt opp.

---

## 13. Praktisk

**Norsk innhold.** Produktnavn, beskrivelser og egenskapsverdier er tekst kunden ser, og Atlas'
kunder er norske. Feltnavnene er engelske; innholdet er norsk.

**Sidevis lesing.** `GET /api/v1/products?limit=50` gir `next_cursor`; send den som `?cursor=` for
neste side. `?updated_since=` er verktøyet for synk. Rekkefølgen er stabil, men ikke kronologisk.

**Tak per kall:** 1000 produkter, 500 registeroppføringer, 200 produkter per leseside.

**Tak per tid:** 600 kall i minuttet og 16 samtidige, per IP — satt romslig med vilje. En import av
100 000 produkter er 100 kall à 1000 og møter aldri taket. Går du likevel over, får du **429** med
`"code": "rate_limited"` og en `Retry-After`-header — svaret er JSON, i samme konvolutt som alt
annet.

Den mest effektive importen er **færre og større kall**: 1000 produkter i ett `POST /products` tar
rundt et halvt sekund og teller som ett kall; tusen enkeltkall tar tusen ganger overheaden.

**Scopes.** En nøkkel har `products:read`, `products:write`, `registers:read`, `registers:write`
og/eller `images:generate` (§12). Ingen arv — `write` gir ikke `read`. Mangler du et, sier
403-en hvilket.

**Kom i gang:** `GET /api/v1` lister endepunktene, hvem nøkkelen er, og hvilke scopes den har.
Denne teksten ligger alltid på `GET /api/v1/skill.md`, servert av den versjonen som faktisk kjører.

---

## 14. MCP — samme API, som verktøy

Alt over finnes også som MCP-verktøy, for klienter som snakker Model Context Protocol i stedet for
rå HTTP:

- **Lokalt/Claude Code:** `npx tsx packages/mcp/src/server.ts` (stdio), med `ATLAS_BASE_URL` og
  `ATLAS_API_KEY` i miljøet.
- **Eksternt/Claude.ai/Cursor:** `POST /api/v1/mcp` (Streamable HTTP), samme nøkkel som over API-et
  — send den som `Authorization: Bearer` slik du allerede gjør.

Verktøyene er tynne innpakninger av nøyaktig de samme endepunktene, med samme regler — et JSON-tall
i `atlas_create_products` avvises på nøyaktig samme måte som over HTTP. Start med `atlas_discover`.
