defmodule FactorialHR do
@moduledoc """
Generic Elixir client for the public Factorial HR REST API.
This package intentionally stays below any product/domain mapping layer. It
knows how to authenticate, build versioned resource URLs, follow Factorial's
cursor pagination and call common HR endpoints, returning Factorial payloads
as maps.
## Quick start
opts = [
api_key: System.fetch_env!("FACTORIAL_API_KEY"),
api_version: "2026-04-01"
]
{:ok, employees} = FactorialHR.list_employees([only_active: true], opts)
The client also accepts bearer access tokens for applications that implement
their own OAuth flow.
## Telemetry
When `:telemetry` is available, requests emit:
* `[:factorial_hr, :request, :start]`
* `[:factorial_hr, :request, :stop]`
* `[:factorial_hr, :request, :exception]`
Request metadata includes the HTTP method, resource path and resolved URL.
"""
alias FactorialHR.Config
alias FactorialHR.Error
@employee_filter_batch_size 50
@user_agent "factorial_hr/0.2.1"
@bulk_delete_selector_keys ~w(ids employee_ids start_at end_at)
@shift_missing_reasons %{
employee_id: :employee_id_missing,
start_at: :start_at_missing,
end_at: :end_at_missing,
company_id: :company_id_missing
}
@type params :: keyword() | map()
@type client_opts :: keyword() | map() | Config.t()
@type result :: {:ok, term()} | {:error, Error.t()}
@type response_result :: {:ok, Req.Response.t()} | {:error, Error.t()}
@doc """
Executes a GET request against a versioned Factorial resource path.
Pass a resource path such as `"/employees/employees"`. The client adds the
`/api/:version/resources` prefix from the configured API version.
Returns the raw `%Req.Response{}` for successful 2xx responses and a
structured `%FactorialHR.Error{}` for non-2xx HTTP responses or request
failures.
"""
@spec get(String.t(), params(), client_opts()) :: response_result()
def get(path, params \\ [], opts \\ []) do
:get
|> request(path, nil, params, opts)
|> normalize_public_response(:get, path)
end
@doc """
Executes a POST request against a versioned Factorial resource path.
"""
@spec post(String.t(), map(), client_opts()) :: response_result()
def post(path, body, opts \\ []) when is_map(body) do
:post
|> request(path, body, [], opts)
|> normalize_public_response(:post, path)
end
@doc """
Executes a DELETE request against a versioned Factorial resource path.
"""
@spec delete(String.t(), client_opts()) :: response_result()
def delete(path, opts \\ []) do
:delete
|> request(path, nil, [], opts)
|> normalize_public_response(:delete, path)
end
@doc """
Fetches every page from a cursor-paginated Factorial collection.
"""
@spec all(String.t(), params(), String.t() | nil, client_opts()) ::
{:ok, list(map())} | {:error, Error.t()}
def all(path, params \\ [], collection_name \\ nil, opts \\ []) do
with {:ok, config} <- Config.new(opts) do
fetch_all_pages(path, normalize_params(params), collection_name, config, [], nil)
end
end
@doc "Lists Factorial employees."
@spec list_employees(params(), client_opts()) :: {:ok, list(map())} | {:error, Error.t()}
def list_employees(params \\ [], opts \\ []) do
all("/employees/employees", params, "employees", opts)
end
@doc "Lists Factorial workplace locations."
@spec list_locations(params(), client_opts()) :: {:ok, list(map())} | {:error, Error.t()}
def list_locations(params \\ [], opts \\ []) do
all("/locations/locations", params, "locations", opts)
end
@doc "Lists Factorial work areas."
@spec list_work_areas(params(), client_opts()) :: {:ok, list(map())} | {:error, Error.t()}
def list_work_areas(params \\ [], opts \\ []) do
all("/locations/work_areas", params, "work_areas", opts)
end
@doc "Lists Factorial teams."
@spec list_teams(params(), client_opts()) :: {:ok, list(map())} | {:error, Error.t()}
def list_teams(params \\ [], opts \\ []) do
all("/teams/teams", params, "teams", opts)
end
@doc """
Lists unique employee IDs from the Factorial teams endpoint.
Factorial team payloads can expose `employee_ids` or expanded `employees`.
This helper is generic and performs no tenant-specific filtering.
"""
@spec list_team_employee_ids(client_opts()) :: {:ok, list(integer())} | {:error, Error.t()}
def list_team_employee_ids(opts \\ []) do
case list_teams([], opts) do
{:ok, teams} ->
{:ok, teams |> Enum.flat_map(&team_employee_ids/1) |> Enum.uniq() |> Enum.sort()}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Lists Factorial shift-management shifts.
Recognized convenience params include `:employee_ids`, `:location_ids`,
`:start_at`, `:end_at`, `:only_published`, `:only_states` and
`:split_overnight_shifts`. Other params are passed through unchanged.
FactorialHR.list_shifts(
[
employee_ids: [123, 456],
start_at: "2026-06-01",
end_at: "2026-06-30",
only_states: ["published"]
],
opts
)
"""
@spec list_shifts(params(), client_opts()) :: {:ok, list(map())} | {:error, Error.t()}
def list_shifts(params \\ [], opts \\ []) do
params = normalize_shift_management_params(params)
fetch_batched_collection("/shift_management/shifts", params, "shifts", opts)
end
@doc """
Lists Factorial attendance shifts for a date range.
"""
@spec list_attendance_shifts(
Date.t() | String.t(),
Date.t() | String.t(),
params(),
client_opts()
) ::
{:ok, list(map())} | {:error, Error.t()}
def list_attendance_shifts(start_on, end_on, params \\ [], opts \\ []) do
params =
params
|> normalize_params()
|> put_param("start_on", format_date(start_on))
|> put_param("end_on", format_date(end_on))
fetch_batched_collection("/attendance/shifts", params, "shifts", opts)
end
@doc """
Lists Factorial contract versions.
"""
@spec list_contract_versions(params(), client_opts()) ::
{:ok, list(map())} | {:error, Error.t()}
def list_contract_versions(params \\ [], opts \\ []) do
all("/contracts/contract_versions", params, "contract_versions", opts)
end
@doc """
Lists Factorial contract compensations.
"""
@spec list_compensations(params(), client_opts()) :: {:ok, list(map())} | {:error, Error.t()}
def list_compensations(params \\ [], opts \\ []) do
all("/contracts/compensations", params, "compensations", opts)
end
@doc """
Creates one shift in Factorial shift management.
`company_id` can be passed per shift or configured once in the client opts.
FactorialHR.create_shift(
%{
employee_id: 42,
start_at: "2026-06-01T08:00:00Z",
end_at: "2026-06-01T16:00:00Z",
location_id: 7,
work_area_id: 8
},
Keyword.put(opts, :company_id, 123)
)
"""
@spec create_shift(map(), client_opts()) :: {:ok, map()} | {:error, Error.t()}
def create_shift(params, opts \\ []) when is_map(params) do
with {:ok, config} <- Config.new(opts),
{:ok, body} <- build_shift_body(params, config) do
case request(:post, "/shift_management/shifts", body, [], config) do
{:ok, %{status: status, body: body}} when status in [200, 201] ->
{:ok, body}
{:ok, %{status: status, body: body}} ->
http_error(status, body, :post, "/shift_management/shifts")
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Creates several shifts in Factorial shift management.
"""
@spec bulk_create_shifts([map()], client_opts()) :: {:ok, list(map())} | {:error, Error.t()}
def bulk_create_shifts(shifts, opts \\ []) when is_list(shifts) do
with {:ok, config} <- Config.new(opts),
{:ok, bodies} <- build_shift_bodies(shifts, config) do
body = %{"shifts" => bodies}
case request(:post, "/shift_management/shifts/bulk_create", body, [], config) do
{:ok, %{status: status, body: body}} when status in [200, 201] ->
created_shifts(body)
{:ok, %{status: status, body: body}} ->
http_error(status, body, :post, "/shift_management/shifts/bulk_create")
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Deletes one shift by Factorial shift-management ID.
A `404` is treated as success to make delete operations idempotent.
"""
@spec delete_shift(integer() | String.t(), client_opts()) :: :ok | {:error, Error.t()}
def delete_shift(shift_id, opts \\ []) do
path = "/shift_management/shifts/#{shift_id}"
case request(:delete, path, nil, [], opts) do
{:ok, %{status: status}} when status in [200, 204, 404] -> :ok
{:ok, %{status: status, body: body}} -> http_error(status, body, :delete, path)
{:error, reason} -> {:error, reason}
end
end
@doc """
Bulk-deletes shifts.
Pass a list of IDs or a map/keyword list matching Factorial's bulk delete
filters. `author_id` is read from params first, then from client config.
Empty ID lists are rejected before making an API request.
"""
@spec bulk_delete_shifts([integer()] | params(), client_opts()) :: :ok | {:error, Error.t()}
def bulk_delete_shifts(ids_or_params, opts \\ []) do
with {:ok, config} <- Config.new(opts),
{:ok, body} <- build_bulk_delete_body(ids_or_params, config) do
path = "/shift_management/shifts/bulk_delete"
case request(:post, path, body, [], config) do
{:ok, %{status: status}} when status in [200, 204] -> :ok
{:ok, %{status: status, body: body}} -> http_error(status, body, :post, path)
{:error, reason} -> {:error, reason}
end
end
end
defp request(method, path, body, params, opts) do
with {:ok, config} <- Config.new(opts) do
url = resource_url(config, path)
req_opts = build_req_options(method, url, body, params, config)
meta = %{method: method, path: path, url: url}
start_time = System.monotonic_time()
telemetry([:factorial_hr, :request, :start], %{system_time: System.system_time()}, meta)
case Req.request(req_opts) do
{:ok, %Req.Response{} = response} ->
telemetry(
[:factorial_hr, :request, :stop],
%{duration: System.monotonic_time() - start_time},
Map.put(meta, :status, response.status)
)
{:ok, response}
{:error, %Req.TransportError{reason: reason}} ->
error = Error.new(:transport_error, reason: reason, request: meta)
telemetry(
[:factorial_hr, :request, :exception],
%{duration: System.monotonic_time() - start_time},
Map.merge(meta, %{kind: :transport_error, reason: reason})
)
{:error, error}
{:error, reason} ->
error = Error.new(:request_error, reason: reason, request: meta)
telemetry(
[:factorial_hr, :request, :exception],
%{duration: System.monotonic_time() - start_time},
Map.merge(meta, %{kind: :request_error, reason: reason})
)
{:error, error}
end
end
end
defp normalize_public_response({:ok, %{status: status} = response}, _method, _path)
when status >= 200 and status < 300 do
{:ok, response}
end
defp normalize_public_response({:ok, %{status: status, body: body}}, method, path) do
http_error(status, body, method, path)
end
defp normalize_public_response({:error, reason}, _method, _path), do: {:error, reason}
defp build_req_options(method, url, body, params, config) do
req_options = Keyword.delete(config.req_options, :headers)
[
method: method,
url: url,
headers: request_headers(config),
receive_timeout: config.receive_timeout,
retry: false
]
|> maybe_put_params(params)
|> maybe_put_json(body)
|> Keyword.merge(req_options)
end
defp request_headers(config) do
required_headers = [
auth_header(config),
{"accept", "application/json"},
{"content-type", "application/json"},
{"user-agent", @user_agent}
]
config.req_options
|> Keyword.get(:headers, [])
|> normalize_headers()
|> merge_headers(required_headers)
end
defp normalize_headers(nil), do: []
defp normalize_headers(headers) when is_map(headers), do: Map.to_list(headers)
defp normalize_headers(headers) when is_list(headers), do: headers
defp normalize_headers(_headers), do: []
defp merge_headers(custom_headers, required_headers) do
required_names =
required_headers
|> Enum.map(fn {name, _value} -> header_name(name) end)
|> MapSet.new()
custom_headers
|> Enum.reject(fn {name, _value} -> MapSet.member?(required_names, header_name(name)) end)
|> Kernel.++(required_headers)
end
defp header_name(name), do: name |> to_string() |> String.downcase()
defp maybe_put_params(opts, params) do
params = normalize_params(params)
if params == [] do
opts
else
Keyword.put(opts, :params, params)
end
end
defp maybe_put_json(opts, nil), do: opts
defp maybe_put_json(opts, body), do: Keyword.put(opts, :json, body)
defp auth_header(%Config{auth_mode: :bearer, token: token}),
do: {"authorization", "Bearer #{token}"}
defp auth_header(%Config{auth_mode: :api_key, token: token}), do: {"x-api-key", token}
defp resource_url(%Config{} = config, path) when is_binary(path) do
cond do
String.starts_with?(path, "http://") or String.starts_with?(path, "https://") ->
path
String.starts_with?(path, "/api/") ->
config.base_url <> path
true ->
config.base_url <> "/api/#{config.api_version}/resources" <> normalize_resource_path(path)
end
end
defp normalize_resource_path(path) when is_binary(path) do
if String.starts_with?(path, "/"), do: path, else: "/" <> path
end
defp fetch_batched_collection(path, params, collection_name, opts) do
employee_ids = repeated_values(params, "employee_ids[]")
if length(employee_ids) > @employee_filter_batch_size do
employee_ids
|> Enum.chunk_every(@employee_filter_batch_size)
|> Enum.reduce_while({:ok, []}, fn batch, {:ok, acc} ->
batch_params =
params
|> reject_param("employee_ids[]")
|> put_repeated_param("employee_ids[]", batch)
case all(path, batch_params, collection_name, opts) do
{:ok, records} -> {:cont, {:ok, [records | acc]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> flatten_accumulated_pages()
|> dedupe_collection()
else
all(path, params, collection_name, opts)
end
end
defp dedupe_collection({:ok, records}) do
{:ok,
Enum.uniq_by(records, fn
%{"id" => id} when not is_nil(id) -> {:id, id}
record -> {:record, record}
end)}
end
defp dedupe_collection(result), do: result
defp fetch_all_pages(path, params, collection_name, config, accumulated, cursor) do
params_with_cursor = put_param(params, "after_id", cursor)
case request(:get, path, nil, params_with_cursor, config) do
{:ok, %{status: 200, body: body}} ->
with {:ok, records, meta} <- parse_collection_response(body, collection_name) do
pages = [records | accumulated]
end_cursor = Map.get(meta, "end_cursor")
if Map.get(meta, "has_next_page", false) and valid_next_cursor?(cursor, end_cursor) do
fetch_all_pages(path, params, collection_name, config, pages, end_cursor)
else
flatten_accumulated_pages({:ok, pages})
end
end
{:ok, %{status: status, body: body}} ->
http_error(status, body, :get, path)
{:error, reason} ->
{:error, reason}
end
end
defp parse_collection_response(body, _collection_name) when is_list(body), do: {:ok, body, %{}}
defp parse_collection_response(%{"data" => records} = body, _collection_name)
when is_list(records) do
{:ok, records, Map.get(body, "meta", %{})}
end
defp parse_collection_response(body, collection_name)
when is_binary(collection_name) and is_map(body) do
case Map.get(body, collection_name) do
records when is_list(records) -> {:ok, records, Map.get(body, "meta", %{})}
_other -> unexpected_response(body)
end
end
defp parse_collection_response(body, _collection_name), do: unexpected_response(body)
defp unexpected_response(body) do
{:error, Error.new(:unexpected_response, body: body)}
end
defp http_error(status, body, method, path) do
{:error,
Error.new(:http_error, status: status, body: body, request: %{method: method, path: path})}
end
defp normalize_shift_management_params(params) do
params
|> normalize_params()
|> rename_repeated_param(:employee_ids, "employee_ids[]")
|> rename_repeated_param("employee_ids", "employee_ids[]")
|> rename_repeated_param(:location_ids, "location_ids[]")
|> rename_repeated_param("location_ids", "location_ids[]")
|> rename_repeated_param(:only_states, "only_states[]")
|> rename_repeated_param("only_states", "only_states[]")
|> rename_param(:start_at, "start_at")
|> rename_param(:end_at, "end_at")
|> rename_param(:only_published, "only_published")
|> rename_param(:split_overnight_shifts, "split_overnight_shifts")
end
defp normalize_params(nil), do: []
defp normalize_params(params) when is_list(params), do: params
defp normalize_params(params) when is_map(params), do: Map.to_list(params)
defp put_param(params, _key, nil), do: params
defp put_param(params, key, value), do: [{key, value} | reject_param(params, key)]
defp put_repeated_param(params, _key, nil), do: params
defp put_repeated_param(params, key, values) when is_list(values) do
Enum.reduce(values, params, fn value, acc -> [{key, value} | acc] end)
end
defp put_repeated_param(params, key, value), do: [{key, value} | params]
defp reject_param(params, key) do
Enum.reject(params, fn {param_key, _value} -> param_key == key end)
end
defp repeated_values(params, key) do
params
|> Enum.filter(fn {param_key, _value} -> param_key == key end)
|> Enum.map(fn {_key, value} -> value end)
end
defp rename_repeated_param(params, source, target) do
values =
params
|> Enum.filter(fn {key, _value} -> key == source end)
|> Enum.flat_map(fn {_key, value} -> List.wrap(value) end)
params
|> reject_param(source)
|> put_repeated_param(target, values)
end
defp rename_param(params, source, target) do
case Enum.find(params, fn {key, _value} -> key == source end) do
nil ->
params
{_key, value} ->
params
|> reject_param(source)
|> put_param(target, value)
end
end
defp build_shift_bodies(shifts, config) do
shifts
|> Enum.reduce_while({:ok, []}, fn shift, {:ok, acc} ->
case build_shift_body(shift, config) do
{:ok, body} -> {:cont, {:ok, [body | acc]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, bodies} -> {:ok, Enum.reverse(bodies)}
error -> error
end
end
defp build_shift_body(params, config) do
employee_id = params |> param_value(:employee_id) |> Config.parse_int()
start_at = param_value(params, :start_at)
end_at = param_value(params, :end_at)
company_id =
params |> param_value(:company_id) |> value_or(config.company_id) |> Config.parse_int()
location_id = params |> param_value(:location_id) |> Config.parse_int()
work_area_id = params |> param_value(:work_area_id) |> Config.parse_int()
with :ok <- validate_shift_field(:employee_id, employee_id),
:ok <- validate_shift_field(:start_at, start_at),
:ok <- validate_shift_field(:end_at, end_at),
:ok <- validate_shift_field(:company_id, company_id) do
{:ok,
%{
"employee_id" => employee_id,
"start_at" => start_at,
"end_at" => end_at,
"company_id" => company_id
}
|> maybe_put("name", param_value(params, :name))
|> maybe_put("notes", param_value(params, :notes))
|> maybe_put("location_id", location_id)
|> maybe_put("work_area_id", work_area_id)}
end
end
defp created_shifts(body) when is_list(body), do: {:ok, body}
defp created_shifts(%{"shifts" => shifts}) when is_list(shifts), do: {:ok, shifts}
defp created_shifts(%{"data" => shifts}) when is_list(shifts), do: {:ok, shifts}
defp created_shifts(body), do: unexpected_response(body)
defp build_bulk_delete_body([], _config) do
{:error, Error.new(:invalid_request, reason: :ids_missing)}
end
defp build_bulk_delete_body(ids_or_params, config) when is_list(ids_or_params) do
if Keyword.keyword?(ids_or_params) do
ids_or_params
|> Map.new()
|> build_bulk_delete_body(config)
else
build_ids_bulk_delete_body(ids_or_params, config)
end
end
defp build_bulk_delete_body(params, config) when is_map(params) do
body = params |> normalize_params() |> Map.new() |> stringify_keys()
author_id = body |> Map.get("author_id") |> value_or(config.author_id) |> Config.parse_int()
with :ok <- validate_bulk_delete_ids(body),
:ok <- validate_author_id(author_id),
:ok <- validate_bulk_delete_selector(body) do
{:ok, Map.put(body, "author_id", author_id)}
end
end
defp build_ids_bulk_delete_body(ids, config) do
if Enum.all?(ids, &is_integer/1) do
build_bulk_delete_body(%{"ids" => ids}, config)
else
{:error, Error.new(:invalid_request, reason: :invalid_ids)}
end
end
defp validate_bulk_delete_ids(body) do
ids = Map.get(body, "ids")
cond do
is_nil(ids) ->
:ok
ids == [] ->
{:error, Error.new(:invalid_request, reason: :ids_missing)}
is_list(ids) and Enum.all?(ids, &is_integer/1) ->
:ok
true ->
{:error, Error.new(:invalid_request, reason: :invalid_ids)}
end
end
defp validate_bulk_delete_selector(body) do
if Enum.any?(@bulk_delete_selector_keys, &present_selector?(Map.get(body, &1))) do
:ok
else
{:error, Error.new(:invalid_request, reason: :bulk_delete_selector_missing)}
end
end
defp present_selector?(value) when is_list(value), do: value != []
defp present_selector?(value), do: not blank?(value)
defp validate_author_id(author_id) do
if blank?(author_id) do
{:error, Error.new(:invalid_request, reason: :author_id_missing)}
else
:ok
end
end
defp validate_shift_field(field, value) do
if blank?(value) do
{:error, Error.new(:invalid_request, reason: Map.fetch!(@shift_missing_reasons, field))}
else
:ok
end
end
defp param_value(params, key) when is_map(params) do
Map.get(params, key) || Map.get(params, Atom.to_string(key))
end
defp param_value(_params, _key), do: nil
defp maybe_put(body, _key, nil), do: body
defp maybe_put(body, _key, ""), do: body
defp maybe_put(body, key, value), do: Map.put(body, key, value)
defp value_or(value, default) do
if blank?(value), do: default, else: value
end
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp blank?(nil), do: true
defp blank?(_value), do: false
defp flatten_accumulated_pages({:ok, pages}) do
{:ok, pages |> Enum.reverse() |> List.flatten()}
end
defp flatten_accumulated_pages(result), do: result
defp stringify_keys(map) do
Map.new(map, fn
{key, value} when is_atom(key) -> {Atom.to_string(key), value}
{key, value} -> {key, value}
end)
end
defp team_employee_ids(%{"employee_ids" => ids}) when is_list(ids), do: ids
defp team_employee_ids(%{"employees" => employees}) when is_list(employees) do
Enum.flat_map(employees, fn
%{"id" => id} -> [id]
id when is_integer(id) -> [id]
_other -> []
end)
end
defp team_employee_ids(_team), do: []
defp valid_next_cursor?(current_cursor, next_cursor) do
is_binary(next_cursor) and next_cursor != "" and next_cursor != current_cursor
end
defp format_date(%Date{} = date), do: Date.to_iso8601(date)
defp format_date(date) when is_binary(date), do: date
defp telemetry(event, measurements, metadata) do
if Code.ensure_loaded?(:telemetry) do
:telemetry.execute(event, measurements, metadata)
end
end
end