appscript.dev
Automation Advanced Sheets Gmail

Build an end-to-end event-registration app

Handle Northwind event signups, payment, and confirmation in one Apps Script web app.

Published Sep 29, 2025

When Northwind runs a paid event — a workshop, a community day, a launch party with a cover charge — the moving parts add up fast. A form to collect names, a checkout to take payment, a webhook to confirm it, a registrations sheet to work from on the day, an email to send the ticket. Off-the-shelf event tools handle the lot but charge per attendee and pull your data into their schema.

This script keeps every piece inside Apps Script. The same project serves the form, opens the Stripe Checkout session, accepts the webhook, marks the row paid, and emails the ticket. The state lives in a single Sheet you can sort, filter, and read in person at the door. The pieces below assume you already know how to deploy a web app — see Deploy Apps Script as a public web app for the basics — and how to receive a Stripe webhook, covered in Build a webhook receiver as a web app.

What you’ll need

  • A Google Sheet to store registrations. Columns: id, email, name, ticket, status, created_at. One row per signup.
  • A Stripe account with a secret key, saved as STRIPE_SECRET_KEY in Script Properties. See Store API keys and secrets securely.
  • A Stripe price ID per ticket type — a Map keyed by ticket name to Stripe price IDs lives in the config block below.
  • An Apps Script web app deployment with access set to Anyone.

The HTML (Register.html)

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
      /* Boring, readable form styles — the look the team can match to
         the marketing site later without fighting a framework. */
      body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2em auto; padding: 0 1em; }
      label { display: block; margin: 1em 0 0.3em; font-weight: 600; }
      input, select { width: 100%; padding: 0.5em; font-size: 1em; box-sizing: border-box; }
      button { margin-top: 1.5em; padding: 0.7em 1.4em; font-size: 1em; }
      #status { margin-top: 1em; color: #555; }
    </style>
  </head>
  <body>
    <h1>Northwind community day</h1>
    <p>Pick a ticket type and we'll send you a confirmation email.</p>
    <form id="reg">
      <label>Full name<input name="name" required></label>
      <label>Email<input name="email" type="email" required></label>
      <label>Ticket type
        <select name="ticket" required>
          <option value="standard">Standard — $25</option>
          <option value="supporter">Supporter — $75</option>
        </select>
      </label>
      <button type="button" onclick="go()">Continue to payment</button>
    </form>
    <div id="status"></div>

    <script>
      function go() {
        // Collect the form values into a plain object. FormData turns
        // the form's named inputs into entries we iterate over.
        var form = document.getElementById('reg');
        var data = {};
        new FormData(form).forEach(function (v, k) { data[k] = v; });
        document.getElementById('status').textContent = 'Creating your checkout&hellip;';

        // google.script.run is the bridge to the server. The success
        // handler redirects to the Stripe-hosted checkout page.
        google.script.run
          .withSuccessHandler(function (res) { window.location = res.checkoutUrl; })
          .withFailureHandler(function (err) {
            document.getElementById('status').textContent = 'Sorry — ' + err.message;
          })
          .registerAndCheckout(data);
      }
    </script>
  </body>
</html>

The web-app handler

// Sheet that holds registrations. Append-only — the webhook updates
// rows by ID rather than appending duplicates.
const REGISTRATIONS_SHEET_ID = '1abcRegistrationsId';

// Map ticket types to Stripe price IDs. Add a new ticket here, add the
// matching <option> in Register.html, and the rest of the code keeps
// working untouched.
const TICKETS = {
  standard: { priceId: 'price_1Standard', label: 'Standard' },
  supporter: { priceId: 'price_1Supporter', label: 'Supporter' },
};

// Where Stripe sends the customer back after checkout. Both URLs hit
// the same web app — doGet looks at the path on the URL to render the
// thank-you or cancel page.
const SUCCESS_URL = ScriptApp.getService().getUrl() + '?page=thanks';
const CANCEL_URL = ScriptApp.getService().getUrl() + '?page=cancel';

/**
 * Renders the registration form, or one of the post-checkout pages
 * depending on the ?page= query parameter.
 *
 * @param {GoogleAppsScript.Events.DoGet} e
 * @return {GoogleAppsScript.HTML.HtmlOutput}
 */
function doGet(e) {
  const page = e && e.parameter && e.parameter.page;
  if (page === 'thanks') {
    return HtmlService.createHtmlOutput(
      '<h1>Thanks!</h1><p>Your ticket is on its way. Check your inbox.</p>'
    ).setTitle('Northwind — confirmed');
  }
  if (page === 'cancel') {
    return HtmlService.createHtmlOutput(
      '<h1>No worries</h1><p>No charge was made. Reload to try again.</p>'
    ).setTitle('Northwind — cancelled');
  }
  return HtmlService.createHtmlOutputFromFile('Register')
    .setTitle('Northwind community day');
}

/**
 * Called by the form. Creates a pending row, opens a Stripe Checkout
 * session, returns the URL for the page to redirect to.
 *
 * @param {{name: string, email: string, ticket: string}} data
 * @return {{checkoutUrl: string}}
 */
function registerAndCheckout(data) {
  if (!data || !data.email || !data.name || !TICKETS[data.ticket]) {
    throw new Error('Please fill in every field.');
  }
  const id = Utilities.getUuid();
  SpreadsheetApp.openById(REGISTRATIONS_SHEET_ID).getSheets()[0]
    .appendRow([id, data.email, data.name, data.ticket, 'pending', new Date()]);

  const checkoutUrl = createCheckoutSession(id, data);
  return { checkoutUrl };
}

/**
 * Asks Stripe to create a Checkout session. The registration ID rides
 * along as client_reference_id so the webhook can match the row back.
 *
 * @param {string} id The registration ID we just created.
 * @param {Object} data The form payload.
 * @return {string} The Stripe-hosted checkout URL.
 */
function createCheckoutSession(id, data) {
  const key = PropertiesService.getScriptProperties()
    .getProperty('STRIPE_SECRET_KEY');
  if (!key) throw new Error('STRIPE_SECRET_KEY is not set.');

  const ticket = TICKETS[data.ticket];
  const payload = {
    'mode': 'payment',
    'line_items[0][price]': ticket.priceId,
    'line_items[0][quantity]': '1',
    'customer_email': data.email,
    'client_reference_id': id,
    'success_url': SUCCESS_URL,
    'cancel_url': CANCEL_URL,
  };

  const res = UrlFetchApp.fetch('https://api.stripe.com/v1/checkout/sessions', {
    method: 'post',
    headers: { Authorization: 'Bearer ' + key },
    payload, // Stripe accepts form-encoded — URLFetch encodes maps for us.
    muteHttpExceptions: true,
  });
  const body = JSON.parse(res.getContentText());
  if (res.getResponseCode() >= 300) {
    throw new Error('Stripe: ' + (body.error && body.error.message));
  }
  return body.url;
}

/**
 * The Stripe webhook lands here. Looks up the row by registration ID,
 * marks it paid, and emails the ticket.
 *
 * @param {GoogleAppsScript.Events.DoPost} e
 */
function doPost(e) {
  const event = JSON.parse(e.postData.contents);
  if (event.type !== 'checkout.session.completed') {
    return ContentService.createTextOutput('{"ok":true}')
      .setMimeType(ContentService.MimeType.JSON);
  }
  const session = event.data.object;
  const id = session.client_reference_id;
  markPaid(id);
  emailTicket(id, session.customer_details && session.customer_details.email);
  return ContentService.createTextOutput('{"ok":true}')
    .setMimeType(ContentService.MimeType.JSON);
}

/**
 * Finds the row whose column A matches the registration ID and flips
 * status (column E) from 'pending' to 'paid'.
 *
 * @param {string} id The registration ID.
 */
function markPaid(id) {
  const sheet = SpreadsheetApp.openById(REGISTRATIONS_SHEET_ID).getSheets()[0];
  const ids = sheet.getRange('A2:A').getValues().flat();
  const rowIndex = ids.indexOf(id);
  if (rowIndex < 0) throw new Error('Unknown registration id: ' + id);
  sheet.getRange(rowIndex + 2, 5).setValue('paid');
}

/**
 * Sends the ticket confirmation. Keep it short — the calendar invite
 * is the real artefact people care about.
 *
 * @param {string} id Registration ID.
 * @param {string} email Recipient email.
 */
function emailTicket(id, email) {
  if (!email) return;
  GmailApp.sendEmail(email, 'Your Northwind community day ticket',
    'Thanks for signing up! Your ticket reference is ' + id + '. ' +
    'We will email the calendar invite a week before the event.');
}

How it works

  1. The page is a small HTML form. google.script.run.registerAndCheckout posts the form values back to the server.
  2. registerAndCheckout validates the payload, generates a UUID, and appends a pending row to the registrations Sheet before talking to Stripe — so even a failed checkout leaves a breadcrumb to follow up on.
  3. createCheckoutSession calls Stripe’s REST API with the matching price ID and our registration ID as client_reference_id. Stripe returns a hosted checkout URL; the page redirects to it.
  4. After payment, Stripe POSTs checkout.session.completed to the web app’s /exec URL. doPost ignores everything else, looks up the row, marks it paid, and sends the ticket email.
  5. doGet doubles as the thank-you and cancel page using ?page=thanks or ?page=cancel, set as Stripe’s success_url and cancel_url. The whole journey lives at a single URL.

Example run

A visitor lands on the form and signs up as Ada Lovelace, [email protected], Supporter ticket. The Registrations Sheet immediately gets:

idemailnameticketstatuscreated_at
6f1c-…-9b2e[email protected]Ada Lovelacesupporterpending2026-05-27 09:14

The browser redirects to checkout.stripe.com/..., Ada pays, and Stripe sends the webhook. The row updates:

idemailnameticketstatuscreated_at
6f1c-…-9b2e[email protected]Ada Lovelacesupporterpaid2026-05-27 09:14

Ada gets an email titled “Your Northwind community day ticket” with her reference ID. On the day, you sort the Sheet by status = paid and check her in.

Deploy it

  1. Create the Registrations Sheet with the column headers listed under “What you’ll need”.
  2. Paste the JavaScript into Code.gs and the HTML into Register.html. Set REGISTRATIONS_SHEET_ID and the TICKETS map.
  3. Save STRIPE_SECRET_KEY in Script Properties.
  4. DeployNew deployment → Web app. Execute as Me, access Anyone. Copy the /exec URL.
  5. In the Stripe dashboard, add the /exec URL as a webhook endpoint and subscribe to checkout.session.completed.
  6. Test once with Stripe’s test keys before pointing the form at live keys.

Watch out for

  • Apps Script has no built-in Stripe signature verification. Once the flow works end to end, add an HMAC check on the Stripe-Signature header inside doPost and reject unsigned bodies. Without it, anyone who guesses the URL can forge a “paid” event.
  • The same /exec URL handles GET (the form) and POST (the webhook). That is convenient but means a webhook outage breaks the form too. For higher volumes, split the registration and webhook receivers into two scripts.
  • Stripe webhooks retry on 5xx. Always wrap the handler body in try/catch and return 200 after logging; otherwise a transient Sheet glitch produces duplicate “paid” updates on the same row.
  • client_reference_id is the only thing tying a checkout back to your Sheet. Do not change the registration ID once it has been sent to Stripe — the webhook will look up nothing and the row stays pending.
  • For an event with hundreds of attendees, the per-day Gmail send quota matters. Send the confirmation from the webhook in real time but generate the calendar invite in a daily batch via MailApp.sendEmail with a quota check.

Related