Blog / Construction Operations
A Near-Miss and Incident Report Form That Alerts the Right People
By Keyona Meeks, Founder, ReRev Labs · Oct 31, 2026 · 10 min read
The short answer: A near-miss form is only useful if a report reaches a person who will read it. A Google Form can collect the report. A short Apps Script then emails the right person when a new report appears, and writes a line to an "Alerted" tab so no one is alerted twice. The script never changes the report itself. Who gets which alert is your decision. This post is recordkeeping and routing. It is not safety advice. I tested the script with a mock in Node. Nothing was run in Google, and I sent no real email.
By the end of this post, you'll be able to:
- List the fields a near-miss report should capture.
- Route a new report to a person you choose.
- Avoid double alerts.
- Know what this setup cannot do.
I sell build work, so weigh this post with that in mind. I am not a safety professional or an attorney. Check with an attorney, insurer or safety professional before you rely on this sheet for any safety, contract or legal question.
What does OSHA say about close calls?
I read OSHA's "Incident Investigation - Overview" page. The tool returned it as a short page with no review date. As the tool reported it, OSHA says employers should "investigate all incidents in which a worker was hurt, as well as close calls," and describes a close call as an event where a worker "might have been hurt if the circumstances had been slightly different." It says an investigation gives employers and workers the chance to identify hazards and the corrective actions needed to prevent future incidents. The tool paraphrased in places, so check the wording on the page. This is as of October 31, 2026.
I did not find, and did not look for, a rule that says how a company must collect close-call reports. I read no OSHA recordkeeping rules for this post. What your company must record, report or investigate is a question for a safety professional.
What fields should the form capture?
These are my suggestions, not requirements:
- When it happened.
- Where. A site or area from a list, so the spelling stays the same.
- Type. A short list you choose, such as Near miss, Injury or Other.
- What happened. Plain words. A free-text box.
- Reporter, if you want one. Whether to allow anonymous reports is a decision for you and a safety professional.
Keep it short. A form that takes two minutes gets used. I did not read Google's help pages for Forms, so I give no click path. Use Google's own help to build the form and send the answers to a sheet. The script expects a tab named Reports with the headings in its HEADERS block, and a tab named Alerted with any heading in row 1. I could not confirm that a form adds a column called Timestamp, so check your sheet and edit HEADERS if it differs.
Who should get the alert?
You decide, and I do not suggest a person or a role. Set DEFAULT_TO to one address, and use ROUTES if you want certain types to go to someone else. A report with no route and no default is not sent, and the script does not log it, so it is picked up again once you set an address. Ask the people you name before you add them.
What does the script look like?
Paste this into the Apps Script project attached to the sheet. Nothing in it runs until you call it.
// Emails the right people when a new near-miss report arrives. Reads the "Reports" tab. Logs each alert in an "Alerted" tab so nobody is alerted twice. Never edits the Reports tab.
var DEFAULT_TO = ''; // Paste the address that gets an alert when no route below matches. Your decision.
var ROUTES = {}; // Optional. Example: { 'Injury': 'name@example.com' }. The key must match the Type text exactly. Your decision.
var MAX_ALERTS = 20; // Most alerts in one run.
var HEADERS = { // Change the text on the right to match YOUR column headings.
when: 'Timestamp', where: 'Location', type: 'Type', what: 'What happened'
};
function findColumns(headerRow) {
var idx = {};
Object.keys(HEADERS).forEach(function (k) {
var at = headerRow.map(function (h) { return String(h).trim(); }).indexOf(HEADERS[k]);
if (at === -1) throw new Error('Missing column in Reports: ' + HEADERS[k]);
idx[k] = at;
});
return idx;
}
function text(value, tz) {
if (Object.prototype.toString.call(value) === '[object Date]') return Utilities.formatDate(value, tz, 'yyyy-MM-dd HH:mm:ss');
return String(value).trim();
}
function findNew(rows, idx, alerted, tz) {
var seen = {};
alerted.forEach(function (r) { seen[String(r[0])] = true; });
var out = [];
rows.forEach(function (r) {
var when = text(r[idx.when], tz);
var what = text(r[idx.what], tz);
if (when === '' && what === '') return;
var key = when + '|' + text(r[idx.where], tz) + '|' + what.slice(0, 40);
if (seen[key]) return;
seen[key] = true;
out.push({ key: key, when: when, where: text(r[idx.where], tz), type: text(r[idx.type], tz), what: what });
});
return out;
}
function sendNearMissAlerts() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var reports = ss.getSheetByName('Reports');
var alerted = ss.getSheetByName('Alerted');
if (!reports || !alerted) throw new Error('Missing a tab: Reports and Alerted are both needed.');
var values = reports.getDataRange().getValues();
if (values.length === 0) throw new Error('The Reports tab is empty.');
var idx = findColumns(values[0]);
var tz = Session.getScriptTimeZone();
var fresh = findNew(values.slice(1), idx, alerted.getDataRange().getValues().slice(1), tz);
var sent = 0;
var unrouted = 0;
for (var i = 0; i < fresh.length && sent < MAX_ALERTS; i++) {
var f = fresh[i];
var to = ROUTES[f.type] || DEFAULT_TO;
if (!to) { unrouted++; continue; }
if (MailApp.getRemainingDailyQuota() < 1) break;
MailApp.sendEmail(to, 'Near-miss report: ' + f.type + ' at ' + f.where,
'A new report was filed.\n\nWhen: ' + f.when + '\nWhere: ' + f.where + '\nType: ' + f.type + '\n\nWhat happened:\n' + f.what);
alerted.appendRow([f.key, Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd HH:mm')]);
sent++;
}
console.log('Alerts sent: ' + sent + '. Not routed: ' + unrouted + '.');
return { sent: sent, unrouted: unrouted, fresh: fresh.length };
}
function installTrigger() {
ScriptApp.newTrigger('sendNearMissAlerts').timeBased().everyMinutes(15).create();
}
What the parts do, with Google's own descriptions as I read them in the method tables this session:
getActiveSpreadsheet(): "Returns the currently active spreadsheet, ornullif there is none."getSheetByName(name): "Returns a sheet with the given name."getDataRange(): "Returns aRangecorresponding to the dimensions in which data is present."getValues(): "Returns the rectangular grid of values for this range."Utilities.formatDate"Formats date according to specification described in Java SE SimpleDateFormat class."Session.getScriptTimeZone()"Gets the time zone of the script."MailApp.sendEmail(recipient, subject, body)"Sends an email message."getRemainingDailyQuota()"Returns the number of recipients you can send emails to for the rest of the day."Sheet.appendRow(rowContents): "Appends a row to the bottom of the current data region in the sheet." The script uses it to log each alert.everyMinutes(n)"Specifies to run the trigger everynminutes," andn"must be 1, 5, 10, 15 or 30."
The script reads the Reports tab and never edits it. It builds a key from the time, place and the first 40 characters of the description, and skips any key already in Alerted. If two reports match on all three, they count as one. It sends at most 20 alerts per run and stops if the email quota runs out.
How do you put it on a schedule?
Run installTrigger once. It checks every 15 minutes, which is one of the values Google allows. Check your project's triggers list for exactly one entry. I could not confirm which minute of the hour a trigger fires, so do not expect 7:00 sharp. Per an earlier review of Google's triggers page, installable triggers "always run under the account of the person who created them." Per an earlier review of Google's authorization page, scopes are "determined automatically by scanning the script code." Read the permission prompt before you approve it.
How do you test it?
- Use test addresses that you own. Set
DEFAULT_TOto one of them. - Submit three test reports through the form, one of each type.
- Run
sendNearMissAlertsby hand. You should get three emails, andAlertedshould get three rows. - Run it again. Nothing should be sent.
- Add a route for one type and submit another report. Check it goes to the route address.
- Clear
DEFAULT_TOand submit a report. Check nothing is sent and nothing is logged. - Run
installTriggerand check the triggers list, then delete the test rows.
What did the test show?
I tested the script in Node with a mock of the Google services. All 16 checks passed. They covered routing by type, a default address, a rerun sending nothing, a new report being the only one alerted, a report with no route not being logged, the 20-alert cap, a used-up quota, date-value timestamps, a missing heading, an empty tab, missing tabs, identical rows in one run and the trigger chain. The mock cannot show how Google returns your form data. No real email was sent.
Where does an LLM fit in this?
An LLM can help with the messy input. For example, it can read a long free-text report and suggest a one-line summary, or suggest which type fits. A person must check the result, because an LLM can understate a serious event and sound sure, so the alert should always carry the original text, as this script does. The routing, the key check and the email are plain rules. Do not send an LLM tool any report that names a person unless your own rules allow it. 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. For where to start with your own automations, see what to automate first.
Where this has limits
- This is routing and recordkeeping, not safety or legal advice. Check with an attorney, insurer or safety professional before you rely on this sheet for any safety, contract or legal question.
- I read one OSHA page, and the tool paraphrased in places. I did not read OSHA recordkeeping or reporting rules.
- A late email is not a response. The script does not know if anyone read the alert or acted.
- It does not decide how serious a report is. A person does.
- Reports can name people. Control who can open the sheet and the script.
- Nothing was run in Google, and no real email was sent.
- Email quotas apply. I quote the quota call above. Check Google's limits for your account type.
- No ReRev prices and no promises about results.
If you want help turning this into a system your whole team uses, see our AI operations service or tell us how reports reach you now. Do not send incident reports, names of workers, passwords or login details.
Frequently asked questions
What should a near-miss report form ask?
Date, place, type and what happened, in my suggestion. A safety professional can tell you what else your company needs.
Does OSHA require near-miss reports?
I do not know. The OSHA page I read says employers should investigate close calls. I did not read OSHA's recordkeeping rules.
Can the script decide who needs to know?
No. You set the addresses. The script follows your list.
Will the same report alert twice?
No. Each alert is logged in the Alerted tab and skipped next time.
Can reports be anonymous?
Ask a safety professional. I make no claim either way.
Does it use an LLM?
Not in the script. An LLM can suggest a summary, and a person checks it.
Read next: Digital toolbox talk sign-in sheets is the same form pattern for the safety meeting that often prevents the near miss.