Developers
// sdk reference

SDK Reference.

Ozem Page SDK — API Reference

The Ozem Page SDK is a small vanilla-JavaScript client auto-injected into every published page. It lets your site read collections, submit records, and handle visitor login — all same-origin, no build step.

The SDK is exposed as window.PageSDK. All methods return a Promise that resolves with parsed JSON on a 2xx response and rejects on any non-2xx (the rejection value is the parsed JSON body, or an { error } object).

Availability across render modes. window.PageSDK is injected on both sanitized-mode and code-mode pages. However, in sanitized mode (ozem_update_page) all <script> tags are stripped on render, so you cannot call the SDK from JavaScript. To use the SDK from client-side code, author the page with ozem_write_page_code (code mode) — your JavaScript runs as-is behind a strict Content-Security-Policy whose connect-src 'self' permits same-origin SDK calls. See the build guide for render-mode details.

Page SDK Security Rules & Best Practices

When writing client-side JavaScript for Ozem pages, always implement these security standards:

  1. Authentication & Session Discipline:

    • The SDK communicates using an httpOnly, SameSite=Lax session cookie. JavaScript cannot and should not read or store session tokens.
    • Do not store passwords, tokens, or sensitive end-user PII in localStorage or sessionStorage.
    • Use PageSDK.auth.me() and PageSDK.auth.isLoggedIn() solely to toggle UI elements (e.g. login/logout buttons). Remember that data security is enforced server-side by collection access rules and page access settings.
  2. Collection Data Fetching:

    • PageSDK.records.list() only returns records from collections where access_rules.read is "public" or "authenticated".
    • Never query or expose collections meant for staff or private data via public SDK endpoints. Use PageSDK.admin.listRecords() for staff collections where read: "admin".
    • Always handle HTTP 403 (permission_denied) gracefully when visitors lack required roles.
  3. Preventing Stored XSS When Rendering Records:

    • When displaying records retrieved via PageSDK.records.list(), never insert user-provided text directly into innerHTML (e.g. div.innerHTML = record.data.message).
    • Always use safe text nodes or properties: element.textContent = record.data.message, or sanitize before insertion.
  4. Record Mutations & Form Submissions:

    • The Page SDK automatically sends X-Requested-With: XMLHttpRequest on all mutating requests (create, update, delete), protecting against cross-site request forgery (CSRF).
    • Ensure form submissions target collections configured with write: "public" (for open contact forms) or write: "authenticated" (for member-only submissions). Never make submissions collections read: "public".
  5. Payment Checkout:

    • Always use PageSDK.payments.checkout() with product record IDs (items: [{ record_id, quantity }]). Client prices are ignored by the server.
    • Redirect to checkout_url upon receiving a successful checkout response.
    • On the return page, verify order completion using PageSDK.payments.status(orderId) before displaying confirmation.
  6. File Uploads:

    • PageSDK.uploads.create() / .attach() / .delete() are gated by the collection's write tier (authenticated or public). Never set read: "public" on a file-upload collection — it would expose all uploaded documents to any visitor.
    • Accepted formats: jpg, jpeg, png, pdf only; 5 MB max. The server verifies the real MIME type from file bytes — never attempt to rename or disguise file types.
    • For write: "public" (guest) upload forms, add a CAPTCHA / Cloudflare Turnstile widget before calling uploads.create to suppress bot abuse. Anonymous uploads have no account attribution (IP audit trail only).
    • file.url is a site-relative /storage/... path. Use it directly as src on code-mode pages (same-origin). Never embed it in innerHTML unsanitized — use it as an element attribute (img.src, iframe.src).

Configuration

On load the SDK reads window.PAGE_SDK_CONFIG (injected by the server) into PageSDK.config:

window.PAGE_SDK_CONFIG = { siteSlug: "my-portfolio" };
PageSDK.config = { siteSlug: "my-portfolio" };

All request URLs are built from the site slug: base = /api/public/sites/${siteSlug}. Requests are relative (same-origin) and always send X-Requested-With: XMLHttpRequest and Accept: application/json.

PageSDK.records.list(collectionSlug, [options])

Reads records from a collection. The collection's access_rules.read tier must permit the caller (see Collection access tierspublic / authenticated are reachable via this method; admin is not, use PageSDK.admin.listRecords; private is never reachable via the public API).

const { collection, count, records } = await PageSDK.records.list("testimonials", {
  filter: { approved: true }, // optional: exact-match field filters
  limit: 20,                  // optional, max 200, default 50
});

// records: [{ id, data, created_at }, ...]
  • filter — an object of { field: value } exact-match conditions.
  • limit — capped at 200, defaults to 50. Records are newest-first.
  • Resolves: { collection, count, records: [{ id, data, created_at }] }.

PageSDK.records.create(collectionSlug, data)

Submits a new record. Requires the collection access_rules.write tier to permit the caller — see Collection access tiers. For admin-writable collections use PageSDK.admin.createRecord. Respects the site's plan total-entries limit.

const { success, record } = await PageSDK.records.create("contact-submissions", {
  name: "Ada",
  email: "ada@example.com",
  message: "Hello!",
});

// record: { id, data, created_at }
  • data — an object (or array) of fields to store.
  • Resolves (HTTP 201): { success: true, record: { id, data, created_at } }.

PageSDK.records.update(collectionSlug, recordId, data, [options])

Updates an existing record by id. Requires the collection access_rules.write tier to permit the caller (same as create — see Collection access tiers; for admin-writable collections use PageSDK.admin.updateRecord). Supports two modes:

// Merge (default): shallow-merge the given fields over the existing data.
const { success, record } = await PageSDK.records.update("contact-submissions", 42, {
  status: "read",
  read_at: "2026-08-20",
});

// Replace: overwrite the whole data object with the given one.
await PageSDK.records.update("contact-submissions", 42,
  { status: "archived", note: "moved" }, { mode: "replace" });

// record: { id, data, created_at, updated_at }
  • data — object of fields to store. In merge mode, keys present here overwrite existing keys and any keys not mentioned are preserved; in replace mode the whole data object is overwritten.
  • options.mode"merge" (default) or "replace".
  • Resolves (HTTP 200): { success: true, record: { id, data, created_at, updated_at } }. updated_at advances on every change.

PageSDK.records.delete(collectionSlug, recordId)

Deletes a record by id (hard delete — there is no trash/undo via the public API). Requires the collection access_rules.write tier to permit the caller (see Collection access tiers; for admin-writable collections use PageSDK.admin.deleteRecord).

const { success, deleted } = await PageSDK.records.delete("contact-submissions", 42);

// deleted: { record_id, collection }
  • Resolves (HTTP 200): { success: true, deleted: { record_id, collection } }.
  • Deleting an already-deleted id returns 404 not_found (not idempotent via the public API).

PageSDK.uploads.create(collectionSlug, file, [meta], [field])

Uploads a file and creates a new record containing the file sub-object plus optional metadata. Requires the collection access_rules.write tier to permit the caller — authenticated (members) or public (guests). See Collection access tiers.

const fileInput = document.querySelector('#receipt');
const { success, record } = await PageSDK.uploads.create("orders", fileInput.files[0], {
  booking: "REF-123",
  total: 150,
});

// record: { id, data, created_at }
// record.data.file = { asset_id, url, name, size, mime }
// record.data.submitted_by = { id, email, username } (member) or { ip, user_agent } (guest)
  • file — a File object (from <input type="file">). Accepted: jpg, jpeg, png, pdf only; 5 MB max. The server cross-checks the real MIME type from the file bytes against the declared extension.
  • meta — optional object of additional fields to store alongside the file (merged into the record data).
  • field — optional string (alphanumeric + underscore, default "file") naming the key under which the file sub-object is stored. Use distinct field names for multi-file records (see below).
  • Resolves (HTTP 201): { success: true, record: { id, data, created_at } }.
  • record.data[field] is a file sub-object: { asset_id, url, name, size, mime }.
  • url is a site-relative /storage/... path — same-origin. Prefix with the site origin to produce a full clickable URL.
  • Counts toward the site's storage plan limit and the per-tier total-entries limit.
  • Rejects (422) on invalid file type, MIME mismatch, or oversized file (code: "too_large" / "validation_failed").

PageSDK.uploads.attach(collectionSlug, recordId, file, [meta], [field])

Merges a file into an existing record by id. Same validation and tier requirements as create. If the target field already held a file, the old physical file and asset row are deleted before overwriting (no orphaned files on replacement).

// Attach a receipt to an existing order (scene 2B):
const { success, record } = await PageSDK.uploads.attach("orders", 42, receiptFile, {
  status: "receipt_uploaded",
});

// record: { id, data, created_at, updated_at }
  • Same parameters as create, plus recordId.
  • No canAddEntry check — this merges into an existing record, not a new entry.
  • Resolves (HTTP 200): { success: true, record: { id, data, created_at, updated_at } }.

PageSDK.uploads.delete(collectionSlug, recordId, [field])

Removes one file field from a record — deletes the physical file, the asset row, and clears the field from the record's data. The record itself stays. Requires the collection access_rules.write tier to permit the caller.

// Remove just the front ID image, keep the record + back image:
await PageSDK.uploads.delete("kyc", 42, "id_front");
// { success: true, deleted: { record_id: 42, field: "id_front" } }
  • field — the key to clear (defaults to "file" if omitted).
  • Resolves (HTTP 200): { success: true, deleted: { record_id, field } }.
  • Rejects (404 not_found) when the record or the file field does not exist.
  • To delete the whole record (cascading all files), use PageSDK.admin.deleteRecord (admin tier) or PageSDK.records.delete (public write tier).

Multi-file records & the field convention

All three methods accept an optional field (default "file"). This names the key under which the file sub-object is stored in the record's data. Single-file forms use the default; multi-file forms pass distinct field names:

// KYC: two files in one record
await PageSDK.uploads.create("kyc", frontFile, { applicant: "ada@example.com" }, "id_front");
await PageSDK.uploads.attach("kyc", recordId, backFile, null, "id_back");

// record.data = {
//   applicant: "ada@example.com",
//   id_front: { asset_id, url, name, size, mime },
//   id_back:  { asset_id, url, name, size, mime },
//   submitted_by: { ... },
// }

File-upload security

  • Permission: gated by the collection's write tier — authenticated (members only) or public (guests too). private/admin tiers are not writable via the public upload endpoints.
  • Accepted formats: jpg, jpeg, png, pdf only; 5 MB max; counts toward site storage quota. Real-MIME cross-check prevents rename/polyglot attacks.
  • For write: public upload forms, recommend adding a CAPTCHA / Cloudflare Turnstile widget before calling uploads.create — anonymous uploads have no account attribution (IP audit trail only).
  • file.url is site-relative — to display a full link (e.g. in an admin table) prefix with the site origin; on a code-mode admin page just use it directly as src (same-origin). For PDFs use <iframe src={file.url}>; for images <img src={file.url}>. Multi-file: iterate named fields.
  • Deleting a whole record cascades to delete all linked files automatically.

PageSDK.auth.me()

Returns the current visitor's login state (resolved from the httpOnly session cookie — the token itself is never readable by JS).

const { authenticated, end_user } = await PageSDK.auth.me();

if (authenticated) {
  // end_user: { id, email, username, role, active }
}
  • Resolves: { authenticated: false } when anonymous, or { authenticated: true, end_user: { id, email, username, role } } when logged in.
  • The SDK refreshes its cached login flag by calling this on load.

PageSDK.auth.login(identifier, password)

Logs a visitor in. Only works when the site has visitor auth enabled (configure_site_auth). identifier is matched against the enabled auth methods (email and/or username). Passwords are verified server-side with Hash::check.

const { success, end_user } = await PageSDK.auth.login("ada@example.com", "secret");

// end_user: { id, email, username, role, active }
  • Resolves: { success: true, end_user: { id, email, username, role, active } }.
  • Sets the ozem_visitor httpOnly, SameSite=Lax session cookie (30-day expiry). The browser never sees the token. The cookie uses path /, so the visitor session persists across every page on the same site subdomain without re-logging in.
  • Rejects (403) when auth is disabled, or when the account is deactivated (code: "account_disabled"); rejects (401/422) on bad credentials.

PageSDK.auth.register(payload)

Registers a new visitor account from the published site. Only works when the site has self-registration enabled (configure_site_authregistration.enabled). Which identifiers may be registered is set by the site's registration.methods (email and/or username).

// Approval mode (default): the account is created but stays inactive until an
// admin activates it. The response is pending and no session is set.
const { success, pending, end_user } = await PageSDK.auth.register({
  email: "ada@example.com",
  password: "secret123",   // min 8 characters
});
// end_user: { id, email, username, role, active }

// Open mode: the account is active immediately and the visitor is logged in.
const { success, authenticated, end_user } = await PageSDK.auth.register({
  email: "ada@example.com",
  password: "secret123",
});
  • payload{ email?, username?, password }. Send only the identifiers enabled for registration; at least one is required.
  • Resolves (approval): { success: true, pending: true, end_user: { id, email, username, role, active } } (HTTP 201, active: false, no cookie).
  • Resolves (open): { success: true, authenticated: true, end_user: { id, email, username, role, active } } (HTTP 200, active: true, session cookie set — same as login).
  • Rejects (403) when registration is disabled (code: "registration_disabled"), the plan's end-users feature is off, or the site entries limit is reached (code: "permission_denied").
  • Rejects (422) when an identifier is already taken (code: "identifier_taken"), the password is too short, or an identifier is missing.
  • Self-registration can never grant the admin role; the default role is set by the site (registration.default_role, default member).

PageSDK.auth.logout()

Ends the current visitor session (deletes the server session row and clears the cookie).

const { success } = await PageSDK.auth.logout(); // { success: true }

PageSDK.auth.isLoggedIn()

Synchronous cached flag, kept in sync by me(). Useful for quick UI checks without awaiting a request.

if (PageSDK.auth.isLoggedIn()) { /* show logged-in UI */ }

PageSDK.admin.listEndUsers()

Lists the site's end users. Requires the current visitor to be an active site admin (an end_user with role=admin for this site — never a platform user). Use this to build a custom admin dashboard on the published site to approve/activate members.

const { end_users } = await PageSDK.admin.listEndUsers();
// end_users: [{ id, email, username, role, active, created_at }, ...]
  • Resolves: { end_users: [{ id, email, username, role, active, created_at }] }.
  • Rejects (403) when the visitor is not an admin for this site.

PageSDK.admin.updateEndUser(id, changes)

Updates an end user's access and/or role. Requires an active site admin. Disabling a user (active: false) revokes their sessions immediately.

// Approve/activate a self-registered member:
await PageSDK.admin.updateEndUser(42, { active: true });

// Promote to admin:
await PageSDK.admin.updateEndUser(42, { role: "admin" });

// Deactivate:
await PageSDK.admin.updateEndUser(42, { active: false });
  • changes{ active?, role? } where role is "visitor" | "member" | "admin".
  • Resolves: { success: true, end_user: { id, email, username, role, active } }.
  • Guards: an admin cannot deactivate or demote themselves, and cannot demote the last remaining active admin (→ 422 validation_failed).
  • Rejects (403) when the visitor is not an admin for this site.

PageSDK.admin — record management

The four methods below let a site admin (an end_user with role=admin for this site) read and write records in a collection whose access_rules.read / access_rules.write tier is set to "admin". They are the site-admin counterpart of PageSDK.records.* and use a separate URL prefix so the public routes never serve an admin audience.

Use these to build a custom staff dashboard on the published site (e.g. a /manage page where a holiday-rental operator edits the properties collection). The tenant owner retains full control from /dashboard and via MCP — the admin tier does not change owner authority.

All four methods:

  • Require the current visitor to be an active site admin for this site (403 admin_required otherwise).
  • Require the target collection's relevant tier to be "admin" (otherwise 403 with "This collection is not admin-readable/writable"). Anonymous visitors and member/visitor end_users are always rejected.
  • Enforce the same per-site entry quota (PlanService::canAddEntry) and 256 KB payload cap as the public records endpoints.
  • Are throttled per site, per visitor (member id when logged in, IP when anonymous): 60 reads / 10 writes / 5 registrations per minute — reads and writes are separate budgets.

PageSDK.admin.listRecords(collectionSlug, [options])

List records in a collection whose read tier is admin.

const { collection, count, records } = await PageSDK.admin.listRecords("properties", {
  limit: 50,
  filter: { status: "available" }
});
// records: [{ id, data, created_at, updated_at }, ...]
  • options.limit (optional, 1–200, default 50).
  • options.filter (optional) — object of { field: value } pairs; field must be alphanumeric + underscore. Filters are evaluated as JSON_EXTRACT(data, '$.field') = value server-side.
  • Resolves: { collection, count, records: [{ id, data, created_at, updated_at }] }.

PageSDK.admin.createRecord(collectionSlug, data)

Create a record in a collection whose write tier is admin.

const { success, record } = await PageSDK.admin.createRecord("properties", {
  name: "Beach Cabin",
  price: 180,
  status: "available"
});
  • Resolves: { success: true, record: { id, data, created_at } } (201).
  • Rejects (403 entries_limit_reached) if the site is at its per-tier entries cap.

PageSDK.admin.updateRecord(collectionSlug, recordId, changes)

Update a record. changes accepts { data, mode? } where mode is "merge" (default, shallow-merges over existing fields) or "replace" (overwrites the whole data object). The target collection's write tier must be admin.

// Merge: keeps untouched fields, updates price only.
await PageSDK.admin.updateRecord("properties", 42, {
  data: { price: 200 }
});

// Replace: overwrites the entire data object.
await PageSDK.admin.updateRecord("properties", 42, {
  data: { name: "Beach Cabin", price: 200 },
  mode: "replace"
});
  • Resolves: { success: true, record: { id, data, created_at, updated_at } }.

PageSDK.admin.deleteRecord(collectionSlug, recordId)

Delete a record. The target collection's write tier must be admin.

await PageSDK.admin.deleteRecord("properties", 42);
// { success: true, deleted: { record_id: 42, collection: "properties" } }
  • Resolves: { success: true, deleted: { record_id, collection } }.

PageSDK.payments.checkout(payload)

Creates a hosted-checkout payment session. The server resolves real prices from product records (catalog mode) — client-supplied prices are never trusted. On success, redirect the browser to the returned checkout_url (the gateway's hosted payment page). The visitor pays there (e.g. card / FPX / DuitNow via CHIP, cards & wallets via Stripe, PayPal balance/cards) and is redirected back to your site.

Requires the site owner to have enabled at least one payment gateway with credentials on the site's Payments settings, and designated an Orders collection (configure_site_payments).

// Catalog mode (recommended): items reference product records by ID
const { checkout_url, order_id } = await PageSDK.payments.checkout({
  items: [
    { record_id: 1, quantity: 2 },
    { record_id: 5, quantity: 1 },
  ],
  customer: { email: "buyer@example.com", full_name: "Ada Lovelace", phone: "+60123456789" },
  shipping: { address: "123 Jalan Ampang", city: "Kuala Lumpur", postcode: "50450" },
});

// Redirect to the gateway's hosted checkout page
window.location = checkout_url;
// Custom-amount mode (only when allow_custom_amount is enabled):
const { checkout_url, order_id } = await PageSDK.payments.checkout({
  items: [{ name: "Donation", amount: 50.00 }],
  customer: { email: "donor@example.com", full_name: "Ada" },
});
window.location = checkout_url;
  • items — an array of { record_id, quantity } (catalog mode) or { name, amount } (custom mode, only when allow_custom_amount is on). Catalog mode requires a Products collection to be configured.
  • customer{ email, full_name, phone? }. Required for guest checkout; optional for logged-in visitors (the backend fills from the end_user).
  • shipping — optional { address, city, postcode, ... } object stored on the order.
  • gateway — optional "chip" | "stripe" | "paypal" choice of payment method. Must be one of the gateways enabled for this site; omitted = the site's default gateway.
  • The product record stores price as a decimal value (e.g. 99.99). The server converts to integer cents for the gateway. Client-supplied prices are rejected in catalog mode.
  • An item whose product record has on_paid: "grant_role" requires a logged-in visitor (guests get 403 login_required). On successful payment, the visitor's role is upgraded to member.
  • Resolves (201): { checkout_url: "https://...", order_id: "<uuid>" }.
  • Rejects (403) when payments are not enabled or configured for the site.
  • Rejects (422) on validation errors (missing customer, unknown product, zero total, unavailable gateway).

Which payment methods can I offer?

The server injects the site's available methods into every rendered page:

window.PAGE_SDK_CONFIG.payments
// → { enabled: true, gateways: ["stripe", "paypal"], default_gateway: "stripe" }

Use it to render a chooser before calling checkout():

const cfg = window.PAGE_SDK_CONFIG?.payments ?? {};
if (!cfg.enabled) { /* hide buy buttons */ }

const method = await showChooser(cfg.gateways);        // your UI
const { checkout_url } = await PageSDK.payments.checkout({
  items: cart,
  customer,
  gateway: method,                                     // optional
});

PageSDK.payments.status(orderId)

Polls the status of a payment order. Use this on the success/return page (read ?paid=ID from the query string). If the webhook hasn't landed yet, the server reconciles directly with the gateway before answering.

// On the success page (URL: /mysite/thank-you?paid=abc-123)
const params = new URLSearchParams(window.location.search);
const orderId = params.get('paid');
const { status, amount_cents, currency } = await PageSDK.payments.status(orderId);

if (status === 'paid') { /* show success */ }
else if (status === 'pending') { /* show "processing..." and poll again */ }
  • orderId — the UUID returned from checkout().
  • Resolves: { order_id, status, amount_cents, currency } where status is "pending" | "paid" | "failed" | "cancelled" | "refunded".
  • The order_id UUID is unguessable, preventing enumeration of other visitors' orders.

Page access levels

Every published page has an access level (write_page_code / update_page access arg, or the dashboard page editor):

Level Who can view the published page
public Anyone (default)
authenticated Any active logged-in end_user of the same site (visitor, member, or admin)
admin Active end_user with role=admin for the site

Access is enforced server-side on every page render (subdomain and custom domain). When access is denied:

  • Anonymous visitors see a themed "Log in required" page (403).
  • Logged-in visitors who lack permission (e.g. a member on an admin page) see a themed "Access denied" page (403).

The owner builds the actual sign-in / registration UX on a code-mode page (using PageSDK.auth.login / register). The themed denied-pages only inform the visitor.

Access-controlled pages are never served from the full-page file cache: they are composed fresh per request and sent with Cache-Control: private, no-store and an ETag that varies by the visitor's access bucket (anon / auth / admin), so members-only HTML can never leak to anonymous visitors through a shared cache.

Collection access tiers

Every collection has a per-direction access tier (read, write) set via the dashboard collection editor or ozem_update_collection_rules. Tier values are the same four used by page access:

Tier Read allowed when Write allowed when
public always always
authenticated active end_user of the same site active end_user of the same site
admin active end_user with role=admin of the same site active end_user with role=admin of the same site
private never via the public API (owner-only via MCP / dashboard) never via the public API

Default when unset: { read: "public", write: "private" }.

Use admin to give site staff (an end_user with role=admin for the site) a way to manage a collection from the published site through the PageSDK.admin.* record methods (see the PageSDK.admin — record management section above). Tenant owners retain full control from /dashboard and via MCP regardless of the tier.

Error format

Non-2xx responses reject the returned Promise. The rejection value is the parsed JSON body when available, otherwise { error: "<status text>" }. The body always carries an error string (read this for a human message) plus stable machine-readable siblings:

  • success — always false on errors.
  • error — human-readable string (backward compatible; keep reading this).
  • code — machine-readable error code (see table below).
  • fields — present only on validation errors (code: "validation_failed"); an object of { field: [messages] }.
Status code Meaning
403 permission_denied Access rule denied, feature disabled, or site plan limit reached
403 registration_disabled Self-registration (or visitor login) is not enabled for this site
403 account_disabled Login attempted with an account that an admin has deactivated
404 not_found Site not published, or collection/record slug or id not found
419 Mutating request (POST/PATCH/DELETE) missing the X-Requested-With header
422 validation_failed Request input invalid (fields lists the offending fields)
422 identifier_taken Registration: the email or username already exists on this site
422 too_large Record data payload exceeds the 256 KB limit
429 rate_limited Rate limit exceeded — per site, per visitor (member id when logged in, IP when anonymous): 60 reads / 10 writes / 5 registrations per minute; reads and writes are separate budgets
401 Invalid login credentials
try {
  await PageSDK.records.create("private-col", { a: 1 });
} catch (err) {
  console.error(err.error); // e.g. "This collection does not allow public writes."
  if (err.code === 'validation_failed' && err.fields) {
    console.warn(err.fields); // { data: ["The data field is required."] }
  }
}

Reserved page slugs

The following slugs are reserved by the platform and cannot be used as a page (or site) slug — they route to platform URLs (/api/…, /dashboard, etc.) and would collide with your content:

api  admin  dashboard  assets  storage  login  register  logout  mcp

If you want a page that conveys one of these words, pick an alternative such as sign-in (instead of login), media (instead of assets), control-panel (instead of admin), or data-api. Page creation rejects reserved slugs with a validation error.

Combining the SDK with custom JavaScript

On a code-mode page (ozem_write_page_code) your own JavaScript runs alongside window.PageSDK. A common pattern is to keep UI state in memory and persist it via the SDK. For example, a simple order form that reads a menu collection and submits an order:

// 1. Load menu items from a public collection
const { records } = await PageSDK.records.list('menu', { limit: 50 });

// 2. Build cart state in memory
const cart = {};
function add(id) { cart[id] = (cart[id] || 0) + 1; render(); }

// 3. Submit the finished order to a public-write collection
async function checkout(customer) {
  await PageSDK.records.create('orders', {
    customer,
    items: cart,
    total: Object.entries(cart).reduce((s, [id, q]) => s + priceOf(id) * q, 0),
  });
  // Then hand off to WhatsApp via navigation (CSP-allowed):
  window.location.href = 'https://wa.me/60123456789?text=' + encodeURIComponent(JSON.stringify(cart));
}

Both the SDK call (connect-src 'self') and the WhatsApp navigation succeed under the code-mode CSP. See the build guide for the full list of CSP constraints.

Security notes

  • The session token lives only in an httpOnly cookie — JavaScript (and the SDK) cannot read it.
  • Mutating endpoints require the X-Requested-With header as a CSRF defense (the SDK sends it automatically; plain cross-site forms/images cannot).
  • tenant_id / site_id are never trusted from the client — they are always derived from the request host and the slug.

Served from the same source as the <code class="text-lime">ozem_get_sdk reference</code> MCP tool.

Back to overview →