A web app gives your script its own URL. Anyone you allow can open it as a web page or call it from another
program. This site's own contact form sends its messages to an Apps Script web app, using the same
doPost pattern you'll build here.
doGet: the front door
Start a standalone project at script.google.com, then:
Code.gs
// doGet runs when someone opens the web app's URL.function doGet(e) { const name = e.parameter.name || 'visitor'; // ?name=Ploy in the URL return HtmlService.createHtmlOutput(`<h1>Hello, ${escapeHtml(name)}!</h1>`) .setTitle('My first web app');}function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);}
When a browser opens the URL, Google calls doGet(e). Whatever it returns (an
HtmlOutput for a page, or a TextOutput for data) is what the visitor gets.
Part of e
For ...exec?name=Ploy&tag=a&tag=b
e.parameter
{ name: "Ploy", tag: "a" } (first value of each)
e.parameters
{ name: ["Ploy"], tag: ["a", "b"] }
e.queryString
name=Ploy&tag=a&tag=b
e.postData (doPost only)
{ contents, type, length }: the raw body
escapeHtml matters: the name comes from the URL, so anyone can put HTML in it. Escape anything from outside before you put it in a page.
Click Deploy → New deployment. Click the ⚙ next to "Select type" and choose Web app.
Description: "first version". Execute as: Me. Who has access: Only myself (for now).
Click Deploy, authorize, and copy the Web app URL ending in /exec.
Open it, then add ?name=Ploy to the end.
Setting
Choices and meaning
Execute as
Me: runs with your permissions for every visitor (can read your sheet). User accessing: each visitor signs in and the script uses their data.
Who has access
Only myself · anyone in your school domain · anyone with a Google account · Anyone (no sign-in, needed for public forms and APIs)
/exec vs /dev, and versions
The /exec URL serves a fixed version. Saving new code does not change it. To publish
changes: Deploy → Manage deployments → ✏ Edit → Version: New version → Deploy. The URL stays the same.
Deploy → Test deployments gives a /dev URL that always runs your latest saved code, but only
for you. Use it while developing.
Make a sheet with an Events tab (Title, Date) and a Sign-ups tab. Put its ID in the code.
Code.gs
// A web page built from Index.html, with data from the sheet.const SHEET_ID = 'PASTE_THE_SPREADSHEET_ID_HERE'; // web apps have no "active" sheetfunction doGet() { const t = HtmlService.createTemplateFromFile('Index'); t.events = getUpcoming(); return t.evaluate() .setTitle('Club events') .addMetaTag('viewport', 'width=device-width, initial-scale=1'); // looks right on phones}function getUpcoming() { const rows = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Events') .getDataRange().getValues().slice(1); const tz = Session.getScriptTimeZone(); return rows .filter((r) => r[1] instanceof Date && r[1] >= new Date()) .map((r) => ({ title: r[0], when: Utilities.formatDate(r[1], tz, 'EEE d MMM') }));}// The page's form posts here through google.script.run.function signUp(form) { const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Sign-ups'); sheet.appendRow([new Date(), String(form.name).slice(0, 100), String(form.event).slice(0, 100)]); return 'Thanks, you are on the list!';}
Any website, app or script can now read your sheet through that URL:
any-page.html
<!-- On any web page: read the API --><script> const URL = 'https://script.google.com/macros/s/AKfy…/exec'; fetch(URL + '?room=' + encodeURIComponent('M.5/3')) .then((r) => r.json()) .then((data) => console.log(data.count, data.students)); // Send to doPost. URL-encoded form data avoids an extra CORS "preflight" request. fetch(URL, { method: 'POST', body: new URLSearchParams({ name: 'Ploy', message: 'Hello!' }), }) .then((r) => r.json()) .then((res) => console.log(res.ok));</script>
Redirects and CORS
Apps Script answers from script.googleusercontent.com after a redirect. Browsers and
fetch follow it automatically. For POST from another site, send URL-encoded or plain-text bodies;
a JSON Content-Type triggers a CORS preflight that Apps Script doesn't answer.
A public doPost can be called by anyone, including bots. This version is the shape of a real contact-form relay:
Code.gs
// In the same project as the API above: it reuses SHEET_ID and json().// Receive data from another website's form or from fetch().function doPost(e) { try { // Form fields arrive in e.parameter; a JSON body arrives in e.postData.contents. const data = e.postData && e.postData.type === 'application/json' ? JSON.parse(e.postData.contents) : e.parameter; if (data.website) return json({ ok: true }); // honeypot: bots fill hidden fields const name = clean(data.name, 100); const message = clean(data.message, 2000); if (!name || !message) return json({ ok: false, error: 'Name and message are required.' }); SpreadsheetApp.openById(SHEET_ID).getSheetByName('Messages') .appendRow([new Date(), name, message]); return json({ ok: true }); } catch (err) { return json({ ok: false, error: 'Server error' }); }}// Trim, shorten, and stop "=" "+" "-" "@" at the start from becoming a formula.function clean(value, max) { const s = String(value || '').trim().slice(0, max); return /^[=+\-@]/.test(s) ? "'" + s : s;}
🍯 Honeypot
The form has a hidden "website" field. People never fill it; bots do. Pretend success and store nothing.
✂️ Limit length
Shorten every value so nobody can fill your sheet with megabytes of text.
🧮 No formulas
Text starting with = + - @ gets an apostrophe so Sheets keeps it as text.
🧯 try / catch
Always return JSON, even on errors, so the caller can show a message.
A web app that returns JSON like {"sides":20,"roll":13} for ?sides=20, defaulting to 6 and never allowing fewer than 2 or more than 100 sides.
Show a solutionCode.gs
function doGet(e) { const sides = Math.min(Math.max(parseInt(e.parameter.sides, 10) || 6, 2), 100); const roll = 1 + Math.floor(Math.random() * sides); return ContentService.createTextOutput(JSON.stringify({ sides, roll })) .setMimeType(ContentService.MimeType.JSON);}// ...exec?sides=20 → {"sides":20,"roll":13}
2. Two pages Medium
Make one web app show Index.html normally and About.html for ?page=about. Add links between them using ScriptApp.getService().getUrl() and target="_top".
3. Guestbook Challenge
A public page that shows the last 10 messages from a sheet and has a form to add one. Use every protection from the doPost tab, and escape messages when you display them.