Skip to content

Developers

The Wikiteadia API

Everything here is public, unauthenticated and open to every origin. There is no key, no quota, no registration and no terms page to click through.

That is not an oversight to be tidied up later — a reference work that meters reuse has stopped being a reference work — and it has a deliberate consequence: we cannot tell who is reading, which means we cannot break a shape once somebody depends on it. So the promises on this page are the whole contract. Everything not promised may change.

What is live

EndpointReturnsCache
GET /api/v1/placesTea houses and tea shops, with coordinates1 hour
GET /api/v1/{category}Every published subject of one category1 hour
GET /api/v1/festivalsTea festivals, with dates, venues and coordinates1 hour
GET /tea-festivals-{year}.geojsonThat year's festival editions as GeoJSON5 minutes
GET /tea-festivals-{year}.icsThe same editions as a calendar feed1 hour

Caching is s-maxage — shared caches only, so a browser refetches while a CDN does not.

There is no search endpoint and no way to write. The site talks to itself over one other JSON URL, /search?format=json, which is deliberately not in that table and carries none of these promises: it is one of two representations of a page, it changes whenever that page does, and it will do so without notice.

  1. GET /api/v1/places

    Every tea house and tea shop whose page has been published and which carries the seven facts a listing cannot be built without.

    GET https://wikiteadia.org/api/v1/places
    GET https://wikiteadia.org/api/v1/places?city=lisbon
    GET https://wikiteadia.org/api/v1/places?country=pt&limit=50
    NameShapeDefault
    citya lowercase slug — lisbon, kyoto, hackneyall cities
    countryISO 3166-1 alpha-2, either caseall countries
    limit1–2000500

    A value that could not be a city slug or a country code is ignored, not rejected. An ignored filter returns everything, which is a wrong answer you can see and correct; a 500 is a wrong answer that looks like our fault.

    {
      "places": [
        {
          "slug": "cha-do-adro",
          "name": "Chá do Adro",
          "kind": "tea_house",
          "lat": 38.7223,
          "lng": -9.1393,
          "city_slug": "lisbon",
          "country_code": "PT",
          "timezone": "Europe/Lisbon",
          "url": "https://wikiteadia.org/place/cha-do-adro",
          "address": "Rua do Adro 12, Lisboa",
          "opening_hours": "Tu-Su 11:00-19:00",
          "hours": [[2, "11:00", "19:00"]],
          "website": "https://chadoadro.pt",
          "teas": ["Gyokuro", "Sencha"]
        }
      ],
      "count": 1,
      "generated_at": "2026-09-20T11:00:00.000Z"
    }

    slug is stable forever. It is the key another application's check-ins, favourites and bookmarks hang off, so renaming a place page is a breaking change to data that does not live here and that we cannot migrate.

    Eight fields are always present: slug, name, lat, lng, city_slug, country_code, timezone, url. Every other field is absent rather than null when we do not know it, so a template cannot print the word “null” at a reader.

    hours is derived, opening_hours is the source. The raw string is what a contributor copied off the shop window, in OSM syntax, stored verbatim. hours is that string parsed into [day, open, close] triples where day 0 is Sunday. When the string falls outside the subset the parser understands, hours is absent and opening_hours still arrives — half a parsed week is not a smaller answer, it is a claim that a tea house is open on a day its own sign says shut.

    There is no featured, rank or sponsored field, and there will not be one. Whether a tea house is worth a reader's evening is editorial judgement belonging to whoever publishes the guide — it is not a fact about the building, and a reference work that ranks its own subjects has started selling them. Rank your own listings.

  2. GET /api/v1/{category}

    Every published subject of one category, as the revision a reviewer approved. {category} is the plural in the site's own URLs, so a page you can read is a page you can parse: /teas in a browser, /api/v1/teas in a script.

    teasregionsplacesproducerscultivarsprocessesbrewingtopicspeopleworks

    Do not hard-code that list. It is generated from the kind registry and grows when a kind is added — including on this page, which reads the same registry the router does. Ask for a category and read the 404 body if you guessed wrong:

    { "error": "unknown category", "categories": ["teas", "regions", "…"] }

    places is the exception. It answers the bespoke contract above, not the shape below. It was written for a specific consumer before this endpoint existed, and a shape somebody already depends on does not get quietly replaced.

    NameShapeDefault
    limit0–500100
    offsetany non-negative integer0

    Both are clamped, not rejected. ?limit=99999 is somebody asking for everything, which is a reasonable thing to want and an unreasonable thing to serialise in one response; you get 500 rows and a limit of 500 in the body saying so.

    {
      "category": "teas",
      "kind": "tea",
      "total": 412,
      "limit": 100,
      "offset": 0,
      "subjects": [
        {
          "slug": "da-hong-pao",
          "name": "Da Hong Pao",
          "kind": "tea",
          "url": "https://wikiteadia.org/tea/da-hong-pao",
          "updated_at": "2026-08-02T11:14:52.310Z",
          "name_native": "大红袍",
          "class": "oolong",
          "water_temp_c": "95",
          "relations": [
            { "type": "grown_in", "kind": "region",
              "slug": "wuyi-shan", "name": "Wuyi Shan" }
          ]
        }
      ]
    }

    total counts the category; subjects is this page. Page until offset + subjects.length >= total — and please check it, because the commonest way to import a wiki badly is to read the first hundred rows and call it the dataset.

    Every subject has slug, name, kind and url. Everything else may be absent. The other keys are that kind's own fields, under the same names the contributor filled in on the site's form — so a new field appears on the form, in the database and in this response on the same commit, and nothing internal is ever served by accident, because a key the form does not post is a key this endpoint does not know about. ?limit=1 is the fastest way to see what a category currently carries.

    Values are strings as typed. water_temp_c is "95" and not 95: the field is free text because a contributor may legitimately write "90–95" or "just off the boil", and coercing that to a number here would mean inventing one.

    relations is outbound only — what this subject's own revision claims about the world. A region's page shows inbound edges too, because otherwise it would be blank; an API row is a record, and a record states what its own revision said. The inbound edges appear on the rows that wrote them, in the same response. There are no UUIDs anywhere in this API. To follow a relation, ask for that kind's category and join on slug.

  3. GET /api/v1/festivals

    Every tea festival this wiki has dates for. The resource is an edition, not a series. “Tokyo Tea Festival” is the series — a name, an organiser and a website, with no date and no venue. An edition is the 2026 one: the thing with dates, an address, coordinates and a ticket link, and therefore the thing an application can put in a list, on a map or in a calendar.

    festivals is not a category and never will be. The two festival kinds declare no browse path — they are read through the year pages — so this is its own endpoint with its own shape, the way places is.

    GET https://wikiteadia.org/api/v1/festivals
    GET https://wikiteadia.org/api/v1/festivals?year=2026
    GET https://wikiteadia.org/api/v1/festivals?country=portugal&format=in_person
    NameShapeDefault
    yeara four-digit year — 2026every year
    countrya lowercase hyphenated country name — portugal, united-statesall countries
    formatin_person, online or hybridall formats
    seriesa series_slug — every year of one festivalall festivals
    bboxw,s,e,n in degreeseverywhere
    limit0–500200
    offsetany non-negative integer0

    No year means every year, past and future. The year pages exist because a person browsing wants one year at a time; an application importing festivals usually does not, so the default here is the opposite of the default there.

    country is a name, not an ISO code, unlike /api/v1/places. That asymmetry is a wart rather than a design: festivals carry the country name the source listings publish, and nobody has yet done the work of mapping them onto ISO codes. So ?country=pt finds no festivals and ?country=portugal finds no places. Whichever way it is reconciled, it will be reconciled by adding a field, which is not a breaking change.

    {
      "festivals": [
        {
          "slug": "berlin-tea-festival-2026-11-07",
          "name": "Berlin Tea Festival",
          "url": "https://wikiteadia.org/tea-festivals/…",
          "start_date": "2026-11-07",
          "end_date": "2026-11-08",
          "timezone": "Europe/Berlin",
          "series_slug": "berlin-tea-festival",
          "status": "announced",
          "format": "in_person",
          "venue": "silent green Kulturquartier",
          "address": "Gerichtstraße 35, 13347 Berlin",
          "country": "germany",
          "lat": 52.54569,
          "lng": 13.36649,
          "geo_precision": "surveyed"
        }
      ],
      "total": 52,
      "limit": 200,
      "offset": 0
    }

    Dates are wall dates, never instants. 2026-11-07 is the seventh of November in Berlin, which is not a moment in time, and nothing in this endpoint converts it to one. timezone is a label saying whose calendar those days belong to — apply it to the dates and you have invented a timestamp we did not publish. If you are storing these, store them as dates.

    Coordinates arrive together or not at all, and geo_precision comes with them: surveyed, geocoded, village, county or region. It matters because a point is not self-describing — the centre of a county and the door of a tea house are both two numbers — and drawing the first as a street pin is a mistake this field lets you avoid.

    Unplaced editions are served, which is the difference between this and the GeoJSON feed. An online festival has no coordinates by nature and a newly-added one has not been geocoded yet; both are real festivals with real dates, and only a map has a reason to drop them.

    Page until you get an empty array — not until you get fewer rows than you asked for, because a page can be short without being the last. Ordering is by start_date then slug, and the second half is load-bearing: festivals cluster on the same weekends, and paging through an order that does not break ties drops rows and repeats others.

  4. GET /tea-festivals-{year}.geojson · .ics

    A FeatureCollection of that year's editions, one Point per edition — and the same editions again as a subscribable calendar. Each calendar event's UID is stable across regenerations, so a subscriber's calendar updates an event rather than duplicating it.

    GET https://wikiteadia.org/tea-festivals-2026.geojson
    GET https://wikiteadia.org/tea-festivals-2026.geojson?bbox=-10,36,-6,42
    GET https://wikiteadia.org/tea-festivals-2026.ics

    Editions with no coordinates are dropped rather than emitted at (0, 0), which is the reason /api/v1/festivals exists beside this: that one is shaped for records, this one for a map. bbox may cross the antimeridian — ?bbox=170,-20,-170,-10 is ten degrees of Pacific, not three hundred and fifty.

    These predate /api/v1 and keep their URLs. They are not versioned, so they are the one shape here that could move; if they do, they will move to /api/v1 and these paths will keep answering.

What is not live

Writes
Everything here is read-only. Adding a write means deciding how a reviewer sees a contribution that arrived without a person behind it. Until then the form on the site is the only way in, which is not a limitation we are in a hurry to remove.
Search
An /api/v1/search is a reasonable thing to want and a different thing to build, with a contract that holds still. Ask.
Bulk export
Paging a category 500 rows at a time works and is what we would suggest today, but a single dump somebody can diff against last week's is a better thing for a mirror or an archive.
Festival series as records
You get a series' name, slug and website through its editions. To learn that a festival is defunct you currently have to notice it has no future editions — which is also what a festival taking a year off looks like.
Cancelled editions
Excluded everywhere, so an importer sees one disappear rather than being told it was called off. That is why status is in the payload at all: a future ?status= can widen this without changing a shape.
Region boundaries
The obvious thing to serve as GeoJSON, and the one dataset we cannot serve until we know where each boundary came from. Licensing, not engineering.

Reuse

Everything in these responses was contributed by people who expect it to be reused. Please:

Link back
To the url field. It is where a reader corrects the record, and corrections are the only reason an open wiki is worth reading through an API at all.
Cache
An hour for every /api/v1/ endpoint, five minutes for the GeoJSON. Refetching per page view asks the database a question you already have the answer to. A correction therefore takes up to an hour to reach you: tolerable for an address, and about the limit for an opening time.
Tell us what you are building
hello@wikiteadia.org. Not a requirement, and there is no key to revoke if you do not. It is simply the only way we learn that a shape has a dependant before we consider changing it.

Something missing or wrong? Add it — the API serves what the wiki knows, so the fix is an edit rather than a ticket.