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). Settingread: "public"on a submissions collection creates a critical data leak where any anonymous visitor can dump all customer emails, phone numbers, and messages viaPageSDK.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, oraccess: "admin"for staff-only pages when callingozem_write_page_codeorozem_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
httpOnlycookies (ozem_visitor). Do not write custom token storage,localStorageauth handlers, or manual Authorization headers in client JS. - Admin Roles Cannot Be Self-Registered: Self-registration (
registration.enabled: true) can only grantvisitorormemberroles. Self-registration can never grantadmin. The site admin must be bootstrapped by the owner via MCP (ozem_create_end_userwithrole: "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()orPOSTto 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 viaPageSDK.records.list. Useread: "private"(owner-only via MCP/dashboard) orread: "admin"(site admin reviews on-site viaPageSDK.admin.listRecords). - The
writetier 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 callingPageSDK.uploads.createto 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>, andon*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 notfetch()external APIs (blocked byconnect-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 ofstylesheet,icon,preconnect,preload, with anhttps://href.- Head
<script>must have ansrcfrom 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 thejavascriptfield, not inhead.
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_pageto read a page back. The response includesrender_mode("sanitized"or"code"); code-mode pages also returnhtml,css,javascript, andheadso you can re-edit idempotently. - Use
ozem_delete_pageto remove a page. - Use
ozem_get_site/ozem_list_sitesto 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_siteis 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):
- Create a Products collection (
read: public,write: private) and add product records with a decimalpricefield:ozem_create_collection → slug: "products" ozem_update_collection_rules → read="public", write="private" ozem_create_record → { title: "Ebook", price: 19.90, description: "..." } - Create an Orders collection (
write: private):ozem_create_collection → slug: "orders" ozem_update_collection_rules → read="private", write="private" - 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=falseThe 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:
- Create a collection whose
writetier controls who may upload (authenticated= members;public= guests). Never setread: "public"on a file-upload collection (see Security Rule §7).ozem_create_collection → slug: "contact" ozem_update_collection_rules → read="private", write="public" - 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 } }); - 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_site → url). 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/createblocked past the limit).total_entries— combined cap on records + end users per site (ozem_create_record/ozem_create_end_userblocked 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:
- Call
ozem_preview_notification_payloadwithsite_slug+event(+collection_slugforrecord.created) → discover available variables, filters, enriched fields, and a reference template with rendered sample output. - Compose
subject+bodytemplates using the discovered variables + filters. - Call
ozem_configure_site_notificationsto save the rule. - Check the response for
rendered_preview+missing_variableswarnings (the save-time feedback loop). - If
missing_variablesis 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-lineName x2 — RM50.00),order.items_count. - Record events (
record.created):record.fields_summary(multi-lineLabel: value, arrays joined, booleans asYes/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.