UpWolf Developers

Endpoints

Five GET endpoints, all read-only, all returning JSON. Every example below is a real response from our sandbox feed — the curl line next to it works as it stands.

Conventions

These apply to every endpoint. Most of them exist because someone asked, and one of them (the meaning of 404) is the reason the contract can be implemented in stages.

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.

Reference

GET offers 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.

shell
curl -s "https://upwolf.com/dev/sandbox/feed/offers" \
  -H "Authorization: Bearer sandbox-demo-token"
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 by field: OffersSnapshot · JSON Schema

GET offers/{external_id} 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.

shell
curl -s "https://upwolf.com/dev/sandbox/feed/offers/DEMO-1001" \
  -H "Authorization: Bearer sandbox-demo-token"
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 by field: Offer · JSON Schema

GET offers?updated_since={iso8601}&page={n} 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.
Parameter Type Required Note
updated_since string (ISO 8601) yes Lower bound; document whether it is inclusive.
page scalar no Value of `next_page` from the previous response.
shell
curl -s "https://upwolf.com/dev/sandbox/feed/offers?updated_since=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer sandbox-demo-token"
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 by field: OfferChanges · JSON Schema

GET campaign-candidates?updated_since={iso8601}&page={n} 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.
Parameter Type Required Note
updated_since string (ISO 8601) no Omitted on the first walk — then we want everything.
page scalar no Value of `next_page` from the previous response.
shell
curl -s "https://upwolf.com/dev/sandbox/feed/campaign-candidates?updated_since=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer sandbox-demo-token"
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 by field: CampaignCandidates · JSON Schema

GET offers/{external_id}/applications-stats?from={iso8601} 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.
Parameter Type Required Note
from string (ISO 8601) yes Count responses from this moment on.
shell
curl -s "https://upwolf.com/dev/sandbox/feed/offers/DEMO-1001/applications-stats?from=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer sandbox-demo-token"
json
{
    "external_id": "DEMO-1001",
    "from": "2026-09-01T00:00:00+02:00",
    "count": 4,
    "total_count": 11
}

Field by field: ApplicationsStats · JSON Schema

Error responses

Any non-2xx answer should carry this body. We show the message to our operators, so write it for a person who does not know your system.

json
{
    "error": {
        "code": "not_found",
        "message": "Nabídka 123456 ve feedu není."
    }
}