# Feed pro UpWolf — kontrakt v1.0

Vystavíte malé read-only JSON API; UpWolf ho čte a dělá z vaší inzerce reklamní kampaně na Metě. Tenhle jeden soubor je celý kontrakt: konvence, endpointy, pole a co se rozbije, když pole chybí.

- Živý sandbox feed (funguje hned teď): `https://upwolf.com/dev/sandbox/feed`
- OpenAPI: `https://upwolf.com/dev/openapi.json` · JSON Schema: `https://upwolf.com/dev/schema/Offer.json`
- Referenční implementace: `https://upwolf.com/assets/dev/reference-feed.php.txt`
- Validátor vlastního feedu: `https://upwolf.com/dev/validator`

## Konvence

### Transport a kódování

Jen HTTPS, odpovědi v JSON a UTF-8, `Content-Type: application/json`. Holé HTTP a neveřejné adresy odmítneme ještě před odesláním požadavku.

### Autentizace

Jeden bearer token na konzumenta: `Authorization: Bearer <token>`. Na své straně ho držte jako hash, nechte ho rotovatelný a řekněte nám, jak dlouhé je okno rotace. Když váš stack neumí číst hlavičky, vezmeme token i v URL — jen nám to řekněte.

### Dva tvary cest

Výchozí je cesta `{base}/offers/123456`. Když váš hosting nepředává `PATH_INFO` za PHP souborem (na sdíleném hostingu běžné), použijte variantu s parametrem: `{base}?upwolf_route=offers/123456`. Lomítko uvnitř route zůstává nezakódované, identifikátor se kóduje zvlášť. Vyberte si jednu variantu, řekněte nám ji a držte ji na všech endpointech.

### Na poli `version` stojí celá detekce změn

Neprůhledný řetězec, který se změní pokaždé, když se změní obsah nabídky — hash obsahu i časové razítko poslední revize fungují stejně dobře. Nikdy ho nerozebíráme, jen ho porovnáváme s verzí, ze které vznikla kreativa. Když se nemění nikdy, nepřegenerujeme kreativu u upravených nabídek; když se mění při každém dotazu, přegenerováváme pořád všechno.

### Chyby a význam 404

Chyba je HTTP status a k tomu `{"error": {"code": "…", "message": "…"}}`. **404 je platná odpověď**, ne selhání: u nabídky znamená „není ve feedu", u celého endpointu „zatím neumíme". Právě díky tomu jde kontrakt nasazovat po částech — my si zjistíme, co existuje, a zbytek degradujeme. Cokoliv 5xx opakujeme třikrát s exponenciálním backoffem.

### Časové limity, se kterými pracujeme

Čtení na pozadí má timeout 20 sekund. Jedna cesta je výrazně přísnější: když kampaň přijde pushem, máme na celou odpověď půl sekundy, takže detail nabídky tahá s rozpočtem 300 ms a jediným pokusem. Feed, který odpoví do 300 ms, dostane cílení hned napoprvé; pomalejší funguje taky, jen u toho jednoho volání spadne na výchozí hodnoty.

### Stránkování a `updated_since`

Stránkované endpointy vracejí `next_page` (jakýkoliv skalár, vracíme ho zpátky beze změny; `null` = konec). Dvě věci potřebujeme mít popsané: jestli je hranice `updated_since` inkluzivní nebo exkluzivní a jestli je řazení stabilní. Watermark si posouváme na čas začátku průchodu, takže při exkluzivní hranici a nestabilním řazení položky mezi stránkami tiše propadávají.

### Identita a idempotence

`external_id` identifikuje nabídku a nikdy se nesmí použít pro jinou. `(source_type, external_key)` identifikuje kandidáta na kampaň. Opakování je vždycky bezpečné: stejná dvojice nikdy nezaloží druhou kampaň, jen aktualizuje tu existující. Proto musí být zrušení výslovné `status: "cancelled"`, ne zmizení z výpisu — zmizení se nedá odlišit od vypadnutí z okna `updated_since`.

### Zavřené nabídky zůstávají čitelné

Když se nabídka zavře, přepněte `status` na `closed`, ale detail nechte dál odpovídat. Kampaň, která už běžela, potřebuje data nabídky i poté, co nabídka zmizela — kvůli výsledným metrikám, archivu kreativy a reportu, který vidí klient.

### Cachování (volitelné, ale oceníme)

Posílej na výpisech `ETag` nebo `Last-Modified` a odpovídej `304`, když se nic nezměnilo. Snapshot pulujeme v řádu minut; u velkého katalogu je tohle nejlevnější věc, kterou můžete pro vlastní servery udělat.

## Endpointy

### GET offers

**Úroveň:** povinné

Autoritativní množina pro srovnání: co ve snapshotu není, není aktivní. Procházíme ho v řádu minut a kampaním u nabídek, které z něj vypadly, zastavujeme běh. Bez stránkování a bez parametrů — jestli je váš katalog tak velký, že je to problém, ozvěte se a domluvíme stránkování. Když sem přidáte `name` a `company.name`, ušetříte nám jedno volání detailu na nabídku a celý výpis vznikne z jednoho requestu.

```json
{
    "items": [
        {
            "external_id": "DEMO-1001",
            "name": "Senior Backend Engineer",
            "company": {
                "name": "Nimbus Data s.r.o."
            }
        },
        {
            "external_id": "DEMO-1002",
            "name": "Skladník / skladnice",
            "company": {
                "name": "Retail Logistics a.s."
            }
        }
    ]
}
```

| Pole | Typ | Úroveň | Popis | Když chybí |
|---|---|---|---|---|
| `items[].external_id` | string | povinné | Stabilní ID nabídky. Nikdy se nepoužije pro jinou nabídku. | — |
| `items[].name` | string | doporučené | Název nabídky, ať výpis vznikne bez volání detailu. | Jeden request na detail navíc u každé nabídky jen kvůli názvu. |
| `items[].company.name` | string | doporučené | Název firmy ze stejného důvodu. | Totéž co výše. |

### GET offers/{external_id}

**Úroveň:** povinné

Text reklamy vzniká z `name`, `description` a `company`; cílení z `area_id` a `locations`. Na neznámou nabídku odpověz `404`. U zavřených nabídek odpovídej dál.

```json
{
    "external_id": "DEMO-1001",
    "version": "v2026-09-01T10:00:00Z",
    "name": "Senior Backend Engineer",
    "description": "Hledáme backend vývojáře do týmu, který staví platební bránu pro evropské e-shopy.\n\nCo tě čeká: návrh a vývoj služeb v PHP 8 a Go, práce s PostgreSQL a Kafkou, code review a mentoring juniorů.\n\nCo čekáme: pár let na podobné pozici, zkušenost s návrhem API a chuť starat se o to, co jsi napsal, i v provozu.\n\nNabízíme: čtyři dny v kanceláři jednou za měsíc, zbytek remote, 5 týdnů dovolené a rozpočet na vzdělávání.",
    "language_code": "cs",
    "status": "published",
    "company": {
        "name": "Nimbus Data s.r.o.",
        "introduction": "Stavíme platební infrastrukturu pro e-commerce ve střední Evropě. 40 lidí, vlastní produkt, žádné agenturní projekty.",
        "logo_url": "https://upwolf.com/assets/img/sandbox-logo.png",
        "logo_approved": true
    },
    "salaries": [
        {
            "minimum": 90000,
            "maximum": 130000,
            "currency": "CZK",
            "measure": "month"
        }
    ],
    "area_id": "142",
    "area_root_id": "180",
    "locations": [
        {
            "city": "Praha"
        },
        {
            "city": "Brno"
        }
    ],
    "updated_at": "2026-09-01T10:00:00+02:00"
}
```

| Pole | Typ | Úroveň | Popis | Když chybí |
|---|---|---|---|---|
| `external_id` | string | povinné | Totéž ID jako ve snapshotu. | — |
| `version` | string | povinné | Neprůhledný otisk obsahu. Mění se, kdykoliv se změní obsah nabídky. | — |
| `name` | string | povinné | Název nabídky. Stane se z něj titulek kreativy. | — |
| `description` | string | povinné | Prostý text nebo HTML. Všechno, z čeho se píše text reklamy — čím bohatší, tím lepší reklama. | — |
| `status` | string — published \| pending \| closed | povinné | Kampaň běží jen u `published`; `closed` ji během minut zastaví. | — |
| `updated_at` | string (ISO 8601) | povinné | Kdy se nabídka naposled změnila. | — |
| `company.name` | string | povinné | Jméno inzerenta. Objeví se v kreativě a uvozuje text reklamy. | — |
| `company.logo_url` | string (URL) | doporučené | Veřejně dostupný HTTPS obrázek. Čtvercový nebo široký, aspoň 200 px. | Kreativa se vyrenderuje bez loga — funguje, jen vypadá obecně. |
| `company.introduction` | string | volitelné | Krátké představení firmy; dá textu reklamy něco navíc k samotné nabídce. | — |
| `company.logo_approved` | boolean | volitelné | Nastavte false, když logo není ověřené; pak ho do renderované kreativy nedáme. | — |
| `area_id` | string | doporučené | Vaše ID oboru nabídky. Jakýkoliv stabilní identifikátor z vaší vlastní taxonomie. | Každá kampaň startuje hned, místo aby dala nabídce pár dní obsadit se organicky, a publikum nemá zájmové cílení — je tedy širší a dražší. |
| `area_root_id` | string | doporučené | Kořen vašeho stromu oborů pro tutéž nabídku. | Reklamy jedou z výchozí stránky místo té, která oboru odpovídá. |
| `locations[].city` | string | doporučené | Město tak, jak ho píšete v inzerátu. Mapování na identifikátory reklamních systémů si děláme sami. Pole posílejte i prázdné — tím říkáte „remote", ne „ještě to neumíme". | Cílí se na celou zemi místo rádiusu 80 km kolem měst — platíme imprese lidem, kteří tam nikdy nedojedou. |
| `language_code` | string (ISO 639-1) | volitelné | Jazyk obsahu. Bez něj jazyk detekujeme z textu. | — |
| `salaries[].minimum` | integer | doporučené | Spodní hranice nabízené částky, v celých jednotkách měny. | Kreativa nemůže ukázat částku. Reklamy s uvedeným číslem měřitelně vydělávají víc. |
| `salaries[].maximum` | integer | volitelné | Horní hranice, když zveřejňujete rozpětí. | — |
| `salaries[].currency` | string (ISO 4217) | doporučené | Měna částky, např. `CZK`. | Částku z kreativy raději vypustíme, než abychom ji ukázali bez měny. |
| `salaries[].measure` | string — hour \| day \| month \| year \| once | doporučené | Období, ke kterému se částka vztahuje. | Předpokládáme měsíční částku, což u hodinové odměny vypadá jako nesmysl. |

### GET offers?updated_since={iso8601}&page={n}

**Úroveň:** doporučené

Stejná cesta jako snapshot, ale s parametrem `updated_since` vrací identitu a `version` místo živé množiny. Každou verzi porovnáme s tou, ze které vznikla kreativa, a kreativu označíme za neaktuální. Bez tohohle endpointu běží upravená nabídka dál s reklamou napsanou ze starého textu, dokud si toho někdo nevšimne.

> **Bez tohohle endpointu:** Kreativa se po úpravě nabídky nikdy neobnoví.

```json
{
    "items": [
        {
            "external_id": "DEMO-1001",
            "version": "v2026-09-01T10:00:00Z",
            "status": "published",
            "updated_at": "2026-09-01T10:00:00+02:00"
        },
        {
            "external_id": "DEMO-1002",
            "version": "8f14e45fceea167a",
            "status": "published",
            "updated_at": "2026-09-03T08:30:00+02:00"
        },
        {
            "external_id": "DEMO-1003",
            "version": "v2026-08-20T16:00:00Z",
            "status": "closed",
            "updated_at": "2026-08-20T16:00:00+02:00"
        }
    ],
    "next_page": null
}
```

| Pole | Typ | Úroveň | Popis | Když chybí |
|---|---|---|---|---|
| `items[].external_id` | string | povinné | Která nabídka se změnila. | — |
| `items[].version` | string | povinné | Její aktuální otisk obsahu. | — |
| `items[].status` | string — published \| pending \| closed | doporučené | Ušetří volání detailu, když potřebujeme jen stav. | Jeden request na detail u každé změněné nabídky jen kvůli tomu, jestli je pořád otevřená. |
| `items[].updated_at` | string (ISO 8601) | doporučené | Kdy se změnila. | Watermark se dá posunout jen na čas průchodu, takže pomalý průchod čte znovu nabídky, které už viděl. |
| `next_page` | scalar | null | povinné | Vracíme ho beze změny jako `page`. `null` ukončuje průchod. | — |

### GET campaign-candidates?updated_since={iso8601}&page={n}

**Úroveň:** doporučené

Tohle je ta věc, díky které kampaně vznikají samy. Kandidát je vaše byznysové rozhodnutí — placená viditelnost, aktivovaný PPC produkt, cokoliv prodáváte — a nese okno i případné přebití rozpočtu. Nefiltruj podle toho, jestli je nabídka publikovaná; to si vyhodnotíme sami z detailu. Zrušení je `status: "cancelled"` u téže dvojice, nikdy ne zmizení. Výpis zároveň slouží jako srovnávací kolo: když se někdy ztratí push volání, nejbližší průchod to dorovná sám.

> **Bez tohohle endpointu:** Žádná kampaň nevznikne sama — každá musí přijít pushem na naše API nebo vzniknout ručně.

```json
{
    "items": [
        {
            "source_type": "visibility",
            "external_key": "98765",
            "offer_external_id": "DEMO-1001",
            "product": "Extra",
            "window": {
                "since": "2026-09-01T00:00:00+02:00",
                "until": "2026-09-30T23:59:59+02:00"
            },
            "status": "active",
            "budget_override": null,
            "delay_override_days": null,
            "first_campaign_boost": false,
            "updated_at": "2026-09-01T10:00:05+02:00"
        },
        {
            "source_type": "ppc",
            "external_key": "98766",
            "offer_external_id": "DEMO-1002",
            "product": "Ppc",
            "window": {
                "since": "2026-09-03T00:00:00+02:00",
                "until": null
            },
            "status": "cancelled",
            "budget_override": 250000,
            "delay_override_days": 0,
            "first_campaign_boost": true,
            "updated_at": "2026-09-05T11:20:00+02:00"
        }
    ],
    "next_page": null
}
```

| Pole | Typ | Úroveň | Popis | Když chybí |
|---|---|---|---|---|
| `items[].source_type` | string — visibility \| ppc | povinné | Jaký druh produktu kandidáta vytvořil. | — |
| `items[].external_key` | string | povinné | ID toho nákupu u vás. Pro nás neprůhledné; číslo je v pořádku. | — |
| `items[].offer_external_id` | string | povinné | Nabídka, ke které kampaň patří. | — |
| `items[].product` | string | povinné | Klíč produktu dohodnutý s námi — vybírá rozpočet, cíl kampaně i reklamní účet. | — |
| `items[].window.since` | string (ISO 8601) | povinné | Před tímhle okamžikem kampaň běžet nesmí. | — |
| `items[].window.until` | string (ISO 8601) | null | doporučené | Konec okna. U `ppc` smí být null, dopočítáme si ho z konfigurace produktu. | Kampaň běží výchozí dobu produktu místo okna, které jste prodali. |
| `items[].status` | string — active \| cancelled | povinné | `cancelled` u téže dvojice kampaň zastaví. Zrušení nikdy nevyjadřuj vynecháním. | — |
| `items[].updated_at` | string (ISO 8601) | povinné | Podle něj funguje stránkování přes `updated_since`. | — |
| `items[].budget_override` | integer | null | volitelné | Rozpočet v haléřích. Přebije výchozí hodnotu produktu. | — |
| `items[].delay_override_days` | integer | null | volitelné | Přebije, jak dlouho čekáme před startem. | — |
| `items[].first_campaign_boost` | boolean | volitelné | Nový klient — start hned, bez čekání. Výchozí false. | — |
| `next_page` | scalar | null | povinné | Vracíme ho beze změny jako `page`. `null` ukončuje průchod. | — |

### GET offers/{external_id}/applications-stats?from={iso8601}

**Úroveň:** doporučené

Dvě čísla rozhodují o tom, kdy přestaneme utrácet. `count` jsou kvalitní reakce — jak si kvalitu definujete, je čistě vaše věc — a podle nich se kampaň ukončí, jakmile překročí práh. `total_count` jsou všechny přihlášky; nikdy podle nich nerozhodujeme, jen díky nim nelže graf, protože „5 kvalitních" z šesti a z šedesáti je něco úplně jiného. Číslo smí i klesnout (stažené reakce) a je to v pořádku: naše rozhodnutí se nevracejí, takže pokles nerozjede zastavenou kampaň.

> **Bez tohohle endpointu:** Kampaně dojedou celé okno a utratí celý rozpočet i u nabídek, které jsou dávno obsazené, a nikdy se neuplatní pojistka, která kampaň vůbec nezaloží u nabídky obsazující se sama.

```json
{
    "external_id": "DEMO-1001",
    "from": "2026-09-01T00:00:00+02:00",
    "count": 4,
    "total_count": 11
}
```

| Pole | Typ | Úroveň | Popis | Když chybí |
|---|---|---|---|---|
| `external_id` | string | povinné | Zopakované ID nabídky. | — |
| `from` | string (ISO 8601) | volitelné | Zopakovaná dolní hranice z dotazu. Hodí se při ladění, nic na ní nestojí. | — |
| `count` | integer | povinné | Kvalitní reakce od `from`. Musí to být celé číslo — číslo jako řetězec ignorujeme. | — |
| `total_count` | integer | doporučené | Všechny přihlášky od `from`. Musí být větší nebo rovno `count`, jinak ho bereme jako neznámé. | Grafy ukážou jen kvalitní reakce, bez informace, z kolika přihlášek vznikly. |

## Checklist, než nám řeknete, že je hotovo

- [ ] Snapshot `offers` vrací všechny právě aktivní nabídky a nic navíc.
- [ ] `version` se mění při každé změně obsahu nabídky — a nemění se, když se obsah nezměnil.
- [ ] Detail odpovídá i u nabídky se stavem `closed`.
- [ ] Neznámá nabídka vrací `404` s tělem `{"error": {...}}`, ne `200` s prázdným objektem.
- [ ] Zrušený kandidát má `status: "cancelled"`, nezmizí z výpisu.
- [ ] `locations` posíláte i jako prázdné pole, když nabídka žádné město nemá.
- [ ] `count` u statistiky reakcí je celé číslo, ne řetězec.
- [ ] Máš popsané, jestli je hranice `updated_since` inkluzivní, a jestli je řazení stabilní.
- [ ] Validátor na `/dev/validator` projde bez červené.
