Skip to content
campaiDeveloper
campai.com

Build the calendar on your own website and read its event data from your server. The API key stays on that server. Your public page receives only the event fields you deliberately expose.

This guide reads individual event occurrences, selects public offers, and generates a complete HTML event list you can open and add to your website. It does not create bookings or bypass an offer's booking restrictions.

Read Understand money, dates, and data types before mapping amounts, dates, IDs, or missing fields into your own system.

What you’ll have at the end

A public event list with titles and local times, generated on your server from public occurrences.

  1. Public events
  2. All pages
  3. Window overlap
  4. HTML event list
Before you start
  • Mandates Read and Events Read
  • A server runtime and an explicit display window
  • The official share link if you want to add booking navigation
On this page

Create a key and find your organization and mandate IDs. Replace YOUR_API_KEY, YOUR_ORGANIZATION_ID, and YOUR_MANDATE_ID directly in your chosen example. Each tab is complete and uses only cURL or the runtime’s built-in functions.

The key needs Mandates → Read and Events → Read. Use POST /{organizationId}/{mandateId}/booking/events/list. Its response has an events array and a count property.

The current implementation applies both fromDate and toDate to period.to, the occurrence's end time. They are not a generic “starts between” filter. In particular, an event that starts inside a window but ends after toDate will not match that upper bound.

The example below asks for events that have not ended before the window begins, then your backend checks overlap locally: event.period.from < windowEnd and event.period.to >= windowStart. It does not send toDate, so a long event crossing the end of the calendar window is not lost. For large catalogues, add a supported userFilter on the start time after testing it against your data.

Other choices are explicit:

  • offerModes: ['public'] excludes private, protected, and disabled offers from this public feed.
  • canceled: false excludes canceled occurrences.
  • group: false returns occurrences rather than one grouped record per recurring series.
  • Sorting by period.from and _id gives stable ordering when several events start together.

These filters do not guarantee that a visitor can book a seat. Booking windows, capacity, rates, and confirmation rules remain separate.

This example reads one page for a calendar window beginning November 1, 2026. Replace fromDate with your window's start timestamp.

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,
  "limit": 50,
  "offset": 0,
  "sort": {
    "period.from": "asc",
    "_id": "asc"
  }
}'

Read subsequent pages with offsets 50, 100, and so on until fewer than 50 events are returned. Apply the overlap check above to each occurrence before including it in the feed, and deduplicate by _id.

Your public response should contain only _id, info.title, period.from, period.to, and schedule.timezone. Keep instructor records, internal statistics, and other administrative fields on your server. A recurring series can have several occurrence IDs; do not deduplicate by title or series ID if you want every date.

  1. Download the renderer into a private server directory. Choose Node.js 22+ or PHP 8.2+ with HTTPS streams; no package installation is needed.
  2. Open render.mjs or render.php and replace the three key and scope placeholders. Keep this source file outside your public website and out of source control once it contains a key.
  3. Run node render.mjs or php render.php in that directory. The default window begins now and ends 30 days later. Change windowStart and windowEnd together if you need a fixed month.
  4. Open the generated events.html in a browser. Compare a known public event with campai before publishing that HTML file or adapting its list markup into your website template.

The complete calendar renderer

Choose render.mjs for Node.js or render.php for PHP. Each file independently generates events.html from public occurrences in the next 30 days.

Download ZIP
View and copy the complete source

The complete calendar renderer

import { open, rename, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';

// Run on your server, in a private directory outside the website's public root.
const apiKey = 'YOUR_API_KEY';
const organization = 'YOUR_ORGANIZATION_ID';
const mandate = 'YOUR_MANDATE_ID';
const output = resolve('events.html');
if (!/^[a-f0-9]{24}$/i.test(organization) || !/^[a-f0-9]{24}$/i.test(mandate) || apiKey === 'YOUR_API_KEY') {
  throw new Error('Replace the key and full organization/mandate IDs first.');
}
const url = `https://cloud.campai.com/api/${organization}/${mandate}/booking/events/list`;
const limit = 50;
const records = new Map();
const windowStart = new Date();
const windowEnd = new Date(windowStart.getTime() + 30 * 86400_000);
const lockPath = output + '.lock';
const temporary = output + '.' + process.pid + '.tmp';
// An existing lock means a run is active, or a stopped run needs investigation.
const lock = await open(lockPath, 'wx', 0o600);
try {
  let finished = false;
  for (let page = 0; page < 10_000; page++) {
    const response = await fetch(url, {
      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 }),
      redirect: 'error', signal: AbortSignal.timeout(30_000),
    });
    if (!response.ok) throw new Error(`campai HTTP ${response.status}; previous output kept.`);
    const result = await response.json();
    const rows = result.events;
    if (!Array.isArray(rows) || rows.length > limit) throw new Error('Unexpected events response.');
    for (const row of rows) {
      if (!row || typeof row._id !== 'string' || !/^[a-f0-9]{24}$/i.test(row._id)) throw new Error('Invalid record ID.');
      if (records.has(row._id)) throw new Error('Records moved during pagination. Run a fresh scan.');
      records.set(row._id, row);
    }
    if (rows.length < limit) { finished = true; break; }
  }
  if (!finished) throw new Error('Page limit reached; previous output kept.');
  const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
  const items = [...records.values()].flatMap(event => {
    const start = new Date(event.period?.from), end = new Date(event.period?.to);
    if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) throw new Error('Invalid event period.');
    if (!(start < windowEnd && end >= windowStart)) return [];
    const zone = event.schedule?.timezone;
    if (typeof zone !== 'string' || !zone || typeof event.info?.title !== 'string') throw new Error('Missing public event fields.');
    const format = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short', timeZone: zone });
    return [`<li><h2>${escape(event.info.title)}</h2><p>${escape(format.format(start))} – ${escape(format.format(end))} (${escape(zone)})</p></li>`];
  });
  const content = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Upcoming events</title><main><h1>Upcoming events</h1><p>Updated ${escape(new Date().toISOString())}. Check the club’s booking page for availability.</p>${items.length ? '<ul>' + items.join('') + '</ul>' : '<p>No public events in this window.</p>'}</main></html>`;
  const file = await open(temporary, 'wx', 0o600);
  try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
  // Both files are in the same directory: readers see either complete version.
  await rename(temporary, output);
  console.log(`Saved ${records.size} records to ${output}`);
} finally {
  await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
  await lock.close();
  await unlink(lockPath);
}

Your finished page: an “Upcoming events” heading followed by each public occurrence’s title, local start and end time, and time zone. A valid empty result displays “No public events in this window.” The page also shows when it was generated. Your site can style the ordinary headings and list items; there is no browser-side API call.

The renderer follows every page, checks window overlap, escapes titles as text, and formats times in each event’s configured time zone. It keeps the API key and all other event fields out of the HTML. Invalid dates, missing required display fields, or a failed API page stop the run and preserve the previous complete HTML file.

Copy only events.html to the public site after review. The script and its lock/temp files stay private. If you run it on a schedule, use the same working directory so overlapping runs share the lock. Investigate a stale lock only after confirming the previous process has stopped. Set a maximum acceptable age for the generated page and stop presenting stale availability as current.

Keep startsAt and endsAt as timestamps in your feed. Format them for your visitor using Intl.DateTimeFormat and the returned timeZone, and show the time zone when visitors may be in different regions. Do not truncate an ISO timestamp to create a local calendar date: an event near midnight can belong to a different local day.

Render titles as text, not HTML. If you add descriptions, establish a sanitization policy before rendering rich content. If you include images, refresh their signed resource URLs instead of treating them as permanent identifiers.

Use the booking link copied from campai's event sharing interface for the intended offer. Do not construct an undocumented booking URL from an event ID. The event-list operation supplies event data; it does not reserve a seat or authenticate the website visitor.

Compare one known public occurrence with the same event in the admin app: title, local start and end time, cancellation state, and series membership. Check a recurring event, an event spanning midnight, and a canceled event. Confirm private offers are absent from the public feed.

Cache the projected feed on your server for an interval suitable for the calendar. Refresh after your own event changes and on a regular schedule. The current record-change webhook types do not include bookings or events, so do not depend on an invented events.updated notification.

An empty array is a valid result for a window without matching public events. On an API error, retain the last successful feed with an explicit freshness policy; do not replace it with an empty calendar and silently imply that events were removed.

For a styled page with title search, local-date filtering, and verified booking buttons, continue with Build a searchable course calendar.

Check your result

Private or canceled occurrence
It is absent from the public page.
Event crossing the window end
It remains when its period overlaps the window.
API failure
The last complete HTML file is preserved.
Continue with Build a searchable course calendar for your website