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_KEYin 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…';
// 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
- The page is a small HTML form.
google.script.run.registerAndCheckoutposts the form values back to the server. registerAndCheckoutvalidates the payload, generates a UUID, and appends apendingrow to the registrations Sheet before talking to Stripe — so even a failed checkout leaves a breadcrumb to follow up on.createCheckoutSessioncalls Stripe’s REST API with the matching price ID and our registration ID asclient_reference_id. Stripe returns a hosted checkout URL; the page redirects to it.- After payment, Stripe POSTs
checkout.session.completedto the web app’s/execURL.doPostignores everything else, looks up the row, marks it paid, and sends the ticket email. doGetdoubles as the thank-you and cancel page using?page=thanksor?page=cancel, set as Stripe’ssuccess_urlandcancel_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:
| id | name | ticket | status | created_at | |
|---|---|---|---|---|---|
| 6f1c-…-9b2e | [email protected] | Ada Lovelace | supporter | pending | 2026-05-27 09:14 |
The browser redirects to checkout.stripe.com/..., Ada pays, and Stripe sends
the webhook. The row updates:
| id | name | ticket | status | created_at | |
|---|---|---|---|---|---|
| 6f1c-…-9b2e | [email protected] | Ada Lovelace | supporter | paid | 2026-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
- Create the Registrations Sheet with the column headers listed under “What you’ll need”.
- Paste the JavaScript into
Code.gsand the HTML intoRegister.html. SetREGISTRATIONS_SHEET_IDand theTICKETSmap. - Save
STRIPE_SECRET_KEYin Script Properties. - Deploy → New deployment → Web app. Execute as Me, access
Anyone. Copy the
/execURL. - In the Stripe dashboard, add the
/execURL as a webhook endpoint and subscribe tocheckout.session.completed. - 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-Signatureheader insidedoPostand reject unsigned bodies. Without it, anyone who guesses the URL can forge a “paid” event. - The same
/execURL 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_idis 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.sendEmailwith a quota check.
Related
Build a branded approval interface
Approve Northwind requests through a custom UI — clients click, decision is logged.
Updated Nov 8, 2025
Build an interactive quiz or assessment app
Run Northwind tests with scoring and feedback — questions in a Sheet, results in another.
Updated Nov 4, 2025
Build a multi-page web app with routing
Structure a real Northwind app across views — query-param routing, shared layout.
Updated Oct 31, 2025
Build a form-to-PDF web service
Convert Northwind form submissions to PDFs on the fly — POST in, PDF out.
Updated Oct 27, 2025
Build an expiring secure-download generator
Issue time-limited Northwind links via a web app — token in URL, server-side check.
Updated Oct 23, 2025