Webhooks as a Frontend: Build Real frontends with Make and n8n Using One HTML Page
Webhooks as a Frontend: Build Real Apps with Make and n8n Using One HTML Page
The fast way to ship an app is simple. Serve one HTML page. Post the form to a Make Custom Webhook. Do the work in your scenario. Send a Webhook Response back to the same tab. That is enough to deliver a tool that feels like a product. You can show progress, timers, results, history, limits, and even exports. No servers. No framework. You can do the same in n8n too.
The partner link unlocks Pro worth about 18 USD for one month with about 10k operations. Free plan is 1k operations.

Why this pattern works
Most apps boil down to one truth. The browser sends a request. Your logic runs somewhere. The browser gets a result it can render. When you use Make, you get this with almost no setup. A Custom Webhook is your URL. A Webhook Response is your reply. Everything between those two modules is your logic. You can call APIs, run code, create files, read from a spreadsheet, or write into a Data Store. The Data Store is a small key and value database. It is not meant to be heavy, but it is perfect for user limits, request history, and simple caches.
The payoff is speed. You can launch a public tool in an afternoon. You add polish later. You can scale logic across scenarios. You can also start in Make and move out parts of it when you need more control. You decide your tradeoffs. For a lot of simple but valuable tools, this setup is more than enough.
The core loop
Three steps
[Browser]
HTML page with a form and a little JavaScript
POST → Make Custom Webhook
[Make]
Validate input
Run logic with HTTP, Code, and Data Store
Send Webhook Response with JSON or HTML
[Browser]
Render the JSON result inside the same page
Two routes
?query=new_ui
Return the full HTML page from Webhook Response
?query=action
Process input and return JSON or HTML
These two routes cover most microapps. You can add a third route for polling results by request id if you want async.
You can post the form in the classic way and let the page reload, or you can intercept submit with fetch and keep the user on the same page. I prefer fetch for a smoother feel. I still keep a normal action and method on the form for fallback. The Webhook Response is flexible. You can return JSON and let the page handle it, or you can return pure HTML blocks that the page inserts into a container.
Build it step by step
Step 1. Create the scenario
- Add Webhooks then Custom webhook.
- Create the webhook. Copy the URL.
- Add a router that checks the query parameter named
query
. - Branch for
new_ui
. Add Webhook Response. Paste the full HTML of your page here. - Branch for
action
. Add your logic. End with Webhook Response that sends JSON.
Step 2. Add storage
Create a Data Store named limits
with keys: email
, assigned
, used_today
, cooldown_seconds
, last_request_at
.
Add a Data Store named jobs
with keys: request_id
, email
, status
, payload_json
, error
, created_at
.
Step 3. Decide sync or async
If your logic finishes under a few seconds, return the final result. If it can take longer or if you expect spikes, return a quick accepted JSON and tell the page to poll another route by request_id
. Both are fine. Start simple.
Step 4. Write a small client
The HTML can be a single file returned by the new_ui
branch. Keep markup simple, use semantic tags, and keep the CSS small. Use one script block at the end of the body. Avoid heavy libraries. Use native form validation where possible. Add your own checks where needed. Use a clean progress overlay while the request is in flight. Disable the submit button until you get a response. If you want to limit repeat usage, show a countdown for the cooldown. Keep the tone friendly.
Data model design
Tables you can reuse
- users: email, plan, credits, renew_at, created_at
- limits: email, assigned, used_today, cooldown_seconds, last_request_at
- jobs: request_id, email, status, payload_json, error, created_at, ttl
- audit: ts, email, event, meta
Keep values small. Store only what you need to render results and keep limits fair. Archive old jobs to a sheet if you need history.
Idempotency and duplicates
// server side pseudo
if store.jobs.has(request_id) {
return store.jobs.get(request_id)
}
if not limit_ok(email) {
return {"status":"limit","retry_in": remaining_seconds}
}
reserve_slot(email)
result = run_logic()
store.jobs.set(request_id, result, ttl=7d)
return {"status":"ok","result":result}
For free tools a simple rule works well. Assign 3 runs per day per email. Apply a 60 second cooldown after each run. If someone comes back and hits submit twice, the second call returns the same data because you use the same request_id
until the page refreshes or the email changes.
UX patterns that feel like a product
Clear labels and hints
Put the field name outside the input. Add a short help line under it. Use simple words. Avoid jargon. Tell people what a good input looks like with a short example.
Inline checks
Use native required and maxlength. Add one custom check per field where it saves time. Keep errors short. Use a light red background in the field group only when it really fails.
Progress overlay
When they click submit, show a soft overlay and a counter like 1 s, 2 s, 3 s. The counter reduces anxiety. Disable the button during this state. Keep the right panel visible so they can read while they wait.
Cooldown badge
After a run, show Next request in 60 s. Hide the badge until after the first run. When the timer hits zero, show a short green note for 3 to 4 seconds that says You can try again now.
History groups
Show Request 1, Request 2, and so on. Use an accordion. Open the latest by default. Keep the rest collapsed. Add a Download JSON button that gives the last N results in one file.
Accessible defaults
Use real buttons, not links. Always use labels that connect to inputs. Keep focus outlines. Test with keyboard. Use a readable contrast in text. Avoid pale gray body text.
JSON contracts you can reuse
Request
// POST form fields
email, name, request_id, inputs...
// add honeypot field if you like: website or company_website, leave it blank client side
Success response
{
"status":"ok",
"request_id":"AB12CD34",
"limit":{"assigned":3,"used_after":1,"cooldown_seconds":60},
"result": { /* payload that your UI expects */ },
"extnid":"S-20250831-001" // optional execution id
}
Limit reached
{
"status":"limit",
"request_id":"AB12CD34",
"retry_in": 34,
"limit":{"assigned":3,"used_after":3,"cooldown_seconds":60}
}
Error response
{
"status":"error",
"request_id":"AB12CD34",
"message":"We could not reach the pricing API. Try again in a minute."
}
Throughput, queues, and fairness
Make runs custom webhooks instantly and in parallel by default. That is perfect for short jobs. If a partner API is strict, switch the scenario to sequential so only one run is active at a time. If you need a steady pace, convert to a scheduled webhook. The platform will queue incoming calls and drain them on your schedule. You can set how many items it will handle per run. If the queue fills, return a friendly JSON that says to try again later.
You can also split the work. Accept fast with a small JSON. Write a job record to Data Store with status pending. Run a second scenario on a schedule that picks up pending jobs and processes them. The page polls a ?query=result
route with the same request_id
until status done. This is a good pattern for large CSV conversions or slow batch checks.
Errors and resilience
Retry rules
- Retry network calls with a short backoff like 400 ms, 800 ms, 1600 ms.
- Stop retrying after a short limit. Do not loop forever.
- Log the final error with a clear event name and the request id.
Friendly copy
Tell people what happened. Use short plain text. Suggest one next step. Do not paste raw error messages. Save technical details to logs only.
For idempotency, never run the heavy part twice for the same request id. Either return the cached result or reject if the job is still running. On the client, block the button until the call returns. Both sides should protect against double submits.
Security and CORS
The easiest path is to serve the page from the new_ui
branch using Webhook Response. The page and the POST share the same origin. CORS is not an issue. If you host the HTML elsewhere, set the response headers in the Webhook Response. Allow only the origin that serves your page. Escape any text before you echo it in HTML. Use a honeypot field. Limit lengths. Respect privacy. If your scenario calls other scenarios, add an HMAC signature in a header and check it on the receiving side.
Deploy, version, and migrate
Keep a copy of your HTML in a code block asset in the scenario or in a repo. When you need a new version, update the HTML, test in a separate scenario, and then swap the webhook to point at the new one. If you ever move the UI to static hosting, you can still post to the same webhook url. If a part of your logic grows large, move that part to a separate scenario and call it from the main one. Use clear names for the scenarios and store your constants in variables.
Analytics and feedback
For small tools you do not need heavy analytics. A simple event table is enough. Add an audit
table with fields ts
, email
, event
, meta
. Log page_loaded
, request_submitted
, result_shown
, and download_json
. This gives you a clean view of usage. If you want web analytics, add a simple page view script or tag manager, but do not let it slow down the page.
Monetize and limit usage
You can start free then meter usage. The users
table can track credits. Each request costs one credit. When credits are zero, show a small upgrade card. Stripe Checkout or a hosted pay page works fine. On success, a webhook from Stripe can add credits. Keep it simple. You can also do coupons for early testers. For partners, add your affiliate links inside the result blocks in a way that helps the user rather than distracts them.
Case study. Instant Quote with PDF
This tool takes a few fields and returns a price range with a breakdown. It also gives a PDF that the user can save or email. The full app is one page and one scenario.
- Route
?query=new_ui
returns the page HTML. - Route
?query=quote_requested
validates, scores, prices, generates the PDF, saves a record, and replies JSON.
Fields
- Name and email are required.
- Project type from a short list.
- Scope sliders for features, speed, complexity.
- Urgency switch for rush.
- Hidden request id from the client.
Scenario outline
Webhook (router on query)
├─ if query=new_ui → Webhook Response (HTML page)
└─ if query=quote_requested
├─ Validate and limits
├─ Code JS: compute price
├─ PDF module: generate file
├─ Save record to Data Store or Sheet
└─ Webhook Response with JSON: {price, pdf_url, limit, request_id}
Pricing logic in plain JavaScript
// inputs: features 1..5, speed 1..5, complexity 1..5, rush bool
function price(features, speed, complexity, rush){
let base = 400 + features*120 + speed*90 + complexity*110;
if (rush) base *= 1.18;
const low = Math.round(base * 0.92);
const high = Math.round(base * 1.15);
return {low, high, currency:"USD"};
}
Example response
{
"status":"ok",
"request_id":"QT7H2Z8A",
"price":{"low":649,"high":980,"currency":"USD"},
"factors":["Feature count medium","Timeline rush plus 18 percent","Complexity low minus 6 percent"],
"pdf_url":"https://files.example.com/quotes/QT7H2Z8A.pdf",
"limit":{"assigned":3,"used_after":1,"cooldown_seconds":60}
}
UX tips
- Overlay with a spinner and a short line that explains what is happening.
- Timer badge during cooldown. Hide it before the first run.
- History groups with JSON download.
- Offer Book a call and Email me the PDF as links.
Live demo Open the Instant Quote page
This is a Make only build. No server. One HTML page, one scenario, Data Store for limits and history, and a small code step for pricing.
Case study. CSV bulk processor
Some tasks are batch heavy. Think of cleaning a CSV, enriching rows from an API, then creating a new file to download. This is a perfect async job case. The page uploads a file and creates a job with a request id. The scenario replies with accepted status. A second scenario drains jobs on a schedule. The page polls a result route with the same request id until status is done and then shows a download link.
Status API
{
"status":"pending",
"request_id":"J987XYQ2",
"progress":{"done":120,"total":1000}
}
// then later
{
"status":"done",
"request_id":"J987XYQ2",
"download_url":"https://files.example.com/bulk/J987XYQ2.csv"
}
Key points
- Do not block the main webhook for long jobs.
- Keep progress simple. Percent is enough.
- Expire finished jobs after a few days to save space.
Case study. Support intake with status
A light support form can help a lot. People submit a short description. The scenario creates a ticket id, sends an email to your team, writes a row to a sheet, and replies with the ticket id. The page shows You will get an email in a bit and a link to check status by id. A second route returns the latest status and any notes. Keep the copy human.
Ticket created
{
"status":"ok",
"ticket_id":"TCK-10452",
"message":"We logged your request. Check your email for details."
}
Status check
{
"ticket_id":"TCK-10452",
"state":"in_progress",
"last_update":"2025-08-31T13:04:00Z",
"note":"We are waiting on the vendor reply."
}
Do the same in n8n
n8n has the same core idea. Use the Webhook trigger as your URL. You can set it to respond when the last node finishes or you can add a Respond to Webhook node. If the flow errors before a response, n8n returns HTTP 500. If two Respond nodes fire, the first wins. The client side pattern is identical. One page. One post. One response. Idempotency and cooldown rules still apply.
n8n sketch
Webhook (Respond when last node finishes)
→ Code or HTTP or Database nodes
→ Respond to Webhook (custom response if needed)
Notes
- Set codes and headers on the Webhook node.
- Aggregate items if you want to return one body.
- Keep the same JSON contract so the page does not care which backend runs it.
FAQ and checklist
Can I push live updates without polling
For most microapps polling is good enough. Keep the interval short only while the user is on the page. If you really need realtime, you can add a small hosted function with Server Sent Events, but that adds more moving parts. Start simple.
What about auth
For public tools just key by email and an optional one time code by email if needed. For internal tools use a shared passcode or IP allow list. Keep secrets in variables, not in code blocks.
How do I test
Keep one scenario for draft and one for live. Build with sample payloads. Add a small test harness page that posts known values. Log events to your audit table. Review results in a sheet while you tweak the copy and UX.
Launch checklist
- Inputs have max length and clear labels.
- Submit is disabled during work.
- Cooldown appears only after the first run.
- All responses follow the JSON contract.
- Logs show request id on every line.
- Images have alt text. Buttons are real buttons.
- CTAs link to the Make partner link.
Create your Make account
Start with one page and one scenario. Use Webhook Response for the UI, Data Store for limits and history, and simple JavaScript for any custom math. You can ship in a day and improve every week.
Use the partner link. Build with the extra headroom. Keep what you build.
Images you can embed
Swap the links with your Cloudinary URLs so readers see the exact tool you built.
Comments
Post a Comment