Display a cinema’s showtimes on any website, in HTML and JavaScript

A div and a script show a cinema’s programme for the coming days, posters and booking links included, kept up to date by a 50-line PHP or Node.js relay that holds your key and caches the answers. No framework.

7 min read

On this page
  1. Why a relay, and not the key in the page
  2. What you need
  3. 1.The relay
  4. 2.The script
  5. 3.The page and a little style
  6. 4.How it works
  7. 5.No PHP on your host? The same relay in Node.js
  8. Going further

A cinema's website, a town hall page, a tourism office or a local news site: the programme must be there and must stay up to date without anyone retyping it on Monday morning. This tutorial does it with one <div> and one script in the page, plus one small server-side file that holds your API key and caches the answers. No framework, no build step, no dependency.

HTML
<div class="cinema-showtimes" data-cinema="le-champo-paris-5e-arrondissement" data-days="7"></div>
<script src="showtimes.js" defer></script>

The result: the coming days, film by film, with poster, running time, director and one link per screening to the cinema's booking page.

Why a relay, and not the key in the page

Everything in a web page can be read, so an API key in showtimes.js would be found in minutes and anyone could spend your quota. The page therefore calls your own server (a 50-line showtimes.php, or a Node.js file if your host has no PHP), which adds the key, calls API Cinéma, and keeps each answer for two hours.

browser ──GET showtimes.php?cinema=…──▶ your server ──Bearer key──▶ API Cinéma
 (no key)                                (the key, a 2-hour cache)

The cache also means a page viewed 10,000 times a day costs 12 requests a day: within the free plan.

What you need

  • A web host that runs PHP (almost every shared host does) or Node.js: see step 5 for the Node.js version.
  • An API Cinéma key: create a free account, confirm your e-mail address, then create a key from the dashboard. The free plan (150 requests a week) is enough to try it or for a non-commercial site, thanks to the cache; a cinema's or business website needs a paid plan, from €12 a month.
  • The slug of the cinema. Search it by name:
Terminal
curl "https://api.api-cinema.com/v1/cinemas?q=champo" -H "Authorization: Bearer ac_live_…"

The answer gives "slug": "le-champo-paris-5e-arrondissement". Any French or US cinema of the API works the same way.

The relay: showtimes.php

Put your key in the define line and upload the file next to your page:

showtimes.php
<?php
// Relay between the page and API Cinéma: holds the key, caches each cinema's programme for two hours,
// answers only GET showtimes.php?cinema=<slug>&days=<1-14>. Upload it next to showtimes.js.
define('API_CINEMA_KEY', 'ac_live_…');
const CACHE_SECONDS = 2 * 3600;

$cinema = $_GET['cinema'] ?? '';
$days = max(1, min(14, (int) ($_GET['days'] ?? 7)));
header('Content-Type: application/json; charset=utf-8');
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $cinema) || strlen($cinema) > 80) {
    http_response_code(400);
    exit('{"error":"invalid cinema"}');
}

$cacheFile = sys_get_temp_dir() . '/api-cinema-' . $cinema . '-' . $days . '.json';
if (is_file($cacheFile) && filemtime($cacheFile) > time() - CACHE_SECONDS) {
    header('X-Cache: hit');
    readfile($cacheFile);
    exit;
}

$url = 'https://api.api-cinema.com/v1/cinemas/' . rawurlencode($cinema) . '/showtimes?' . http_build_query([
    'to' => date('Y-m-d', strtotime('+' . ($days - 1) . ' days')),
    'include' => 'movie',
    'limit' => 1000,
]);
$body = @file_get_contents($url, false, stream_context_create(['http' => [
    'header' => 'Authorization: Bearer ' . API_CINEMA_KEY,
    'timeout' => 10,
    'ignore_errors' => true,
]]));
$status = isset($http_response_header[0]) && preg_match('#HTTP/\S+ (\d{3})#', $http_response_header[0], $m) ? (int) $m[1] : 0;
if ($status === 404) {
    http_response_code(404);
    exit('{"error":"unknown cinema"}');
}
$data = $status === 200 ? json_decode($body, true)['data'] ?? null : null;

if ($data === null) {
    if (is_file($cacheFile)) { // the API is unreachable: serve the last programme rather than nothing
        header('X-Cache: stale');
        readfile($cacheFile);
        exit;
    }
    http_response_code(502);
    exit('{"error":"api unavailable"}');
}

$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
file_put_contents($cacheFile, $json, LOCK_EX);
header('X-Cache: miss');
echo $json;

What it does: it accepts only a cinema slug (letters, digits and dashes) and a number of days from 1 to 14, so nobody can use it to call any other route with your key. It keeps the answer in a file for two hours. When API Cinéma cannot be reached, it serves the last programme it has rather than an error. The data goes through unchanged: showtimes.js reads the same fields as the API.

The script: showtimes.js

showtimes.js
// Fills every <div class="cinema-showtimes" data-cinema="…"> with the cinema's programme, day by day, film by film.
// The page never holds the API key: it asks your relay (showtimes.php or server.js), which calls API Cinéma.
const TEXT = { loading: 'Loading the programme…', empty: 'No showtimes in the coming days.', error: 'Programme unavailable for now.' }
const LANG = document.documentElement.lang || navigator.language
const dayFormat = new Intl.DateTimeFormat(LANG, { weekday: 'long', day: 'numeric', month: 'long' })

document.querySelectorAll('.cinema-showtimes[data-cinema]').forEach(fill)

async function fill(root) {
  root.textContent = TEXT.loading
  const params = new URLSearchParams({ cinema: root.dataset.cinema, days: root.dataset.days || '7' })
  try {
    const res = await fetch(`${root.dataset.relay || 'showtimes.php'}?${params}`)
    if (!res.ok)
      throw new Error(`relay ${res.status}`)
    const showtimes = await res.json()
    root.replaceChildren(...(showtimes.length ? render(showtimes) : [el('p', TEXT.empty)]))
  }
  catch (err) {
    console.error(err)
    root.textContent = TEXT.error
  }
}

// One <section> per day, one <article> per film, one link per screening (plain text when the cinema has no online booking).
function render(showtimes) {
  const days = new Map()
  for (const s of showtimes) {
    const day = s.startsAt.slice(0, 10)
    if (!days.has(day))
      days.set(day, new Map())
    const films = days.get(day)
    if (!films.has(s.movie.id))
      films.set(s.movie.id, { movie: s.movie, times: [] })
    films.get(s.movie.id).times.push(s)
  }

  return [...days].map(([day, films]) => {
    const section = el('section', '', { class: 'cs-day' })
    section.append(el('h2', dayFormat.format(new Date(`${day}T12:00:00`))))
    for (const { movie, times } of films.values()) {
      const article = el('article', '', { class: 'cs-film' })
      if (movie.posterUrl)
        article.append(el('img', '', { src: movie.posterUrl, alt: '', loading: 'lazy', width: 60 }))
      const body = el('div', '', { class: 'cs-body' })
      body.append(el('h3', movie.title))
      const meta = [movie.durationMin && `${movie.durationMin} min`, movie.director].filter(Boolean).join(' · ')
      if (meta)
        body.append(el('p', meta, { class: 'cs-meta' }))
      const list = el('p', '', { class: 'cs-times' })
      for (const s of times) {
        const label = s.startsAt.slice(11, 16) + (s.version !== 'VF' ? ` ${s.version}` : '') + (s.format !== '2D' ? ` ${s.format}` : '')
        list.append(s.bookingUrl ? el('a', label, { href: s.bookingUrl, target: '_blank', rel: 'noopener nofollow' }) : el('span', label))
      }
      body.append(list)
      article.append(body)
      section.append(article)
    }
    return section
  })
}

// A text node, never HTML: titles and links come from the cinemas' websites.
function el(tag, text, attrs = {}) {
  const node = document.createElement(tag)
  if (text)
    node.textContent = text
  for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v)
  return node
}

Everything is built with createElement and textContent: titles and links come from the cinemas' own websites and are never injected as HTML. Day names follow the lang attribute of the page (Monday 2 November on an English page, lundi 2 novembre on a French one).

The page and a little style

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Le Champo – This week's programme</title>
  <link rel="stylesheet" href="showtimes.css">
</head>
<body>
  <h1>Le Champo</h1>
  <p>51 rue des Écoles, Paris 5e</p>

  <div class="cinema-showtimes" data-cinema="le-champo-paris-5e-arrondissement" data-days="7"></div>

  <script src="showtimes.js" defer></script>
</body>
</html>

data-days goes from 1 to 14 (7 by default). data-relay is the address of the relay; leave it out when showtimes.php sits next to the page.

showtimes.css
.cinema-showtimes { max-width: 40rem; font-family: system-ui, sans-serif; }
.cs-day h2 { margin: 1.5em 0 0.5em; font-size: 1.1rem; }
.cs-day h2::first-letter { text-transform: uppercase; }
.cs-film { display: flex; gap: 0.8rem; margin: 0.8em 0; }
.cs-film img { width: 60px; height: 90px; object-fit: cover; border-radius: 4px; }
.cs-film h3 { margin: 0; font-size: 1rem; }
.cs-meta { margin: 0.1em 0; color: #666; font-size: 0.85rem; }
.cs-times { margin: 0.3em 0; }
.cs-times a, .cs-times span {
  display: inline-block; margin: 0.15em 0.3em 0.15em 0; padding: 0.15em 0.5em;
  border: 1px solid currentColor; border-radius: 4px; text-decoration: none; font-variant-numeric: tabular-nums;
}

Open the page: the programme appears after a short "Loading the programme…", and the second visit is served from the cache.

How it works

  • One request every two hours at most, per cinema and number of days. 12 requests a day, 84 a week, whatever the traffic.
  • to and no from. Without from, the API starts from now in the cinema's local time, so screenings already started are left out; to=2026-11-08 includes the whole of that day.
  • include=movie. The cinema is the same on every row: embedding only the film makes the answer lighter.
  • Local times. startsAt is the cinema's wall-clock time (2026-11-02T20:30:00): the script shows it as it is, whatever the time zone of the server or the visitor.
  • Versions and formats. VF (dubbed in French) is the default in France and is not shown; VO and VOST are added next to the time, as are 3D, IMAX or 4DX. For a US theater every screening is VO: change the test to !['VF', 'VO'].includes(s.version) to hide it.
  • Errors. A wrong slug gets a 400, an unknown cinema a 404, an unreachable API a 502 (or the stale cache): the page then shows "Programme unavailable for now." and logs the detail in the console.

No PHP on your host? The same relay in Node.js

server.js serves your public/ folder and answers GET /showtimes?cinema=…&days=… with the same rules and an in-memory cache. No package to install.

server.js
import { readFile } from 'node:fs/promises'
// Node.js relay, no dependency: serves the public/ folder and GET /showtimes?cinema=<slug>&days=<1-14>.
// Run with API_CINEMA_KEY=ac_live_… node server.js, then open http://localhost:3000.
import { createServer } from 'node:http'
import { extname, join, normalize } from 'node:path'

const KEY = process.env.API_CINEMA_KEY
const PORT = process.env.PORT || 3000
const CACHE_MS = 2 * 3600 * 1000
const cache = new Map() // "slug|days" → { at, body }
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml' }

createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost')
  if (url.pathname === '/showtimes')
    return showtimes(url, res)
  const file = url.pathname === '/' ? '/index.html' : normalize(url.pathname)
  try {
    const body = await readFile(join('public', file))
    res.writeHead(200, { 'Content-Type': TYPES[extname(file)] || 'application/octet-stream' }).end(body)
  }
  catch {
    res.writeHead(404).end('Not found')
  }
}).listen(PORT, () => console.log(`http://localhost:${PORT}`))

async function showtimes(url, res) {
  const cinema = url.searchParams.get('cinema') || ''
  const days = Math.max(1, Math.min(14, Number(url.searchParams.get('days')) || 7))
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(cinema) || cinema.length > 80)
    return json(res, 400, '{"error":"invalid cinema"}')

  const key = `${cinema}|${days}`
  const hit = cache.get(key)
  if (hit && Date.now() - hit.at < CACHE_MS)
    return json(res, 200, hit.body, 'hit')

  const to = new Date(Date.now() + (days - 1) * 86_400_000).toISOString().slice(0, 10)
  try {
    const api = await fetch(`https://api.api-cinema.com/v1/cinemas/${cinema}/showtimes?to=${to}&include=movie&limit=1000`, {
      headers: { Authorization: `Bearer ${KEY}` },
      signal: AbortSignal.timeout(10_000),
    })
    if (api.status === 404)
      return json(res, 404, '{"error":"unknown cinema"}')
    if (!api.ok)
      throw new Error(`API Cinéma ${api.status}`)
    const body = JSON.stringify((await api.json()).data)
    cache.set(key, { at: Date.now(), body })
    json(res, 200, body, 'miss')
  }
  catch (err) {
    console.error(err)
    if (hit)
      return json(res, 200, hit.body, 'stale') // the API is unreachable: last programme rather than nothing
    json(res, 502, '{"error":"api unavailable"}')
  }
}

function json(res, status, body, cacheHeader) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...(cacheHeader && { 'X-Cache': cacheHeader }) })
  res.end(body)
}

Put index.html, showtimes.js and showtimes.css in public/, set data-relay="/showtimes" on the <div>, then:

Terminal
echo '{"type":"module"}' > package.json
API_CINEMA_KEY=ac_live_… node server.js

Open http://localhost:3000. On a Node host (Render, Fly.io, a VPS), put API_CINEMA_KEY in its secrets. The relay of the React Native tutorial shows the same idea with a rate limit per address, useful on a busy site.

Going further

  • Several cinemas: the <div> works any number of times on a page; each cinema has its own cache.
  • Synopsis and genres: movie.synopsis and movie.genres are already in the answer; add them to render().
  • A film page: movie.tmdbId and movie.allocineId link the film to TMDB and Allociné.
  • Every cinema of a town: /v1/showtimes?city=… or near=lat,lng instead of a single cinema, with include=movie,cinema to get the cinema's name on each row; see the guide for France or for the US.
  • On WordPress: the WordPress tutorial does the same with a shortcode, no JavaScript.

Every parameter and field is in the documentation.