@awesomate/hosting-mcp
Version:
Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases
138 lines (110 loc) • 7.95 kB
Markdown
# Webhook pages: designed HTML pages served straight from your n8n
A webhook can serve a full, beautiful web page — a branded form, a phone camera-capture
mini-app the user saves to their home screen, an approval page that resumes a paused
workflow, a live status page. No hosting, no CORS, no deploy pipeline: the page exists
while the workflow is active.
**Never guess the page's hostname.** The live address is `{instanceUrl}/webhook/<path>`,
with the path from the webhook trigger you wrote and `instanceUrl` from
`awesomate_n8n_context`. Most instances are `https://<slug>.awesomate.io`, but not all of
them are, so a hostname built from the slug is a guess that fails silently for the clients
on a different domain. Give the user the URL built from `instanceUrl`.
**The contract: GET renders, POST acts.** Two webhook trigger nodes on the SAME path —
one `httpMethod: GET`, one `POST`, both `responseMode: "responseNode"`. Never let a GET
change anything: email scanners and link previewers click every URL they see. A webhook
set to `onReceived` refuses to run (500 "Unused Respond to Webhook node") if any Respond
node exists in the workflow — always use `responseNode` in page workflows. Exception: in
Wait-based approval flows the signed resume URL IS the act side, so the render webhook can be
GET-only; add a POST twin only when the page posts back to itself.
## Rendering a page
Webhook (GET) → Set "Workflow Config" → IF key gate → Code "Render Page" → Respond to Webhook.
- The Code node returns `{ status_code, html }`, escaping every interpolated value with a
local `esc()` helper (`& < > "`).
- Respond node: `respondWith: "text"`, `responseBody: ={{ $json.html }}`,
`options.responseCode: ={{ $json.status_code }}`, header
`Content-Type: text/html; charset=utf-8`. Every outcome branch (success, already-done,
invalid) must converge into ONE respond node — an unwired branch silently swallows the
response.
- To pass config to the page's JavaScript safely, embed
`<script id="__cfg" type="application/json">BASE64</script>` and read it with
`JSON.parse(atob(document.getElementById("__cfg").textContent))` — immune to quote
escaping.
## Brand the page from the business variables
Start the workflow with a "Workflow Config" Set node (Include Other Fields ON) mapping the
brand kit with fallbacks — dot notation only:
```
brand_primary ={{ $vars.brand_primary_color || '' }}
brand_secondary ={{ $vars.brand_secondary_color || '' }}
brand_font ={{ $vars.brand_font_primary || '' }}
logo_url ={{ $vars.biz_logo_url || '' }}
biz_name ={{ $vars.biz_name || '' }}
```
In the page, feed these into CSS custom properties (`:root{--brand:…}`) with a tasteful
default palette in the Code node for any unset key. Every page you build this way is
automatically on-brand for the business, and re-brands itself if the variables change.
## What the browser can and can't do (important)
n8n serves your HTML with a security sandbox header (no iframe — your page is delivered
verbatim). Measured behavior:
- **Works:** JavaScript, `fetch()` back to the same webhook (relative or absolute path,
no CORS setup needed), plain `<form method="POST">` submits, links, redirects, phone
camera inputs (`<input type="file" accept="image/*" capture>`).
- **Blocked (throws SecurityError):** localStorage, cookies, service workers.
So: **state rides the URL** — query parameters, hidden form fields, and (for installed
mini-apps) the key echoed into the manifest `start_url`. Never rely on localStorage.
## Getting data in — three modes
| Mode | The workflow receives | Use for |
|---|---|---|
| Plain `<form method=POST>` | Parsed fields in `$json.body` | Zero-JS forms, approve/reject buttons |
| `fetch` + JSON, files as base64 | `body.files[].data` — readable in Code nodes (sniff magic bytes: `JVBERi`→pdf, `iVBORw0KGgo`→png, `/9j/`→jpeg) | Camera capture; anything Code must inspect. Downscale photos client-side first (canvas, 1600px JPEG q0.85, white background fill) |
| Multipart (native file form / FormData) | Binary properties (name/type/size; bytes NOT readable in Code nodes) | Large files; process with Extract From File or HTTP nodes |
POST back to `location.pathname` — same origin, zero configuration. Respond with a small
HTML page confirming what was received.
## Multi-view apps and assets from one webhook
Route on a query parameter (`?view=home|form|manifest|icon`), never on `/:path` segments
(those change the URL shape). A Switch node fans out to one Respond node per response
type: pages (text/html), a web-app manifest (`application/manifest+json`), even a binary
PNG icon (Code nodes can return binary:
`{ json, binary: { data: { data: b64, mimeType: 'image/png', fileName } } }`). Redirects
return **307**. n8n sets its own cache lifetimes per content type — version assets with a
query param, not cache headers.
## Save-to-home-screen mini-apps (phones)
- **Always ship an icon.** Serve a 512×512 PNG from an `?view=icon` route and reference it
from BOTH the `apple-touch-icon` link and the manifest `icons` entry — without it iOS
installs a screenshot of the page and Android shows a letter tile. Use the business logo
(`$vars.biz_logo_url`) when set, else a brand-gradient placeholder.
- Page head: `<link rel="manifest" href="?view=manifest&key=K">`,
`<link rel="apple-touch-icon" href="?view=icon&key=K">`,
`apple-mobile-web-app-capable` + `mobile-web-app-capable` + `theme-color` metas.
- Manifest: `display: "standalone"`, **absolute** paths everywhere
(`start_url: "/webhook/<path>?view=app&key=K"`, `scope: "/webhook/"`, absolute icon
src) — a relative `start_url` 404s. **Echo the access key into `start_url`**: it is the
only persistence that survives installation.
- iPhone: Share → Add to Home Screen gives a standalone branded app. Android: "Add to
Home screen" works as a shortcut; the full install prompt needs a service worker, which
the sandbox blocks — treat Android as shortcut-only.
- iOS snapshots the manifest at install time: if you fix it later, users must remove and
re-add the icon.
## Approval pages that resume a paused workflow
1. Before a Wait node, capture `$execution.resumeUrl` (n8n signs it — unguessable) and
deliver it: email it, or respond to the start request with a designed page linking to
your approval view.
2. Wait node: `resume: "webhook"`, `httpMethod: "POST"`, a wait-time limit, and
`responseMode: "responseNode"` (set it even though the UI hides it — without it the
approval click gets a JSON ack instead of your page).
3. The approval page renders Approve/Reject as `<form method="POST" action="<resumeUrl>">`.
4. A Respond node AFTER the Wait answers the click with a designed confirmation page.
Replaying a used resume URL returns 409 — safe by default.
## Access control
- Customer one-shot links: generate a long random token, store only its SHA-256 hash
(in a data table or your workflow's database, with `{token_hash, record_id, status,
expires_at}`), look up by hash with an expiry, and return a vague 410 for anything
unknown. Lookup errors fail CLOSED (410, never render). Sensible expiries: days for
record links, hours for approval waits.
- Team pages / mini-apps: a key in `$vars`, checked by an **IF node as the first step**
(never the webhook's `onlyRunIf` option with `$vars` — it fails open).
- Every page request is one workflow execution — designed for human-scale traffic, not
public high-volume sites. Inline all CSS/JS and embed images as data URIs (response
size is generous — multi-MB pages are fine).
## When NOT to use a webhook page
Login sessions and multi-user accounts (no cookies), public high-traffic sites, or a real
app with a database UI — those belong on hosting (awesomate-app-builder). A quick internal
form with no design needs can stay on n8n's Form node.