Blog / Product Brand Operations

Send an Order Confirmation Email From a Google Sheet or Form

By Keyona Meeks, Founder, ReRev Labs · Oct 31, 2026 · 10 min read

The short answer: Put orders in one Google Sheet, either from a Google Form or typed by hand. A short Google Apps Script gives each order a number (ORD-0001), emails the customer a plain confirmation and stamps the time it sent. Form orders confirm on their own. Hand-typed rows wait until you type Yes in a "Ready to confirm" column, so a half-finished row never emails anyone. The email says the order was received and has not shipped. It makes no promises you did not type in. I mock-tested the script; I did not run it in a live Google account.

By the end of this post, you'll be able to:

  • Build an order form and link it to a sheet.
  • Paste in a script that numbers orders and sends a confirmation.
  • Confirm hand-entered orders without risking a half-typed email.
  • Test it on a copy before a real customer gets an email.

A buyer places a wholesale order by email. A customer asks for a custom set in a direct message. Someone pays by invoice and never touches your store. Each of those orders needs the same small reply: we got it, here is what we have, here is what happens next. When it comes late or never, the customer writes again, and now you have two threads about one order.

If your store platform already sends a confirmation for the orders it takes, use that. This post is for the orders that do not go through it. I did not research how any store platform's own emails work, so check yours.

What should an order confirmation say?

Less than you think. When I searched "send order confirmation email from google sheets" on October 30, 2026, page one was a mix of tutorials, code samples and form add-on pages. Titles only. I did not review them and I name none. Most show how to send an email. Few say what the email should contain.

Say only what you know:

  • That you received the order. Not that it is approved or paid.
  • An order number. So the customer can quote it.
  • What was ordered and where it goes. So they can catch a mistake.
  • What happens next. A ship date if you have typed one, and a plain line if you have not.
  • How to reach you. Reply to the email.

The script below sends exactly that, and it says the order has not shipped. It does not show prices or totals, because the sheet should not be the place a customer's invoice is decided.

Is a confirmation email a marketing email?

No, but the rule is narrow. The FTC's CAN-SPAM guide says an email's primary purpose is transactional or relationship "if it consists only of content that:" and lists categories, the first being "Facilitates, completes, or confirms a commercial transaction that the recipient already has agreed to;". It warns that "the law views these categories narrowly." It also says such a message "may not contain false or misleading routing information" and is "otherwise exempt from most provisions of the CAN-SPAM Act." Source: CAN-SPAM Act: A Compliance Guide for Business, checked October 30, 2026. I read the page through a fetch tool that returned excerpts.

The plain point: keep the confirmation to the order. If you add a sale or a newsletter pitch, it may stop being a transactional message. I am not a lawyer. If you are unsure, ask an attorney.

How do you build the order form?

Make a Google Form with five questions:

  1. Name. Short text.
  2. Email. Short text.
  3. Items. Paragraph. Ask for product and quantity.
  4. Ship to. Paragraph.
  5. Notes. Paragraph. Optional.

Skip a file upload question. Google's help says "To answer this question, responders need to sign in to a Google Account," and many buyers will not. Source: Google's help on file upload questions, checked October 30, 2026.

Google's help page gives the steps. In Google Forms: open your form, click Responses, then the Summary tab. At the top right, click More, then Select destination for responses. Choose Create a new spreadsheet or Select existing spreadsheet, then click Create or Select. Source: Google's help page on choosing where to save form responses, checked October 30, 2026.

Question titles become column headers, and the response tab is usually called Form Responses 1 with a Timestamp column first. I know that from using the product, not from a page I opened for this post, so check yours. Then add five headers to the right of the form columns, spelled exactly like this:

Order ID, Ready to confirm, Expected ship date, Confirmation sent, Error

The script fills Order ID, Confirmation sent and Error. You fill Ready to confirm (for hand-typed rows) and Expected ship date.

How do hand-typed orders work?

Type the order into the next empty row: name, email, items, ship to. Leave Timestamp blank. The script will give the row an order number on its next run but it will not email. When the row is right, type Yes in Ready to confirm. On its next run, the script sends.

That one column is the guard. Without it, a row with an email typed and nothing else would send a half-empty message. Form rows skip the guard because the form made them complete.

If you know the ship date, type it in Expected ship date before you type Yes. The email will include it. If the cell is empty, the email says you will write again when you have a date.

How do you set up the script?

  1. Open the sheet. Choose Extensions, then Apps Script. If this is new to you, read what Google Apps Script is first.
  2. Delete the placeholder code and paste the script below.
  3. Change STORE_NAME and REPLY_TO to your own.
  4. Click Save.
  5. In the function list, choose setupOrderTriggers and click Run. Google asks you to allow the script to read your sheet and send email as you. Read the list. Approve it only if you agree.
// Order confirmations: email the customer when an order lands in this sheet.
// Paste into Extensions > Apps Script in the sheet that receives your order form.

const STORE_NAME = 'Your Brand';
const REPLY_TO = 'orders@yourbrand.com';
const SHEET_NAME = 'Form Responses 1';
const ID_PREFIX = 'ORD-';

const COL = {
  stamp: 'Timestamp',
  name: 'Name',
  email: 'Email',
  items: 'Items',
  shipTo: 'Ship to',
  notes: 'Notes',
  orderId: 'Order ID',
  ready: 'Ready to confirm',
  shipDate: 'Expected ship date',
  sent: 'Confirmation sent',
  error: 'Error'
};

function onOrderSubmit() {
  sendOrderConfirmations();
}

function sendOrderConfirmations() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30000)) return;
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    const values = sheet.getDataRange().getValues();
    if (values.length < 2) return;
    const head = values[0].map(String);
    const at = {};
    Object.keys(COL).forEach(function (k) { at[k] = head.indexOf(COL[k]); });
    const missing = Object.keys(COL).filter(function (k) { return at[k] < 0; });
    if (missing.length) {
      throw new Error('Missing columns: ' + missing.map(function (k) { return COL[k]; }).join(', '));
    }

    let top = 0;
    for (let r = 1; r < values.length; r++) {
      const n = parseInt(String(values[r][at.orderId]).replace(/\D/g, ''), 10);
      if (n > top) top = n;
    }

    for (let r = 1; r < values.length; r++) {
      const row = values[r];
      const email = String(row[at.email]).trim();
      if (!email) continue;

      let orderId = String(row[at.orderId]).trim();
      if (!orderId) {
        top += 1;
        orderId = ID_PREFIX + ('0000' + top).slice(-4);
        sheet.getRange(r + 1, at.orderId + 1).setValue(orderId);
      }

      const fromForm = String(row[at.stamp]).trim() !== '';
      const ready = String(row[at.ready]).trim().toLowerCase() === 'yes';
      const alreadySent = String(row[at.sent]).trim() !== '';
      const hasError = String(row[at.error]).trim() !== '';
      if (alreadySent || hasError || !(fromForm || ready)) continue;

      if (MailApp.getRemainingDailyQuota() < 1) return;

      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
        sheet.getRange(r + 1, at.error + 1).setValue('Email does not look valid');
        continue;
      }

      try {
        MailApp.sendEmail({
          to: email,
          subject: STORE_NAME + ' order ' + orderId + ' received',
          body: buildBody_(row, at, orderId),
          name: STORE_NAME,
          replyTo: REPLY_TO
        });
        sheet.getRange(r + 1, at.sent + 1).setValue(new Date());
      } catch (err) {
        sheet.getRange(r + 1, at.error + 1).setValue(String(err.message || err));
      }
    }
  } finally {
    lock.releaseLock();
  }
}

function buildBody_(row, at, orderId) {
  const first = String(row[at.name]).trim().split(/\s+/)[0] || 'there';
  const ship = row[at.shipDate];
  let when = 'We will email you again when we have a ship date.';
  if (ship instanceof Date) {
    when = 'Expected ship date: ' +
      Utilities.formatDate(ship, Session.getScriptTimeZone(), 'MMM d, yyyy') + '.';
  } else if (String(ship).trim() !== '') {
    when = 'Expected ship date: ' + String(ship).trim() + '.';
  }
  const lines = [
    'Hi ' + first + ',',
    '',
    'Thank you. We received your order. It has not shipped yet.',
    '',
    'Order number: ' + orderId,
    'Items: ' + String(row[at.items]).trim(),
    'Ship to: ' + String(row[at.shipTo]).trim()
  ];
  const notes = String(row[at.notes]).trim();
  if (notes) lines.push('Notes: ' + notes);
  lines.push('', when, '', 'If anything here looks wrong, reply to this email and quote the order number.', '', STORE_NAME);
  return lines.join('\n');
}

function setupOrderTriggers() {
  ScriptApp.getProjectTriggers().forEach(function (t) {
    const f = t.getHandlerFunction();
    if (f === 'onOrderSubmit' || f === 'sendOrderConfirmations') ScriptApp.deleteTrigger(t);
  });
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  ScriptApp.newTrigger('onOrderSubmit').forSpreadsheet(ss).onFormSubmit().create();
  ScriptApp.newTrigger('sendOrderConfirmations').timeBased().everyMinutes(10).create();
}

What does the script do?

  • Numbers orders. It finds the highest number already in Order ID and continues from there.
  • Sends once. It sends only when Confirmation sent is empty and Error is empty.
  • Records failures. A bad email address or a send error goes in the Error column. It does not retry on its own.
  • Stops at the daily limit. It asks Google how many recipients are left and leaves the rest for the next run.
  • Takes a lock. If the form trigger and the 10-minute trigger fire at once, only one runs.

Two triggers are created. One runs when the form submits. One runs every 10 minutes and picks up hand-typed rows you marked Yes. Google's documentation says everyMinutes(n): "Specifies to run the trigger every n minutes. n must be 1, 5, 10, 15 or 30." Source: ClockTriggerBuilder, checked October 30, 2026.

Google's installable triggers page says "they can call services that require authorization" and that they "always run under the account of the person who created them." Source: Installable triggers, checked October 30, 2026. So the email goes out as the account that ran setupOrderTriggers. Run it from the account you want customers to see.

Who does the email come from?

MailApp's documentation says name is "the name of the sender of the email. Defaults to the sender's username," and replyTo is "an email address to use as the default reply-to address (default: the user's email address)." Source: Class MailApp, checked October 30, 2026, read through a fetch tool that returned excerpts. The script sets both. The address it sends from is still the account that owns the trigger. I could not confirm how every mail app displays the name, so send a test to yourself and to a second address.

How many confirmations can it send?

Google's quotas page lists email recipients per day at "100" for consumer accounts and "1,500" for Google Workspace accounts, and says "All quotas are subject to elimination, reduction, or change at any time, without notice." Source: Quotas for Google Services, checked October 30, 2026. MailApp's page says "Quotas are based on the number of email recipients." If you take more orders than that in a day, this is the wrong tool.

How do you test it before a customer sees it?

Test on a copy of the sheet, with your own email address.

  1. Make a copy of the sheet and paste the script in the copy.
  2. Submit the form once with your own email. Within a minute, you should get the confirmation and see an order number and a time in the sheet.
  3. Type a hand row with your email. Check that nothing sends. Type Yes and wait up to 10 minutes. You should get the email.
  4. Type a ship date and a second hand row. Check that the date shows in the email.
  5. Type a bad address such as nobody into a row marked Yes. Check that the Error column fills and nothing sends.
  6. Clear the Confirmation sent cell on a row you tested. Check that it resends. That is how you resend on purpose.
  7. Look at the email on your phone and in a second mail app.

Do not point this at real customers until steps 2 to 6 behave.

What can go wrong?

  • Nothing happens. Check that the headers match exactly, including spaces and capital letters. The script stops with a "Missing columns" message and the executions log shows it.
  • Two emails for one order. This should not happen with the lock in place. If it does, check that you ran setupOrderTriggers only once. It removes its own earlier triggers before creating new ones.
  • A customer says it went to spam. I cannot predict that. Ask the customer to look, and send yourself a test to a second mail account.
  • You edited an order after confirmation. The script does not resend. Clear Confirmation sent only if you want the customer to get a new copy of the changed order.

Where does an LLM fit in this?

Here, nowhere in the sending. A confirmation is a fixed template filled from cells, and plain rules do that better than a model. An LLM could help earlier, where the input is messy: someone pastes an order from an email thread and wants it split into name, items and address. A person should check that output before it goes in the sheet. My stance: AI writes the code, plain automation runs it, and an LLM shows up only where the input is messy and a person checks the result. If you are unsure which of your steps to automate first, read what to automate first in a small business.

Where this has limits

  • Not run in Google. I tested the logic in a mock with 28 checks. I have not run it in a live Google account.
  • Unconfirmed behavior. I did not confirm the Timestamp header name on a form sheet, the lock behavior under real simultaneous triggers, or how mail apps display the sender name. The test steps cover each.
  • No payment, stock or tax logic. The script does not check inventory, take payment or decide prices.
  • Daily limits apply. See the quotas above.
  • A confirmation is not a contract. Terms, refunds and cancellation are yours to write. I am not a lawyer. If you are unsure, ask an attorney.
  • Wholesale terms vary. Your agreements decide what you owe a buyer.

If your orders arrive in several places and you want one flow from first message to shipped, bring the workflow to us on /letstalk. You can also read how we approach this work on our AI-powered operations page. Do not send customer data or passwords.

Frequently asked questions

How do I send an order confirmation email from Google Sheets?

Link a Google Form to a sheet, then add an Apps Script that emails the customer from each new row and records the send time. The script in this post does that.

Can I send confirmations for orders I type in by hand?

Yes. Type the row, then put Yes in the Ready to confirm column. The script sends on its next run and not before.

Will the customer see my Gmail address?

The email is sent from the Google account that created the trigger. The script sets a sender name and a reply-to address. Send yourself a test to see how it shows.

A message that only confirms an order the customer agreed to is treated differently from marketing under CAN-SPAM, but the FTC says the categories are read narrowly. Keep the email to the order, and ask an attorney if you are unsure.

How do I resend a confirmation?

Clear the Confirmation sent cell on that row. The script sends again on its next run.

Can this work with Shopify or another store platform?

I did not research that this run. If the platform sends its own confirmation, use it. This post is for orders that do not go through a store.

Read next: Return requests and a returns log for the orders that come back, late shipment alerts for the ones that go quiet, and a back-in-stock waitlist for the ones you could not fill. Operations automation for product brands maps the whole order side.