# UpWolf portal feed — contract v1.0

You expose a small read-only JSON API; UpWolf reads it and turns your listings into ad campaigns on Meta. This single file is the whole contract: conventions, endpoints, fields, and what breaks when a field is missing.

- Live sandbox feed (works right now): `https://upwolf.com/dev/sandbox/feed`
- OpenAPI: `https://upwolf.com/dev/openapi.json` · JSON Schema: `https://upwolf.com/dev/schema/Offer.json`
- Reference implementation: `https://upwolf.com/assets/dev/reference-feed.php.txt`
- Validator for your own feed: `https://upwolf.com/dev/validator`

## Conventions

### Transport and encoding

HTTPS only, JSON responses in UTF-8, `Content-Type: application/json`. Plain HTTP and non-public addresses are rejected before the request is even made.

### Authentication

A single bearer token per consumer: `Authorization: Bearer <token>`. Store it hashed on your side, keep it rotatable, and let us know the rotation window. A token in the URL works too if your stack cannot read headers — say so and we will use it.

### Two ways to shape the routes

Path style `{base}/offers/123456` is the default. If your hosting drops `PATH_INFO` behind a PHP file — which is common on shared hosting — use the query style instead: `{base}?upwolf_route=offers/123456`. The slash inside the route stays unencoded, the id is encoded separately. Pick one, tell us which, and keep it for every endpoint.

### The `version` field is the whole change detection

An opaque string that changes whenever the content of the offer changes — a content hash or the timestamp of the last revision both work. We never parse it, we only compare it to the version a creative was built from. If it never changes, we never regenerate creative for edited offers; if it changes on every request, we regenerate everything all the time.

### Errors and the meaning of 404

Errors are an HTTP status plus `{"error": {"code": "...", "message": "..."}}`. **404 is a valid answer**, not a failure: on an offer it means "not in the feed", on a whole endpoint it means "not implemented yet". That is what lets you ship the contract in stages — we detect what exists and degrade the rest. Anything 5xx is retried three times with exponential backoff.

### Timeouts we work with

Background reads time out after 20 seconds. One path is much tighter: when a campaign arrives over push we have half a second for the whole response, so the offer detail is fetched with a 300 ms budget and a single attempt. A feed that answers in under 300 ms gets targeting right on the first try; a slower one still works, it just falls back to defaults on that one call.

### Pagination and `updated_since`

Paged endpoints return `next_page` (any scalar we hand back verbatim; `null` ends the walk). Two things we need you to document: whether the `updated_since` boundary is inclusive or exclusive, and whether the ordering is stable. We move a watermark to the moment the walk started, so an exclusive boundary with unstable ordering silently drops items between pages.

### Identity and idempotency

`external_id` identifies an offer and must never be reused for a different offer. `(source_type, external_key)` identifies a campaign candidate. Repeating either is always safe: the same pair never creates a second campaign, it updates the existing one. This is why cancellation must be an explicit `status: "cancelled"` and not a disappearance from the list — disappearing is indistinguishable from falling out of the `updated_since` window.

### Closed offers stay readable

When an offer closes, flip its `status` to `closed` but keep the detail endpoint answering. A campaign that already ran needs the offer data after the offer is gone — for the final metrics, for the creative archive, and for the report the client sees.

### Caching (optional, appreciated)

Send `ETag` or `Last-Modified` on the list endpoints and answer `304` when nothing changed. We poll the snapshot every few minutes; on a large catalogue this is the cheapest thing you can do for your own servers.

## Endpoints

### GET offers

**Level:** required

The authoritative reconciliation set: what is not in the snapshot is not active. We walk it every few minutes and pause campaigns for offers that dropped out. No pagination and no parameters — if your catalogue is large enough that this becomes a problem, tell us and we will agree on paging. Including `name` and `company.name` here saves us one detail call per offer, so the whole listing renders from a single request.

```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."
            }
        }
    ]
}
```

| Field | Type | Level | Description | When missing |
|---|---|---|---|---|
| `items[].external_id` | string | required | Stable id of the offer. Never reused for a different offer. | — |
| `items[].name` | string | recommended | Offer title, so the listing renders without a detail call. | One extra detail request per offer just to show a name. |
| `items[].company.name` | string | recommended | Company name for the same reason. | Same as above. |

### GET offers/{external_id}

**Level:** required

The text of the ad is generated from `name`, `description` and `company`; the targeting comes from `area_id` and `locations`. Answer `404` for an offer you do not know. Keep answering for closed offers.

```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"
}
```

| Field | Type | Level | Description | When missing |
|---|---|---|---|---|
| `external_id` | string | required | Same id as in the snapshot. | — |
| `version` | string | required | Opaque content fingerprint. Changes whenever the offer content changes. | — |
| `name` | string | required | Offer title. Becomes the headline of the creative. | — |
| `description` | string | required | Plain text or HTML. Everything the ad copy is written from — the richer, the better the ad. | — |
| `status` | string — published \| pending \| closed | required | Only `published` offers get a running campaign; `closed` pauses it within minutes. | — |
| `updated_at` | string (ISO 8601) | required | When the offer last changed. | — |
| `company.name` | string | required | Advertiser name. Appears in the creative and prefixes the ad copy. | — |
| `company.logo_url` | string (URL) | recommended | Publicly reachable HTTPS image. Square or wide, at least 200 px. | Creative is rendered without a logo — it works, it just looks generic. |
| `company.introduction` | string | optional | Short company blurb; gives the ad copy something to work with beyond the offer itself. | — |
| `company.logo_approved` | boolean | optional | Set false if the logo is unverified; we then keep it out of rendered creative. | — |
| `area_id` | string | recommended | Your category id for the offer. Any stable identifier from your own taxonomy. | Every campaign starts immediately instead of giving the offer a few days to fill organically, and the audience has no interest targeting — so it is wider and more expensive. |
| `area_root_id` | string | recommended | Root of your category tree for the same offer. | Ads run from the default page instead of the one matching the field. |
| `locations[].city` | string | recommended | City as you write it in the listing. We map it to ad platform ids ourselves. Send the array even when empty — that tells us "remote", not "not implemented". | Targeting falls back to the whole country instead of an 80 km radius around the cities — impressions paid for people who could never commute there. |
| `language_code` | string (ISO 639-1) | optional | Language of the content. Without it we detect the language from the text. | — |
| `salaries[].minimum` | integer | recommended | Lower bound of the offered amount, in whole units of the currency. | The creative cannot show an amount. Ads that name a number perform measurably better. |
| `salaries[].maximum` | integer | optional | Upper bound, when you publish a range. | — |
| `salaries[].currency` | string (ISO 4217) | recommended | Currency of the amount, e.g. `CZK`. | The amount is dropped from the creative rather than shown without a currency. |
| `salaries[].measure` | string — hour \| day \| month \| year \| once | recommended | Period the amount applies to. | We assume a monthly amount, which reads as nonsense on hourly pay. |

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

**Level:** recommended

Same path as the snapshot, but with `updated_since` it returns identity and `version` instead of the live set. We compare each version against the one a creative was built from and flag the creative as stale. Without this endpoint an edited offer keeps running with the ad written from its old text until someone notices.

> **Without this endpoint:** Creative is never refreshed after an offer is edited.

```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
}
```

| Field | Type | Level | Description | When missing |
|---|---|---|---|---|
| `items[].external_id` | string | required | Which offer changed. | — |
| `items[].version` | string | required | Its current content fingerprint. | — |
| `items[].status` | string — published \| pending \| closed | recommended | Saves a detail call when all we need is the status. | One detail request per changed offer just to learn whether it is still open. |
| `items[].updated_at` | string (ISO 8601) | recommended | When it changed. | The watermark can only move to the moment of the walk, so a slow walk re-reads offers it already saw. |
| `next_page` | scalar | null | required | Handed back verbatim as `page`. `null` ends the walk. | — |

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

**Level:** recommended

This is what makes campaigns appear on their own. A candidate is your business decision — a paid visibility product, an activated PPC product, whatever you sell — and it carries the window and any budget override. Do not filter by whether the offer is published; we evaluate that ourselves from the detail endpoint. Cancellation is `status: "cancelled"` on the same pair, never a disappearance. The list doubles as reconciliation: if a push call is ever lost, the next walk fixes it by itself.

> **Without this endpoint:** No campaign is ever created automatically — every one has to be pushed to our API or created by hand.

```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
}
```

| Field | Type | Level | Description | When missing |
|---|---|---|---|---|
| `items[].source_type` | string — visibility \| ppc | required | Which kind of product produced the candidate. | — |
| `items[].external_key` | string | required | Id of that product purchase on your side. Opaque to us; a number is fine. | — |
| `items[].offer_external_id` | string | required | The offer this campaign is for. | — |
| `items[].product` | string | required | Product key agreed with us — it selects budget, objective and ad account. | — |
| `items[].window.since` | string (ISO 8601) | required | Campaign may not run before this. | — |
| `items[].window.until` | string (ISO 8601) | null | recommended | End of the window. May be null for `ppc`, where we derive it from the product configuration. | The campaign runs for the product default length instead of the window you sold. |
| `items[].status` | string — active \| cancelled | required | `cancelled` on the same pair stops the campaign. Never express cancellation by omission. | — |
| `items[].updated_at` | string (ISO 8601) | required | Drives `updated_since` paging. | — |
| `items[].budget_override` | integer | null | optional | Budget in minor units (hundredths). Overrides the product default. | — |
| `items[].delay_override_days` | integer | null | optional | Overrides how long we wait before starting. | — |
| `items[].first_campaign_boost` | boolean | optional | New client — start immediately, skip the waiting period. Default false. | — |
| `next_page` | scalar | null | required | Handed back verbatim as `page`. `null` ends the walk. | — |

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

**Level:** recommended

Two numbers decide when we stop spending. `count` is the quality responses — how you define quality is entirely your business — and campaigns stop once it crosses the threshold. `total_count` is every application; it never drives a decision, it only makes the charts honest, because "5 good ones" reads very differently out of 6 than out of 60. The count may go down (withdrawn responses) and that is fine: our decisions never reverse, so a drop cannot restart a stopped campaign.

> **Without this endpoint:** Campaigns run for the full window and spend the whole budget even on offers that were filled long ago, and the guard that skips campaigns for offers filling up organically never fires.

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

| Field | Type | Level | Description | When missing |
|---|---|---|---|---|
| `external_id` | string | required | Echo of the offer id. | — |
| `from` | string (ISO 8601) | optional | Echo of the requested lower bound. Handy when debugging, nothing depends on it. | — |
| `count` | integer | required | Quality responses since `from`. Must be an integer — a numeric string is ignored. | — |
| `total_count` | integer | recommended | All applications since `from`. Must be greater than or equal to `count`, otherwise we treat it as unknown. | Charts show the quality count alone, with no idea how many applications it came out of. |

## Checklist before you tell us it is ready

- [ ] The `offers` snapshot returns everything that is live right now, and nothing else.
- [ ] `version` changes on every content change — and does not change when the content did not.
- [ ] The detail endpoint still answers for offers with status `closed`.
- [ ] An unknown offer returns `404` with an `{"error": {...}}` body, not `200` with an empty object.
- [ ] A cancelled candidate carries `status: "cancelled"` instead of disappearing from the list.
- [ ] You send `locations` as an empty array when the offer has no city.
- [ ] `count` in the response stats is an integer, not a string.
- [ ] You have documented whether the `updated_since` boundary is inclusive and whether ordering is stable.
- [ ] The validator at `/dev/validator` comes back without a red line.
