Developers
// build guide

Build Guide.

Ozem — Build & Publish Guide (MCP)

This guide walks through building and publishing a complete site using the Ozem MCP tools. All tools run authenticated as the site owner; tenant/site resolution happens automatically from your account.

Mandatory Security Rules for AI Builders

When generating sites, collections, pages, and client logic on Ozem, you must strictly adhere to the following platform security boundaries:

1. Collection Access Rules (Principle of Least Privilege)

Every collection has { "read": "...", "write": "..." } access rules (public | authenticated | admin | private).

  • Contact Forms, Leads, Feedback & Submissions: NEVER set read: "public". Use { "read": "private", "write": "public" } (or "read": "admin" if site staff review submissions on-site). Setting read: "public" on a submissions collection creates a critical data leak where any anonymous visitor can dump all customer emails, phone numbers, and messages via PageSDK.records.list.
  • Public Catalogs & Content (menus, products, FAQs, blog posts): Use { "read": "public", "write": "private" } (or "write": "admin" for staff).
  • Internal / Staff Data & Orders: Use { "read": "admin", "write": "admin" } or { "read": "private", "write": "private" }.
  • Note: MCP tools run as the workspace owner and bypass collection rules; the browser Page SDK strictly enforces them.

2. Server-Enforced Page Access (No Client-Side Security Obscurity)

  • Never rely on client-side JS redirects (if (!isLoggedIn) window.location = ...) to protect member or admin pages. Client-side checks leak private page HTML to anyone inspecting source or disabling JS, and public pages are cached.
  • Always declare server-side page access: Set access: "authenticated" for member-only pages, or access: "admin" for staff-only pages when calling ozem_write_page_code or ozem_update_page. The server rejects unauthorized visitors with HTTP 403, serves an un-cached response (Cache-Control: private, no-store), and prevents page leakage.

3. Visitor Authentication & Identity Separation

  • Platform vs Site Separation: Platform workspace owners/collaborators never log into published sites. Published sites have their own site-scoped visitor accounts (end_users).
  • No Manual Token Handling: Visitor sessions are managed via secure, server-set httpOnly cookies (ozem_visitor). Do not write custom token storage, localStorage auth handlers, or manual Authorization headers in client JS.
  • Admin Roles Cannot Be Self-Registered: Self-registration (registration.enabled: true) can only grant visitor or member roles. Self-registration can never grant admin. The site admin must be bootstrapped by the owner via MCP (ozem_create_end_user with role: "admin") or the dashboard.

4. Zero Secrets in Code-Mode Pages

  • Everything in ozem_write_page_code (html, css, javascript) is served raw to the browser and is publicly visible in View Source.
  • Never embed private credentials, API keys (OpenAI, Resend, database credentials, or payment gateway secrets) in page code.
  • Payment credentials are credential-free via AI: Configure gateways with ozem_configure_site_payments, but inform the site owner to enter their gateway secret keys in their dashboard (/dashboard/sites/{site}/payments).

5. CSP & External Communication Boundaries

Code mode runs under a strict Content-Security-Policy (connect-src 'self', form-action 'self'):

  • Client-side JS cannot fetch() or POST to third-party endpoints (e.g. external webhooks, Telegram, AI APIs).
  • Use browser navigation (window.location.href = '...' or <a href="...">) for third-party handoffs like WhatsApp (wa.me) or payment checkout redirects.
  • External scripts in <head> are allowed only from approved CDNs (cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com, esm.sh, code.jquery.com, cdn.tailwindcss.com).

6. E-Commerce & Price Integrity

  • In catalog mode, PageSDK.payments.checkout() calculates prices server-side from product records in the designated Products collection. Never write client-side price tampering logic.
  • The Orders collection must be { "read": "private", "write": "private" }. The server creates order records internally during checkout.

7. File-Upload Collections

Collections that receive file uploads (receipts, resumes, KYC documents, product images submitted by visitors) follow the same read discipline as contact forms:

  • NEVER set read: "public" on a collection that receives file uploads — it would let any anonymous visitor dump all uploaded documents via PageSDK.records.list. Use read: "private" (owner-only via MCP/dashboard) or read: "admin" (site admin reviews on-site via PageSDK.admin.listRecords).
  • The write tier controls who may upload: "authenticated" (logged-in members only) or "public" (anonymous guests too). "public" write is riskier (no uploader attribution, storage-quota DoS potential) — recommend adding a CAPTCHA / Cloudflare Turnstile widget to guest upload forms before calling PageSDK.uploads.create to suppress bot abuse.
  • Uploaded files count toward the site's storage plan limit (same pool as the media library).
  • Accepted formats: jpg, jpeg, png, pdf only; 5 MB max per file. The server cross-checks the real MIME type against the declared extension (no rename/polyglot attacks).
  • Files are stored with a UUID v4 filename (122-bit entropy — the URL is the capability token). There is no per-request auth on file download; anyone who obtains a URL can download that one file. Do not share upload URLs publicly unless intended.

Typical build flow

ozem_create_site  ->  ozem_write_page_code  ->  (ozem_create_collection)  ->  ozem_publish_site

ozem_write_page_code is the recommended default for authoring pages (full HTML + CSS + JavaScript, code mode). Use ozem_update_page only for simple static content. You can repeat page-authoring, ozem_create_collection, ozem_create_record, and asset tools as needed before publishing. A site only becomes publicly reachable after ozem_publish_site. Optional steps include enabling visitor login, accepting payments, and building file-upload forms.

1. Create a site

ozem_create_site({ "name": "My Portfolio", "slug": "my-portfolio" })

The slug must be unique within your workspace and alpha_dash (letters, numbers, dashes). The site starts unpublished.

2. Write pages

There are two ways to author a page. write_page_code (code mode) is the recommended default for building a real site — it supports full HTML + CSS + JavaScript and is not subject to the sanitized-mode restrictions. Reserve update_page (sanitized mode) for simple content-only pages where you are certain no JavaScript, class-based CSS, or forms are needed.

Why prefer code mode? Sanitized mode strips <script>, <style>, <form>, and on* handlers on render, so any page that later turns out to need interactivity must be rebuilt from scratch — wasting time and context. Starting in code mode avoids that rework.

2a. Interactive pages — write_page_code (code mode) — recommended default

For pages that need client-side logic — carts, tabs, modals, forms, quantity steppers, dynamic totals, generated deep links — use write_page_code. It stores separate HTML, CSS, and JavaScript fields and renders them as-is behind a strict Content-Security-Policy. Scripts, styles, forms, and event handlers all work. Use this as your default unless the page is purely static.

ozem_write_page_code({
  "site_slug": "my-portfolio",
  "page_slug": "menu",
  "title": "Menu",
  "html": "<div id=\"app\"><button id=\"btn\">Add to cart</button></div>",
  "css": "#app { font-family: sans-serif; } #btn { background: #10b981; color: white; }",
  "javascript": "document.getElementById('btn').addEventListener('click', () => { alert('Added!'); });"
})

write_page_code is upsert (creates the page if missing, otherwise overwrites) and publishes immediately — the page is live in code mode the moment the tool returns. Use get_page to read back the source for idempotent re-edits.

Content-Security-Policy (code mode). Code-mode pages run under a strict CSP. Plan around these constraints:

Directive Allows Blocks
script-src inline JS (<script>, onclick, etc.), /js/page-sdk.js, and external scripts from cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com, esm.sh, code.jquery.com, cdn.tailwindcss.com all other external script sources
style-src inline <style> and inline style=""; external CSS from any https:// URL non-https CSS
img-src https:, data:, blob: (uploaded asset URLs work) non-https images
connect-src same-origin only (the Page SDK API works) external fetch/XHR to other domains
form-action same-origin only cross-origin form POSTs (use JS navigation instead)

WhatsApp / external handoffs: opening a WhatsApp deep link is a navigation, not a fetch — it is allowed. Build the link in JavaScript and trigger it with location.href = 'https://wa.me/…?text=…' or an <a href> click. Do not fetch() external APIs (blocked by connect-src).

Size limits (per page):

Field Limit
html 1 MB
css 1 MB
javascript 5 MB
head 200 KB
Combined 10 MB

<head> markup rules. The optional head field may contain only title, meta, style, link, and script elements:

  • on* event-handler attributes are rejected.
  • <meta http-equiv> and <meta name="referrer"> are rejected (platform-managed).
  • <link rel> must be one of stylesheet, icon, preconnect, preload, with an https:// href.
  • Head <script> must have an src from an approved CDN (cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com, esm.sh, code.jquery.com, cdn.tailwindcss.com) and no inline content — put your custom code in the javascript field, not in head.

Caching & publishing. Published pages are cached server-side (HTTP ETag/304 + a 24-hour file cache) to keep large game pages fast under load. Republish a page to see changes immediately — any publish or draft save rotates the cache key automatically. Previews are always fresh (Cache-Control: no-store). For HTML5 games, minify and bundle your JS before publishing so the initial render stays quick.

2b. Simple static pages — update_page (sanitized mode)

For content-only pages (text, images, links) where you are certain no JavaScript, class-based CSS, or forms are needed, update_page is lighter (a single content field). The HTML is sanitized on render: scripts, styles, forms, and event handlers are stripped for safety.

ozem_update_page({
  "site_slug": "my-portfolio",
  "page_slug": "home",
  "title": "Home",
  "content": "<h1>Hello world</h1><p>Welcome to my site.</p>"
})

Sanitized mode keeps: all standard content tags (<p>, <h1><h6>, <a href="https://…">, <img src="https://…">, <ul>, <table>, <div>, <span>, etc.), and inline style="" attributes.

Sanitized mode strips (removed on render):

Removed Why
<script> No JavaScript
<style> No class-based CSS (inline style="" still works)
<form>, <input>, <button>, <textarea>, <select>, <option> No forms
<iframe>, <object>, <embed>, <applet> No embedded frames/plugins
<link>, <meta>, <base> No document-level markup
on* attributes (onclick, onload, …) No event handlers
javascript:, data:, vbscript: URL schemes No script URLs

If a page might ever need interactivity, start with write_page_code (2a) to avoid rebuilding it later.

Reading & deleting pages

  • Use ozem_get_page to read a page back. The response includes render_mode ("sanitized" or "code"); code-mode pages also return html, css, javascript, and head so you can re-edit idempotently.
  • Use ozem_delete_page to remove a page.
  • Use ozem_get_site / ozem_list_sites to inspect your sites.

3. Add data collections (optional)

Collections are like database tables for structured data (e.g. products, testimonials, form submissions).

ozem_create_collection({
  "site_slug": "my-portfolio",
  "name": "Testimonials",
  "slug": "testimonials",
  "access_rules": { "read": "public", "write": "public" }
})

access_rules controls the public Page SDK API (see ozem_get_sdk_reference):

Key public authenticated admin private
read anonymous OK logged-in visitors only logged-in visitors with role=admin only never via public API (owner only)
write anyone can submit logged-in visitors only logged-in visitors with role=admin only never via public API (owner only)

Default when omitted: { "read": "public", "write": "private" }. Change rules later with ozem_update_collection_rules.

Manage records as the owner with ozem_create_record, ozem_list_records, ozem_update_record, and ozem_delete_record. Record updates support a mode of "merge" (default — shallow-merge fields) or "replace" (overwrite the whole data object).

MCP vs browser SDK — record capabilities

Both layers can now read, create, update, and delete records. The browser SDK enforces the collection access_rules on every call; the MCP tools run as the owner, so they bypass access_rules and can mutate any collection.

Operation MCP tool (ozem_*) Browser SDK — public Browser SDK — site admin Access enforced by
List/read list_records PageSDK.records.list PageSDK.admin.listRecords Public SDK: read rule · Admin SDK: read rule must be "admin" + visitor is site admin · MCP: owner (any)
Create create_record PageSDK.records.create PageSDK.admin.createRecord Public SDK: write rule · Admin SDK: write rule must be "admin" + visitor is site admin · MCP: owner (any)
Update update_record PageSDK.records.update PageSDK.admin.updateRecord Public SDK: write rule · Admin SDK: write rule must be "admin" + visitor is site admin · MCP: owner (any)
Delete delete_record PageSDK.records.delete PageSDK.admin.deleteRecord Public SDK: write rule · Admin SDK: write rule must be "admin" + visitor is site admin · MCP: owner (any)

Public deletes via the SDK are hard deletes (no trash/undo). Restore and soft-delete are owner-only.

Site-admin record management

When the site has a staff member (an end_user with role=admin for the site) who should manage a collection from the published site — e.g. a holiday-rental operator editing properties — set the collection's write tier to "admin" (and read to "public" or "admin" depending on whether the public should see the records). The site admin authenticates on the published site with the visitor cookie; their browser calls the PageSDK.admin.* record methods (listRecords, createRecord, updateRecord, deleteRecord) which are gated by the collection's admin tier. Build a code-mode page (e.g. /manage) that calls these methods — see the PageSDK.admin — record management section of the SDK reference for the full method signatures. The tenant owner can still manage the same collection from /dashboard or via MCP — the admin tier does not change owner authority.

Reserved page slugs

These slugs are platform-reserved and cannot be used for pages (or sites) — they collide with platform routes:

api  admin  dashboard  assets  storage  login  register  logout  mcp

Pick descriptive alternatives (e.g. sign-in, media, control-panel, data-api).

4. (Optional) Enable visitor login

ozem_configure_site_auth({ "site_slug": "my-portfolio", "enabled": true, "auth_methods": ["email"] })

Then create visitor accounts with ozem_create_end_user (requires the end_users plan feature). Visitor login is handled by the browser SDK (PageSDK.auth.login) on the published site — it never exposes tokens to JavaScript.

5. Publish the site

ozem_publish_site({ "site_slug": "my-portfolio" })

Publishing charges hosting credits (one month upfront, renewable every 30 days). The site becomes live at:

https://{subdomain}.{root_domain}/{site_slug}
https://{subdomain}.{root_domain}/{site_slug}/{page_slug}
  • Insufficient credits? Publishing fails with the required/balance amounts — top up via the dashboard.
  • Already live? ozem_publish_site is a safe no-op.
  • Take it offline with ozem_unpublish_site (credits are not refunded).

Interactive patterns (code mode)

These require write_page_code — they are not possible with update_page (sanitized mode strips scripts, styles, and forms).

Images

Upload images with ozem_upload_asset (returns a public url), then reference them in either mode:

<img src="https://{subdomain}.{root_domain}/storage/tenants/{tenant}/{site}/assets/photo.jpg" alt="Menu item">

Asset URLs survive sanitized mode (only javascript:/data: schemes are stripped — https:// is fine). In code mode all of https:, data:, and blob: are CSP-allowed for images.

To use an AI-generated image (e.g. from ChatGPT/DALL-E) or a stock photo, call ozem_import_asset_from_url with the public HTTPS URL — the image is downloaded and copied permanently into your site storage. Import promptly after generation, since some AI image URLs expire. The returned url can then be referenced in <img src> exactly like an uploaded asset.

Cart / quantity steppers / live totals

Store cart state in a plain JS object, render it with class-based CSS, and update the DOM on every change. Example skeleton:

const cart = {}; // { "item-id": qty }

function addItem(id, name, price) {
  cart[id] = (cart[id] || 0) + 1;
  render();
}
function removeItem(id) {
  if (cart[id]) { cart[id]--; if (cart[id] <= 0) delete cart[id]; }
  render();
}
function total() {
  return Object.entries(cart).reduce((sum, [id, qty]) => sum + priceOf(id) * qty, 0);
}
function render() {
  // update DOM: cart count badge, line items, total
}

Tabs / show-hide sections

Use buttons that toggle a CSS class (hidden / active) on section containers — no navigation needed.

Modal checkout

A hidden <div> overlay shown via a class toggle; collect table number / name / phone in <input> fields (allowed in code mode), then build the order message.

Send order to WhatsApp

WhatsApp deep links open by navigation (CSP-allowed), not fetch. Build the text and redirect:

function sendToWhatsApp(phone, text) {
  const url = 'https://wa.me/' + phone + '?text=' + encodeURIComponent(text);
  window.location.href = url; // navigation — allowed under CSP
}

Persist orders (optional)

To also save the order server-side, create a collection with write: "public" (or authenticated) and submit via the Page SDK:

await PageSDK.records.create('orders', {
  table: 5, name: 'Ali', items: cart, total: total()
});

This is a same-origin fetch — allowed under connect-src 'self'.

Accept payments (multi-gateway)

To sell products, memberships, or accept donations on a published site, the owner connects their own payment gateway(s) — Chip, Stripe, and PayPal are supported. Funds settle directly to the owner's gateway account — the platform never holds visitor funds.

Setup (once, via MCP or dashboard):

  1. Create a Products collection (read: public, write: private) and add product records with a decimal price field:
    ozem_create_collection  → slug: "products"
    ozem_update_collection_rules → read="public", write="private"
    ozem_create_record → { title: "Ebook", price: 19.90, description: "..." }
    
  2. Create an Orders collection (write: private):
    ozem_create_collection  → slug: "orders"
    ozem_update_collection_rules → read="private", write="private"
    
  3. Enable gateways + designate collections:
    ozem_configure_site_payments → enable_gateways=["stripe"], default_gateway="stripe", orders_collection_slug="orders", products_collection_slug="products", allow_custom_amount=false
    

    The AI cannot set payment credentials. After enabling a gateway, tell the site owner to enter their own keys themselves in Dashboard → Sites → {site} → Payments (e.g. Stripe secret key, PayPal client ID/secret, CHIP API key + brand ID). Checkout stays unavailable until they do.

Checkout page (code mode):

// List products from the public Products collection
const { records } = await PageSDK.records.list('products');

// Available methods for this site (server-injected):
// window.PAGE_SDK_CONFIG.payments = { enabled, gateways, default_gateway }

// Build a cart, then on submit:
const { checkout_url, order_id } = await PageSDK.payments.checkout({
  items: [{ record_id: cart[0].id, quantity: 2 }],
  customer: { email: 'buyer@example.com', full_name: 'Ada' },
  gateway: 'stripe',  // optional; omit to use the site's default gateway
});
window.location = checkout_url;  // redirect to the gateway's hosted checkout

Success / return page:

const orderId = new URLSearchParams(location.search).get('paid');
const { status } = await PageSDK.payments.status(orderId);
if (status === 'paid') { /* show confirmation */ }

Price integrity: the server reads data.price from product records and sums the total — client-supplied prices are never trusted. Store price as a decimal value (e.g. 99.99).

Access / membership products: set on_paid: "grant_role" on a product record. On successful payment, a logged-in visitor's role is upgraded to member. Guests are rejected at checkout (403 login_required).

Custom amounts (donations): enable allow_custom_amount and send { name, amount } items instead of { record_id, quantity }.

File uploads (members & guests)

Visitor file uploads let members (and optionally anonymous guests) attach files — receipts, resumes, KYC documents, product images — to collection records. Uploads are a client-side PageSDK.uploads capability (runtime), not an MCP server action — the AI builds the form page that calls the SDK; it does not upload files itself.

Setup:

  1. Create a collection whose write tier controls who may upload (authenticated = members; public = guests). Never set read: "public" on a file-upload collection (see Security Rule §7).
    ozem_create_collection  → slug: "contact"
    ozem_update_collection_rules → read="private", write="public"
    
  2. Build a code-mode upload form page:
    <input type="file" id="file" accept=".jpg,.jpeg,.png,.pdf">
    <input type="text" id="name" placeholder="Your name">
    <textarea id="message" placeholder="Message"></textarea>
    <button id="submit">Send</button>
    
    document.getElementById('submit').addEventListener('click', async () => {
      const file = document.getElementById('file').files[0];
      if (!file) return;
      const { record } = await PageSDK.uploads.create('contact', file, {
        name: document.getElementById('name').value,
        message: document.getElementById('message').value,
      });
      // record.data.file = { asset_id, url, name, size, mime }
    });
    
  3. Build an admin page (access: "admin") to review submissions:
    const { records } = await PageSDK.admin.listRecords('contact', { limit: 50 });
    records.forEach(r => {
      const file = r.data.file;
      if (!file) return;
      // Images: render inline (CSP img-src https: data: blob:)
      // PDFs: render in an iframe (CSP frame-src 'self'; relative URL = same-origin)
      const el = file.mime === 'application/pdf'
        ? `<iframe src="${file.url}" style="width:100%;height:500px"></iframe>`
        : `<img src="${file.url}" style="max-width:300px">`;
      // To remove one file only while keeping the record:
      // await PageSDK.uploads.delete('contact', r.id, 'file');
      // To delete the whole record (cascades all files):
      // await PageSDK.admin.deleteRecord('contact', r.id);
    });
    

Attach a file to an existing record (scene 2B — receipt after order):

await PageSDK.uploads.attach('orders', orderId, receiptFile, { status: 'receipt_uploaded' });

Multi-file records (scene 3 — KYC front + back): use distinct field names per upload:

await PageSDK.uploads.create('kyc', frontFile, { applicant: 'ada@example.com' }, 'id_front');
await PageSDK.uploads.attach('kyc', recordId, backFile, null, 'id_back');
// Remove just the front image:
await PageSDK.uploads.delete('kyc', recordId, 'id_front');

Relative URL caveat: file.url is a site-relative /storage/... path (same-origin). To present a clickable download link in chat, prefix it with the site's public origin (from ozem_get_siteurl). On a code-mode admin page just use it directly as src — it is same-origin.

Reading submissions back: ozem_list_records returns each record's data verbatim, including the file sub-object and submitted_by. For write: public uploads, submitted_by contains { ip, user_agent } (forensics only — never rendered on the public site). For write: authenticated uploads, submitted_by contains { id, email, username }.

URL structure

{subdomain}.{root_domain}/{site}              -> serves the "home" page (or first page)
{subdomain}.{root_domain}/{site}/{page}       -> serves a specific page
/api/public/sites/{site}/...                  -> Page SDK JSON API (same origin)

{subdomain} is your workspace subdomain (set at registration). {root_domain} is the platform root domain.

Credits & publish billing

  • Publishing debits hosting_credits_per_month (default 5) credits and starts a 30-day hosting window (hosting_charged_until).
  • Renewal: a scheduled job re-charges on/after the due date. Insufficient balance suspends the site (suspended_for_payment = true, published = false); a successful renewal republishes it.
  • Unpublishing takes the site offline immediately but does not refund credits and does not reset the hosting window.
  • Track balance with ozem_get_credit_balance.

Plan limits

Limits and feature flags are configured by the platform admin and gated across both the dashboard and MCP:

  • pages — max pages per site (ozem_update_page/create blocked past the limit).
  • total_entries — combined cap on records + end users per site (ozem_create_record/ozem_create_end_user blocked past the limit).
  • storage_mb — total asset bytes per site.
  • Features: collections, end_users, media_library, analytics, csv_export, payments, email_notifications, webhooks — toggling a feature off blocks the related tools and endpoints.

Use ozem_get_site / ozem_get_site_analytics to inspect a site, and ozem_get_build_guide / ozem_get_sdk_reference to retrieve these docs at runtime.

Notifications & Webhooks

Per-site email notifications and outbound webhooks let a site react to platform events.

Event catalog

Event Fires when Payload variables
record.created A form submission or site-admin record insert collection.slug, collection.name, record.id, record.data.*
order.created A checkout is started order.public_id, order.amount_cents, order.currency, order.status, order.customer.email, order.customer.full_name, order.items.*, record.data.*
order.paid A payment settles (idempotent on the pending→paid transition) Same as order.created
end_user.registered A visitor signs up end_user.email, end_user.username, end_user.role

Email rules

Each rule is self-contained: an event, an optional collection (required for record.created), a static recipient list, an optional dynamic recipient field (dot-path into the payload), and a subject/body template with {{dot.path}} substitution. Bodies are plain text. Missing variables resolve to an empty string (recorded as a warning on the delivery row).

Configure rules via the dashboard (Sites > site > Notifications) or ozem_configure_site_notifications. SMTP passwords and provider API keys are dashboard-only — the AI cannot set them.

Recommended AI workflow

To minimise template mistakes, follow this workflow before composing rules:

  1. Call ozem_preview_notification_payload with site_slug + event (+ collection_slug for record.created) → discover available variables, filters, enriched fields, and a reference template with rendered sample output.
  2. Compose subject + body templates using the discovered variables + filters.
  3. Call ozem_configure_site_notifications to save the rule.
  4. Check the response for rendered_preview + missing_variables warnings (the save-time feedback loop).
  5. If missing_variables is non-empty, fix the template and re-save.

Template syntax

  • Substitution: {{order.customer.full_name}}John Buyer.
  • Pipe filters: {{value | filter:args}} — formats a resolved value.
  • Plain {{variable}} with no pipe is fully backward compatible (renders the scalar, or JSON for arrays).
  • Enriched fields (filter-free easy path): {{order.amount_formatted}}RM99.00, {{order.items_summary}} → multi-line item list, {{record.fields_summary}} → multi-line field listing.

Filter reference

Filter Syntax Example Notes
money {{value | money:currency_path}} {{order.amount_cents | money:order.currency}}RM29.90 Formats integer cents. Arg = dot-path to currency (resolved first) or literal ISO code; omit for platform default.
date {{value | date:format}} {{record.data.booking_date | date:d/m/Y}}18/09/2026 PHP date() format. Default d/m/Y.
default {{value | default:fallback}} {{order.customer.phone | default:Not provided}} Returns fallback when value is null/empty (not flagged as missing).
join {{value | join:separator}} {{record.data.tags | join:, }}tag1, tag2 Joins array values. Scalars pass through. Default , .
yesno {{value | yesno}} {{record.data.subscribe | yesno}}Yes Converts boolean/truthy scalars to Yes/No.
upper {{value | upper}} {{order.status | upper}}PAID Uppercase. No args.
lower {{value | lower}} {{order.status | lower}}paid Lowercase. No args.

v1 supports a single filter per variable (no chaining like {{a | upper | default:x}}). Use enriched fields for filter-free formatting when a single filter isn't enough.

Enriched fields (easy path)

These pre-formatted fields are added automatically at dispatch time — no filter needed:

  • Order events (order.created, order.paid): order.amount_formatted (RM99.00), order.items_summary (multi-line Name x2 — RM50.00), order.items_count.
  • Record events (record.created): record.fields_summary (multi-line Label: value, arrays joined, booleans as Yes/No), record.field_keys (all top-level data keys).
  • End user events: no enrichment needed (fields are already flat/scalar).

Example templates

order.paid / order.created — reference template:

Hi {{order.customer.full_name}},

Thank you for your order!

Order ID: {{order.public_id}}
Total: {{order.amount_formatted}}

Items:
{{order.items_summary}}

We'll be in touch soon.

record.created — reference template:

New {{collection.name}} submission

{{record.fields_summary}}

View it in your dashboard.

end_user.registered — reference template:

A new member just signed up:

Username: {{end_user.username}}
Email: {{end_user.email}}
Role: {{end_user.role}}

Mail transports

Exactly one active transport per site (settings.email.transport): smtp (default), brevo, mailjet, smtp2go, enginemailer. Secrets are encrypted at rest. The from_email must be a verified sender / sending domain in the provider's account, else the send is rejected (error recorded in the delivery log).

Outbound webhooks

Up to 5 HTTPS endpoints per site. Each delivery POSTs a signed JSON payload:

{
  "id": "<uuid>",
  "event": "order.paid",
  "created_at": "2026-09-17T12:00:00Z",
  "site": { "id": 1, "slug": "my-site", "name": "My Site" },
  "data": { ... }
}

Verify the signature (header X-Ozem-Signature) using HMAC-SHA256:

$parts = [];
foreach (explode(',', $headerValue) as $part) {
    [$k, $v] = explode('=', trim($part), 2);
    $parts[$k] = $v;
}
$expected = hash_hmac('sha256', $parts['t'].'.'.$rawRequestBody, $secret);
$valid = hash_equals($expected, $parts['v1']);
// Reject if timestamp is older than 5 minutes (replay protection).

Retries: 5 attempts with backoff 60s → 300s → 1800s → 7200s → 21600s. SSRF protection blocks private/reserved/loopback/link-local IPs.

Delivery latency

Both channels ride the same notifications queue. On shared hosting (cron worker) delivery starts 10–60s later (avg ~30–50s); on a VPS with Supervisor it's sub-second.

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

Back to overview →