appscript.dev
Automation Intermediate Sheets

Build an embeddable feedback widget

Collect input from any Northwind page via a small JavaScript snippet that posts to your script.

Published Aug 24, 2025

The best feedback is the kind people leave in the moment — when they cannot find the button they wanted, when the wording confuses them, when something quietly delights. By the time they have closed the tab and filed it under “things I might email someone about later”, the moment is gone. Northwind needs a way to catch that input where it happens, on the page itself, without shipping a third-party widget that loads a megabyte of tracking with it.

This is a one-script, one-snippet feedback widget. The Apps Script side is a doPost web app that appends submissions to a Sheet. The page side is a tiny button you paste into any Northwind site — internal wiki, marketing page, docs — that prompts for a message and posts it to your script. No backend, no build step, no dependencies.

What you’ll need

  • A Google Sheet to store submissions. The script appends one row per message with timestamp, source URL, the message itself, and an optional email.
  • An Apps Script project containing the doPost function below, deployed as a web app with access set to Anyone.
  • Editor access to any HTML page where the widget will live — the snippet is one button and a few lines of JavaScript.

The web-app handler

// The sheet that stores submitted feedback. Headers expected in row 1:
//   timestamp | url | message | email
const FEEDBACK_SHEET_ID = '1abcFeedbackId';

// Cap on message length — anything past this gets truncated before storage.
// Stops a runaway paste from breaking the row.
const MAX_MESSAGE_LENGTH = 4000;

/**
 * Receives a feedback POST from the embedded widget and appends it to
 * the feedback sheet. Returns plain text so the browser sees a clean
 * 200 response.
 *
 * Expected payload: { url, message, email? }
 */
function doPost(e) {
  // 1. Guard against empty or malformed bodies — a misconfigured embed
  //    can fire a POST with no payload at all.
  if (!e || !e.postData || !e.postData.contents) {
    return ContentService.createTextOutput('Empty body');
  }

  let data;
  try {
    data = JSON.parse(e.postData.contents);
  } catch (err) {
    return ContentService.createTextOutput('Bad JSON');
  }

  // 2. The message is the only field we genuinely need. Reject blank ones
  //    so the sheet does not fill with empty rows from accidental submits.
  const message = String(data.message || '').trim();
  if (!message) {
    return ContentService.createTextOutput('No message');
  }

  // 3. Append the row. The source URL helps you tell where each piece of
  //    feedback came from; email is optional.
  SpreadsheetApp.openById(FEEDBACK_SHEET_ID).getSheets()[0].appendRow([
    new Date(),
    String(data.url || ''),
    message.slice(0, MAX_MESSAGE_LENGTH),
    String(data.email || ''),
  ]);

  return ContentService.createTextOutput('OK');
}

The widget (paste on any page)

<!-- Drop this anywhere on a Northwind page. The button floats wherever
     you place it in the markup — style it to match your design. -->
<button onclick="showFeedbackWidget()">Feedback</button>
<script>
  // Replace this with the /exec URL of your deployed Apps Script web app.
  const FEEDBACK_ENDPOINT = 'https://script.google.com/macros/s/AKfycb.../exec';

  function showFeedbackWidget() {
    // The simplest possible UI: a native prompt. Swap for a modal if you
    // need styling, but this version has zero dependencies.
    const msg = prompt('What can we improve?');
    if (!msg) return;

    // Fire-and-forget POST. We do not await the response because Apps
    // Script web apps return a CORS-allowed text/plain body that some
    // browsers refuse to read from a cross-origin fetch — so we just
    // send and move on.
    fetch(FEEDBACK_ENDPOINT, {
      method: 'POST',
      // text/plain avoids a CORS preflight, which Apps Script does not
      // answer. The server still parses JSON from the body.
      headers: { 'Content-Type': 'text/plain;charset=utf-8' },
      body: JSON.stringify({
        url: location.href,
        message: msg,
      }),
    }).catch(() => {
      // Network errors are silent on purpose — the user does not need to
      // know whether the row landed in the sheet.
    });

    alert('Thanks — sent.');
  }
</script>

How it works

  1. A visitor clicks the Feedback button. The script uses prompt() to ask for a message — primitive, but it works on every browser and needs no CSS.
  2. The page POSTs a JSON body with the current location.href and the typed message to the web-app /exec URL.
  3. doPost parses the body, guards against empty messages and broken JSON, and appends one row to the feedback sheet with a timestamp, the source URL, the message, and the optional email.
  4. Messages are truncated to MAX_MESSAGE_LENGTH so a stray paste of a 50 KB essay does not break the row. The browser sees a 200 OK response either way and the user gets an alert confirming the send.

Example run

A reader on https://northwind.example/docs/setup clicks Feedback, types Step 4 says click 'next' but the button is labelled 'continue' and hits OK.

The feedback sheet picks up a new row:

timestampurlmessageemail
2025-08-24 14:12:09https://northwind.example/docs/setupStep 4 says click ‘next’ but the button is labelled ‘continue’

A second submitter pastes a 12,000-character bug report — the row stores the first 4,000 characters; the rest is dropped on the floor.

Deploy it

  1. Paste the doPost script into a new Apps Script project.
  2. Click Deploy → New deployment, pick Web app, set Execute as to your account and Who has access to Anyone (required because the widget POSTs from a third-party page, unauthenticated).
  3. Copy the /exec URL and paste it into FEEDBACK_ENDPOINT in the widget snippet.
  4. Drop the snippet into any Northwind page and submit a test row to confirm the sheet receives it.

When you edit the server code you must redeploy a new version — Deploy → Manage deployments → edit → New version — and the existing URL stays the same.

Watch out for

  • The endpoint is public by design. Anyone who finds the URL can POST to it, so do not expose any sensitive logic from doPost. Add a shared secret in the payload (data.secret === 'xyz') if you need to dissuade casual abuse.
  • fetch with Content-Type: text/plain skips the CORS preflight that Apps Script does not answer. Setting it to application/json will look fine in dev tools but the request never reaches your script.
  • The browser usually cannot read the response body cross-origin, so do not rely on it. Validate that submissions arrive by watching the sheet, not the network panel.
  • High-volume pages will hit the daily web-app trigger quota. For a heavily trafficked site, batch submissions or write to a queue first — see Cache API responses to stay under quotas.
  • A prompt() is rough UX. Once the flow is proven, swap it for a real modal with a textarea and an email field — the server already accepts both.

Related