Give visitors one place to find your club’s public sessions: search by activity, choose a start date, and open the right booking page. The complete program below generates a light, responsive calendar with a visible refresh time. You can open the HTML file immediately and then publish it on your own website.
This is an external API integration. Your server reads campai and builds the page; the visitor’s browser receives only the finished calendar. A hosted Developer App fits an internal administrative tool, such as the membership dashboard. It is not needed for this public website page.
What you’ll have at the end
A complete public course calendar with search, local-date filters, and verified booking links.
- Your private renderer
- campai public occurrences
- Reviewed HTML file
- Visitor searches and opens booking
Before you start
- Node.js 22+ or PHP 8.2+ CLI with HTTPS streams
- A key with Mandates Read and Events Read
- Two synthetic occurrences and their public Share links
On this page
Use Node.js 22+ or PHP 8.2+ CLI with working HTTPS streams. Neither version needs packages. Put the chosen program in a private directory outside your website root.
- Create two disposable events in Events / Rentals → Events. Give them different titles and dates within the next 30 days. Leave instructors, attendees, resources, and payment settings unused for this first test.
- In each event, review Offering → Booking visibility. The calendar includes only Public offers. A protected offer is publicly visible in campai but deliberately excluded by this example’s stricter rule.
- Create a key with Mandates → Read and Events → Read, restricted to the chosen mandate.
- Find the full organization and mandate IDs. These are separate values; the short organization identifier in an admin URL is not the full organization ID.
- Copy each occurrence’s full ID from its event-detail URL. Keep the configured source and key out of source control.
Open the complete source at the end of this guide. Choose render.mjs or render.php; you need only one.
| Setting | What to enter |
|---|---|
apiKey |
Your private read-only API key. |
organization |
The full organization ID. |
mandate |
The full mandate ID allowed by the key. |
clubName |
The public club name in the calendar introduction. |
eventIds |
Your two synthetic occurrence IDs for the first run. |
bookingLinks |
Occurrence IDs mapped to their exact public Share URLs. |
The test IDs are sent in the API request’s selection.ids; they restrict the actual read. An empty eventIds list means all public occurrences in the mandate, so make that change deliberately after reviewing your public catalogue. The code rejects IDs outside a nonempty test selection.
In each intended event, open Share and copy its public booking link. Add that exact link under the matching occurrence ID. Keep authenticated admin URLs, credentials, and private tokens out of this map. Do not construct a URL from an event ID or assume every series occurrence shares the same booking destination.
A missing mapping is safe: the card asks the visitor to contact the club instead of displaying a guessed booking button. The program accepts only HTTPS links without embedded usernames or passwords; you still need to verify that each link leads to the intended public offer.
Run node render.mjs or php render.php from the private directory. A successful run reports how many public sessions it saved to course-calendar.html.
Open that HTML file in your browser. Its CSS and search code are included in the file, so it needs no separate frontend build, font download, or package installation. No API key or campai request belongs in the visitor’s browser.
Compare each card with campai: title, local date, start time, end time, and named time zone. Open Details & booking and check the destination, stopping before submitting a booking. That booking page owns capacity, registration requirements, payment, and confirmation.
The card’s presence does not promise an available seat. An occurrence’s period.status: "available" means it is not blocked by a resource or calendar; it is not a seat count.
Type part of a title into Search sessions. The result count updates immediately. Choose Starts on to keep occurrences whose start date matches in their own time zone. Clear filters restores the complete list.
Try a title that does not exist. You should see “No sessions match these filters” and a way back through Clear filters. This differs from a valid empty API scan, which displays “No public sessions in this window.”
An ongoing multi-day occurrence can appear in the full list while not matching today’s start-date filter. Its start date has not changed just because it continues today. Without JavaScript, the full calendar and refresh timestamp remain readable; the filter controls stay disabled.
The renderer requests List events with group: false, so recurring occurrences remain separate records. It follows pages of 50, sorts by start time and ID, rejects duplicate IDs, and stops at a bounded maximum of 5,000 records. It writes the final file only after the complete scan succeeds.
The window begins when the program runs and lasts 30 days. The API’s fromDate and toDate filters apply to the occurrence’s end time, not a generic start-date range. The program sends only fromDate, then checks overlap locally: start before the window ends and end on or after the window begins. This retains an ongoing event and a long event ending beyond the window.
Before rendering, it checks public visibility again and excludes canceled or blocked occurrences. Missing visibility fields, malformed timestamps, unknown time zones, incomplete pages, and oversized responses stop the refresh. Titles are escaped as text. The public file contains only the selected title/date/time-zone fields and the booking links you supplied; instructors, internal statistics, online meeting instructions, and prices are excluded.
Offset pagination is not a database snapshot. Duplicate detection catches overlapping pages, but it cannot prove that no record moved or was inserted during a scan. Schedule frequent complete refreshes and review changes before using this pattern for a large, rapidly changing catalogue.
Use this private terminal request to diagnose access or filters before changing the renderer. Replace the key, full scope IDs, occurrence ID, and example start timestamp. The response contains an events array; the complete programs project its public fields instead of exposing the raw response on your website.
Example
curl --fail-with-body --silent --show-error --max-time 30 --globoff \
--request POST 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/booking/events/list' \
--header 'X-API-Key: YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data-raw '{
"fromDate": "2026-11-01T00:00:00.000Z",
"offerModes": [
"public"
],
"canceled": false,
"group": false,
"selection": {
"ids": [
"YOUR_EVENT_ID"
]
},
"limit": 50,
"offset": 0,
"sort": {
"period.from": "asc",
"_id": "asc"
}
}'The request reads one page. Subsequent offsets are 50, 100, and so on until fewer than 50 records arrive. Keep group: false and identify occurrences by _id, not by title. See money, dates, and data types for the distinction between instants and local dates.
| Test | Expected result |
|---|---|
| Change a test title or date, then rebuild | The next complete file contains the saved change. |
| Make a test offer Private, then rebuild | That occurrence disappears. |
| Cancel a test occurrence, then rebuild | That occurrence disappears. |
| Use two occurrences from a recurring series | Both dates remain separate when they match the window. |
| Use an occurrence spanning midnight or the window boundary | Local dates and overlap remain correct. |
| Remove one booking-link mapping | Its card shows the contact-the-club message. |
| Revoke the key or simulate a failed later page | The run fails and the previous complete file remains unchanged. |
| Return a valid empty selection | A new page explicitly states there are no public sessions. |
| Start a second run in the same directory | The exclusive lock stops it from replacing the first run’s output. |
A failed refresh must not look like an empty catalogue. Diagnose the failing request, restore access if appropriate, and start a new complete scan. Do not remove a lock until you have confirmed that its process has stopped.
Copy only course-calendar.html to your public website after review. Source, credentials, locks, and temporary files stay private. The generated file starts with private filesystem permissions; your deployment process deliberately publishes the reviewed HTML.
Run the chosen program regularly from the same working directory and rebuild after event changes. Current record-change webhooks do not provide an events.updated trigger. Log failures and alert an operator if a refresh is missed.
The page shows its generation time and, with JavaScript enabled, warns after 24 hours using the visitor’s clock. That warning does not refresh or withdraw the file. A cancellation or change from Public to Private reaches the website only after a successful rebuild. For an urgent withdrawal, remove the old public HTML and its cached copies directly. Define when an outdated calendar should be taken down instead of remaining available.
The source viewer and ZIP use the same files. Choose the Node.js or PHP program, follow the included setup notes, and start with the two synthetic IDs. cURL is available in the request above for API diagnosis; it does not generate this complete website interface.
The complete searchable course calendar
Choose the standalone Node.js or PHP program. Configure your private key, scope, test occurrence IDs, and verified booking links; publish only its generated HTML.
Download ZIPView and copy the complete source
The complete searchable course calendar
import { open, rename, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
// Keep this program private. Publish only the generated course-calendar.html.
const apiKey = 'YOUR_API_KEY';
const organization = 'YOUR_ORGANIZATION_ID';
const mandate = 'YOUR_MANDATE_ID';
const clubName = 'Your club';
// During testing, list only your disposable occurrence IDs. Empty means all public events.
const eventIds = [];
// Map occurrence IDs to their exact public Share URLs copied from campai. No guessed URLs.
const bookingLinks = {};
const output = resolve('course-calendar.html');
const windowStart = new Date();
const windowEnd = new Date(windowStart.getTime() + 30 * 86400_000);
const limit = 50;
const maximumRecords = 5000;
const maximumResponseBytes = 2 * 1024 * 1024;
const id = value => typeof value === 'string' && /^[a-f0-9]{24}$/i.test(value);
if (!id(organization) || !id(mandate) || !apiKey || apiKey === 'YOUR_API_KEY' || /\s/.test(apiKey)) {
throw new Error('Set the API key and separate full organization/mandate IDs.');
}
if (typeof clubName !== 'string' || !clubName.trim() || clubName.length > 120) throw new Error('Set a short club name.');
if (!Array.isArray(eventIds) || eventIds.length > 100 || eventIds.some(value => !id(value)) || new Set(eventIds).size !== eventIds.length) {
throw new Error('Use up to 100 unique occurrence IDs, or an empty list.');
}
for (const [eventId, link] of Object.entries(bookingLinks)) {
if (!id(eventId) || typeof link !== 'string' || link.length > 2048) throw new Error('Invalid booking-link mapping.');
const parsed = new URL(link);
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new Error('Use a public HTTPS Share URL without credentials.');
}
const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
function instant(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)) throw new Error('Invalid event timestamp.');
const normalized = value.includes('.') ? value : value.replace('Z', '.000Z');
const result = new Date(value);
if (!Number.isFinite(result.getTime()) || result.toISOString() !== normalized) throw new Error('Invalid calendar date.');
return result;
}
async function jsonResponse(response) {
if (!response.ok) throw new Error(`campai HTTP ${response.status}; previous complete calendar kept.`);
if (!response.body) throw new Error('Missing API response body.');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8', { fatal: true });
let bytes = 0, text = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.length;
if (bytes > maximumResponseBytes) { await reader.cancel(); throw new Error('API page is too large.'); }
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
} finally { reader.releaseLock(); }
return JSON.parse(text);
}
const lockPath = output + '.lock';
const temporary = output + '.' + process.pid + '.tmp';
const lock = await open(lockPath, 'wx', 0o600);
try {
const records = new Map();
let finished = false;
for (let page = 0; page <= maximumRecords / limit; page++) {
const result = await jsonResponse(await fetch(`https://cloud.campai.com/api/${organization}/${mandate}/booking/events/list`, {
method: 'POST',
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ fromDate: windowStart.toISOString(), offerModes: ['public'], canceled: false, group: false,
sort: { 'period.from': 'asc', _id: 'asc' }, limit, offset: page * limit,
...(eventIds.length ? { selection: { ids: eventIds } } : {}),
}),
redirect: 'error', signal: AbortSignal.timeout(30_000),
}));
if (!Array.isArray(result.events) || result.events.length > limit) throw new Error('Unexpected events response.');
for (const event of result.events) {
if (!event || !id(event._id) || records.has(event._id)) throw new Error('Invalid or repeated event ID; start a fresh scan.');
if (eventIds.length && !eventIds.includes(event._id)) throw new Error('An event outside the selected test scope was returned.');
records.set(event._id, event);
if (records.size > maximumRecords) throw new Error('Calendar record limit exceeded.');
}
if (result.events.length < limit) { finished = true; break; }
}
if (!finished) throw new Error('Incomplete scan; previous complete calendar kept.');
const sessions = [];
for (const event of records.values()) {
if (typeof event.offer?.mode !== 'string' || !Object.hasOwn(event, 'cancelation') ||
!['available', 'blockedResource', 'blockedCalendar'].includes(event.period?.status)) throw new Error('Visibility fields are unavailable.');
// Recheck public visibility before projecting any fields into the public file.
if (event.offer.mode !== 'public' || event.cancelation !== null || event.period.status !== 'available') continue;
const start = instant(event.period.from), end = instant(event.period.to);
if (end <= start) throw new Error('Reversed or empty event period.');
if (!(start < windowEnd && end >= windowStart)) continue;
const title = event.info?.title;
const zone = event.schedule?.timezone;
if (typeof title !== 'string' || !title.trim() || title.length > 300 || typeof zone !== 'string' || !zone) throw new Error('Missing public display fields.');
const format = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short', timeZone: zone });
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: zone }).formatToParts(start).map(p => [p.type, p.value]));
sessions.push({ title, start: start.toISOString(), end: end.toISOString(), startMs: start.getTime(), zone,
startText: format.format(start), endText: format.format(end),
day: `${parts.year}-${parts.month}-${parts.day}`, dayNumber: parts.day,
month: new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric', timeZone: zone }).format(start),
weekday: new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: zone }).format(start),
booking: bookingLinks[event._id] ?? '',
});
}
sessions.sort((a, b) => a.startMs - b.startMs);
const cards = sessions.map(s => `<li class="session" data-day="${s.day}">
<div class="date-tile"><span class="day">${s.dayNumber}</span><span class="month">${escape(s.month)}<br>${escape(s.weekday)}</span></div>
<div class="session-body"><h2>${escape(s.title)}</h2><dl class="times"><div><dt>From</dt><dd><time datetime="${s.start}">${escape(s.startText)}</time></dd></div><div><dt>Until</dt><dd><time datetime="${s.end}">${escape(s.endText)}</time></dd></div></dl><p class="zone">${escape(s.zone)}</p>
${s.booking ? `<a class="booking" href="${escape(s.booking)}" rel="noreferrer">Details & booking</a>` : '<p class="booking-note">Ask your club how to book this session.</p>'}</div></li>`).join('\n');
const generated = new Date();
const refreshed = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }).format(generated) + ' UTC';
const css = `/* Unmodified standard Tailwind palette tokens; embedded so this page needs no CSS download. */
:root { color-scheme: light;
--color-white: #fff;
--color-red-50: oklch(97.1% 0.013 17.38);
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-900: oklch(39.6% 0.141 25.723);
--color-neutral-50: oklch(98.5% 0 none);
--color-neutral-100: oklch(97% 0 none);
--color-neutral-200: oklch(92.2% 0 none);
--color-neutral-500: oklch(55.6% 0 none);
--color-neutral-600: oklch(43.9% 0 none);
--color-neutral-900: oklch(20.5% 0 none);
--color-neutral-950: oklch(14.5% 0 none);
}
*{box-sizing:border-box}
body{margin:0;background:var(--color-neutral-50);color:var(--color-neutral-950);font:16px/1.6 system-ui,sans-serif}
main{max-width:1040px;margin:auto;padding:32px 28px 40px}
h1{font-size:clamp(30px,5vw,40px);line-height:1.12;letter-spacing:-.045em;margin:0 0 20px}
h2{font-size:22px;line-height:1.3;letter-spacing:-.025em;margin:0 0 16px}
p{margin:0 0 16px}
.intro{max-width:680px;color:var(--color-neutral-600);font-size:18px}
.updated{font-size:13px;color:var(--color-neutral-600)}
.filters{display:flex;gap:18px;flex-wrap:wrap;padding:22px;border:1px solid var(--color-neutral-200);border-radius:18px;background:var(--color-white);margin:20px 0 20px}
.field{display:flex;flex-direction:column;gap:7px;flex:1;min-width:200px}
.field label{font-size:14px;font-weight:650}
input,button,a{font:inherit}
input{min-height:46px;width:100%;padding:10px 12px;border:1px solid var(--color-neutral-200);border-radius:9px;background:var(--color-white);color:var(--color-neutral-950)}
input:focus-visible,button:focus-visible,a:focus-visible{outline:3px solid var(--color-red-500);outline-offset:3px}
.date-filter{flex:0 1 230px}
.reset{align-self:end;min-height:46px;border:1px solid var(--color-neutral-200);border-radius:9px;background:var(--color-white);padding:10px 16px;cursor:pointer}
.results{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;color:var(--color-neutral-600);font-size:14px;margin:0 0 18px}
.cards{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}
.session{display:flex;flex-direction:column;background:var(--color-white);border:1px solid var(--color-neutral-200);border-radius:18px;overflow:hidden}
.session[hidden],.notice[hidden]{display:none}
.session-body{padding:22px;display:flex;flex-direction:column;flex:1}
.date-tile{display:flex;align-items:center;gap:16px;background:var(--color-red-50);color:var(--color-red-900);padding:16px 22px;border-bottom:1px solid var(--color-neutral-200)}
.day{font-size:36px;line-height:1;font-weight:650;letter-spacing:-.06em}
.month{font-size:14px;line-height:1.4}
.times{color:var(--color-neutral-600);font-size:14px;display:grid;gap:7px;margin-bottom:12px}
.times div{display:grid;grid-template-columns:42px 1fr;gap:8px}
.times dt{font-weight:650}
.times dd{margin:0}
.zone{font-size:13px;color:var(--color-neutral-600);overflow-wrap:anywhere}
.booking{display:block;margin-top:auto;padding:11px 14px;background:var(--color-neutral-950);color:var(--color-white);border-radius:9px;text-decoration:none;text-align:center;font-size:14px;font-weight:600}
.booking:hover{background:var(--color-neutral-900)}
.booking-note{margin-top:auto;border-top:1px solid var(--color-neutral-200);padding-top:14px;font-size:14px;color:var(--color-neutral-600)}
.notice{padding:20px 22px;border:1px solid var(--color-neutral-200);border-radius:14px;background:var(--color-white);margin:20px 0}
.stale{color:var(--color-red-900);background:var(--color-red-50)}
footer{font-size:13px;color:var(--color-neutral-600);margin-top:30px}
button:disabled,input:disabled{opacity:.65;cursor:default}
@media(max-width:900px){.cards{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:580px){main{padding:32px 18px}
.cards{grid-template-columns:1fr}
.filters{padding:18px;gap:14px}
.field,.date-filter{min-width:0;flex:1 1 100%}
.reset{width:100%}
.session-body{padding:20px}
}
`;
const script = `(() => {
const search = document.querySelector('#search');
const date = document.querySelector('#starts-on');
const reset = document.querySelector('#reset');
const cards = Array.from(document.querySelectorAll('.session'));
for (const card of cards) card.dataset.search = card.querySelector('h2').textContent.toLocaleLowerCase('en-US');
const status = document.querySelector('#result-count');
const empty = document.querySelector('#filter-empty');
function update() {
const term = search.value.trim().toLocaleLowerCase('en-US');
let count = 0;
for (const card of cards) {
card.hidden = !(card.dataset.search.includes(term) && (!date.value || card.dataset.day === date.value));
if (!card.hidden) count++;
}
status.textContent = count + (count === 1 ? ' session shown' : ' sessions shown');
empty.hidden = count !== 0 || cards.length === 0;
}
for (const control of [search, date, reset]) control.disabled = false;
search.addEventListener('input', update);
date.addEventListener('input', update);
reset.addEventListener('click', () => { search.value = ''; date.value = ''; update(); search.focus(); });
const generated = Number(document.querySelector('main').dataset.generated);
function checkAge() {
document.querySelector('#stale').hidden = !(Number.isFinite(generated) && Date.now() - generated > 86400000);
}
update(); checkAge(); setInterval(checkAge, 60000);
})();`;
const content = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(clubName)} sessions</title><style>${css}</style></head><body><main data-generated="${generated.getTime()}">
<h1>Find your next club session</h1><p class="intro">Explore ${escape(clubName)}’s public sessions for the next 30 days. Open the booking page to check current availability and registration requirements.</p><p class="updated">Refreshed ${escape(refreshed)}. Times below use each session’s named time zone.</p>
<p id="stale" class="notice stale" hidden>This calendar was last refreshed more than 24 hours ago. Check the club’s booking page before making plans.</p>
<div class="filters" role="search" aria-label="Find a club session"><div class="field"><label for="search">Search sessions</label><input id="search" type="search" placeholder="Try a course or activity" disabled></div><div class="field date-filter"><label for="starts-on">Starts on (session’s local date)</label><input id="starts-on" type="date" disabled></div><button class="reset" id="reset" type="button" disabled>Clear filters</button></div>
<div class="results"><span id="result-count" role="status" aria-live="polite">${sessions.length} sessions shown</span><span>Public sessions · next 30 days</span></div><noscript><p>Filters need JavaScript. All sessions are listed below.</p></noscript>
${sessions.length ? `<ul class="cards">${cards}</ul>` : '<p class="notice">No public sessions in this window. Check back after the next calendar refresh.</p>'}<p id="filter-empty" class="notice" hidden>No sessions match these filters. Clear the filters or choose another date.</p>
<footer>Dates and titles are a saved calendar, not a live seat count. Booking, payment, and confirmation happen on the linked booking page.</footer></main><script>${script}</script></body></html>`;
const file = await open(temporary, 'wx', 0o600);
try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
await rename(temporary, output);
console.log(`Saved ${sessions.length} public sessions to ${output}`);
} finally {
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
await lock.close();
await unlink(lockPath);
}
Once the page works, adapt its markup to your website while preserving the public-field projection, escaping, named time zones, visibility checks, and refresh boundary. If you need to send booking changes to another system, continue with Synchronize bookings with your system.
Check your result
- Private or canceled occurrence
- Absent after a successful rebuild.
- Unknown title or local start date
- A clear empty-filter result, with Clear filters available.
- Failed API page or invalid key
- The prior complete HTML stays unchanged; the run fails.