For developers

KDE TO SOM API

Points of interest, trails and events for your own website. One GET, JSON back. Nothing to install, nothing to run server-side.

This page is in English only — the parameters, fields and error names it describes are English, and a translation would only put a gap between the two.

Your first request

curl -H 'Origin: https://example.sk' \
  'https://api2.kdetosom.sk/?tk=YOUR_TOKEN&lang=en&poifltr=[3]'
{
  "meta": {
    "lang": "en",
    "numRows": 130,
    "categoryName": "Cultural and historical sights",
    "mediaBase": "https://kdetosom.sk/data/poi/",
    "audioBase": "https://kdetosom.sk/data/poi/audio/",
    "posterBase": "https://data.kdetosom.pro/posters/"
  },
  "data": [
    {
      "poi_id": 11989,
      "poi_detail_id": 6965,
      "poi_name": "Vartovka",
      "description": "The Krupina watchtower was built around 1564…",
      "lat": 48.3492767, "lng": 19.0791407,
      "muni_name": "Krupina",
      "img_name": "qx1gbDVMXoEbQ354PNAQwa95y", "img_ext": "jpg", "img_version": 8,
      "has_audio": 1
    }
  ]
}

Notice what is not in there: image URLs. See Images and audio — it is the one part of this API that surprises people, and the reason it works that way is that the files can move without your site changing.


Authentication

Every request carries a token: ?tk=YOUR_TOKEN. Two things gate it.

Your domain. A token is bound to the host it may be used from. Browsers send Origin automatically; server-side calls must send it themselves. Anything else gets 401 OriginNotAllowed.

Your monthly allowance, if your subscription has one. Over it, 406 QuotaExceeded. Unlimited subscriptions never see it.

The token is not a secret the way a password is — it sits in the source of a public page, and the domain binding is what protects it. Do not put it in a QR code or anywhere it can be lifted and reused from another host you also authorised.


Endpoints

One URL. What comes back is chosen with actp.

actpreturns
omitted / wiam_poisa list of points
wiam_poione point
wiam_tagsthe category tree, with counts
wiam_territoriesdistrict / county / region names
wiam_groupstrails and audioguides you may show
wiam_groupone trail, in order
wiam_eventsevents — own section
wiam_event_tagsthe event category tree

Listing points

parametermeaning
langsk en de cz pl hu fr it es ruCzech is cz, not cs
poifltrcategory ids as a JSON array: [3,6]. Omit for your default; [-1] means everything
mncd dscd ccd rgcdmunicipality / district / county / region code
lat lngthe visitor's position — turns on distance sorting
rdsradius in kilometres, not metres
cnthow many — on the distance-sorted path only
limithow many, on the unsorted path
minsig1–4, minimum significance (see below)
kindsattraction, amenity, hospitality, descriptor

Categories are unionedpoifltr=[3,6] is "3 or 6" — and territory filters are intersected with them.

Send lat and lng and every row comes back with dist in kilometres, nearest first. Without a position the list arrives in its stored order and cnt is ignored, so that sites which page the list themselves receive all of it; use limit if you want a cap.

Fields you can rely on

Identity and text poi_id poi_detail_id poi_name description locality_desc url lang version
Where it is lat lng muni_name muni_code district_code county_code region_code is_city dist show_address + the address block
Media img_name img_ext img_version has_custom_photo has_audio audio_version
Classification tag_ids priority img_source desc_source

tag_ids is a comma-separated string of the categories a point carries — the same ids wiam_tags returns. img_source and desc_source are credits, not decoration: some photographs may only be published with their author named. priority is significance, 1 (national) to 4 — the field a "worth travelling for" filter is built on.

dist only means something when you sent lat and lng. v1 emits it either way, and without a position the value is nonsense — point 3192 comes back as "1 km" from wherever you are. Here the field is simply absent unless it was computed. If you are porting code that compensated for that, delete the compensation rather than carrying it over.

poi_id identifies the place; poi_detail_id identifies the text about it in one language and one wording. Link to a point by poi_detail_id — that is what the detail endpoint takes.


Images and audio

The API returns the ingredients of a URL, never the URL. You assemble it:

const img   = `${meta.mediaBase}${row.img_name}.${row.img_ext}?${row.img_version}`;
const audio = `${meta.audioBase}${row.poi_detail_id}.mp3`;

This is the contract, not an inconvenience. The files will move to a CDN; when they do, mediaBase changes and your site keeps working. A hardcoded media host is the one thing guaranteed to break. Read it from every response.

has_audio is not a boolean

0no recording
1this row has its own — use its poi_detail_id
anything elseit is the poi_detail_id whose recording to play
if (row.has_audio) {
  const id = row.has_audio === 1 ? row.poi_detail_id : row.has_audio;
  play(`${meta.audioBase}${id}.mp3`);
}

A place described in nine languages usually has one recording shared by several of them. That is what the third case is.


Categories, territories, trails, events

Categories (actp=wiam_tags) come back as a tree via parent_id, with count and a slug that never changes when a category is renamed — so it is safe to key off. Names arrive already translated.

Territories (actp=wiam_territories) give {districts, countys, regions}, each row {code, name, short, slug}. Every point carries these codes, so this is how you label a filter without shipping your own copy of the list.

Trails: actp=wiam_groups lists what you may show, then actp=wiam_group&grpid=… returns the stops in curated order with order_number_in_group. The order is the content — do not re-sort it. Each stop appears once, in the language you asked for, falling back to Slovak where a translation does not exist — a membership names a text rather than a place, so a fully translated trail has ten memberships per stop.


Events

GET https://api2.kdetosom.sk/?actp=wiam_events&tk=…&lang=en&from=20260901&to=20261231&cnt=20
parametermeaning
from toYYYYMMDD. Default is today onwards — an event list is about what is still to come
munimunicipality code
regionregion code
importance1 = the region's headline events, higher = smaller
cnthow many

Your subscription needs events enabled; without it the endpoint answers 401.

{
  "id": 727,
  "hashed_id": "6a685d4eae40dbdbee9f9b746727",
  "name": "TAI CHI PRE ZDRAVIE",
  "anotation": "Meditation in movement, for beginners…",
  "date_start": "24.8.2026", "date_start_ts": 1787587200, "date_start_sort": "20260824",
  "date_finish": "0000-00-00 00:00:00", "event_takes_more_days": 0,
  "muni_code": 518387, "muni_name": "Dudince",
  "district_code": 605, "county_code": 6, "region": 1, "region_name": "Región Hont",
  "location_desc": "the meadow behind the spa", "location_poi_id": 163019,
  "lat": "0.00000000", "lng": "0.00000000",
  "event_importance": 3, "entry_fee": 0, "organizator": "Kúpele Dudince",
  "tags": "902,3104",
  "images": [{ "order": 1, "file": "6a685d…-668.jpg", "url": "https://…/posters/6a685d…-668.jpg" }]
}

Four things about event rows that will otherwise cost you an hour

Dates come in three forms and you need two of them. date_start is ready to print (24.8.2026, no leading zeros — display it verbatim), date_start_ts is a Unix timestamp to compute with, and date_start_sort is YYYYMMDD for ordering.

date_finish is often 0000-00-00 00:00:00 — a one-day event has no end. Its date_finish_ts is then -62169984000, which is a real number and a nonsense date, so a naïve comparison will happily place the event in the year 1. Check event_takes_more_days before showing an end date at all; do not test the timestamp.

lat and lng are frequently 0. Many events are not at a mappable address. Use location_poi_id when it is set — the event happens at a point of interest you can already link to — and location_desc, which is free text somebody typed.

tags is a comma-separated string, not an array, and they are event tag ids rather than POI categories: "902,3104". Split it and resolve the names with wiam_event_tags.

Posters are in images as { order, file, url }, with url already assembled from meta.posterBase. An event may have several and order is the sequence somebody chose. The legacy poster_ext and poster_version fields are usually empty — ignore them.

Link to an event by hashed_id. There is no single-event endpoint: a detail page is composed from the list response. Ask if you need one. An empty list out of season is normal — say "nothing coming up" rather than showing an error.

Event categories

actp=wiam_event_tags returns a different tree from the POI one, on four axes:

druhwhat kind of thing it is — mandatory
zanergenre and theme
publikumwho it is for
prilezitostthe occasion

An event may carry several tags across several axes. A row with is_axis: 1 is a heading, not a value — check the flag, never an id range, because the ranges change the day somebody adds an axis.


Significance and kind

Two questions that look like one.

kinds — what sort of thing it is: attraction, amenity, hospitality, descriptor.

minsig — how much it matters:

1national — people travel across the country for it
2regional — worth a detour
3local — worth a stop if you are passing
4supplementary — a wayside cross, a memorial plaque

minsig=2 means "1 and 2". A tourism site usually wants kinds=attraction&minsig=3; a town map wants everything.


Errors

{ "error": { "type": "UnknownToken", "title": "Unauthorized",
             "detail": "This token is not valid.", "status": 401 } }
400an actp we do not know, or a missing parameter
401missing, malformed, unknown or inactive token; or a domain that is not yours
404no such point or trail
406monthly allowance exhausted
501the cache is not built, or the endpoint is not implemented

501 never means "no results". An unbuilt cache and an empty answer are deliberately different responses, so a deployment problem cannot be mistaken for a data one.


Caching

Responses are gzipped, application/vnd.api+json, with Access-Control-Allow-Origin: *. The API answers from memory and a whole-country list costs it under a millisecond, so you need not be shy with it. Even so, cache by URL on your side: categories and territories change rarely (an hour is fine), point lists rarely (five minutes). Data is refreshed when an editor changes something, so an edit is visible within a minute or two rather than instantly.


Getting a token

Tokens are issued per website. Ask for one with the domain it will run on, the languages you need and whether you want events. If you are building for a customer, ask them to have their own domain added rather than reusing a token from another site — the domain binding is what keeps a token from being useful to whoever copies it.

About the project and contact