Automating Erlangly

On this page

Erlangly is the record of the schedule and of adherence. Your HR system stays the record of people and their leave balances, and payroll stays the record of pay. This guide is for the IT team that keeps them in step, so nobody types the same thing into two systems.

There are three ways in: the API, for your own scripts; webhooks, which tell a system when something changes; and the erlangly command on the server, for imports and exports without a script. The API reference has every endpoint and field. This guide puts them together into the jobs IT teams are asked for, with examples you can run.

The examples use curl, and Ruby and Rust for the longer scripts (Before you start). Any language that speaks HTTPS and JSON works the same way.

What it does: keeps people, leave and balances in step with your HR system, gives payroll and billing the schedule's hours, and tells your other systems when the schedule changes.

Use it when: someone is retyping data between Erlangly and another system, or a system needs to know about shifts as they change.

You need

  • An admin, to make an integration and a webhook under Settings → Integrations.
  • Somewhere to run a script: a server with cron, a scheduler, or a workflow tool.
  • Your HR system's API, for the HR sync. The examples call a made-up HR API; swap in your own system's calls.

You get

  • A nightly HR sync: joiners and leavers, leave booked in HR, and balances in, with leave approved in Erlangly sent back.
  • Hours for payroll and billing, by person and by client.
  • Building access, chat notices and other systems kept to the schedule as it changes.

Before you start

Every example reads Erlangly's address and a token from the environment, so neither is written into a script. Set them, then ask Erlangly who the token is:

export ERLANGLY=https://wfm.example.com
export ERLANGLY_TOKEN="$(cat /etc/hr-sync/erlangly.token)"
curl -s -H "Authorization: Bearer $ERLANGLY_TOKEN" "$ERLANGLY/api/v1/me"

An integration's token answers with its name, what it may do, and the HR system it imports from:

{ "id": 5, "name": "HR sync", "email_address": "integration-5@erlangly.invalid", "role": "planner", "person": null,
  "integration": true, "abilities": ["read", "import_time_off", "import_people"], "hr_source": "hris" }

A 401 means the token is wrong, has expired or was revoked. A 403 means Erlangly isn't activated yet, or the token can't use that endpoint.

The whole scripts below come in Ruby and in Rust, each doing the same requests in the same order. Each Ruby script needs Ruby 3.1 or later, and nothing else to install unless it says so. Under each, The same script in Rust opens its Rust version: three programs in one Cargo project, for Rust 1.88 or later.

The Rust project

Put each program in src/bin/ under its name (hr-sync.rs, payroll-hours.rs and badges.rs), beside this Cargo.toml. cargo build --release builds all three into target/release/.

[package]
name = "erlangly-scripts"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
publish = false

[dependencies]
base64 = "=0.23.1"
chrono = { version = "=0.4.45", default-features = false, features = ["clock"] }
hmac = "=0.13.0"
serde_json = { version = "=1.0.154", features = ["preserve_order"] }
sha2 = "=0.11.1"
tiny_http = "=0.12.0"
ureq = { version = "=3.5.0", default-features = false, features = ["rustls", "platform-verifier"] }
  • ureq makes the HTTPS requests, trusting the system's certificates as Ruby does, and serde_json reads and writes the JSON.
  • chrono gives the dates and times; sha2 makes each import's Idempotency-Key, and with hmac and base64 checks a webhook's signature.
  • tiny_http is the badge receiver's web server.

The versions are pinned, so a build today is the build that was checked. Move them on once you've checked newer ones.

Getting a token

A script needs a token, and there are two kinds for scripts. For anything that runs on its own, use an integration.

An integrationA personal token
WhoseYour organization'sOne person's
Made underSettings → Integrations, by an adminYour account, by anyone
Acts asA user of its own, named after the integration, who can't sign inThe person, with their role
Can doOnly what its abilities open: read, personal details, import time off and balances, import peopleWhat the person can do: only read, unless it's made to change things
StopsWhen it expires or is revoked; never because the admin who made it leavesWhen it expires, is revoked, or its person leaves or has their password reset
Use it forThe HR sync, payroll and billing exports, building accessTrying things out, and a person's own scripts

AI assistants can't use an integration's token: they use a person's own sign-in or personal token. A third kind, a connection's token, is for an ACD sending agent states and volumes (Connecting your ACD).

Making an integration

  1. Under Settings → Integrations, choose Add an integration. Enter your password again, and the code your authenticator app shows if you use two-factor authentication. An admin who signs in with single sign-on signs in to the identity provider again instead, which must say when they did (each provider's step).
  2. Name it after the system it's for: "HR sync", "Payroll export", "Door access". The audit trail shows what it changes under that name, "with the integration “HR sync”".
  3. Tick only the abilities its job needs (below).
  4. For importing time off and balances, give the HR system's short name, in lowercase, like hris. Leave and balances are kept by it, so keep the name once leave has come in.
  5. Where you have several clients, choose its clients: all, or only some. People imports need all clients.
  6. Choose when it stops working: after 30 days, 90 days or 1 year, the default.
  7. Copy the token. It's shown once, and starts erl_. Every admin is emailed that it was made.
The jobAbilities
The nightly HR syncRead; Import time off and balances; Import people, with all clients
Payroll and billing exportsRead
Building access, desks, transport, chat noticesRead
A script that reads balances, email addresses or exceptions' reasonsRead and Personal details

A week before an integration expires, every admin is emailed. Open it and choose Make a new token: the old token stops at once, so put the new one where your script reads it straight away, between runs. Its user, name, HR system and history carry on.

Keeping tokens safe

A token is a password for a system, and should be kept like one.

  • Never in a script or a repository. Keep it in your secret store (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, 1Password and the like), or in a file only the script's own account can read (chmod 600), and hand it to the script in the environment, as the examples do. Not in a URL, a ticket, a chat message or a log either.
  • Not on a command line others can see. In the curl examples the token is one of curl's arguments, which anyone signed in to the same machine can read with ps while it runs. On a shared server, put the whole header in a file only you can read, and give curl -H @/etc/hr-sync/erlangly.header instead. The Ruby and Rust scripts here keep it inside the process.
  • One integration for each system, so revoking one doesn't stop the others, and the audit trail says which system did what.
  • Only the abilities it needs. An export needs only Read.
  • If a token may have been seen, open its integration and choose Make a new token, or Revoke to stop it for good. A personal token is revoked under Your account.

A nightly HR sync

From your HR system to ErlanglyFrom Erlangly to your HR system
People: joiners, moves and leaversLeave approved or canceled in Erlangly
Leave booked and approved in HRHR's own ID for that leave, sent back to Erlangly
Leave balances, in hours

Each night, in this order:

  1. People, so leave for someone new finds them.
  2. Leave from Erlangly: what was approved or canceled since the last run goes into HR, and HR's ID for it comes back. Leave changed in Erlangly once HR has it isn't sent again, so change it in HR too.
  3. Leave from HR. Leave approved in Erlangly now carries HR's ID, so HR's own export matches it rather than adding it twice.
  4. Balances, which HR has worked out with Erlangly's leave in them.

Preview, then apply

People, leave and balances come in the same way, as the import pages take a spreadsheet: send the rows, read what they would do, then apply them.

curl -s -X POST "$ERLANGLY/api/v1/imports" \
  -H "Authorization: Bearer $ERLANGLY_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: hris-time_off-20261101T0215" \
  -d '{ "kind": "time_off", "rows": [
        { "employee_number": "RS-1001", "type": "VACATION", "first_day": "2026-11-09", "last_day": "2026-11-13", "hr_reference": "LV-20417" } ] }'

It answers 201 with the import, previewed: what each row would do (added, updated, canceled, unchanged or conflict) and any problems with it. Nothing has changed yet.

{ "import": { "id": 31, "kind": "time_off", "status": "previewed", "source": "hris", …,
  "counts": { "added": 1, "updated": 0, "canceled": 0, "unchanged": 0, "conflicts": 0, "problems": 0 },
  "rows": [ { "line": 2, "hr_reference": "LV-20417", "person": "Tanya Graham", "action": "added",
              "description": "Vacation, Mon, Nov 9 to Fri, Nov 13, 2026", "applied": false, "errors": [] } ] } }

Then apply it, by its id:

curl -s -X POST "$ERLANGLY/api/v1/imports/31/application" -H "Authorization: Bearer $ERLANGLY_TOKEN"
It answersWhat it means, and what to do
200Applied: status is applied. Asked again, it answers the same.
500It stopped partway: status is partly_applied, and error says the line. What it applied stays. Ask for its application again to carry on.
409Another request is applying it. Read where it got to with GET /api/v1/imports/31, and ask again later.
422It was undone since, or its file was cleared a week after it came: send its rows again as a new import. For people, it can also be the guard against a broken export, with the imports applied since the preview: error says why. Once a person has checked HR's export, ask again with { "force": true }.

Rows with problems are skipped when it's applied, and the rest go in. Each row's errors says what's wrong, by its line (the first row is line 2, as in a spreadsheet), for HR to fix.

Employee numbers match in upper or lower case, in every import: rs-1001 is RS-1001, and Erlangly keeps a number as it was first entered. Where two people's numbers differ only in case, from before numbers matched so, a row is the one whose number it has exactly, and one in a third case is refused, as it could be either.

An integration reads and applies only the imports it sent. A balance import's rows show each balance only to a token with personal details.

Naming an import. Erlangly calls it "time off from the API" (or people, or balances) on its page and in the audit trail. Send "filename" with the rows, like "hris nightly 2026-11-01", to give it a name of your own.

The Idempotency-Key header is for the network. If a request fails before its answer reaches you, send it again with the same key: Erlangly answers with the import the first one made, rather than making a second. A key is any text up to 255 characters. The same key with a body that differs in any way answers 422, so make a new one for each import in each run, as the script below does.

How much at once: up to 5,000 rows of time off or balances a request, and 1,000 people (422 over that, before anything is tried). Send a big file in parts.

People: joiners and leavers

Rows have the people import's columns (People → Import from a spreadsheet), and left_on:

curl -s -X POST "$ERLANGLY/api/v1/imports" \
  -H "Authorization: Bearer $ERLANGLY_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: hris-people-20261101T0215" \
  -d '{ "kind": "people", "rows": [
        { "employee_number": "RS-2001", "first_name": "Kemar", "last_name": "Reid", "team": "Team Tulloch",
          "site": "Kingston", "hired_on": "2026-11-09", "lobs": "BILLING" },
        { "employee_number": "RS-1001", "left_on": "2026-11-13" } ] }'
  • Matching: by employee_number, in upper or lower case. A new number adds someone, with first_name and last_name, kept as it's sent; a known one changes them; a blank or missing field leaves what's there. Teams, sites and lines of business must exist in Erlangly first. Nobody is invited to sign in: a planner or admin does that under People.
  • Leavers: left_on is their first day not working, like 2026-11-13. They're recorded as leaving on it, as from their page: they come off your license that day, their time off from then is canceled, and their schedule is kept under Attrition to the end of that week, or only to that day, as Settings → Organization says. The same date again changes nothing; another date moves it.
  • Old leaving dates: for someone whose status is active, a left_on gone by, or before their hire date, is an old one HR kept, like a rehire's. It's skipped, with a note saying so, and the rest of the row goes in.
  • Refused, by row: otherwise, a left_on before their hire date. One more than 7 days back, unless it's sent with force: recording it would cancel their past time off from then on and put their past shifts under Attrition, unpaid, and the row's error says how much. The date Erlangly has already never needs force. And leaving for the last admin who can sign in and isn't leaving: make someone else an admin first.
  • No date leaves leaving alone: a row without left_on, or with it empty, never cancels a leaving, whatever the other rows have. HR can set a leaving date or move it, but not take one away, as a planner may record someone leaving before HR does. If someone isn't leaving after all, a planner cancels it on their page in Erlangly; a row hiring them again since says they're still recorded as leaving. So send left_on only when HR has a date, as the script below does. "status": "inactive" without a date makes someone inactive, as for long leave.
  • Seats: rows that free a seat go first, so joiners anywhere in the request can take them. Your license's limit holds: a row that would need a seat when none is left is refused, and says so. If you split a file, send the leavers first.

Each row's action is added, updated, leaving (or leaving on a new date) or unchanged, with a note on what else changes, like a move or their schedule.

Up to 1,000 people a request, so each is previewed and applied well inside the web server's 30 seconds. Ask your HR system for the people who changed in the last week, as the script below does, rather than everyone.

The guard against a broken export. A request is refused when, within a day, it would make more than a tenth of your active people, and at least 10:

  • leave or go inactive;
  • leave earlier than recorded: a left_on moved to an earlier day (a later one doesn't count);
  • or come off a line of business they're scheduled on: lobs that end a line they have shifts on from then, which go.

Someone it does more than one of these to counts once. Active people are those not leaving already, and those the request changes. The people imports from HR applied in the last 24 hours count with the request, each person once, so a file sent in parts is checked as one; one that needed force isn't counted again. It answers 422, saying what it counts:

{ "error": "The import wasn't previewed", "problems": [ "This file would make 30 of your 212 active people leave or go inactive and 12 come off a line of business they're scheduled on. …" ] }

It's checked again when the import is applied, with the people imports from HR applied since its preview, so parts all previewed before any is applied are caught too. Applying answers 422 then, with the same reason in error, and changes nothing.

Don't send force from a script on its own. Stop, and have a person check HR's export. If it's right, send the same rows again with "force": true, under a new Idempotency-Key, as the request has changed. An import refused when it's applied takes { "force": true } with its application instead, or Import anyway on its page in Erlangly.

Force is true ("true" or "1" in a query string). false or none isn't, and any other value answers 400 rather than being guessed at: PowerShell writes $false as False in a query string. Force a request didn't need isn't kept, so the people it changes count for the next request's guard as any others do.

People imports need an integration that can import people and sees all clients, as a file from HR can name anyone. One limited to some clients answers 403.

Leave booked in HR

Each row carries HR's own ID for the leave, its hr_reference, kept under the integration's HR system name. That makes sending it again always safe:

  • The same reference again changes that time off to match: its kind, its days or times, its note. Unchanged, it stays as it is. So a sync can send every leave from a month back every night.
  • Canceled in HR: send it with "status": "canceled". A canceled row needs only employee_number and hr_reference.
  • Kinds of leave: type is the kind's code in Erlangly, as it's written there, like VACATION, or its name, like Vacation. Or send HR's own name in hr_type, like "Annual leave", once a planner or admin has said which kind that is under Settings → Time off, for that HR system, as the script below does.
  • Days and times: first_day and last_day in the YYYY-MM-DD form, like 2026-11-09, and from and to on the 24-hour clock, like 13:00 (or 13:00:00), for part of a day. Others are refused, by row: 01/05/2027 could be January or May, and 02-11-26 could be any of three days.
  • Leavers: leave from someone's leaving day on isn't booked, as leaving covers it. Its row is unchanged, with a note.
  • Approved: leave from HR comes in approved, as it was approved there. It isn't checked against labor rules again, and no supervisor is asked. It's refused where it overlaps other time off the person has, unless the same request cancels or moves that one.
  • Conflicts: leave HR sends unchanged that was canceled or declined in Erlangly since stays as it is in Erlangly. Its row's action is conflict, for HR to settle. Leave whose days or kind changed in HR is booked again as HR has it.
  • Telling people: they're told, as when a planner enters their time off, of leave added, booked again or canceled, unless it's all past. The first load from an HR system tells nobody, as they know of that leave already.

Balances

Rows have employee_number, the kind (type, or hr_type, as below), balance_hours and as_of:

curl -s -X POST "$ERLANGLY/api/v1/imports" \
  -H "Authorization: Bearer $ERLANGLY_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: hris-balances-20261101T0215" \
  -d '{ "kind": "balances", "rows": [
        { "employee_number": "RS-1001", "type": "VACATION", "balance_hours": 32, "as_of": "2026-10-31" },
        { "employee_number": "RS-1002", "hr_type": "Annual leave", "balance_hours": 7.5, "as_of": "2026-10-31" } ] }'
  • In hours, fractions too, with a decimal point (7.5), and below zero where HR lets people borrow ahead. A comma is taken only between thousands, as in 1,250.5: 7,5 is refused. A file in days is refused: how many hours a day of leave is differs, so Erlangly doesn't guess.
  • As of the day HR worked it out, which Erlangly shows with it. A day after tomorrow is refused (tomorrow is taken, for time zones ahead of yours), and so is one earlier than the balance Erlangly already has, so a file sent late can't put an older balance back.
  • Each import replaces the balances of the people and kinds it carries. Someone it leaves out keeps their last balance, with its old date.
  • hr_type is what HR calls the kind, like "Annual leave", once a planner or admin has said which kind that is under Settings → Time off, for that HR system.

Erlangly shows balances, and never stops a request past one. A script reads them back from GET /api/v1/people/:id, with personal details.

Sending HR's ID back

HR has to take leave approved in Erlangly off its balances. So the sync reads what Erlangly approved, changed or canceled since its last run (/time_off_requests?after=, below), records each in HR, and sends back HR's ID for it:

curl -s -X PATCH "$ERLANGLY/api/v1/time_off_requests/52" \
  -H "Authorization: Bearer $ERLANGLY_TOKEN" -H "Content-Type: application/json" \
  -d '{ "hr_reference": "LV-20502" }'

It goes under the integration's HR system name, and null takes it off. HR's next export then matches that time off, rather than refusing it as an overlap or adding it twice.

  • Which leave: status is approved, and hr_reference is null. Leave with a reference is HR's already. Leave out type ATTRITION: that's how Erlangly keeps a leaver's schedule after they go, not leave HR should count. Answers spell canceled time off cancelled; imports take either spelling.
  • How long: days gives its minutes on each day, from the published shift it covers. Leave approved before its schedule is published has none yet. It comes again with after= once the schedule is published, or HR can count it from its days.

Reading what changed

Shifts, exceptions, people and time off can each say what changed since a script last asked, so it doesn't read whole weeks again:

  1. The first time, read everything as usual: /time_off_requests?include_past=1 (all of it, in one answer, or what ends from a day on with since=, as the script below reads from a year back; without either, only time off that hasn't ended), /people, or /shifts and /exceptions with from and to. The answer ends with next, a cursor for that endpoint. Keep it.
  2. From then on, ask with ?after= and the cursor. The answer has what changed since, as it is now; in removed, the IDs of what was deleted or is no longer yours to see; and a new next. While more is true, ask again with that next straight away.
  3. Keep the new next once you've handled what came, and start from it next time.
curl -s -H "Authorization: Bearer $ERLANGLY_TOKEN" \
  "$ERLANGLY/api/v1/time_off_requests?after=IjEyMzQuMTc5MTkwNzIwMCI--87f36652b9b7215f2d56219a7ec8156bad0d2c9b75bb671e5c41432ab0b8f6da"

A record changed twice comes once, as it is now, and one changed while you read everything comes again after the cursor. So apply what comes by ID, and seeing it twice does no harm. Changes come even when nobody saved anything, like someone's lobs on the day a new line starts for them. A few don't: a person's new name comes with /people, not on their shifts, exceptions and time off, and some settings, like your organization's time zone, mean reading everything again. The API reference lists what comes and what doesn't.

When a cursor can't be used, read everything again and go on from the next that gives:

  • 410: the cursor is more than 30 days old, from before Erlangly's data was restored from a backup or an upgrade rolled back, or from before an admin changed the clients or role of the token's user, or whether the token includes personal details. The answer says how to read everything again on that endpoint.
  • 400: it isn't a cursor Erlangly gave, was changed or cut short on the way, or is another endpoint's (each endpoint's next goes on there only), or Erlangly was restored without its secret key.

The whole script

Here are the four steps as one script. It talks to a made-up HR system whose API answers these. Change the four HR calls to your own system's, and the names in HR_TYPES, and say which Erlangly kind each of HR's kinds of leave is under Settings → Time off:

The script asks HRHR answers
GET /employees?changed_since=2026-10-25Each person who changed: employee_number, first_name, last_name, email, team, site, hire_date, status (active, on_leave or terminated) and termination_date
GET /leave?from=2026-10-02Each leave: id, employee_number, type, start_date, end_date and status (approved or canceled)
GET /balancesEach balance: employee_number, leave_type, hours and as_of
POST /leave and POST /leave/:id/cancelLeave approved in Erlangly recorded, with its new id; leave canceled
#!/usr/bin/env ruby
# hr-sync.rb: a nightly sync between your HR system and Erlangly (erlangly.com/docs/automating).
# Ruby 3.1 or later, and nothing else to install.
require "date"
require "digest"
require "json"
require "net/http"
require "securerandom"
require "uri"

$stdout.sync = true # the log in the order things happen, warnings among the rest

ERLANGLY  = ENV.fetch("ERLANGLY")        # https://wfm.example.com
TOKEN     = ENV.fetch("ERLANGLY_TOKEN")  # the integration's token, from your secret store
HR        = ENV.fetch("HR_API")          # your HR system's API, like https://hr.example.com/api
HR_TOKEN  = ENV.fetch("HR_TOKEN")
FORCE     = ENV["FORCE"] == "yes"        # only once a person has checked a refused people import
RUN       = "#{Time.now.utc.strftime("%Y%m%d%H%M%S")}-#{SecureRandom.hex(4)}" # this run's, in each import's key
TODAY     = Date.today                   # the server's date: run it on a server in your organization's time zone

# Where the sync got to, kept between runs: Erlangly's cursor; the leave that was approved before the first run, which
# isn't sent to HR; and leave HR has taken whose ID hasn't got back to Erlangly yet, by Erlangly's ID.
STATE_FILE = "hr-sync-state.json"
OLD_CURSOR = "time-off-cursor.txt" # where an earlier version of this script kept its cursor, and nothing else
FIRST_RUN  = !File.exist?(STATE_FILE) && !File.exist?(OLD_CURSOR)
STATE      = File.exist?(STATE_FILE) ? JSON.parse(File.read(STATE_FILE)) : { "cursor" => nil, "not_for_hr" => [], "sent_to_hr" => {} }
STATE["cursor"] ||= File.read(OLD_CURSOR).strip if File.exist?(OLD_CURSOR) # the first run since that version goes on from it

# Written to a new file, then renamed over the old one, so a run stopped partway can't leave half a state file.
def save_state
  File.write("#{STATE_FILE}.new", JSON.generate(STATE))
  File.rename("#{STATE_FILE}.new", STATE_FILE)
end

# What your HR system calls the kinds of leave approved in Erlangly, by their codes here. Leave and balances from HR
# come with HR's own names, which Erlangly reads as said under Settings → Time off.
HR_TYPES = { "VACATION" => "Annual leave", "PERSONAL" => "Personal day", "SICK" => "Sick leave" }

# Answers are read as JSON only when they say they are: an error can be a proxy's web page instead, which stands as an
# error saying what it was, and one that worked but isn't JSON stops the script.
def call(method, url, token, body = nil, headers = {})
  uri = URI(url)
  request = Net::HTTP.const_get(method.to_s.capitalize).new(uri, { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }.merge(headers))
  request.body = JSON.generate(body) if body
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 120) { |http| http.request(request) }
  status, kind, text = response.code.to_i, response.content_type, response.body.to_s
  return [ status, {} ] if text.empty?
  return [ status, JSON.parse(text) ] if kind == "application/json"
  not_json = "an answer in #{kind || "no content type"}, not JSON"
  raise "#{method.upcase} #{url.split("?").first} got #{not_json}" if status.between?(200, 299)
  [ status, { "error" => not_json } ]
end

# Every request to Erlangly is safe to send again when the network fails: reads, the imports (by their key) and the rest.
def erlangly(method, path, body = nil, headers = {})
  tries = 0
  begin
    call(method, "#{ERLANGLY}/api/v1#{path}", TOKEN, body, headers)
  rescue Net::OpenTimeout, Net::ReadTimeout, SystemCallError, OpenSSL::SSL::SSLError => error
    raise if (tries += 1) > 3
    warn "Erlangly: #{error.message}, trying again"
    sleep 10 * tries
    retry
  end
end

def hr(method, path, body = nil)
  status, answer = call(method, "#{HR}#{path}", HR_TOKEN, body)
  raise "HR answered #{status} to #{method.upcase} #{path}" unless status.between?(200, 299)
  answer
end

# Previews an import, says what's wrong with any rows, then applies the rest. Its Idempotency-Key is this run's, so a
# request sent again after a network failure answers with the import the first one made.
def import(kind, rows)
  return puts("#{kind}: nothing to send") if rows.empty?
  body = { kind: kind, rows: rows }
  body[:force] = true if FORCE && kind == "people"
  key = "#{HR_SOURCE}-#{kind}-#{RUN}-#{Digest::SHA256.hexdigest(JSON.generate(body))[0, 16]}"

  status, answer = erlangly(:post, "/imports", body, "Idempotency-Key" => key)
  abort "#{kind}: #{answer["error"]}: #{Array(answer["problems"]).join(" ")}" unless status == 201
  id = answer.dig("import", "id")
  answer.dig("import", "rows").each do |row|
    puts "#{kind}, line #{row["line"]}: #{row["errors"].join(" ")}" if row["errors"].any?
    puts "#{kind}, line #{row["line"]}: canceled in Erlangly, still booked in HR: #{row["description"]}" if row["action"] == "conflict"
  end

  5.times do
    status, answer = erlangly(:post, "/imports/#{id}/application")
    return puts("#{kind}: #{answer.dig("import", "counts")}") if status == 200
    abort "#{kind}: #{answer["error"]}" if status == 422 # undone or its file cleared: send the rows again; refused by the guard: see FORCE
    warn "#{kind}: #{answer["error"]}" # 500, partly applied, or 409, being applied elsewhere: ask again
    sleep 30
  end
  abort "#{kind}: import #{id} isn't applied yet: look at it in Erlangly"
end

# Leave approved in Erlangly goes into HR, and HR's ID for it comes back; leave canceled here is canceled there.
def record_in_hr(time_off)
  return if time_off["type"] == "ATTRITION" # a leaver's schedule kept under Attrition is Erlangly's own, not leave for HR
  if time_off["status"] == "approved" && time_off["hr_reference"].nil?
    return if STATE["not_for_hr"].include?(time_off["id"]) # approved before the first run: HR may have it already
    # HR's ID is kept before it's sent back, so if that fails, the next run sends it again rather than adding the leave twice.
    STATE["sent_to_hr"][time_off["id"].to_s] ||= begin
      minutes = time_off["days"].sum { |day| day["minutes"] }
      hr(:post, "/leave", { employee_number: time_off.dig("person", "employee_number"), type: HR_TYPES.fetch(time_off["type"], time_off["type_name"]),
                            starts_at: time_off["starts_at"], ends_at: time_off["ends_at"], hours: (minutes / 60.0 if minutes > 0) })["id"].to_s
    end
    save_state
    send_hr_id_back(time_off["id"])
  elsif time_off["status"] == "cancelled" && time_off["hr_source"] == HR_SOURCE
    # Leave HR canceled itself comes back here too, once it's imported, so HR may answer that it's canceled already.
    status, _ = call(:post, "#{HR}/leave/#{time_off["hr_reference"]}/cancel", HR_TOKEN)
    warn "time off #{time_off["id"]}: HR answered #{status} to canceling #{time_off["hr_reference"]}" unless status.between?(200, 299)
  end
end

# HR's ID for leave it took, sent back to Erlangly. Until Erlangly has it, it stays in the state file for the next run.
def send_hr_id_back(id)
  status, answer = erlangly(:patch, "/time_off_requests/#{id}", { hr_reference: STATE["sent_to_hr"][id.to_s] })
  if status == 404 # deleted in Erlangly since, or no longer this token's to see: there's nothing to send it to
    warn "time off #{id}: #{answer["error"]}: HR's ID #{STATE["sent_to_hr"][id.to_s]} isn't sent back, so check that leave in HR"
  elsif status != 200
    return warn("time off #{id}: #{answer["error"]}: HR's ID goes back next run")
  end
  STATE["sent_to_hr"].delete(id.to_s)
  save_state
end

# The HR system's short name, as on the integration: leave and balances are kept under it.
status, me = erlangly(:get, "/me")
abort "Erlangly answered #{status}: #{me["error"]}" unless status == 200
HR_SOURCE = me["hr_source"] or abort "Give the integration its HR system's name under Settings → Integrations"

# 1. Joiners, moves and leavers first, so leave for someone new finds them. A leaving date goes only when HR has one:
#    Erlangly keeps a leaving HR doesn't have, as a planner may record it first, and a planner cancels one there.
people = hr(:get, "/employees?changed_since=#{TODAY - 7}").map do |employee|
  { employee_number: employee["employee_number"], first_name: employee["first_name"], last_name: employee["last_name"],
    email: employee["email"], team: employee["team"], site: employee["site"], hired_on: employee["hire_date"],
    status: { "active" => "active", "on_leave" => "inactive" }[employee["status"]] }
    .tap { |row| row[:left_on] = employee["termination_date"] if employee["termination_date"] }
end
# Leavers first, so the seats they free are there for joiners in a later request.
leavers, others = people.partition { |row| row[:left_on] || row[:status] == "inactive" }
(leavers + others).each_slice(1_000) { |rows| import("people", rows) }

# 2. HR's IDs that didn't get back to Erlangly last time, then what Erlangly approved or canceled since the last run.
#    The first run only notes where to start from, and the leave already approved, past or to come, which isn't sent to HR.
STATE["sent_to_hr"].keys.each { |id| send_hr_id_back(id) }
cursor = STATE["cursor"]
everything = FIRST_RUN ? "/time_off_requests?since=#{TODAY - 365}" : "/time_off_requests" # the first run notes a year's past leave too
loop do
  status, answer = erlangly(:get, cursor ? "/time_off_requests?after=#{URI.encode_www_form_component(cursor)}" : everything)
  if cursor && [ 400, 410 ].include?(status) # too old, or Erlangly was restored: read everything again
    warn "time off: #{answer["error"]}"
    cursor = nil
    next
  end
  abort "time off: #{answer["error"]}" unless status == 200
  answer["time_off_requests"].each do |time_off|
    if FIRST_RUN
      STATE["not_for_hr"] << time_off["id"] if time_off["status"] == "approved" && time_off["hr_reference"].nil?
    else
      record_in_hr(time_off)
    end
  end
  cursor = answer["next"]
  break unless answer["more"]
end
STATE["cursor"] = cursor
save_state

# 3. Leave booked in HR, from a month back: sending it all again every night is safe.
leave = hr(:get, "/leave?from=#{TODAY - 30}").map do |booked|
  { employee_number: booked["employee_number"], hr_type: booked["type"],
    first_day: booked["start_date"], last_day: booked["end_date"], hr_reference: booked["id"].to_s,
    status: ("canceled" if booked["status"] == "canceled") }
end
leave.each_slice(5_000) { |rows| import("time_off", rows) }

# 4. Balances, in hours, as HR worked them out.
balances = hr(:get, "/balances").map do |balance|
  { employee_number: balance["employee_number"], hr_type: balance["leave_type"],
    balance_hours: balance["hours"], as_of: balance["as_of"] }
end
balances.each_slice(5_000) { |rows| import("balances", rows) }
The same script in Rust

src/bin/hr-sync.rs, in the Rust project:

// hr-sync: a nightly sync between your HR system and Erlangly (erlangly.com/docs/automating).
// The Rust version of hr-sync.rb: cargo build --release, then run target/release/hr-sync.
use std::{env, fs, path::Path, process, thread, time::Duration};

use chrono::{Days, Local, Utc};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use ureq::Agent;
use ureq::tls::{RootCerts, TlsConfig};

// Where the sync got to, kept between runs: Erlangly's cursor; the leave that was approved before the first run, which
// isn't sent to HR; and leave HR has taken whose ID hasn't got back to Erlangly yet, by Erlangly's ID.
const STATE_FILE: &str = "hr-sync-state.json";
const OLD_CURSOR: &str = "time-off-cursor.txt"; // where an earlier version of this script kept its cursor, and nothing else

// What your HR system calls the kinds of leave approved in Erlangly, by their codes here. Leave and balances from HR
// come with HR's own names, which Erlangly reads as said under Settings → Time off.
const HR_TYPES: &[(&str, &str)] = &[("VACATION", "Annual leave"), ("PERSONAL", "Personal day"), ("SICK", "Sick leave")];

type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

struct HrSync {
    erlangly: String, // https://wfm.example.com
    token: String,    // the integration's token, from your secret store
    hr: String,       // your HR system's API, like https://hr.example.com/api
    hr_token: String,
    force: bool,       // only once a person has checked a refused people import
    run: String,       // this run's, in each import's Idempotency-Key
    hr_source: String, // the HR system's short name, as on the integration
    state: Value,
    agent: Agent,
}

impl HrSync {
    // Answers are read as JSON only when they say they are: an error can be a proxy's web page instead, which stands as an
    // error saying what it was, and one that worked but isn't JSON stops the script.
    fn call(&self, method: &str, url: &str, token: &str, body: Option<&Value>, headers: &[(&str, &str)]) -> Result<(u16, Value)> {
        let mut request = ureq::http::Request::builder()
            .method(method)
            .uri(url)
            .header("Authorization", format!("Bearer {token}"))
            .header("Content-Type", "application/json");
        for (name, value) in headers {
            request = request.header(*name, *value);
        }
        // A GET or DELETE has no body; a POST with nothing to send has an empty one, of length 0.
        let mut response = match (method, body) {
            ("GET" | "DELETE", _) => self.agent.run(request.body(())?)?,
            (_, body) => self.agent.run(request.body(body.map_or_else(String::new, Value::to_string))?)?,
        };
        let status = response.status().as_u16();
        let content_type = response.headers().get("Content-Type").and_then(|value| value.to_str().ok());
        let kind = content_type.map(|value| value.split(';').next().unwrap_or_default().trim().to_ascii_lowercase());
        let text = response.body_mut().read_to_string()?;
        if text.is_empty() {
            return Ok((status, json!({})));
        }
        if kind.as_deref() == Some("application/json") {
            return Ok((status, serde_json::from_str(&text)?));
        }
        let not_json = format!("an answer in {}, not JSON", kind.as_deref().unwrap_or("no content type"));
        if (200..=299).contains(&status) {
            return Err(format!("{method} {} got {not_json}", url.split('?').next().unwrap_or(url)).into());
        }
        Ok((status, json!({ "error": not_json })))
    }

    // Every request to Erlangly is safe to send again when the network fails: reads, the imports (by their key) and the rest.
    fn erlangly(&self, method: &str, path: &str, body: Option<&Value>, headers: &[(&str, &str)]) -> Result<(u16, Value)> {
        let url = format!("{}/api/v1{path}", self.erlangly);
        let mut tries = 0;
        loop {
            match self.call(method, &url, &self.token, body, headers) {
                Err(error) if network_failure(&*error) && tries < 3 => {
                    tries += 1;
                    eprintln!("Erlangly: {error}, trying again");
                    thread::sleep(Duration::from_secs(10 * tries));
                }
                answer => return answer,
            }
        }
    }

    fn hr(&self, method: &str, path: &str, body: Option<&Value>) -> Result<Value> {
        let (status, answer) = self.call(method, &format!("{}{path}", self.hr), &self.hr_token, body, &[])?;
        if !(200..=299).contains(&status) {
            return Err(format!("HR answered {status} to {method} {path}").into());
        }
        Ok(answer)
    }

    // Previews an import, says what's wrong with any rows, then applies the rest. Its Idempotency-Key is this run's, so a
    // request sent again after a network failure answers with the import the first one made.
    fn import(&self, kind: &str, rows: &[Value]) -> Result<()> {
        if rows.is_empty() {
            println!("{kind}: nothing to send");
            return Ok(());
        }
        let mut body = json!({ "kind": kind, "rows": rows });
        if self.force && kind == "people" {
            body["force"] = json!(true);
        }
        let digest: String = Sha256::digest(body.to_string())[..8].iter().map(|byte| format!("{byte:02x}")).collect();
        let key = format!("{}-{kind}-{}-{digest}", self.hr_source, self.run);

        let (status, answer) = self.erlangly("POST", "/imports", Some(&body), &[("Idempotency-Key", &key)])?;
        if status != 201 {
            abort(&format!("{kind}: {}: {}", text(&answer["error"]), strings(&answer["problems"]).join(" ")));
        }
        let id = text(&answer["import"]["id"]);
        for row in list(&answer["import"]["rows"]) {
            let errors = strings(&row["errors"]);
            if !errors.is_empty() {
                println!("{kind}, line {}: {}", row["line"], errors.join(" "));
            }
            if row["action"] == "conflict" {
                println!("{kind}, line {}: canceled in Erlangly, still booked in HR: {}", row["line"], text(&row["description"]));
            }
        }

        for _ in 0..5 {
            let (status, answer) = self.erlangly("POST", &format!("/imports/{id}/application"), None, &[])?;
            if status == 200 {
                println!("{kind}: {}", answer["import"]["counts"]);
                return Ok(());
            }
            if status == 422 {
                abort(&format!("{kind}: {}", text(&answer["error"]))); // undone or its file cleared: send the rows again; refused by the guard: see FORCE
            }
            eprintln!("{kind}: {}", text(&answer["error"])); // 500, partly applied, or 409, being applied elsewhere: ask again
            thread::sleep(Duration::from_secs(30));
        }
        abort(&format!("{kind}: import {id} isn't applied yet: look at it in Erlangly"))
    }

    // Leave approved in Erlangly goes into HR, and HR's ID for it comes back; leave canceled here is canceled there.
    fn record_in_hr(&mut self, time_off: &Value) -> Result<()> {
        if time_off["type"] == "ATTRITION" {
            return Ok(()); // a leaver's schedule kept under Attrition is Erlangly's own, not leave for HR
        }
        let id = text(&time_off["id"]);
        if time_off["status"] == "approved" && time_off["hr_reference"].is_null() {
            if list(&self.state["not_for_hr"]).contains(&time_off["id"]) {
                return Ok(()); // approved before the first run: HR may have it already
            }
            // HR's ID is kept before it's sent back, so if that fails, the next run sends it again rather than adding the leave twice.
            if self.state["sent_to_hr"][&id].is_null() {
                let minutes: i64 = list(&time_off["days"]).iter().filter_map(|day| day["minutes"].as_i64()).sum();
                let hr_type =
                    HR_TYPES.iter().find(|(code, _)| time_off["type"] == *code).map_or(time_off["type_name"].clone(), |(_, name)| json!(name));
                let hours = if minutes > 0 { json!(minutes as f64 / 60.0) } else { Value::Null };
                let leave = json!({ "employee_number": time_off["person"]["employee_number"], "type": hr_type,
                    "starts_at": time_off["starts_at"], "ends_at": time_off["ends_at"], "hours": hours });
                self.state["sent_to_hr"][&id] = json!(text(&self.hr("POST", "/leave", Some(&leave))?["id"]));
            }
            self.save_state()?;
            self.send_hr_id_back(&id)?;
        } else if time_off["status"] == "cancelled" && time_off["hr_source"] == self.hr_source {
            // Leave HR canceled itself comes back here too, once it's imported, so HR may answer that it's canceled already.
            let reference = text(&time_off["hr_reference"]);
            let (status, _) = self.call("POST", &format!("{}/leave/{reference}/cancel", self.hr), &self.hr_token, None, &[])?;
            if !(200..=299).contains(&status) {
                eprintln!("time off {}: HR answered {status} to canceling {reference}", time_off["id"]);
            }
        }
        Ok(())
    }

    // HR's ID for leave it took, sent back to Erlangly. Until Erlangly has it, it stays in the state file for the next run.
    fn send_hr_id_back(&mut self, id: &str) -> Result<()> {
        let hr_reference = json!({ "hr_reference": self.state["sent_to_hr"][id] });
        let (status, answer) = self.erlangly("PATCH", &format!("/time_off_requests/{id}"), Some(&hr_reference), &[])?;
        if status == 404 {
            // deleted in Erlangly since, or no longer this token's to see: there's nothing to send it to
            let reference = text(&self.state["sent_to_hr"][id]);
            eprintln!("time off {id}: {}: HR's ID {reference} isn't sent back, so check that leave in HR", text(&answer["error"]));
        } else if status != 200 {
            eprintln!("time off {id}: {}: HR's ID goes back next run", text(&answer["error"]));
            return Ok(());
        }
        self.state["sent_to_hr"].as_object_mut().map(|sent| sent.remove(id));
        self.save_state()
    }

    // Written to a new file, then renamed over the old one, so a run stopped partway can't leave half a state file.
    fn save_state(&self) -> Result<()> {
        fs::write(format!("{STATE_FILE}.new"), self.state.to_string())?;
        Ok(fs::rename(format!("{STATE_FILE}.new"), STATE_FILE)?)
    }
}

fn main() {
    if let Err(error) = run() {
        abort(&error.to_string());
    }
}

fn run() -> Result<()> {
    let mut sync = HrSync {
        erlangly: env_var("ERLANGLY"),
        token: env_var("ERLANGLY_TOKEN"),
        hr: env_var("HR_API"),
        hr_token: env_var("HR_TOKEN"),
        force: env::var("FORCE").is_ok_and(|force| force == "yes"),
        run: format!("{}-{:08x}", Utc::now().format("%Y%m%d%H%M%S"), random_u32()),
        hr_source: String::new(),
        state: json!({ "cursor": null, "not_for_hr": [], "sent_to_hr": {} }),
        agent: Agent::config_builder()
            .http_status_as_error(false) // the script reads every answer's status itself
            .timeout_connect(Some(Duration::from_secs(60)))
            .timeout_recv_response(Some(Duration::from_secs(120)))
            .tls_config(TlsConfig::builder().root_certs(RootCerts::PlatformVerifier).build()) // the system's certificates
            .build()
            .into(),
    };
    let today = Local::now().date_naive(); // the server's date: run it on a server in your organization's time zone
    let first_run = !Path::new(STATE_FILE).exists() && !Path::new(OLD_CURSOR).exists();
    if Path::new(STATE_FILE).exists() {
        sync.state = serde_json::from_str(&fs::read_to_string(STATE_FILE)?)?;
    } else if Path::new(OLD_CURSOR).exists() {
        sync.state["cursor"] = json!(fs::read_to_string(OLD_CURSOR)?.trim()); // the first run since that version goes on from it
    }

    // The HR system's short name, as on the integration: leave and balances are kept under it.
    let (status, me) = sync.erlangly("GET", "/me", None, &[])?;
    if status != 200 {
        abort(&format!("Erlangly answered {status}: {}", text(&me["error"])));
    }
    let Some(hr_source) = me["hr_source"].as_str() else { abort("Give the integration its HR system's name under Settings → Integrations") };
    sync.hr_source = hr_source.to_string();

    // 1. Joiners, moves and leavers first, so leave for someone new finds them. A leaving date goes only when HR has one:
    //    Erlangly keeps a leaving HR doesn't have, as a planner may record it first, and a planner cancels one there.
    let employees = sync.hr("GET", &format!("/employees?changed_since={}", today - Days::new(7)), None)?;
    let mut people: Vec<Value> = list(&employees)
        .iter()
        .map(|employee| {
            let status = match employee["status"].as_str() {
                Some("active") => json!("active"),
                Some("on_leave") => json!("inactive"),
                _ => Value::Null,
            };
            let mut row = json!({ "employee_number": employee["employee_number"], "first_name": employee["first_name"], "last_name": employee["last_name"],
                "email": employee["email"], "team": employee["team"], "site": employee["site"], "hired_on": employee["hire_date"],
                "status": status });
            if !employee["termination_date"].is_null() {
                row["left_on"] = employee["termination_date"].clone();
            }
            row
        })
        .collect();
    // Leavers first, so the seats they free are there for joiners in a later request: false, a leaver, sorts before true.
    people.sort_by_key(|row| row["left_on"].is_null() && row["status"] != "inactive");
    for rows in people.chunks(1_000) {
        sync.import("people", rows)?;
    }

    // 2. HR's IDs that didn't get back to Erlangly last time, then what Erlangly approved or canceled since the last run.
    //    The first run only notes where to start from, and the leave already approved, past or to come, which isn't sent to HR.
    let unsent: Vec<String> = sync.state["sent_to_hr"].as_object().map(|sent| sent.keys().cloned().collect()).unwrap_or_default();
    for id in unsent {
        sync.send_hr_id_back(&id)?;
    }
    let mut cursor = sync.state["cursor"].as_str().map(String::from);
    loop {
        let path = match &cursor {
            Some(cursor) => format!("/time_off_requests?after={}", form_encode(cursor)),
            None if first_run => format!("/time_off_requests?since={}", today - Days::new(365)), // the first run notes a year's past leave too
            None => "/time_off_requests".to_string(),
        };
        let (status, answer) = sync.erlangly("GET", &path, None, &[])?;
        if cursor.is_some() && matches!(status, 400 | 410) {
            // too old, or Erlangly was restored: read everything again
            eprintln!("time off: {}", text(&answer["error"]));
            cursor = None;
            continue;
        }
        if status != 200 {
            abort(&format!("time off: {}", text(&answer["error"])));
        }
        for time_off in list(&answer["time_off_requests"]) {
            if !first_run {
                sync.record_in_hr(time_off)?;
            } else if time_off["status"] == "approved" && time_off["hr_reference"].is_null() {
                sync.state["not_for_hr"].as_array_mut().expect("a list").push(time_off["id"].clone());
            }
        }
        cursor = answer["next"].as_str().map(String::from);
        if answer["more"] != true {
            break;
        }
    }
    sync.state["cursor"] = json!(cursor);
    sync.save_state()?;

    // 3. Leave booked in HR, from a month back: sending it all again every night is safe.
    let booked = sync.hr("GET", &format!("/leave?from={}", today - Days::new(30)), None)?;
    let leave: Vec<Value> = list(&booked)
        .iter()
        .map(|booked| {
            let status = if booked["status"] == "canceled" { json!("canceled") } else { Value::Null };
            json!({ "employee_number": booked["employee_number"], "hr_type": booked["type"],
                "first_day": booked["start_date"], "last_day": booked["end_date"], "hr_reference": text(&booked["id"]),
                "status": status })
        })
        .collect();
    for rows in leave.chunks(5_000) {
        sync.import("time_off", rows)?;
    }

    // 4. Balances, in hours, as HR worked them out.
    let balances: Vec<Value> = list(&sync.hr("GET", "/balances", None)?)
        .iter()
        .map(|balance| {
            json!({ "employee_number": balance["employee_number"], "hr_type": balance["leave_type"],
                "balance_hours": balance["hours"], "as_of": balance["as_of"] })
        })
        .collect();
    for rows in balances.chunks(5_000) {
        sync.import("balances", rows)?;
    }
    Ok(())
}

// A dropped connection or a timeout, which a request is safe to send again after; not an answer, nor one that isn't JSON.
fn network_failure(error: &(dyn std::error::Error + 'static)) -> bool {
    use ureq::Error::{ConnectionFailed, Io, Rustls, Timeout, Tls};
    matches!(error.downcast_ref::<ureq::Error>(), Some(Io(_) | Timeout(_) | ConnectionFailed | Tls(_) | Rustls(_)))
}

// A random number for this run's Idempotency-Keys, from the standard library's hasher, which the system seeds randomly.
fn random_u32() -> u32 {
    use std::hash::{BuildHasher, RandomState};
    RandomState::new().hash_one(process::id()) as u32
}

// As a form encodes it, so a cursor reaches Erlangly as it was given.
fn form_encode(text: &str) -> String {
    text.bytes()
        .map(|byte| match byte {
            b'*' | b'-' | b'.' | b'_' | b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' => (byte as char).to_string(),
            b' ' => "+".to_string(),
            _ => format!("%{byte:02X}"),
        })
        .collect()
}

fn env_var(name: &str) -> String {
    env::var(name).unwrap_or_else(|_| abort(&format!("Set {name} in the environment")))
}

// A JSON value as text: a string as it is, nothing as nothing.
fn text(value: &Value) -> String {
    match value {
        Value::String(text) => text.clone(),
        Value::Null => String::new(),
        other => other.to_string(),
    }
}

fn list(value: &Value) -> &[Value] {
    value.as_array().map_or(&[], Vec::as_slice)
}

fn strings(value: &Value) -> Vec<String> {
    list(value).iter().map(text).collect()
}

fn abort(message: &str) -> ! {
    eprintln!("{message}");
    process::exit(1)
}

Run it each night from cron, as an account of its own, through a small wrapper that reads the tokens from files only that account can read (chmod 600):

#!/bin/sh
# /opt/hr-sync/run.sh, from the sync account's crontab: 15 2 * * * /opt/hr-sync/run.sh
set -e
umask 077 # the log and the state file are for this account only
cd /var/lib/hr-sync
exec >> hr-sync.log 2>&1
export ERLANGLY=https://wfm.example.com HR_API=https://hr.example.com/api
ERLANGLY_TOKEN="$(cat /etc/hr-sync/erlangly.token)" # stops here if a file can't be read
HR_TOKEN="$(cat /etc/hr-sync/hr.token)"
export ERLANGLY_TOKEN HR_TOKEN
exec ruby /opt/hr-sync/hr-sync.rb # or the Rust version: exec /opt/hr-sync/hr-sync

The log has no tokens in it: what each import did, and the lines HR should look at, whose problems can name the person. Keep it as you keep HR's own reports. A night's reads like this:

people: {"added" => 1, "updated" => 0, "canceled" => 0, "unchanged" => 1, "conflicts" => 0, "problems" => 0, "leaving" => 1, "staying" => 0}
time_off, line 2: canceled in Erlangly, still booked in HR: Vacation, Tue, Nov 3 to Wed, Nov 4, 2026
time_off: {"added" => 1, "updated" => 0, "canceled" => 0, "unchanged" => 1, "conflicts" => 1, "problems" => 0}
balances: {"added" => 0, "updated" => 2, "canceled" => 0, "unchanged" => 0, "conflicts" => 0, "problems" => 0}

Ruby 3.4 and later print the counts that way; Ruby 3.1 to 3.3 print {"added"=>0, "updated"=>2, …}, and the Rust version prints them as JSON, {"added":0,"updated":2,…}. The other lines are the same.

Where it got to is in hr-sync-state.json, beside the log: Erlangly's cursor, the leave approved before the first run, and HR's IDs that haven't got back to Erlangly yet. Keep it between runs.

  • HR's ID didn't get back, as a line like time off 52: Too many requests: slow down: HR's ID goes back next run says: HR has the leave, and its ID waits in the state file. The next run sends it to Erlangly first, and doesn't record that leave in HR again. If that time off is gone from Erlangly meanwhile, its ID is dropped, with a line in the log: check that leave in HR.
  • HR's answer didn't get back, when the script recorded leave there: HR may have the leave, but the script never learned its ID, so the next run records it again, unless HR's API can spot the duplicate.
  • After a 400 or 410, the script reads all time off still to come again, and sends HR what it hasn't had: approved leave with no HR ID, except what was approved before the first run. Canceled leave is canceled in HR again, which HR may answer is done already. Leave that ended while the cursor couldn't be used isn't read again, so check HR for it.
  • A lost state file starts the sync again as a first run: leave approved in Erlangly since the last run that worked is taken as HR's already, and isn't sent. Check HR for it.
  • From an earlier version of this script, which kept only its cursor, in time-off-cursor.txt: its first run goes on from that cursor, as a night like any other. Delete the old file once hr-sync-state.json is there.

The first run brings in people, leave and balances, and only notes where Erlangly's leave starts from: leave approved in Erlangly before then isn't sent to HR, then or later, in case HR has it already. Check that HR has it. It reads Erlangly's leave from a year back (since=), so leave that ended more than a year before the first run isn't taken as HR's already: if it's ever changed later, the sync sends it to HR. If a people import is refused by the guard, the script stops with Erlangly's reason; once a person has checked HR's export, run it again with FORCE=yes. A leaving date more than 7 days back is a line to fix in the log, and FORCE=yes records it too.

Other ways to call HR. A unified HR API (Merge, Finch, Kombo and the like) can stand in for your HR system's own API in the four HR calls. It puts a third party between your HR data and your server, so weigh that first. A workflow tool such as n8n can run the same steps instead of a script: an HTTP request for each call, with the token in its credentials.

Payroll and billing

Hours for a pay period

/hours adds up each person's minutes for a pay period, up to 62 days, as the schedule has them:

curl -s -H "Authorization: Bearer $ERLANGLY_TOKEN" "$ERLANGLY/api/v1/hours?from=2026-10-01&to=2026-10-15"
{ "from": "2026-10-01", "to": "2026-10-15", "days": "start", "split": null,
  "rows": [
    { "person": { "id": 12, "employee_number": "RS-1001", "name": "Tanya Graham" },
      "paid_minutes_scheduled": 4740, "overtime_minutes": 240, "unpaid_exception_minutes": 120,
      "paid_time_off_minutes": 480, "unpaid_time_off_minutes": 0, "holiday_minutes": 0,
      "billable_minutes": 4500, "not_billable_minutes": 240, "minutes_in_adherence": 4243,
      "time_off": [ { "type": "VACATION", "name": "Vacation", "paid": true, "minutes": 480 } ] } ] }

These are the schedule's minutes, never hours worked. Pay what your own record of hours worked says (a time clock, badge swipes or ACD log-ins), as the law where your people work requires. Erlangly's figures are for checking that record against, and for planning.

FieldWhat it is
paid_minutes_scheduledThe shifts' paid time, less unpaid activities like lunch and unpaid exceptions like late or absent, with paid overtime, outside a shift too
overtime_minutesOvertime, paid or not. It's part of the other figures, not added to them
unpaid_exception_minutesLate, absent, unpaid sick and the like, and unpaid overtime
paid_time_off_minutes, unpaid_time_off_minutesApproved time off over the shifts, with each kind's minutes in time_off
holiday_minutesThe paid minutes on the person's public holidays, for holiday premiums
billable_minutes, not_billable_minutesThe paid minutes a client pays for, and those it doesn't, like internal training
minutes_in_adherenceFor scorecards and incentives, not pay
  • Whose days: each person's own, in their time zone, as their shifts show. So for someone in another time zone, the 1st starts at midnight where they are.
  • Across midnight: days=start (the default) counts a shift whole on the day it starts, as the schedule shows it. days=split splits it at midnight. Use what your payroll does.
  • By client: split=client gives a row for each client each person worked for, and one with client null for time off, which bills nobody, and any overtime no client has.
  • Time off counts once. A minute under approved time off counts as time off, never as scheduled work. Adding up shifts and time off from /shifts yourself would pay a day off twice, so use /hours.

A short script that writes a payroll file, in hours:

#!/usr/bin/env ruby
# payroll-hours.rb FROM TO > hours.csv: each person's scheduled hours for a pay period, to check the time clock against.
require "json"
require "net/http"
require "uri"

from, to = ARGV
uri = URI("#{ENV.fetch("ERLANGLY")}/api/v1/hours?from=#{from}&to=#{to}")
request = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{ENV.fetch("ERLANGLY_TOKEN")}")
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 60) { |http| http.request(request) }
abort JSON.parse(response.body)["error"] unless response.code == "200"

FIELDS = %w[ paid_minutes_scheduled overtime_minutes paid_time_off_minutes unpaid_time_off_minutes holiday_minutes ]
# Text a spreadsheet would run as a formula (starting with = + - @, a tab or a return) gets a ' in front, as in Erlangly's own files.
cell = lambda do |value|
  text = value.is_a?(String) && value.start_with?("=", "+", "-", "@", "\t", "\r") ? "'#{value}" : value.to_s
  text.match?(/[",\r\n]/) ? "\"#{text.gsub('"', '""')}\"" : text
end
puts [ "employee_number", "name", *FIELDS.map { |field| field.sub("minutes", "hours") } ].join(",")
JSON.parse(response.body)["rows"].each do |row|
  hours = row.values_at(*FIELDS).map { |minutes| (minutes / 60.0).round(2) }
  puts [ row.dig("person", "employee_number"), row.dig("person", "name"), *hours ].map(&cell).join(",")
end
The same script in Rust

src/bin/payroll-hours.rs, in the Rust project. It writes the hours with two decimals, as 79.00:

// payroll-hours FROM TO > hours.csv: each person's scheduled hours for a pay period, to check the time clock against.
// The Rust version of payroll-hours.rb.
use std::{env, io::Write, process, time::Duration};

use serde_json::Value;
use ureq::Agent;
use ureq::tls::{RootCerts, TlsConfig};

const FIELDS: [&str; 5] = ["paid_minutes_scheduled", "overtime_minutes", "paid_time_off_minutes", "unpaid_time_off_minutes", "holiday_minutes"];

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args: Vec<String> = env::args().skip(1).collect();
    let [from, to] = args.as_slice() else { abort("Give the pay period: payroll-hours FROM TO > hours.csv") };
    let url = format!("{}/api/v1/hours?from={from}&to={to}", env_var("ERLANGLY"));
    let agent: Agent = Agent::config_builder()
        .http_status_as_error(false) // an answer other than 200 says what's wrong in its error
        .timeout_recv_response(Some(Duration::from_secs(60)))
        .tls_config(TlsConfig::builder().root_certs(RootCerts::PlatformVerifier).build()) // the system's certificates
        .build()
        .into();
    let mut response = agent.get(&url).header("Authorization", format!("Bearer {}", env_var("ERLANGLY_TOKEN"))).call()?;
    let answer: Value = serde_json::from_str(&response.body_mut().read_to_string()?)?;
    if response.status() != 200 {
        abort(answer["error"].as_str().unwrap_or("Erlangly didn't say why"));
    }

    // Text a spreadsheet would run as a formula (starting with = + - @, a tab or a return) gets a ' in front, as in Erlangly's own files.
    let cell = |value: &str| {
        let text = if value.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{value}") } else { value.to_string() };
        if text.contains(['"', ',', '\r', '\n']) { format!("\"{}\"", text.replace('"', "\"\"")) } else { text }
    };
    let mut out = std::io::stdout().lock();
    writeln!(out, "employee_number,name,{}", FIELDS.map(|field| field.replace("minutes", "hours")).join(","))?;
    for row in answer["rows"].as_array().into_iter().flatten() {
        let person = &row["person"];
        let mut line = vec![cell(person["employee_number"].as_str().unwrap_or_default()), cell(person["name"].as_str().unwrap_or_default())];
        for field in FIELDS {
            let minutes = row[field].as_f64().ok_or_else(|| format!("A row has no {field}"))?;
            line.push(format!("{:.2}", minutes / 60.0));
        }
        writeln!(out, "{}", line.join(","))?;
    }
    Ok(())
}

fn env_var(name: &str) -> String {
    env::var(name).unwrap_or_else(|_| abort(&format!("Set {name} in the environment")))
}

fn abort(message: &str) -> ! {
    eprintln!("{message}");
    process::exit(1)
}

By person, a 62-day period of a 500-person floor answers in about a second. Split by client takes a few seconds on a big floor.

Billing clients

What a client is billed for is your contract's call, not Erlangly's: scheduled time, productive time, time in adherence, or something your ACD or payroll keeps, like handle time or contacts. Erlangly gives what it owns, the schedule and adherence, split by client, and your script picks the measure:

curl -s -H "Authorization: Bearer $ERLANGLY_TOKEN" "$ERLANGLY/api/v1/adherence?from=2026-10-01&to=2026-10-31&by=client"
curl -s -H "Authorization: Bearer $ERLANGLY_TOKEN" "$ERLANGLY/api/v1/adherence?from=2026-10-01&to=2026-10-31&by=person&split=client"
  • Each row has scheduled_minutes, in_adherence_minutes, out_of_adherence_minutes, and the same over the time on the phones (productive_scheduled_minutes, productive_in_adherence_minutes). An agent who works for two clients has a row for each, so their time is split between the invoices.
  • unknown_minutes is time with no data from the ACD, neither in nor out of adherence. Decide what your contract says it is, and keep it apart.
  • approximate is true where some of the time was worked out from the ACD's summaries of each interval, rather than each change of state. Say so on the invoice, or leave it out.
  • Time a client doesn't pay for, like internal training, is not_billable_minutes in /hours?split=client, set under Settings → Activities.

Each shift keeps the client it was worked for, so a month already billed doesn't change if a line of business later moves to another client.

Exceptions and holidays

curl -s -H "Authorization: Bearer $ERLANGLY_TOKEN" "$ERLANGLY/api/v1/exceptions?from=2026-10-01&to=2026-10-31"
curl -s -H "Authorization: Bearer $ERLANGLY_TOKEN" "$ERLANGLY/api/v1/exceptions?from=2026-10-01&to=2026-10-31&type=OT"
curl -s -H "Authorization: Bearer $ERLANGLY_TOKEN" "$ERLANGLY/api/v1/holidays?from=2026-12-01&to=2027-01-31"
  • /exceptions lists every approved exception: late, absent, sick, overtime and the rest, with overtime before or after a shift, or on a day off, which /shifts can't show. counts_as_absence says whether its kind counts as absence, for the attendance points many floors run. Its reason can be health information, so it's there only with personal details. With after=, an attendance system reads only what changed.
  • /holidays gives the public holidays of each jurisdiction your people work under, and with person_id, one person's, for payroll's holiday premiums.

On the server

Without a script, an admin on the server gets the same figures as a spreadsheet:

erlangly hours 2026-10-01 2026-10-15 --csv > hours.csv
erlangly hours 2026-10-01 2026-10-15 --split client --days split --csv > hours-by-client.csv

It's everyone's, with the API's names for each figure, and a column for each kind of time off.

Reacting to changes

A system that has to follow the schedule closely, like door access, can ask what changed every few minutes, or be told. A webhook tells it: Erlangly posts a short event to its address as each change is made. The event is a nudge. The details come from the API, with after=, so the system learns only what its token may see.

Setting one up

  1. Under Settings → Integrations, an admin chooses Add a webhook, confirming who they are as for an integration.
  2. Give the address the receiving system listens on, like https://badges.example.com/erlangly, and choose what it's told about: someone's shifts changed, time off decided or canceled, an exception decided, a person added, changed or left. With several clients, it can be limited to some.
  3. Copy the signing secret, which starts whsec_. It's shown once: keep it as you keep a token. Every admin is emailed.
  4. Make an integration with Read for the receiving system, to read the details.
  5. Choose Send a test on the webhook's page. It posts {"type": "test", "webhook_id": 3}, signed. Reload the page a few seconds later: Recent deliveries shows the status the address answered with.

Webhooks go to public https addresses. A private address on your own network, for an internal badge or HR system, only works on a server you run, in a range its operator allows with erlangly webhooks allow 10.20.0.0/16, where plain http is allowed too. erlangly webhooks ca trusts your internal certificate authority (Addresses).

What comes

POST https://badges.example.com/erlangly
webhook-id: msg_3_48211_1793260799482117
webhook-timestamp: 1793260800
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Content-Type: application/json

{"type": "shifts.changed", "person_id": 12, "dates": ["2026-10-27", "2026-10-28"]}
Picked asEvents
Someone's shifts changedshifts.changed: one for each person a change touches, whatever it was, with the days it touched
Time off decided or canceledtime_off.decided when it's approved or declined, time_off.canceled when it's canceled, and time_off.changed for any later change, with time_off_request_id, person_id and the days it covers. HR booking it again, on other days or as another kind, approves it again: it comes as time_off.decided. Time off still waiting for a decision isn't an event
An exception decidedexception.decided when it's approved or declined, or when an agent reports it, for a kind that counts at once (like sickness on a new install). One that labor law holds for a supervisor even so, as it does an agent's overtime that breaks a rule, is exception.decided when they approve or decline it. exception.changed for any later change, a supervisor noting one that counted included, with exception_id, person_id and its days
A person added, changed or leftperson.added once, when they're added; person.left when their leaving is recorded, or moved to another day, which can be weeks before the day they leave; person.changed for anything else, not leaving after all included. Each with person_id

Each type says what the change did, and comes once: an IT checklist acting on person.added, or a chat notice on time_off.decided, acts once. Ignore an event type you don't know: more may come.

Events carry IDs and dates only, never names, times or notes. Something deleted says "removed": true with the type it had (time_off.canceled, exception.decided, person.left); something no longer the webhook's to see, as its person stopped working for the webhook's clients, says "removed": true as time_off.changed, exception.changed or person.changed. Dates are in your organization's time zone.

Handling an event

  1. Check the signature, over the body exactly as it came, and drop an event more than 5 minutes old. The API reference has the details; the receiver below does it.
  2. Drop an event you've already had. A failed event is sent again with the same webhook-id, and no other event is ever given it.
  3. Answer within 10 seconds, one of two ways: handle it, then answer any 2xx, or a 5xx if you couldn't, to have it sent again, as the receiver below does; or answer 2xx at once and keep the work in a queue of your own, as an answered event isn't sent again. Anything else, a timeout or a redirect is a failure too.
  4. Read the details from the API. For shifts, ask /shifts?after= with your cursor: it has every shift that changed, as it is now, whichever events you were sent. Events mostly come in order, but that isn't promised, so act on what the API says now.

A failed event is tried again after 1, 4, 16, 64 and 256 minutes, then every 6 hours, and nothing after it goes until it does. After 3 days of failing, the webhook is turned off and every admin is emailed. Turned back on, it catches up with what changed meanwhile, up to 30 days.

Example: building access

The door-access system should open each person's badge from 30 minutes before their shift to 30 minutes after it. A day wholly off still has its shift on the schedule, under the time off, so a shift's under_time_off says "whole" when time off covers all of it, and its badge window is taken away. Part of a day off is still a day at work.

This receiver is told by a webhook (with "Someone's shifts changed" ticked), reads the shifts that changed, and sets each badge window. It also posts a notice in a chat channel when someone's shifts for today change. It assumes the door system's API takes PUT /windows/:id and DELETE /windows/:id: change those two calls to yours.

# badges.ru: keeps a door-access system's badge windows to Erlangly's schedule, told by its webhooks when to look.
# gem install puma, then: puma --bind tcp://127.0.0.1:8080 badges.ru (behind your usual HTTPS proxy on the same server)
require "date"
require "json"
require "net/http"
require "openssl"
require "set"
require "time"
require "uri"

ERLANGLY    = ENV.fetch("ERLANGLY")        # https://wfm.example.com
TOKEN       = ENV.fetch("ERLANGLY_TOKEN")  # an integration's token that can read
SECRET      = ENV.fetch("WEBHOOK_SECRET")  # whsec_…, shown once when the webhook was added
KEY         = SECRET.delete_prefix("whsec_").unpack1("m0") # its base64 part, as bytes: a secret that isn't one stops it here
DOORS       = ENV.fetch("DOORS_API")       # the door-access system's API
DOORS_TOKEN = ENV.fetch("DOORS_TOKEN")
CHAT        = ENV["CHAT_WEBHOOK"]          # a chat channel's incoming webhook, for notices (optional)
CURSOR      = "shifts-cursor.txt"
MARGIN      = 30 * 60                      # badges work from 30 minutes before a shift to 30 after it

def call(method, url, token, body = nil)
  uri = URI(url)
  headers = { "Content-Type" => "application/json" }
  headers["Authorization"] = "Bearer #{token}" if token
  request = Net::HTTP.const_get(method.to_s.capitalize).new(uri, headers)
  request.body = JSON.generate(body) if body
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
  json = response.content_type == "application/json" && !response.body.to_s.empty? # an error can be a web page instead
  [ response.code.to_i, json ? JSON.parse(response.body) : {} ]
end

def erlangly(path) = call(:get, "#{ERLANGLY}/api/v1#{path}", TOKEN)

# The door system down or out of reach stops the work, so the cursor stays where it was and the webhook's event comes
# again. Any other refusal is only logged: sending the event again wouldn't change it, and every event after it would
# wait. A window already gone is gone.
def doors(method, path, body = nil)
  status, _ = call(method, "#{DOORS}#{path}", DOORS_TOKEN, body)
  return if status.between?(200, 299) || (method == :delete && status == 404)
  raise "the door system answered #{status} to #{method.upcase} #{path}" if status >= 500
  warn "badges: the door system answered #{status} to #{method.upcase} #{path}"
end

# As the API reference checks it: the signature over the body exactly as it came, and nothing older than 5 minutes.
def from_erlangly?(key, headers, body)
  id, timestamp, signatures = headers.values_at("webhook-id", "webhook-timestamp", "webhook-signature")
  return false if (Time.now.to_i - timestamp.to_i).abs > 300
  expected = [ OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}") ].pack("m0")
  signatures.split.any? { |signature| signature.start_with?("v1,") && OpenSSL.secure_compare(signature.delete_prefix("v1,"), expected) }
end

# Reads the shifts changed since it last looked (or the next two weeks', the first time) and sets each badge window.
def sync_badges
  cursor = File.read(CURSOR).strip if File.exist?(CURSOR)
  loop do
    today = Date.today # the server's date: run it on a server in your organization's time zone
    status, answer = erlangly(cursor ? "/shifts?after=#{URI.encode_www_form_component(cursor)}" : "/shifts?from=#{today}&to=#{today + 13}")
    if cursor && [ 400, 410 ].include?(status) # too old, or Erlangly was restored: read everything again
      cursor = nil
      next
    end
    raise "Erlangly answered #{status}: #{answer["error"]}" unless status == 200
    answer["shifts"].each { |shift| set_window(shift) }
    Array(answer["removed"]).each { |id| doors(:delete, "/windows/#{id}") }
    File.write(CURSOR, cursor = answer["next"])
    break unless answer["more"]
  end
end

# A day wholly off isn't a day at work, though its shift stays on the schedule; part of a day off still is.
def set_window(shift)
  if shift["under_time_off"] == "whole"
    doors(:delete, "/windows/#{shift["id"]}")
  else
    doors(:put, "/windows/#{shift["id"]}", { badge: shift.dig("person", "employee_number"),
      opens_at: (Time.iso8601(shift["starts_at"]) - MARGIN).iso8601, closes_at: (Time.iso8601(shift["ends_at"]) + MARGIN).iso8601 })
  end
end

# A notice in the team's chat when someone's shifts for today change. A chat that's down only misses the notice.
def tell_the_team(event)
  return unless CHAT && event["type"] == "shifts.changed" && !event["removed"] && event["dates"].include?(Date.today.iso8601)
  status, person = erlangly("/people/#{event["person_id"]}")
  raise "Erlangly answered #{status} for person #{event["person_id"]}" unless status.between?(200, 299)
  # Chat webhooks answer plain text, like Slack's "ok", so the answer isn't read as JSON.
  answer = Net::HTTP.post(URI(CHAT), JSON.generate(text: "#{person["name"]}'s shifts for today have changed."), "Content-Type" => "application/json")
  warn "badges: the chat answered #{answer.code}" unless answer.is_a?(Net::HTTPSuccess)
rescue => error
  warn "badges: no chat notice: #{error.message}"
end

SEEN = Set.new # the last 10,000 webhook-ids handled; a retry comes with the same one
ONE_AT_A_TIME = Mutex.new

# The work is done before it answers, so if the door system fails, Erlangly hears so, and sends the event again later.
receiver = lambda do |env|
  body = env["rack.input"].read
  headers = { "webhook-id" => env["HTTP_WEBHOOK_ID"], "webhook-timestamp" => env["HTTP_WEBHOOK_TIMESTAMP"], "webhook-signature" => env["HTTP_WEBHOOK_SIGNATURE"] }
  next [ 401, {}, [] ] unless headers.values.all? && from_erlangly?(KEY, headers, body)
  event = JSON.parse(body) rescue nil
  next [ 400, {}, [] ] unless event.is_a?(Hash) # signed, but not an event: sending it again won't change that
  ONE_AT_A_TIME.synchronize do
    unless SEEN.include?(headers["webhook-id"])
      sync_badges if event["type"] == "shifts.changed"
      tell_the_team(event)
      SEEN << headers["webhook-id"] # only once it's done, so an event that failed is done when it comes again
      SEEN.delete(SEEN.first) if SEEN.size > 10_000
    end
  end
  [ 204, {}, [] ]
rescue => error
  warn "badges: #{error.message}"
  [ 503, {}, [] ]
end
run receiver
The same script in Rust

src/bin/badges.rs, in the Rust project. Run target/release/badges behind the same HTTPS proxy: it listens on 127.0.0.1:8080, or the address in LISTEN. It handles one event at a time, as the Ruby does:

// badges: keeps a door-access system's badge windows to Erlangly's schedule, told by its webhooks when to look.
// The Rust version of badges.ru: cargo build --release, then run target/release/badges, which listens on 127.0.0.1:8080
// (or LISTEN), behind your usual HTTPS proxy on the same server.
use std::{collections::VecDeque, env, fs, path::Path, process, time::Duration};

use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use chrono::{DateTime, Days, Local, SecondsFormat, TimeDelta, Utc};
use hmac::{Hmac, KeyInit, Mac};
use serde_json::{Value, json};
use sha2::Sha256;
use tiny_http::{Response, Server};
use ureq::Agent;
use ureq::tls::{RootCerts, TlsConfig};

const CURSOR: &str = "shifts-cursor.txt";
const MARGIN: TimeDelta = TimeDelta::minutes(30); // badges work from 30 minutes before a shift to 30 after it

type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

struct Badges {
    erlangly: String, // https://wfm.example.com
    token: String,    // an integration's token that can read
    doors: String,    // the door-access system's API
    doors_token: String,
    chat: Option<String>, // a chat channel's incoming webhook, for notices (optional)
    agent: Agent,
}

impl Badges {
    fn call(&self, method: &str, url: &str, token: Option<&str>, body: Option<&Value>) -> Result<(u16, Value)> {
        let mut request = ureq::http::Request::builder().method(method).uri(url).header("Content-Type", "application/json");
        if let Some(token) = token {
            request = request.header("Authorization", format!("Bearer {token}"));
        }
        // A GET or DELETE has no body; a POST with nothing to send has an empty one, of length 0.
        let mut response = match (method, body) {
            ("GET" | "DELETE", _) => self.agent.run(request.body(())?)?,
            (_, body) => self.agent.run(request.body(body.map_or_else(String::new, Value::to_string))?)?,
        };
        let status = response.status().as_u16();
        // Only JSON is read: an error can be a web page instead.
        let json = response.headers().get("Content-Type").is_some_and(|value| value.as_bytes().starts_with(b"application/json"));
        let text = response.body_mut().read_to_string()?;
        Ok((status, if json && !text.is_empty() { serde_json::from_str(&text)? } else { json!({}) }))
    }

    fn erlangly(&self, path: &str) -> Result<(u16, Value)> {
        self.call("GET", &format!("{}/api/v1{path}", self.erlangly), Some(&self.token), None)
    }

    // The door system down or out of reach stops the work, so the cursor stays where it was and the webhook's event comes
    // again. Any other refusal is only logged: sending the event again wouldn't change it, and every event after it would
    // wait. A window already gone is gone.
    fn doors(&self, method: &str, path: &str, body: Option<&Value>) -> Result<()> {
        let (status, _) = self.call(method, &format!("{}{path}", self.doors), Some(&self.doors_token), body)?;
        if (200..=299).contains(&status) || (method == "DELETE" && status == 404) {
            return Ok(());
        }
        let refused = format!("the door system answered {status} to {method} {path}");
        if status >= 500 {
            return Err(refused.into());
        }
        eprintln!("badges: {refused}");
        Ok(())
    }

    // Reads the shifts changed since it last looked (or the next two weeks', the first time) and sets each badge window.
    fn sync_badges(&self) -> Result<()> {
        let mut cursor = if Path::new(CURSOR).exists() { Some(fs::read_to_string(CURSOR)?.trim().to_string()) } else { None };
        loop {
            let today = Local::now().date_naive(); // the server's date: run it on a server in your organization's time zone
            let path = match &cursor {
                Some(cursor) => format!("/shifts?after={}", form_encode(cursor)),
                None => format!("/shifts?from={today}&to={}", today + Days::new(13)),
            };
            let (status, answer) = self.erlangly(&path)?;
            if cursor.is_some() && matches!(status, 400 | 410) {
                cursor = None; // too old, or Erlangly was restored: read everything again
                continue;
            }
            if status != 200 {
                return Err(format!("Erlangly answered {status}: {}", text(&answer["error"])).into());
            }
            for shift in list(&answer["shifts"]) {
                self.set_window(shift)?;
            }
            for id in list(&answer["removed"]) {
                self.doors("DELETE", &format!("/windows/{}", text(id)), None)?;
            }
            cursor = answer["next"].as_str().map(String::from);
            fs::write(CURSOR, cursor.as_deref().unwrap_or_default())?;
            if answer["more"] != true {
                return Ok(());
            }
        }
    }

    // A day wholly off isn't a day at work, though its shift stays on the schedule; part of a day off still is.
    fn set_window(&self, shift: &Value) -> Result<()> {
        let window = format!("/windows/{}", text(&shift["id"]));
        if shift["under_time_off"] == "whole" {
            self.doors("DELETE", &window, None)?;
        } else {
            let opens_at = DateTime::parse_from_rfc3339(&text(&shift["starts_at"]))? - MARGIN;
            let closes_at = DateTime::parse_from_rfc3339(&text(&shift["ends_at"]))? + MARGIN;
            let window_times = json!({ "badge": shift["person"]["employee_number"],
                "opens_at": opens_at.to_rfc3339_opts(SecondsFormat::Secs, true), "closes_at": closes_at.to_rfc3339_opts(SecondsFormat::Secs, true) });
            self.doors("PUT", &window, Some(&window_times))?;
        }
        Ok(())
    }

    // A notice in the team's chat when someone's shifts for today change. A chat that's down only misses the notice.
    fn tell_the_team(&self, event: &Value) {
        let Some(chat) = &self.chat else { return };
        let today = Local::now().date_naive().to_string();
        if event["type"] != "shifts.changed" || event["removed"] == true || !list(&event["dates"]).iter().any(|date| *date == today) {
            return;
        }
        let notice = || -> Result<u16> {
            let (status, person) = self.erlangly(&format!("/people/{}", text(&event["person_id"])))?;
            if !(200..=299).contains(&status) {
                return Err(format!("Erlangly answered {status} for person {}", text(&event["person_id"])).into());
            }
            let text = json!({ "text": format!("{}'s shifts for today have changed.", text(&person["name"])) });
            // Chat webhooks answer plain text, like Slack's "ok", so the answer isn't read as JSON.
            Ok(self.agent.post(chat).header("Content-Type", "application/json").send(text.to_string())?.status().as_u16())
        };
        match notice() {
            Ok(status) if (200..=299).contains(&status) => {}
            Ok(status) => eprintln!("badges: the chat answered {status}"),
            Err(error) => eprintln!("badges: no chat notice: {error}"),
        }
    }

    fn handle(&self, event: &Value) -> Result<()> {
        if event["type"] == "shifts.changed" {
            self.sync_badges()?;
        }
        self.tell_the_team(event);
        Ok(())
    }
}

// As the API reference checks it: the signature over the body exactly as it came, and nothing older than 5 minutes.
fn from_erlangly(key: &[u8], id: &str, timestamp: &str, signatures: &str, body: &[u8]) -> bool {
    let Ok(sent_at) = timestamp.parse::<i64>() else { return false };
    if Utc::now().timestamp().abs_diff(sent_at) > 300 {
        return false;
    }
    let mut expected = Hmac::<Sha256>::new_from_slice(key).expect("HMAC takes a key of any length");
    expected.update(format!("{id}.{timestamp}.").as_bytes());
    expected.update(body);
    // verify_slice compares in constant time, as OpenSSL.secure_compare does.
    signatures
        .split_whitespace()
        .filter_map(|signature| BASE64.decode(signature.strip_prefix("v1,")?).ok())
        .any(|signature| expected.clone().verify_slice(&signature).is_ok())
}

fn main() {
    let secret = env_var("WEBHOOK_SECRET"); // whsec_…, shown once when the webhook was added
    let secret = secret.strip_prefix("whsec_").unwrap_or(&secret);
    let key = BASE64.decode(secret).unwrap_or_else(|_| abort("WEBHOOK_SECRET isn't a webhook's signing secret"));
    let badges = Badges {
        erlangly: env_var("ERLANGLY"),
        token: env_var("ERLANGLY_TOKEN"),
        doors: env_var("DOORS_API"),
        doors_token: env_var("DOORS_TOKEN"),
        chat: env::var("CHAT_WEBHOOK").ok(),
        agent: Agent::config_builder()
            .http_status_as_error(false) // the receiver reads every answer's status itself
            .timeout_connect(Some(Duration::from_secs(60)))
            .timeout_recv_response(Some(Duration::from_secs(60)))
            .tls_config(TlsConfig::builder().root_certs(RootCerts::PlatformVerifier).build()) // the system's certificates
            .build()
            .into(),
    };
    let listen = env::var("LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
    let server = Server::http(&listen).unwrap_or_else(|error| abort(&format!("Can't listen on {listen}: {error}")));

    // The work is done before it answers, one event at a time, so if the door system fails, Erlangly hears so, and sends
    // the event again later.
    let mut seen = VecDeque::new(); // the last 10,000 webhook-ids handled; a retry comes with the same one
    for mut request in server.incoming_requests() {
        let header = |name: &'static str| request.headers().iter().find(|header| header.field.equiv(name)).map(|header| header.value.to_string());
        let (id, timestamp, signatures) = (header("webhook-id"), header("webhook-timestamp"), header("webhook-signature"));
        let mut body = Vec::new();
        let status = match (id, timestamp, signatures) {
            (Some(id), Some(timestamp), Some(signatures))
                if request.as_reader().read_to_end(&mut body).is_ok() && from_erlangly(&key, &id, &timestamp, &signatures, &body) =>
            {
                match serde_json::from_slice::<Value>(&body).ok().filter(Value::is_object) {
                    Some(_) if seen.contains(&id) => 204,
                    Some(event) => match badges.handle(&event) {
                        Ok(()) => {
                            seen.push_back(id); // only once it's done, so an event that failed is done when it comes again
                            if seen.len() > 10_000 {
                                seen.pop_front();
                            }
                            204
                        }
                        Err(error) => {
                            eprintln!("badges: {error}");
                            503
                        }
                    },
                    None => 400, // signed, but not an event: sending it again won't change that
                }
            }
            _ => 401,
        };
        if let Err(error) = request.respond(Response::empty(status)) {
            eprintln!("badges: {error}");
        }
    }
}

// As a form encodes it, so a cursor reaches Erlangly as it was given.
fn form_encode(text: &str) -> String {
    text.bytes()
        .map(|byte| match byte {
            b'*' | b'-' | b'.' | b'_' | b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' => (byte as char).to_string(),
            b' ' => "+".to_string(),
            _ => format!("%{byte:02X}"),
        })
        .collect()
}

fn env_var(name: &str) -> String {
    env::var(name).unwrap_or_else(|_| abort(&format!("Set {name} in the environment")))
}

// A JSON value as text: a string as it is, nothing as nothing.
fn text(value: &Value) -> String {
    match value {
        Value::String(text) => text.clone(),
        Value::Null => String::new(),
        other => other.to_string(),
    }
}

fn list(value: &Value) -> &[Value] {
    value.as_array().map_or(&[], Vec::as_slice)
}

fn abort(message: &str) -> ! {
    eprintln!("{message}");
    process::exit(1)
}

It does the work before it answers. If the door system is down (a 5xx) or can't be reached, the cursor stays where it was and Erlangly gets a 503, so the event comes again a minute later and the shifts are read again from there. A window it couldn't take away because it's gone already counts as done. A chat that's down only misses its notice.

Any other refusal from the door system, like a 400 for a badge it doesn't know, is logged and the work carries on: sending the event again wouldn't change the answer, and every event after it would wait, until the webhook was turned off after 3 days. That window is left as it was, so check the log for these lines.

The first event reads the next two weeks, which can take longer than Erlangly waits, so the event comes again. Once the first has finished, the retry is answered at once. If the first failed partway through, the retry reads the two weeks again.

It keeps the last 10,000 IDs it has handled in memory, and only once an event's work is done, so one that failed is handled when it comes again. That's enough while one receiver runs: a restart only means an event may be handled twice, and setting a badge window twice does no harm. Run more than one, and keep them in a shared store.

Without a webhook, the same sync_badges can run every 15 minutes from cron, as after= finds whatever changed meanwhile. For a server with no way in from Erlangly, erlangly schedule 2026-11-02 --json on the Erlangly server gives that day's published shifts, with under_time_off, for a file transfer instead. It covers one day and has no shift IDs, so send a file for each day the door system keeps, and have it replace that day's windows whole.

Example: notices in Teams or Slack

The receiver's tell_the_team posts to a chat channel's incoming webhook. Slack's incoming webhooks take {"text": …} as it is, and answer a plain ok, which the receiver doesn't read as JSON. A Teams channel's webhook, made with Workflows, expects an Adaptive Card instead, so wrap the text in one there.

The same pattern fits other notices: a time_off.decided event read from /time_off_requests?after= for a team's channel, or person.added for IT's new-starter checklist. For live adherence on a channel, a script can read /floor every few minutes. Keep notices to what a channel needs: an event says what changed, and the API says who sees what.

The server's command line

An admin who would rather not run a script can import files from HR and export hours on the Erlangly server itself:

erlangly people import people.csv --source hris          # says what it would do
erlangly people import people.csv --source hris --yes    # does it
erlangly timeoff import leave.csv --source hris --yes
erlangly balances import balances.csv --source hris --yes
erlangly hours 2026-10-01 2026-10-15 --csv > hours.csv
erlangly schedule 2026-11-02 --json > shifts.json
  • Files have the same columns as the API's rows, with a header line: those above, or the import pages' templates. A leave file needs the type (or hr_type) and first_day columns even when every row cancels, empty in those rows.
  • --source is the HR system's short name. Use the integration's, so a file and the nightly sync match the same leave and balances. Balances need one, and so does leave with an HR reference, unless each row names it in hr_source.
  • Without --yes, each import says what it would add, change and cancel, and which lines to fix, and changes nothing.
  • --force sends a people import the guard refused, once a person has checked it.
  • erlangly schedule --json gives a day's published shifts: each person's name and employee number, the line of business, the times, under_time_off, time_off_ids, the paid minutes and the activities.

The commands act as your first admin, and what they change is in the audit trail. Unlike a token, they import that admin's own time off and balance too, as whoever runs them controls the install already. An HR system that drops its files on the server can be imported each night from root's crontab:

30 2 * * * /usr/local/bin/erlangly timeoff import /srv/hr/leave.csv --source hris --yes

Limits and safety

LimitHow much
Requests600 a minute from one address to each endpoint, wrong tokens included, then 429 until the minute is up. An integration also has 600 a minute of its own, across every endpoint.
A request2 MB
Date ranges31 days for shifts and intraday; 62 for exceptions, hours, holidays and adherence (400 over that)
Imports5,000 rows of time off or balances, and 1,000 people, a request
Changes since500 a page; a cursor lasts 30 days
ListsThe newest 100 forecasts and 50 schedule periods; people and time off give them all, time off from a day on with since

A script that runs into 429 should wait for the minute and slow down. Read a month in one call where the range allows it, rather than a day at a time.

What an integration can't do:

  • Anything its abilities don't open. Each endpoint names the ability it needs, and anything not on that list is refused with 403, reading included. A new endpoint is refused to integrations until it's added to the list.
  • Ask for or decide time off, or publish a schedule. Those are a person's. Nor can it send agent states or volumes: that's a connection's token.
  • Be used by an AI assistant. Over MCP it's refused.
  • Sign in, or be told anything. Its user has no password anyone knows, and gets no email or notifications.

Personal details. Email addresses, ACD IDs, time-off notes, balances and exceptions' reasons, which can be health information, are left out unless the integration has the Personal details ability. Give it only to a script that needs them. Webhooks never carry them. Keep what your scripts store to what they need, and keep tokens and personal details out of their logs.

What Erlangly keeps. Everything an integration changes is in the audit trail under its name. An import from HR is one line there, with how many records it changed, and it keeps only the columns it reads: pay or home addresses in HR's export are dropped. Its file is cleared after a week. Once it can no longer be undone, 30 days after it came, the names, dates, balances and notes of its rows go too, with what undoing it needed; its counts, who sent and applied it and when, and its line in the audit trail stay.

Stability. Version 1 of the API won't break: changes are new endpoints, new fields and new optional parameters. Read answers in a way that ignores fields you don't know, and your scripts keep working through upgrades.