{"openapi":"3.1.0","info":{"title":"Aranea data API","description":"Aranea — the data API: continuously surveyed\nweb data, validated against a published schema.\n\n**New here? Read the [integration guide](/docs)** — it walks the whole\npath from key to backfill to live sync, with working code. This page is\nthe endpoint-by-endpoint reference.\n\n## Schemas\n\nEvery source defines its own JSON Schema, and every listing payload from\nthat source is validated against it before storage — a payload that\nviolates the schema is rejected, never served. Fetch the schemas from\n`GET /v1/sources` to generate types or validate on your side. A listing's\ncontent lives entirely in its `payload` object.\n\n## Authentication\n\nAll endpoints except `POST /v1/register` require your API key as a Bearer\ntoken: `Authorization: Bearer spk_...`. Keys are issued when your account\nis approved. Every response is scoped to the sources your licence grants:\nthere is no endpoint that reveals a source you do not hold.\n\nEvery authentication failure — no header, malformed token, unknown key,\nrevoked key, unapproved account — is the same bare `401`. Do not try to\ndistinguish them; re-check the key and contact the operator.\n\n## Polling for changes\n\n`GET /v1/listings?updated_since=<RFC3339>` returns listings whose content\nor status changed since that instant (use the `Z`-suffixed form in query\nstrings). Page with the opaque `cursor` until `next_cursor` is null. The\n`updated_since` bound is inclusive and pages are ordered newest-change\nfirst, so checkpoint the newest `updated_at` of the **first** page and\ndeduplicate by `id`.\n\n## Rate limits\n\nRequests are counted per client in a fixed one-minute window; your ceiling\nis set on your licence. Over it, endpoints return `429` with a\n`Retry-After` header holding the seconds remaining in the current window —\nwait exactly that long and the next request is admitted.\n`POST /v1/register` is limited separately, by IP, per hour.\n\n## Errors\n\nEvery failure — including a malformed body, an unparseable parameter, an\nunknown path and a wrong method — answers with the same `ApiError`\nenvelope:\n\n```json\n{ \"error\": { \"message\": \"malformed cursor\" } }\n```\n\nBranch on the status code. `message` is diagnostic text for your logs, not\na stable machine identifier, and it is deliberately constant for `401` and\n`404` so neither can be used to probe for which keys, accounts or listings\nexist.\n","license":{"name":""},"version":"1.0.0"},"servers":[{"url":"https://aranea.pg.simo.rs","description":"Aranea data API"}],"paths":{"/v1/geocoding/forward":{"post":{"tags":["geocoding"],"summary":"Place an address.","description":"Runs Aranea's configured engine chain: the RGZ address register — the\nRepublic Geodetic Authority's own street centrelines, 101 m median error\nagainst the Google Geocoding API's 232 m on the same addresses — and\nwhatever the operator has configured behind it.\n\nAn address nothing can place is a `200` with `resolution` `not_found` or\n`too_coarse` and a null `coords`; both are authoritative and worth\nremembering. A spent budget or a provider outage is a `503`, because it\nsays nothing about the address and must not be cached as if it did.\n\nThere is no `confidence`: no engine produces one, and a number invented\nhere would be filtered on and mean nothing. `precision` and `provider` are\nthe real signal.","operationId":"forward","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForwardReq"}}},"required":true},"responses":{"200":{"description":"What the chain concluded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForwardResponse"}}}},"400":{"description":"Blank `query`, or an unknown `region_id`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Rate limit exceeded; see `Retry-After`","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"No engine could be reached, or none is configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}},"security":[{"api_key":[]}]}},"/v1/listings":{"get":{"tags":["listings"],"summary":"List listings across your granted sources.","description":"Results are ordered by `updated_at` descending, then `id` descending, and\npaginated with an opaque cursor: follow `next_cursor` until it is null.\nA page that exactly fills `limit` always carries a cursor, so the last\nrequest of a run legitimately returns an empty `items` — that is the\nterminator, not an error.\n\nPoll with `updated_since` to sync everything that changed. Each item\ncarries the source's schema-validated `payload`; filtering on payload\nfields is the consumer's job, since every source defines its own schema\n(see `GET /v1/sources`).","operationId":"search","parameters":[{"name":"source","in":"path","description":"Restrict to one source, by the `code` from `GET /v1/sources`.\nOmitted searches every source your licence grants.","required":true,"schema":{"type":["string","null"]},"example":"halooglasi"},{"name":"status","in":"path","description":"Restrict to one status. Omitted returns every status.","required":true,"schema":{"oneOf":[{"type":"null"},{"type":"string","description":"The statuses a listing can be filtered by. A value outside this set is\na `400` rather than a silent empty page — a typo in a sync job that\nreturns \"no listings\" is indistinguishable from a genuinely quiet feed.","enum":["active","expired","gone"]}]}},{"name":"updated_since","in":"path","description":"Return only listings changed at or after this instant, RFC 3339 with\na `Z` suffix. The bound is inclusive, so checkpointing the newest\n`updated_at` you saw re-delivers that listing on the next poll —\ndeduplicate by `id`.","required":true,"schema":{"type":["string","null"],"format":"date-time"},"example":"2026-08-09T12:00:00Z"},{"name":"limit","in":"path","description":"Page size, 1–100. Values outside that range are clamped, not\nrejected.","required":true,"schema":{"type":["integer","null"],"format":"int64","default":25,"maximum":100,"minimum":1}},{"name":"cursor","in":"path","description":"The previous page's `next_cursor`. Opaque — pass it back verbatim\nand do not parse it.","required":true,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Matching listings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResponse"}}}},"400":{"description":"Malformed cursor, unknown `status`, or an unparseable `updated_since`/`limit`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Rate limit exceeded; see `Retry-After`","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}},"security":[{"api_key":[]}]}},"/v1/listings/{id}":{"get":{"tags":["listings"],"summary":"Fetch one listing by id.","operationId":"get","parameters":[{"name":"id","in":"path","description":"Listing id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The listing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingV1"}}}},"400":{"description":"`id` is not a UUID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"Unknown listing, or one outside your grants","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Rate limit exceeded; see `Retry-After`","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}},"security":[{"api_key":[]}]}},"/v1/listings/{id}/history":{"get":{"tags":["listings"],"summary":"Version history of one listing, newest first.","operationId":"history","parameters":[{"name":"id","in":"path","description":"Listing id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Version timeline","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistoryResponse"}}}},"400":{"description":"`id` is not a UUID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"Unknown listing, or one outside your grants","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Rate limit exceeded; see `Retry-After`","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}},"security":[{"api_key":[]}]}},"/v1/register":{"post":{"tags":["account"],"summary":"Request an account.","description":"The only endpoint that needs no API key. Registration is reviewed by an\noperator, who grants sources and issues your key out-of-band; nothing\nhere returns one. Always responds 202 — including for addresses that\nalready applied — so a 202 is not confirmation that a new account was\ncreated. Limited to 5 attempts per IP per hour.","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterReq"}}},"required":true},"responses":{"202":{"description":"Request received (always, deliberately)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterAccepted"}}}},"400":{"description":"Missing name or invalid email","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Too many registration attempts from this address","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/sources":{"get":{"tags":["sources"],"summary":"List the sources your licence grants access to, each with the JSON\nSchema its listing payloads conform to.","operationId":"list","responses":{"200":{"description":"Granted sources","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SourceV1"}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Rate limit exceeded; see `Retry-After`","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}},"security":[{"api_key":[]}]}}},"components":{"schemas":{"ApiError":{"type":"object","description":"The JSON error envelope every public failure answers with — handler,\nmiddleware, extractor rejection and router fallback alike. Built by\n[`crate::public::error::PublicError`], which is what any new failure\npath should return rather than a bare `StatusCode`.","required":["error"],"properties":{"error":{"$ref":"#/components/schemas/ApiErrorDetail"}}},"ApiErrorDetail":{"type":"object","required":["message"],"properties":{"message":{"type":"string","description":"Human-readable explanation. Diagnostic text, not a stable machine\nidentifier — branch on the status code instead."}}},"ForwardReq":{"type":"object","required":["query"],"properties":{"area":{"type":["string","null"]},"city":{"type":["string","null"]},"place":{"type":["string","null"],"description":"Neighbourhood, municipality and city. The finest one given is what\ndistinguishes one Centar from another."},"query":{"type":"string","description":"The whole address as written, e.g. `Njegoševa 1, Vračar, Beograd`.\nRequired."},"region_id":{"type":["string","null"],"description":"A gazetteer id to scope the search by. Resolved here into the place\nnames the engines actually take, along with its ancestors', so a\ncaller may send the id it already holds instead of restating names."},"street":{"type":["string","null"],"description":"The street, with a house number when there is one. The address\nregister is a join and answers only when it knows the street, so\nsending this separately is what reaches the most accurate engine —\nre-splitting `query` would be guesswork."}}},"ForwardResolution":{"type":"string","description":"What the chain concluded. Every arm here is authoritative and safe to\ncache; a transient failure is a `503` instead.","enum":["found","not_found","too_coarse"]},"ForwardResponse":{"type":"object","required":["resolution"],"properties":{"coords":{"type":["array","null"],"items":{"type":"number","format":"double"},"description":"`[lng, lat]`, GeoJSON order. Null unless `resolution` is `found`."},"formatted_address":{"type":["string","null"],"description":"The answering engine's own rendering of the address, and its own id\nfor it. Provider-shaped: read them alongside `provider`."},"locality":{"type":["string","null"],"description":"The engine's own idea of the city. Recorded, never enforced."},"place_id":{"type":["string","null"]},"precision":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/PrecisionV1"}]},"provider":{"type":["string","null"],"description":"Which engine answered: `rgz`, `google_geocoding`, `photon`, …"},"region":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/RegionRefV1","description":"The finest gazetteer region containing the point."}]},"resolution":{"$ref":"#/components/schemas/ForwardResolution"}}},"HistoryResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/VersionV1"}}}},"ListingV1":{"type":"object","required":["id","source","source_listing_id","url","status","first_seen_at","updated_at","live_seconds","payload"],"properties":{"first_seen_at":{"type":"string","format":"date-time","description":"When Aranea first observed this listing."},"id":{"type":"string","format":"uuid","description":"Aranea's stable id for this listing. Use it as your primary key: it\nsurvives the listing expiring and later returning."},"live_seconds":{"type":"integer","format":"int64","description":"How long the listing has been on the market, in seconds: the gap\nbetween when Aranea first saw it and when Aranea last confirmed it was\nstill live.\n\nWhile `status` is `active` this keeps growing. Once the listing is\n`expired` it is final, and it stops at the last confirmed sighting —\nnot at the moment expiry was detected — so it is not inflated by how\noften we happen to re-check. Note that a listing already on the portal\nbefore Aranea first crawled it starts counting from that first\nsighting, so treat this as a lower bound on true time-on-market."},"payload":{"type":"object","description":"The listing's content, conforming to the source's JSON Schema\n(see `GET /v1/sources`). Shape differs per source."},"source":{"type":"string","description":"Source `code`, matching an entry from `GET /v1/sources`."},"source_listing_id":{"type":"string","description":"The listing's id on the source portal."},"status":{"type":"string","description":"`active` — live on the portal — or `expired` — withdrawn or deleted.\nTreat any other value as not-active: `gone` is reserved, and the\ninternal `candidate` state is reported as `active`."},"updated_at":{"type":"string","format":"date-time","description":"When the listing's content or status last changed. This is the field\n`updated_since` filters and the cursor orders on."},"url":{"type":"string","description":"The listing's URL on the source portal."}}},"PrecisionV1":{"type":"string","description":"Which tier a coordinate describes, matching the `location.precision`\nvocabulary the listing payloads already publish.","enum":["exact","street","neighbourhood","settlement","municipality"]},"RegionLevelV1":{"type":"string","description":"Administrative tier, matching the `location.region_level` vocabulary the\nlisting payloads already publish.","enum":["district","municipality","settlement","neighbourhood"]},"RegionRefV1":{"type":"object","required":["id","level","name"],"properties":{"id":{"type":"string","example":"osm:n:72725583"},"level":{"$ref":"#/components/schemas/RegionLevelV1"},"name":{"type":"string"}}},"RegisterAccepted":{"type":"object","required":["status"],"properties":{"status":{"type":"string","description":"Always `received`."}}},"RegisterReq":{"type":"object","required":["name","contact_email"],"properties":{"company":{"type":["string","null"]},"contact_email":{"type":"string","description":"Where the operator replies, and where the API key is sent once the\napplication is approved. Required."},"intended_use":{"type":["string","null"],"description":"What you intend to build. Optional, but it is what the operator\nreviews — applications without it take longer."},"name":{"type":"string","description":"Who is applying. Required, and must not be blank."}}},"SearchResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ListingV1"}},"next_cursor":{"type":["string","null"]}}},"SourceV1":{"type":"object","required":["code","base_url","schema"],"properties":{"base_url":{"type":"string"},"code":{"type":"string"},"schema":{"type":"object","description":"JSON Schema every listing payload conforms to. One schema serves\nevery source: payloads share a single shape, and a source simply omits\nthe fields its portal does not publish."}}},"VersionV1":{"type":"object","required":["fetched_at","payload"],"properties":{"fetched_at":{"type":"string","format":"date-time"},"payload":{"type":"object","description":"The payload as of this version; diff consecutive entries to see what\nchanged."}}}},"securitySchemes":{"api_key":{"type":"http","scheme":"bearer"}}},"tags":[{"name":"account","description":"Applying for access. No API key required."},{"name":"sources","description":"What your licence grants, and the JSON Schema each source's payloads conform to. Start every integration here."},{"name":"listings","description":"Reading data: search and delta-poll, fetch one listing, walk its version history."},{"name":"geocoding","description":"Address-to-point placement through Aranea's own engine chain."}]}