Skip to content
campaiDeveloper
campai.com

Treat campai as the source for booking state and keep a local projection keyed by the booking's _id. Booking state, payment state, cancellations, and individual records describe different parts of the booking. Copying only a displayed status label loses information.

This guide reads bookings without creating, canceling, confirming, or charging them. It uses polling because the current record-change webhook types do not include booking events.

What you’ll have at the end

A private booking snapshot you can refresh and import by stable booking ID, keeping each state field separate.

  1. Poll pages
  2. Match booking IDs
  3. Compare state
  4. Apply reviewed changes
Before you start
  • Mandates Read and Bookings Read
  • A reviewed local booking table keyed by booking ID
  • A scheduler you control
On this page

Create a key and find your organization and mandate IDs. The key needs Mandates → Read and Bookings → Read.

POST /{organizationId}/{mandateId}/booking/bookings/list lists bookings across the selected mandate. Set type: 'event' for event bookings or type: 'resource' for resource bookings. An event-specific list is a separate operation: List bookings for an event.

The general booking list returns bookings and count. It uses offset pagination. Its published response has createdAt but does not provide a general updatedAt change cursor. Do not query only newly created bookings and assume you will capture later payments or cancellations.

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. This request reads the first 50 event bookings:

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/bookings/list' \
  --header 'X-API-Key: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "type": "event",
  "limit": 50,
  "offset": 0,
  "sort": {
    "createdAt": "asc",
    "_id": "asc"
  }
}'

Repeat with offsets 50, 100, and so on until a page contains fewer than 50 bookings. Keep the same sort. Deduplicate by _id and upsert records into your own database under (organizationId, mandateId, bookingId).

Retain status, paymentStatus, records, createdAt, and cancelations separately. Preserve the API spelling cancelations; changing it to cancellations would discard the actual response value.

A full polling pass is not an atomic database snapshot. Stable sorting and deduplication help, but concurrent changes can shift offset pages. Repeat reconciliation and avoid deleting local records solely because they were absent from one pass.

The page request above proves the connection. To complete a scan, download the exporter below into a private directory outside your web root. Choose Node.js 22+ or PHP 8.2+ with HTTPS streams, fill the three credential/scope placeholders at the top of the chosen file, then run node export.mjs or php export.php from that directory.

The complete booking exporter

Choose one standalone file. It writes booking-snapshot.json with each booking’s separate status, paymentStatus, and cancelations fields.

Download ZIP
View and copy the complete source

The complete booking exporter

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('booking-snapshot.json');
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/bookings/list`;
const limit = 100;
const records = new Map();

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({ sort: { _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.bookings;
    if (!Array.isArray(rows) || rows.length > limit) throw new Error('Unexpected bookings 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.');
      const booking = { _id: row._id };
      for (const field of ['status', 'paymentStatus', 'cancelations']) {
        if (Object.hasOwn(row, field)) booking[field] = row[field];
      }
      records.set(row._id, booking);
    }
    if (rows.length < limit) { finished = true; break; }
  }
  if (!finished) throw new Error('Page limit reached; previous output kept.');
  const content = JSON.stringify({ organization, mandate, completedAt: new Date().toISOString(), records: Object.fromEntries(records) }, null, 2) + '\n';
  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);
}

Open booking-snapshot.json locally. It contains organization, mandate, completedAt, and records keyed by booking _id. Each projection keeps status, paymentStatus, and cancelations when those fields are present. No API key is written to the output. Treat the file as private booking data.

You should see: Saved N records to …/booking-snapshot.json. A second run produces the same record identities. An API error or incomplete scan keeps the old file; an empty successful scan produces an empty records object. Neither result is permission to delete local bookings automatically.

The exclusive .lock file prevents overlapping exports. If the process is forcibly stopped, confirm it has exited before removing its stale lock or temporary file. Offset pagination can move while bookings change, so reconcile again after busy intervals.

For your importer, use (organization, booking _id) as the external key, compare the three state fields independently, and advance your local checkpoint only after the import commits. Run the same snapshot twice before enabling a recurring job; there must be no duplicate local bookings or repeated notifications.

Keep status and paymentStatus as separate API values. Do not translate them into one Boolean such as “active” until you have defined your business rule. A confirmed booking, a paid booking, and a currently valid access entitlement are different conditions.

Review records and cancelations when handling partial changes. A booking can represent more than one participant or service. A change to one record must not automatically revoke or grant everything attached to the booking.

If your consumer needs cancellation receipt details, retrieve GET /{organizationId}/{mandateId}/booking/bookings/{bookingId}. Its detail response adds cancellationReceipts to the booking fields. Use the exact schema for your booking type rather than assuming event and resource details have the same shape.

For each polling pass, stage the received projection, compare it with the previous projection, then commit the new state. Only run a downstream action when a relevant state transition occurs. Re-reading an unchanged booking should produce no second email, access grant, or payment request.

Record the last successful synchronization time and the pass's completion status. A failed request is not a deletion signal. If you narrow the scope by contact or booking type, keep that scope with the checkpoint so records outside the query are not mistakenly removed.

In a test mandate, compare a booking's API values with its detail page in campai. Run your synchronization twice without changes; the second run should leave your local projection unchanged. Then test an authorized state change on a disposable test booking and confirm that polling updates the existing local record instead of inserting another one.

Include canceled, partially changed, unpaid, and empty-list cases in your tests. For an access-control consumer, continue with Plan a door-access integration; a booking record alone is not a complete door policy.

Check your result

Repeated booking
No second local booking is created.
Cancellation or payment change
Update the matching local record independently.
Interrupted scan
Do not infer that unseen bookings were deleted.
Continue with Plan a door-access integration