I ran joiner, mover and leaver automation on paid tooling for years, and it was the right call at the time. Building it yourself is now easy enough that it is worth knowing what you would be buying. This is the whole thing: the pattern, the code, and the two mistakes that turn it from an asset into an outage.
The worked example is PeopleHR, because that is the system I ran this against. The HR half is deliberately isolated in one function, so swapping it makes the rest work against BambooHR, HiBob, Personio or a CSV somebody drops in Drive.
Reconcile, do not replay
This is the decision the whole thing rests on, and it is usually made wrong.
The instinct is events: HR fires a webhook when somebody joins, you create the account. It looks modern and it is fragile. Miss one delivery, because your endpoint was down or the vendor's retry gave up, and there is a person with no account. You find out on their first morning, from them.
The alternative is to compare state. On a schedule, read the entire HR roster, read the entire directory, work out the difference, and fix it. Nothing is remembered between runs.
It is self-healing. A failed run costs you nothing, because the next one sees the same difference and fixes it. There is no queue to drain and no missed event to hunt.
It is idempotent. Running it twice does the same thing as running it once, which means you can run it by hand while debugging without fear.
It is testable. The diff is a plain object you can print. You can see exactly what a run would do before it does anything, which is the basis of the dry run below.
It reports drift. Someone creates an account by hand at 2am during an incident. Reconciliation notices, because it compares against HR rather than against what it did last time.
Every fifteen or thirty minutes is plenty. Nobody needs an account provisioned in nine seconds.
Why Apps Script
Because it is the cheapest thing that is not a hack. No server to patch, a built-in scheduler, built-in secret storage, and it runs as a Google identity so the Admin SDK is available without setting up a service account and domain-wide delegation. For this job that last point saves an afternoon and removes the most dangerous credential you would otherwise create.
Graduate to Cloud Run or a Cloud Function when you outgrow the six minute execution limit, want the code in a repository with review, or need to reconcile more than one system. Until then this is less code to own.
Setting it up: create the project, add the Admin SDK Directory advanced service, put the HR API key in Script Properties rather than in the file, and add a time-driven trigger. Run it as a dedicated administrator identity with a delegated role holding only user and group privileges, not as your own super admin account.
The configuration is the interesting part
Everything specific to your company lives in one object, so the logic below it never needs editing. Note what is not here: no department name is used directly as an organisational unit path. Departments get renamed by HR without telling you, and a lookup table fails loudly on an unknown value instead of silently creating /Sales EMEA (new).
const CONFIG = {
domain: 'example.com',
// Safety. Both of these are explained below. Leave dryRun on
// until a week of logs looks boring.
dryRun: true,
maxChangesPerRun: 25,
minRosterSize: 400,
// Never touched by automation, whatever HR says.
neverTouch: [
'break-glass@example.com',
'provisioning@example.com'
],
// Unknown department is an error, not a new OU.
ouByDepartment: {
'Engineering': '/Staff/Engineering',
'Finance': '/Staff/Finance',
'Contract': '/Contractors'
},
groupsByDepartment: {
'Engineering': ['engineering@example.com', 'all-staff@example.com'],
'Finance': ['finance@example.com', 'all-staff@example.com']
}
};
Reading the roster out of PeopleHR
One function, isolated, returning a normalised shape. Everything downstream works on that shape and knows nothing about PeopleHR, which is what makes the vendor swappable and the logic testable.
PeopleHR exposes a JSON API that takes an API key and an action name in the request body, and returns the employee records you ask for. Generate the key in PeopleHR's own settings, give it the narrowest access that returns what you need, and store it in Apps Script's Script Properties rather than in the file.
What you actually need out of it is short. A stable employee id, first and last name, department, start date, leave date, and enough to tell an active employee from a former one. Everything else on the record is noise for this job.
Confirm the action and field names against PeopleHR's current API documentation before you trust the code below. Vendors rename these between versions, and this is exactly the kind of detail that is right when it is written and wrong a year later. The shape of the function is the part worth copying; the field names are the part to check.
The one field that matters more than the rest is the stable employee id. Not the email, which changes when somebody marries. Not the name, for the same reason. PeopleHR's own immutable id is what lets you recognise that the person whose email just changed is the same person, rather than creating a second account and leaving the first orphaned.
/**
* The only PeopleHR-specific code in the whole job.
* Check the action and field names against PeopleHR's current API
* docs before trusting them. A silently-empty roster is the failure
* mode the circuit breaker further down exists to catch.
*/
function fetchRoster() {
const key = PropertiesService.getScriptProperties()
.getProperty('PEOPLEHR_API_KEY');
const res = UrlFetchApp.fetch(PEOPLEHR_ENDPOINT, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ APIKey: key, Action: 'GetAllEmployeeDetail' }),
muteHttpExceptions: true
});
if (res.getResponseCode() !== 200) {
throw new Error('PeopleHR API returned ' + res.getResponseCode());
}
return JSON.parse(res.getContentText()).Result.map(function (r) {
return {
id: String(r.EmployeeId), // immutable, the anchor
first: r.FirstName,
last: r.LastName,
department: r.Department,
startDate: r.StartDate,
leaveDate: r.LeaveDate || null,
active: !r.LeaveDate
};
});
}
Throwing on a non-200 is deliberate. A run that dies loudly is safe. A run that carries on with an empty array is the one that suspends your company.
The plan, before anything is applied
Build a list of intended actions and return it. Nothing here calls the directory to change anything, which means you can log the plan, eyeball it, and diff two runs against each other.
function buildPlan(roster, directory) {
const byId = {};
directory.forEach(function (u) {
const id = u.externalIds && u.externalIds[0] && u.externalIds[0].value;
if (id) byId[id] = u;
});
const plan = [];
roster.forEach(function (p) {
const existing = byId[p.id];
const ou = CONFIG.ouByDepartment[p.department];
if (!ou) { plan.push({ type: 'ERROR', person: p.id,
why: 'unmapped department ' + p.department }); return; }
if (!existing && p.active) plan.push({ type: 'CREATE', person: p, ou: ou });
if (existing && !p.active) plan.push({ type: 'SUSPEND', user: existing.primaryEmail });
if (existing && p.active && existing.orgUnitPath !== ou) {
plan.push({ type: 'MOVE', user: existing.primaryEmail,
from: existing.orgUnitPath, to: ou });
}
});
return plan;
}
Storing the HR id in the directory user's external id field is what makes the first three lines possible. Set it at creation and never change it. Without it you are matching on email, and the day somebody's surname changes you get a duplicate account and an orphan.
The circuit breaker
Read this paragraph twice. It is the difference between a useful script and a very bad Tuesday.
Your HR API will one day return success with an empty or partial roster. An expired key, a vendor incident, a change to a query parameter. To a reconciliation job, an empty roster does not look like an error. It looks like everybody left the company, and the correct response to that, by its own logic, is to suspend all 1500 accounts.
Refuse to run on an implausible roster. If HR returns fewer than a floor you set, stop and alert. A company does not lose 40% of its staff between two runs.
Cap the actions per run. If the plan contains more changes than a normal day produces, apply nothing and send the plan to a human. A real Monday is a handful of changes.
Never delete, only suspend. Deletion is a separate job with a person approving it. Reconciliation should not be able to destroy data even when it is wrong.
Keep an exclusion list. Break-glass, service accounts and the automation's own identity are never touched, whatever the plan says.
function apply(plan, roster) {
if (roster.length < CONFIG.minRosterSize) {
return alertHuman('Roster returned ' + roster.length +
' people, floor is ' + CONFIG.minRosterSize +
'. Nothing applied.');
}
if (plan.length > CONFIG.maxChangesPerRun) {
return alertHuman('Plan has ' + plan.length + ' changes, cap is ' +
CONFIG.maxChangesPerRun + '. Nothing applied.');
}
plan.forEach(function (action) {
if (CONFIG.neverTouch.indexOf(action.user) !== -1) return;
log(action);
if (CONFIG.dryRun) return;
execute(action);
});
}
Both limits are cheap to write and both will save you at least once. The floor catches the vendor's bad day. The cap catches yours, which is usually an edited mapping table.
The mover is where the value is
Joiners get automated first because they are visible and everybody agrees they matter. Leavers get automated second because security asks. Movers get skipped, and movers are where the access actually accumulates.
Someone transfers from support to finance. The joiner flow gave them support's groups. Nothing removes them, because nothing is watching. Two years and three moves later they can open everything, and no single decision was wrong.
Reconciliation fixes this for free, and that is the strongest argument for the pattern. The plan above already moves the organisational unit on a department change. Extend the same comparison to group membership and the removals happen without anybody remembering to ask for them.
The one place to be careful is groups a human granted deliberately. Decide which groups automation owns and leave the rest alone, otherwise your script and your service desk will spend the year undoing each other.
The parts to keep human
Not everything should be in the job, and pretending otherwise is how these projects lose trust.
- The first password. Generate it randomly, force a change at first sign-in, and deliver it out of band to the manager. Do not email a credential to the address it unlocks.
- Deletion. A separate, reviewed job. See what a leaver's account costs for why the retention period belongs to a person rather than a script.
- Unmapped departments. The plan raises an error and a human adds the mapping. Guessing is how you get an organisational unit with a typo in it that nobody notices for a year.
- The leaver ordering. Suspension alone does not end active sessions. The full leaver sequence matters more than the automation of it.
When to buy instead
Honestly, because this is not free forever. Buy the tool when you have more than one system of record, when non-technical staff need an interface to approve and audit changes, when you need the vendor's support contract for a compliance answer, or when nobody on the team will own a codebase. That last one is the real test: a script with no owner becomes a mystery in eighteen months, and a mystery with directory write access is worse than a licence fee.
Below that, this is two days of work and a config file, and you will understand your own lifecycle far better for having written it.
The short version
Reconcile state on a schedule rather than replaying events, because reconciliation is self-healing and events are not. Anchor everything to an immutable HR id stored in the directory user's external id, never to email. Keep all company specifics in one config object and treat an unmapped department as an error. Build a plan first and apply it second, so a dry run is free. Put in a roster floor and a per-run change cap before you turn enforcement on, because the day the HR API returns an empty list is the day those two lines save you. Automate the mover, which is where access really accumulates. Suspend, never delete.