Web Apps lets your school build small custom tools — a fee calculator, a house points
board, a uniform shop catalogue, a club directory — and host them right inside your
portal. You (or anyone at your school, with help from tools like Claude or ChatGPT)
write a simple app in HTML, CSS and JavaScript; PortalHQ hosts it securely at your
school’s address, controls who can open it, and gives it something a plain HTML file
can’t have on its own: shared data storage, so a change saved by one staff member
is instantly what every parent and student sees.
Every web app gets:
- Its own address —
https://yourschool.portalhq.com.au/apps/your-app/ - A settings document — one shared block of configuration your app can read and
authorised staff can update (e.g. this year’s fees) - Collections — structured lists of records with fields you define (e.g. a list of
clubs, events or products), managed through PortalHQ or from the app itself - Access control — public, logged-in members only, staff only, parents or students,
chosen per app and per piece of data
Before you start
- The module must be enabled for your school. If you don’t see Web Apps in the
staff sidebar, contact PortalHQ support to have it switched on. - You need the “web apps admin” permission. Portal admins have access automatically
and can grant Is web apps admin to other staff via Members → Staff → (person) →
Permissions.
Only staff with this permission can create apps, upload files or change schemas. That’s
deliberate: uploaded JavaScript runs on your portal’s address, so uploading is trusted,
admin-level work — treat app code with the same care as a newsletter that goes to every
family.
Creating a web app
- Open Web Apps in the staff sidebar and click Add Web App.
- Fill in:
- Title — e.g. Fee Calculator. The URL is generated from this (you can set your
own in the URL field; a few names likestaffandapiare reserved).
- Status — start as Draft. Draft apps are only visible to web apps admins, so
you can build and test privately. Switch to Published when ready.
- Who can open the app — see Access levels below.
- Settings read/write access — who may read and change the app’s settings
document (defaults: read Anyone, write Staff only — right for most apps).
- Allow embedding — tick this if the app will be shown inside an iframe on your
school’s public website. - Click Save. You’ll land on the app’s page, ready for files.
Uploading your app’s files
Your app is just files — at minimum one index.html. From the app’s page you can:
- Upload files — pick one or more files, optionally into a folder (e.g.
assets/images). - Upload a zip — zip your whole app folder and upload it in one go. Folder structure
is preserved, and if everything sits inside a single top folder (typical when you zip
adist/folder), that folder is stripped soindex.htmllands at the top. Junk
entries (__MACOSX,.DS_Store) are skipped automatically and anything else skipped
is reported to you with a reason.
Things to know:
index.htmlis the front door — it’s what loads at/apps/your-app/.- Re-uploading a file with the same path replaces it. Deploying an update is just
uploading the new zip. - Allowed file types: HTML, CSS, JavaScript, JSON, text/markdown/CSV/XML, images
(PNG, JPG, GIF, SVG, WebP, AVIF, ICO), fonts (WOFF/WOFF2/TTF/OTF), PDF and web
manifests. Executables and anything else are rejected. - Limits: 5 MB per file, 50 MB per app, 500 files per zip.
- Apps must be self-contained. For security, apps can’t load scripts, styles or
fonts from external websites (CDNs) — bundle everything into your uploaded files.
Inline<script>and<style>blocks are fine, which is exactly how single-file
AI-generated apps are built. If your app genuinely needs an external source, contact
PortalHQ support. - Apps with client-side routing work: any path without a file extension falls back to
yourindex.html.
Click Open App any time to see it live. While the app is a draft, only web apps
admins can open it — everyone else gets “not found”.
Access levels
The same five levels are used everywhere access is configured:
| Level | Who gets in |
|---|---|
| Anyone (no login required) | The general public — including prospective parents |
| Any logged-in member | Anyone signed in to your portal |
| Staff only | Staff (portal admins always included) |
| Parents (and staff) | Parents, plus all staff |
| Students (and staff) | Students, plus all staff |
You set an access level in three places:
- The app itself (Who can open the app) — gates loading the app at all.
- The settings document — separate read and write levels. The classic setup is
read: Anyone / write: Staff only: every visitor sees the current figures, but only
staff can change them. - Each collection — separate read and write levels per collection.
Writes are always enforced by the server. Even if someone digs out the API address, a
parent cannot change data guarded by a Staff only write level.
The settings document
Every app has one settings document: a single block of configuration (stored as
JSON) shared by everyone who uses the app. This is the piece that makes an AI-built
prototype production-ready — instead of settings living in each person’s browser, they
live in PortalHQ, so an update by the business office reaches every family immediately.
Two ways to edit it:
- In PortalHQ: the app page → Settings Document shows the raw JSON in an editor.
Handy for the initial load or an emergency fix. - In the app itself: most apps include their own friendlier admin screen that saves
through the API (see below). Staff open the app, edit figures in a form, click save.
Every save increments a version number, so you can tell at a glance whether anything
has changed. Size limit: 128 KB.
Collections
Where the settings document is one block of data, a collection is a list of
records — like a lightweight spreadsheet your app can query. Use collections for
anything with repeating rows: clubs, events, staff directory entries, products, FAQ
items.
Creating a collection
From the app page, open Collections → Add Collection. Give it a title (the slug
is what your app’s code uses in the API address) and choose read/write access levels.
Then define its fields — each row of the schema builder is one field:
| Setting | What it does |
|---|---|
| Key | The name your app’s code uses, e.g. price (lowercase letters, numbers, underscores) |
| Label | The name staff see in forms, e.g. Price |
| Type | Text, Multi-line text, Number, Yes/No, Date, Email, URL, or Choice |
| Required | Whether the field must be filled in |
| Choices | For Choice fields: the allowed options, one per line |
| Order | Display order in forms |
Everything written to a collection is validated against this schema — wrong types,
missing required fields and unknown field names are rejected with clear error messages.
Adding and editing records
Open the collection’s Items page to add, edit and delete records through a form
generated from your schema — no code involved. Your app can also create records through
the API (for example, a suggestion box where parents submit entries).
If you change the schema later, existing records are never broken: values for removed
fields are kept and shown as “legacy fields” when editing, where you can keep or remove
them. Tip: to rename a field, add a new one rather than renaming the key.
Limits: 5,000 records per collection, 32 KB per record.
For your app developer: the API
This is the part to hand to whoever (or whatever!) writes your app’s code. The app is
served from /apps/<your-app>/, so it calls its own API with relative URLs — no
domain names, no keys to configure. Logged-in users are recognised automatically
through their portal session.
Building with an AI assistant? Paste this whole section into the chat and say
“use this API for loading and saving data”.
Who am I?
const me = await fetch("api/me").then((r) => r.json());
// { authenticated, is_staff, user_type, first_name, csrf_token }
Call this first. It tells the app whether the visitor is signed in and whether they’re
staff — perfect for showing or hiding an admin screen — and hands over the csrf_token
needed for any write.
if (!me.is_staff) {
document.getElementById("admin-tab").style.display = "none";
}
(Hiding is a courtesy — the server enforces write permissions regardless.)
Settings document
// Read (allowed for whoever meets the app's "settings read" level)
const { data, version } = await fetch("api/settings").then((r) => r.json());
// Write (typically staff only)
await fetch("api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", "X-CSRFToken": me.csrf_token },
body: JSON.stringify(newSettings), // must be a JSON object
});
A PUT replaces the whole document and bumps version. To stop two people
overwriting each other, optionally send the version you last read as an
If-Match header — if someone else saved in the meantime, the API answers
412 with the current version instead of silently clobbering their changes:
await fetch("api/settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": me.csrf_token,
"If-Match": String(version),
},
body: JSON.stringify(newSettings),
});
Collections
// List records — returns { count, results: [{ id, created, updated, data }] }
const clubs = await fetch("api/collections/clubs/items").then((r) => r.json());
// Filter on any schema field, sort and paginate
await fetch("api/collections/clubs/items?active=true&ordering=-created&limit=50&offset=0");
// Create a record
await fetch("api/collections/suggestions/items", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": me.csrf_token },
body: JSON.stringify({ title: "More bike racks", category: "facilities" }),
});
// Read, update or delete one record by its id
await fetch(`api/collections/clubs/items/${id}`); // GET
await fetch(`api/collections/clubs/items/${id}`, { method: "PUT", ... }); // full replace
await fetch(`api/collections/clubs/items/${id}`, {
method: "DELETE",
headers: { "X-CSRFToken": me.csrf_token },
});
Notes:
- Filters accept one value per field and must match the schema (
?price=40,
?active=true); unknown filter names return an error rather than silently matching
nothing. orderingiscreatedor-created(newest first, the default).limitcaps at
200 per page; useoffsetto page through.- Validation errors come back as
400with messages keyed by field name — show them to
the user:
{ "name": ["This field is required."], "tier": ["Must be one of: gold, silver"] }
401means the visitor needs to sign in;403means they’re signed in but don’t have
the required level.
Calling the API from outside the app
Other systems (a script on your school’s server, an integration) can call the same
endpoints with a PortalHQ API token instead of a browser session:
GET https://yourschool.portalhq.com.au/apps/your-app/api/collections/clubs/items
Authorization: Token <your-api-token>
Token requests don’t need the CSRF header. Tokens are issued per user by PortalHQ —
contact support if you need one.
Fair-use limits apply to the API: 240 reads and 30 writes per minute.
Embedding an app on your school website
To show an app inside a page on your public website:
- Tick Allow embedding in the app’s settings.
- Add an iframe to your website:
<iframe src="https://yourschool.portalhq.com.au/apps/fee-calculator/"
style="width:100%; height:900px; border:0;"
title="Fee Calculator"></iframe>
For a public tool like a fee calculator, set Who can open the app and the relevant
read levels to Anyone so website visitors aren’t asked to log in.
Moving an app between environments (export / import)
Apps travel as a single export file — a zip containing the app’s files, settings,
collections and data. Use it to move an app from a test environment to production, keep
a dated backup, or copy an app between schools.
- Export: app page → Export. Downloads
webapp-<name>-<date>.zip. - Import as a new app: Web Apps list → Import Web App. Creates the app with
everything included. It arrives as a draft so you can review before publishing. - Deploy into an existing app: app page → Deploy from an export. Made for
releasing updates: files and schemas are brought in line with the export (old files
are cleaned up), while the app’s address, published status, live settings and
collected records are kept — so releasing a new version never wipes out data
entered in production. Tick Overwrite the settings document or Replace collection
items only when you really want the export’s data.
Good practice checklist
- Start as a draft, test with your own account, then publish.
- Keep everything in the uploaded files — no external scripts, styles or fonts.
- Use relative paths in your code (
assets/logo.png,api/settings) so the app
works wherever it’s hosted. - Let the server be the gatekeeper: use
api/meto tidy the interface, but rely on
read/write levels for actual protection. - Export before big changes — it’s a one-click full backup.
- Store big files as app files, not data: upload a PDF as a file and link to it,
rather than embedding it inside the settings document.
Troubleshooting
“Page not found” when opening the app
The app may still be a draft (only web apps admins can open drafts), the Web Apps module
may not be enabled for your school, or the address is misspelt.
The app loads but shows no data
Check the settings document / collection read level. If visitors aren’t logged in,
it needs to be Anyone (no login required).
Saving fails with “you need to be signed in”
The writer must be logged in to the portal in the same browser and meet the write level.
If they are staff and it still fails, the app is probably missing the X-CSRFToken
header on its save request — see the developer section above.
A file was rejected on upload
Only the file types listed under Uploading your app’s files are accepted, and each
file must be under 5 MB.
My app needs React/Tailwind from a CDN
Bundle the libraries into your uploaded files instead (AI assistants can do this
conversion for you — ask for a “self-contained build with no CDN dependencies”). If
that’s not possible, contact PortalHQ support about an exception for your app.
I changed a field and old records look odd
Values for removed fields are kept as legacy fields — edit a record to keep or remove
them. Add new fields rather than renaming keys where possible.