A club rents a studio or runs a training session. Its participants should be able to enter shortly before the session and lose access when their booking is canceled or their time ends. This recipe reads those booking facts from campai, produces a short-lived access plan, and lets you review and test it in a local controller interface.
This is an external API integration running on your server. Your lock provider owns badges, credentials, doors, and device behavior. A hosted Developer App can provide an internal review action, but it does not replace the external worker’s provider credentials, reconciliation, or expiry guarantees.
The included simulator makes the full workflow runnable before you choose a vendor. It never connects to a physical lock. You will finish with a working campai reader, a clear grant contract, and an interface for testing preview, application, cancellation, expiry, and lost responses. The last section explains which parts to map to your provider.
What you’ll have at the end
A runnable booking reader and local controller for reviewed, expiring door-access grants.
- campai booking and occurrence
- Your private planner
- Operator reviews the access plan
- Controller applies and enforces expiry
Before you start
- Node.js 22+ for the controller; Node.js or PHP 8.2+ for the planner
- Mandate-restricted Mandates Read, Events Read, and Bookings Read
- Two free synthetic single-person bookings and verified badge mappings
On this page
You need Node.js 22+ for the local controller. Choose Node.js 22+ or PHP 8.2+ CLI with HTTPS streams for the campai reader. The files use native runtime functions; no package installation or shared client is needed.
- In Events / Rentals → Events, create a private, nonrecurring test event. Choose a short session starting within the next 15 minutes that has not ended. The default rule permits entry 15 minutes early.
- Choose Single person as the booking mode. In Bookings, select Create booking, then Free input. Create two zero-price bookings, each with one synthetic participant, and turn off Notify buyer/applicant before saving each one. Do not attach real payment details, punch cards, or physical badges.
- Copy the full occurrence ID from its event-detail URL. Note each booking number shown in the booking list; resolve its full ID with the request below. A number such as
ABC-123is not the full booking ID. - Create a restricted API key with Mandates → Read, Events → Read, and Bookings → Read for that mandate.
- Find the separate full organization and mandate IDs. Keep the key and configured source in a private directory outside your public website.
Start with zero-price bookings so testing does not create a payment or refund. Paid and partially paid behavior can be exercised with local fixtures before using a provider’s designated test environment.
Run this request privately for each test booking number. Replace the exact-number placeholder in both the request and the result check. Confirm that the returned occurrence ID matches your event. The Node.js/PHP versions print only the mapping fields; cURL shows the raw API response, which must stay private. Search matches prefixes, so reject multiple results or a different full booking number.
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",
"searchTerm": "YOUR_EXACT_TEST_BOOKING_NUMBER",
"limit": 2,
"offset": 0,
"returnCount": true
}'Download the complete files at the end of this guide. Open plan.mjs or plan.php and configure the key, scope, and mappings list.
| Mapping field | Meaning |
|---|---|
bookingId |
The exact single-person booking to read. |
eventId |
The exact occurrence that booking must reference. |
subject |
The participant’s existing, verified identity in your provider. Use a synthetic identifier for the simulator. |
door |
The provider’s door identifier, verified for that occurrence. |
label |
A short review label, such as “Evening studio booking.” |
The buyer can book for someone else. A name, email address, or matching buyer record does not establish who owns a badge. An operator must verify the participant-to-provider mapping. The program does not enroll people or turn campai QR passes into door credentials.
This example supports one participant and one door per booking. It reads only the 1–50 IDs you configure, so there is no broad booking-list export or inferred identity match. The mappings are the worker’s complete desired scope. Removing a mapping can remove its existing grant on the next reconciliation; review that change deliberately.
A booking qualifies only when it is confirmed, has no cancellation records, contains exactly one participant, and uses no punch-card points. It must be free with null/paid payment status, or have a positive total price with fully paid status. Partial cancellation is denied conservatively, including a cancellation affecting only a supplementary service.
The mapped event must be uncanceled, unblocked, and have a valid start/end interval. Private visibility is permitted here because an operator has approved a specific participant and door mapping. The public course calendar has a different rule: it exports only Public offers.
By default, entry can begin 15 minutes before the session. Every generated grant expires at the earlier of the session end or 120 seconds after the scan began. The start is inclusive and the end is exclusive. The program never grants the full remaining session duration from a single old read.
An event too far in the future produces no current grant. Run the worker again as its early-entry window approaches. If a scan takes longer than its lease, it fails instead of publishing already expired evidence. Unknown or missing fields also fail the whole scan; they are not interpreted as an eligible free booking.
Run node simulator.mjs in the private directory and leave it running. Open the printed http://127.0.0.1:9351/ address.
The interface has four parts: a plan importer, the proposed decisions, a badge check, and the controller’s stored grants. At the start, the stored count is zero and every badge check is denied. The simulator binds only to your own computer; there is no vendor host or credential to configure.
The controller writes controller-state.json with private permissions and keeps it across restarts. Keep this state file out of source control. A second process in the same directory is blocked by a lock file; investigate a leftover lock only after confirming its process has stopped.
In another terminal, run node plan.mjs or php plan.php. A successful run writes access-plan.json and reports the number of proposed and denied grants. No provider request has happened.
Open that JSON file, copy its complete contents into Load a fresh plan, and select Preview plan. The importer closes so you can review the decisions without looking through JSON.
Check the label, provider identity, door, reason, expiry, and change counts. A preview has no side effects. If the two-minute review window expires, rerun the planner and preview the new file. Do not edit timestamps inside an old JSON plan; its checksum detects edits and the controller rejects stale evidence.
The checksum is not a campai signature. The local controller intentionally trusts the operator’s private imported plan. It must not become an internet endpoint accepting arbitrary visitors’ grant plans.
Select Apply reviewed plan, then Check access for the mapped identity and door. The simulator saves the reviewed grant set and reports the result using its own clock. A different badge or door must be denied.
Each grant has a stable reference derived from the scope, booking, identity, and door. Separate bookings have separate grants. This lets one booking be removed while another still justifies access for the same badge and door.
Application replaces this integration scope’s complete desired grant set. Inspect every removal in the preview. The controller rejects another organization or mandate once its state file has been initialized, and rejects an apply if another operation changed the revision after your preview.
Generate a fresh plan. Before applying it, select Simulate a lost response. The local controller saves the plan and deliberately drops the response.
The interface then reads the controller’s stored plan ID. If it matches, the interface confirms success without sending the write again. If the controller cannot be read, the result stays uncertain until you reconcile it. A stale revision requires another preview.
Use the same rule with a vendor: a timeout is not proof that no credential was created. Search by the stable external reference or your persisted operation mapping and read the actual result before retrying.
First let the current grants expire without running the planner. Select Check access again: access must be denied without a revocation request. Expired grants remain visible for inspection.
Generate and apply a fresh plan. Then open the test event’s Event page and use Cancel event (under More in the compact toolbar). Turn off Notify attendees and Fully refund, then cancel this disposable session. If the attendee count is stale and the notification switch is absent, close the dialog and reload the event before continuing.
Run the planner again. Both canceled bookings should now say Deny, and the preview should show two removals. Apply the plan and check both badges: neither should have access. The individual Cancel booking dialog currently has no notification switch, so use the event workflow for this notification-free exercise.
When your own test setup permits individual cancellation, also check that canceling one booking leaves another eligible booking’s grant intact. Two bookings mapped to the same identity must retain independent grant references.
Revoke the test API key and run the planner once more. The run must fail and preserve the previous complete JSON. Preserving that file is not continued authorization: the consumer must reject its expired plan and enforce the grant’s own expiry.
This is why a provider’s offline behavior matters. If its device ignores validity times while disconnected, a short lease in your database does not make physical access expire.
Get booking returns the status, payment state, cancellation records, participant count, and occurrence reference. Use this private terminal request to check a mapped synthetic booking. Its raw response can also include buyer and QR-pass information; the complete planner deliberately excludes those fields from its plan and interface.
Example
curl --fail-with-body --silent --show-error --max-time 30 --globoff \
--request GET 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/booking/bookings/YOUR_BOOKING_ID' \
--header 'X-API-Key: YOUR_API_KEY'The planner also reads Get event for the exact mapped occurrence, using its current period, cancelation, and ID. It does not use the booking’s copied event title/date as authoritative timing after a reschedule. Read money, dates, and data types before adapting payment or interval rules.
| Contract element | What the provider adapter must do |
|---|---|
| Scope | Reconcile only this integration’s organization/mandate namespace. Preserve unrelated credentials. |
| Grant ID | Use the provider’s supported external reference, or persist a durable mapping to its own ID. |
| Subject and door | Resolve verified, existing provider identifiers; never guess from names. |
| Validity | Enforce both UTC boundaries on the device, including expiry while the worker is offline. |
| Preview | Read actual provider state and calculate creates, renewals, and removals. |
| Apply | Use supported conditional/idempotent operations and persist operation outcomes. |
| Read-back | Confirm the exact grant and times. Reconcile missing responses before another write. |
| Overlap | Keep independent booking grants, or implement tested interval aggregation if the provider supports only one interval per badge/door. |
The simulator’s atomic replacement endpoint is a local test contract. It does not imply that a vendor has an equivalent bulk API. A real adapter may need several calls, a durable journal, and recovery from partial completion.
Keep provider secrets on the backend. If the provider cannot guarantee expiry, do not substitute it by merely changing a URL. Review the outage rule with the venue operator and verify on a designated test lock before physical deployment. An HTTP success response alone does not prove that the device has applied the change.
Poll both bookings and occurrences. The current record-change webhook types do not include booking or event updates. A contact webhook does not announce a canceled booking.
Choose a polling interval shorter than your evidence lease, monitor failed scans, and alert when no fresh plan has reached the controller. The lease begins at scan start, so slow requests reduce the remaining application time. These reads are not one atomic database snapshot; a change immediately after a read is handled by the next scan or grant expiry.
Test unpaid bookings, partial cancellations, group bookings, wrong mappings, blocked events, rescheduling, missing fields, API outages, provider outages, overlapping access, concurrent operators, and offline-device expiry. Keep physical emergency access and recovery under the venue’s existing procedures.
Choose either planner. The local controller uses Node.js in both cases. All files are complete; the source viewer and ZIP use the same files. cURL is supplied above for reading the underlying API, not as a replacement for the running local server.
The complete booking-access review tool
Choose the Node.js or PHP planner, configure your private key and verified booking mappings, and run the Node.js local controller. Map its reviewed contract to your provider after testing.
Download ZIPView and copy the complete source
The complete booking-access review tool
import { open, rename, unlink } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
// Run privately. This program reads campai and writes a review plan; it never opens a door.
const apiKey = 'YOUR_API_KEY';
const organization = 'YOUR_ORGANIZATION_ID';
const mandate = 'YOUR_MANDATE_ID';
// An operator must verify which single-person booking belongs to each provider identity.
const mappings = [
{ bookingId: 'YOUR_BOOKING_ID', eventId: 'YOUR_EVENT_ID', subject: 'test-badge-001', door: 'studio-entry', label: 'Studio booking' },
];
const minutesBefore = 15;
const leaseSeconds = 120;
const output = resolve('access-plan.json');
const objectId = value => typeof value === 'string' && /^[a-f0-9]{24}$/i.test(value);
const token = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(value);
if (!objectId(organization) || !objectId(mandate) || !apiKey || apiKey === 'YOUR_API_KEY' || /\s/.test(apiKey)) throw new Error('Set your private key and full scope IDs.');
if (!Number.isInteger(minutesBefore) || minutesBefore < 0 || minutesBefore > 60 || !Number.isInteger(leaseSeconds) || leaseSeconds < 30 || leaseSeconds > 300) throw new Error('Use an early-entry window of 0–60 minutes and a lease of 30–300 seconds.');
if (!Array.isArray(mappings) || mappings.length < 1 || mappings.length > 50) throw new Error('Configure 1–50 reviewed booking mappings.');
const mappingKeys = new Set();
for (const m of mappings) {
if (!objectId(m.bookingId) || !objectId(m.eventId) || !token(m.subject) || !token(m.door) || typeof m.label !== 'string' || !m.label.trim() || Array.from(m.label).length > 120) throw new Error('Invalid booking, occurrence, identity, door, or label.');
// One identity per booking: this sample does not infer buyer/attendee ownership.
if (mappingKeys.has(m.bookingId)) throw new Error('Map each single-person booking exactly once.');
mappingKeys.add(m.bookingId);
}
const 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('Missing or malformed event time.');
const normalized = value.includes('.') ? value : value.replace('Z', '.000Z');
const date = new Date(value);
if (!Number.isFinite(date.getTime()) || date.toISOString() !== normalized) throw new Error('Invalid event calendar date.');
return date;
};
async function read(path) {
const response = await fetch(`https://cloud.campai.com/api/${organization}/${mandate}${path}`, {
headers: { 'X-API-Key': apiKey }, redirect: 'error', signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error(`campai HTTP ${response.status}; no new access plan.`);
if (!response.body) throw new Error('Missing API body.');
const reader = response.body.getReader(), decoder = new TextDecoder('utf-8', { fatal: true });
let text = '', bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.length;
if (bytes > 2 * 1024 * 1024) { await reader.cancel(); throw new Error('API response is too large.'); }
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
} finally { reader.releaseLock(); }
return JSON.parse(text);
}
const lockPath = output + '.lock', temporary = output + '.' + process.pid + '.tmp';
const lock = await open(lockPath, 'wx', 0o600);
try {
const started = new Date();
const evidenceUntil = new Date(started.getTime() + leaseSeconds * 1000);
const events = new Map(), decisions = [], grants = [];
for (const m of mappings) {
if (Date.now() >= evidenceUntil.getTime()) throw new Error('Scan took longer than the lease; no new plan.');
const booking = await read(`/booking/bookings/${m.bookingId}`);
if (!booking || booking._id !== m.bookingId || booking.mandate !== mandate || booking.type !== 'event' || booking.event?.event !== m.eventId) throw new Error('Booking identity or event mapping does not match.');
if (!['confirmed', 'partialCancelation', 'canceled'].includes(booking.status) || !Object.hasOwn(booking, 'paymentStatus') || ![null, 'unpaid', 'partial', 'paid'].includes(booking.paymentStatus) || !Array.isArray(booking.cancelations) || !Number.isSafeInteger(booking.totalPrice) || booking.totalPrice < 0 || !Number.isSafeInteger(booking.totalPoints) || booking.totalPoints < 0 || !Number.isInteger(booking.records) || booking.records < 1) throw new Error('Required booking evidence is unavailable.');
let reason = '';
if (booking.status !== 'confirmed' || booking.cancelations.length) reason = 'Canceled or partially canceled booking';
else if (booking.records !== 1) reason = 'This recipe requires one participant per booking';
else if (booking.totalPoints !== 0) reason = 'Punch-card bookings need a separately reviewed rule';
else if (booking.totalPrice > 0 && booking.paymentStatus !== 'paid') reason = 'Payment is not fully settled';
else if (booking.totalPrice === 0 && ![null, 'paid'].includes(booking.paymentStatus)) reason = 'Zero-price booking has an unexpected payment state';
let validFrom, validUntil;
if (!reason) {
if (!events.has(m.eventId)) events.set(m.eventId, await read(`/booking/events/${m.eventId}`));
const event = events.get(m.eventId);
if (!event || event._id !== m.eventId || event.mandate !== mandate || !Object.hasOwn(event, 'cancelation') || !['available', 'blockedResource', 'blockedCalendar'].includes(event.period?.status)) throw new Error('Required event evidence is unavailable.');
const from = instant(event.period.from), until = instant(event.period.to);
if (until <= from) throw new Error('Invalid event interval.');
if (event.cancelation !== null) reason = 'Event canceled';
else if (event.period.status !== 'available') reason = 'Event blocked';
else if (until <= started) reason = 'Event has ended';
else {
validFrom = new Date(Math.max(from.getTime() - minutesBefore * 60000, started.getTime())).toISOString();
validUntil = new Date(Math.min(until.getTime(), evidenceUntil.getTime())).toISOString();
if (validFrom >= validUntil) reason = 'Outside the current short lease window';
}
}
const grantId = 'campai-' + createHash('sha256').update([organization, mandate, m.bookingId, m.subject, m.door].join(':')).digest('hex');
const decision = { bookingId: m.bookingId, eventId: m.eventId, subject: m.subject, door: m.door, label: m.label, grantId, allowed: !reason, reason: reason || 'Confirmed single-person booking; free or fully paid' };
decisions.push(decision);
if (!reason) grants.push({ id: grantId, subject: m.subject, door: m.door, validFrom, validUntil });
}
if (Date.now() >= evidenceUntil.getTime()) throw new Error('Evidence expired before the scan completed.');
const plan = { version: 1, scope: { organization, mandate }, generatedAt: started.toISOString(), expiresAt: evidenceUntil.toISOString(), decisions, grants };
plan.planId = createHash('sha256').update(JSON.stringify(plan)).digest('hex');
const file = await open(temporary, 'wx', 0o600);
try { await file.writeFile(JSON.stringify(plan, null, 2) + '\n'); await file.sync(); } finally { await file.close(); }
await rename(temporary, output);
console.log(`Saved a complete review plan: ${grants.length} short grants, ${decisions.length - grants.length} denied. No provider was called.`);
} finally {
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
await lock.close();
await unlink(lockPath);
}
After testing, revoke the key, remove the synthetic bookings/event, stop the local controller, and remove its test state. Continue with Synchronize bookings with your system when you need a larger, explicitly scoped booking feed rather than this manual mapping list.
Check your result
- Cancel the test event without notifications
- Both decisions deny; applying removes both grants.
- Stop the planner
- The controller denies access after the grant’s expiry.
- Drop an apply response
- Read-back confirms the stored plan without a second write.
- Revoke the API key
- Both planners fail and preserve their previous complete file.