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.PageSDKis 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 withozem_write_page_code(code mode) — your JavaScript runs as-is behind a strict Content-Security-Policy whoseconnect-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:
-
Authentication & Session Discipline:
- The SDK communicates using an
httpOnly,SameSite=Laxsession cookie. JavaScript cannot and should not read or store session tokens. - Do not store passwords, tokens, or sensitive end-user PII in
localStorageorsessionStorage. - Use
PageSDK.auth.me()andPageSDK.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.
- The SDK communicates using an
-
Collection Data Fetching:
PageSDK.records.list()only returns records from collections whereaccess_rules.readis"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 whereread: "admin". - Always handle HTTP 403 (
permission_denied) gracefully when visitors lack required roles.
-
Preventing Stored XSS When Rendering Records:
- When displaying records retrieved via
PageSDK.records.list(), never insert user-provided text directly intoinnerHTML(e.g.div.innerHTML = record.data.message). - Always use safe text nodes or properties:
element.textContent = record.data.message, or sanitize before insertion.
- When displaying records retrieved via
-
Record Mutations & Form Submissions:
- The Page SDK automatically sends
X-Requested-With: XMLHttpRequeston 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) orwrite: "authenticated"(for member-only submissions). Never make submissions collectionsread: "public".
- The Page SDK automatically sends
-
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_urlupon receiving a successful checkout response. - On the return page, verify order completion using
PageSDK.payments.status(orderId)before displaying confirmation.
- Always use
-
File Uploads:
PageSDK.uploads.create()/.attach()/.delete()are gated by the collection'swritetier (authenticatedorpublic). Never setread: "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 callinguploads.createto suppress bot abuse. Anonymous uploads have no account attribution (IP audit trail only). file.urlis a site-relative/storage/...path. Use it directly assrcon code-mode pages (same-origin). Never embed it ininnerHTMLunsanitized — 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 tiers — public / 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. Inmergemode, keys present here overwrite existing keys and any keys not mentioned are preserved; inreplacemode the wholedataobject is overwritten.options.mode—"merge"(default) or"replace".- Resolves (HTTP 200):
{ success: true, record: { id, data, created_at, updated_at } }.updated_atadvances 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— aFileobject (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 recorddata).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 }.urlis 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, plusrecordId. - No
canAddEntrycheck — 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) orPageSDK.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
writetier —authenticated(members only) orpublic(guests too).private/admintiers 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: publicupload forms, recommend adding a CAPTCHA / Cloudflare Turnstile widget before callinguploads.create— anonymous uploads have no account attribution (IP audit trail only). file.urlis 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 assrc(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_visitorhttpOnly,SameSite=Laxsession 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_auth → registration.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 aslogin). - 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
adminrole; the default role is set by the site (registration.default_role, defaultmember).
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? }whereroleis"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_requiredotherwise). - Require the target collection's relevant tier to be
"admin"(otherwise 403 with"This collection is not admin-readable/writable"). Anonymous visitors andmember/visitorend_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;fieldmust be alphanumeric + underscore. Filters are evaluated asJSON_EXTRACT(data, '$.field') = valueserver-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 whenallow_custom_amountis 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
priceas 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 403login_required). On successful payment, the visitor's role is upgraded tomember. - 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 fromcheckout().- Resolves:
{ order_id, status, amount_cents, currency }wherestatusis"pending" | "paid" | "failed" | "cancelled" | "refunded". - The
order_idUUID 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
adminpage) 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— alwaysfalseon 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-Withheader as a CSRF defense (the SDK sends it automatically; plain cross-site forms/images cannot). tenant_id/site_idare never trusted from the client — they are always derived from the request host and the slug.