Recipe: custom map UI with your own map library

Use Yatmo for the data and Leaflet, MapLibre or Google Maps for the rendering. The same places as the JS Map plugin, with your own visual identity.

Architecture

The REST API authenticates with your backend key, which must stay on your servers (see authentication). The browser therefore never calls Yatmo directly:

browser (map)  →  your backend  /api/pois?bound1=…&bound2=…
your backend   →  https://be.yatmo.com/points   (LicenseKey: backend key)
your backend   →  browser                        (the places, and the Nz flag)

If you only need Yatmo's own map, the JS Map plugin runs in the browser with your frontend key and needs no backend at all.

1. Load the market's categories once

/simplifiedcategories returns, for the market of the subdomain, groups of place-type ids ready for /points. The group names are translated (“Shopping” in English, “Magasins” in French), so do not select groups by name. Select them by the category digits of their ids: an id is 1, three digits of category, four of sub-type, six of variant. Education is category 1, transport 2, shopping 3.

For Belgium, on 21 September 2026, the shopping group was [10030001000001]; an id ending in 000001 means every variant of that type.

2. Your backend relay

Server-side JavaScript (Node.js 18 or later, no dependency). It keeps the key, loads the categories once, and forwards the Nz flag.

// Server-side JavaScript (Node.js 18+): node relay.mjs
import http from 'node:http';

const YATMO = 'https://be.yatmo.com';                 // the market is the subdomain
const KEY = process.env.YATMO_BACKEND_KEY;           // never sent to the browser
const WANTED_CATEGORIES = [1, 2, 3];                 // education, transport, shopping

const categoryOf = id => Math.floor(id / 1e10) % 1000;

async function loadTypeIds() {
  const res = await fetch(YATMO + '/simplifiedcategories?language=EN', { headers: { LicenseKey: KEY } });
  if (!res.ok) throw new Error('simplifiedcategories ' + res.status);
  const groups = await res.json();                   // { '<translated name>': [ids] }
  return Object.values(groups).flat().filter(id => WANTED_CATEGORIES.includes(categoryOf(id)));
}

const typeIds = await loadTypeIds();                 // refresh daily; it rarely changes

http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  if (url.pathname !== '/api/pois') { res.writeHead(404).end(); return; }

  const qs = new URLSearchParams({
    bound1: url.searchParams.get('bound1') ?? '',    // 'latitude,longitude' south-west
    bound2: url.searchParams.get('bound2') ?? '',    // 'latitude,longitude' north-east
    language: url.searchParams.get('language') ?? 'EN',
    groupSamePositions: 'true',
    poiTypesIds: typeIds.join(',')
  });
  const upstream = await fetch(YATMO + '/points?' + qs, { headers: { LicenseKey: KEY } });
  res.writeHead(upstream.status, {
    'Content-Type': upstream.headers.get('content-type') ?? 'application/json',
    'X-Needs-Zoom': upstream.headers.get('Nz') ?? 'false'
  });
  res.end(await upstream.text());                    // 400 'The requested area is too large.' passes through
}).listen(8080);

3. The map in the browser

Browser JavaScript. It only talks to your backend, and carries no key.

const map = L.map('map').setView([50.846714, 4.352514], 16);
L.tileLayer('https://{your-tile-server}/{z}/{x}/{y}.png').addTo(map);
const layer = L.layerGroup().addTo(map);
const notice = document.getElementById('zoom-notice');     // any element of yours

async function refresh() {
  const b = map.getBounds();
  const qs = new URLSearchParams({
    bound1: b.getSouth() + ',' + b.getWest(),
    bound2: b.getNorth() + ',' + b.getEast()
  });
  const res = await fetch('/api/pois?' + qs);
  if (res.status === 400) {                                 // area too large: ask to zoom in
    layer.clearLayers();
    notice.hidden = false;
    return;
  }
  const places = await res.json();                          // n name, la / ln coordinates, t type
  notice.hidden = res.headers.get('X-Needs-Zoom') !== 'true';
  layer.clearLayers();
  places.forEach(p => {
    const popup = document.createElement('strong');
    popup.textContent = p.n;                                 // text, never HTML from the data
    L.marker([p.la, p.ln], { title: p.n }).bindPopup(popup).addTo(layer);
  });
}

let timer;
map.on('moveend', () => { clearTimeout(timer); timer = setTimeout(refresh, 250); });
refresh();

Notes