# Doc O' Will

Empowill API

The Empowill API is the very same API that powers the Empowill application — every screen, every workflow. If you can do it in the app, you can do it through the API, at no extra cost: automate your HR processes, plug in your own tools, and pull analysis-ready data on people, trainings, interviews and careers.

Parity

The app runs on this API

Every feature you see in Empowill is available to your integrations. No premium tier, no partner program — the API is included, for everyone.

0

rate limiting — no quotas, no throttling, no surprise invoice

<100ms

typical response time on standard calls

1M

rows per export, streamed as Apache Arrow dataframes

Data

One call, the whole dataset

Jump refs (->) join related resources server-side: employee, manager, order, provider and custom fields land flat in a single response.

5min

from OAuth client to your first successful call

Standards

OAuth2 & OpenID, out of the box

Client credentials flow, discovery endpoint, role-scoped permissions, JSON over HTTPS. Nothing exotic — your HTTP client already knows how to talk to it.

360+

documented endpoints across four focused references

API references

The reference documentation is split by product module, mirroring the way permissions are organized in Empowill. Every module shares the same conventions described below on this page.

Core & RH

Core & RH API

Authentication, companies, users, contracts, roles & permissions, notes, custom fields, imports & exports, billing, actions, configurations.

Training

Training API

Training catalog, providers, plans, requests, orders, sessions, attendees, certifications, medic exams, clearances.

Interview & Career

Interview & Career API

Campaigns, interviews, interview types & templates, goals, jobs, skill frameworks, people reviews, succession plans.

Files

Files API

Upload and download binary content: logos, profile pictures, training documents, PDFs, CSV imports and exports. Signed URLs for heavy attachments and resumable downloads.

Getting started

The Empowill API is a JSON-over-HTTPS API. Each environment exposes its own host; production is https://api.empowill.com. The OpenAPI specs published with this portal are pre-configured with the host of the environment they are deployed on.

  1. Ask an administrator to create an OAuth client from the Empowill console (Settings > API). You get a client_id and a client_secret bound to a role that defines its permissions.
  2. Exchange those credentials for an access token (see Authentication).
  3. Call any endpoint with the token in the Authorization header and the common request headers.

A complete first call:

# 1. Get a token (client credentials flow)
curl -s -X POST https://api.empowill.com/oauth2/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=client_credentials"

# 2. List the first page of users (list endpoints are POST so that
#    filters and sorts travel in the JSON body, under the "page" key)
curl -s -X POST https://api.empowill.com/v2/users/list \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Company-Id: my-company" \
  -H "Content-Type: application/json" \
  -d '{"page": {"pageSize": 20, "pageNumber": 1}}'

Nothing exotic to learn.

OAuth2, OpenID discovery, JSON over HTTPS — your stack already speaks Empowill.

See authentication

Authentication

The API uses the OAuth2 client credentials flow with openid-standard access tokens. The token endpoint is /oauth2/token on the API host, and automatic configuration is supported through the standard discovery document: https://api.empowill.com/.well-known/openid-configuration.

Common request headers

All Empowill APIs share three transport-level headers. Header names are case-insensitive and must be sent on every request — there is no cookie or session fallback.

Header Required Description
Company-Id
(alias X-Company-Id)
Defaulted Defaults to the company of the authenticated client. Can be overridden only if your client has access to multiple companies. The value is the company's human-memorable slug, chosen at creation and immutable afterwards (pattern ^[0-9a-z-_]+$, max 100 chars, e.g. my-company). The backend uses it to resolve permissions and scope queries; with a wrong value, company-scoped endpoints typically respond with permission_denied or not_found. The X-Company-Id alias is kept for legacy clients.
Accept-Language Optional Standard RFC 7231 value (e.g. en, fr-FR). Drives the language of translatable response fields (error messages stay in English). Falls back to the server default when missing.
X-Timezone Optional IANA timezone name (e.g. Europe/Paris, UTC). All timestamps are always stored in UTC on the server; this header is only used when the backend needs to render time in the caller's local zone — typically PDF generation, exports, scheduled notifications and any user-facing formatting. Falls back to UTC when missing.

Time handling

Errors

The API is generated from gRPC services exposed over HTTP. Errors follow the standard gRPC-gateway shape: a JSON body with a gRPC code, a human-readable message (always plain English, not localized) and optional details.

{
  "code": 7,
  "message": "you are not allowed to access this resource",
  "details": []
}
HTTP status gRPC code Meaning
400 3 invalid_argument The request payload failed validation; the message explains which field is invalid.
401 16 unauthenticated Missing, expired or invalid access token.
403 7 permission_denied The client's role does not grant the required permission, or the company is not accessible.
404 5 not_found The resource does not exist in the selected company.
409 6 already_exists Uniqueness conflict: the message names the violated unique index.
409 10 aborted Operation rejected by data integrity or business rules — typically deleting or referencing a record bound by a foreign key constraint.
500 13 internal Unexpected server error; retry later or contact support with the request details.

Real conflict payloads, as returned by the API:

// 409 already_exists — e.g. creating a user with an external id already taken
// (the violated unique index is named):
{
  "code": 6,
  "message": "duplicated value for unique index: \"idx_members_external_id\"",
  "details": []
}

// 409 aborted — deleting a record still referenced elsewhere (foreign key):
{
  "code": 10,
  "message": "foreign key constraint: update or delete on table \"roles\" violates foreign key constraint \"fk_members_role\" on table \"members\"",
  "details": []
}
The Files API returns plain HTTP errors with a simpler body ({"message": "...", "status": 400}) since it serves binary content rather than gRPC-mapped JSON.

One request. Your entire dataset, already joined.

The API does the joins server-side — stop stitching exports together by hand.

See the dataframe guide

Resources and list requests

Every business entity (users, trainings, interviews, ...) is exposed as a resource with a consistent set of endpoints: GET .../{id} to fetch one, POST/PATCH/ DELETE to mutate, and a POST .../list endpoint to query collections. Data collection through list endpoints is the most common integration use case, and they all share the same request shape.

Pagination

Field references

Filters, sorts and selects address fields through a fieldRef: the snake_case path of the field in the resource's data model (e.g. person.last_name, headers.created_at). Custom fields are addressed by their id under the custom fields message (e.g. member.custom_fields.my_custom). Fields referencing another resource can be traversed with -> — a "jump" ref like member.manager_id->person.last_name resolves the manager's name. Pass schemaOnly: true in a list request to get the resource schema — including custom and virtual fields — instead of data.

Filters

Filters are strictly typed comparisons. Multiple root filters always combine with AND; use a filterGroup with op FILTER_GROUP_OP_OR / FILTER_GROUP_OP_NOT for other combinations. The main filter kinds are:

Search, sorts and selects

Aggregations and dataframes

For reporting, list endpoints can also compute server-side aggregations (aggregation with count/sum/avg/min/max grouped by field refs), return distinct rows (distinctOn), or return the whole result set as an Apache Arrow dataframe (asDataframe: true) for efficient bulk extraction.

Example

POST /v2/users/list

{
  "page": {
    "pageSize": 50,
    "pageNumber": 1,
    "filters": [
      {
        "string": {
          "fieldRef": "person.last_name",
          "op": "STRING_OP_CONTAIN",
          "value": "dupont",
          "caseInsensitive": true
        }
      },
      {
        "time": {
          "fieldRef": "headers.created_at",
          "op": "TIME_OP_GREATER_THAN_EQUAL",
          "value": "2026-01-01T00:00:00Z"
        }
      }
    ],
    "sorts": [{ "ref": "headers.created_at", "desc": true }]
  }
}

The exact request shape of each list endpoint is documented in the module references, and the Schema endpoints describe the available field refs per resource.

Bulk extraction to dataframes

For bulk data extraction, the API returns Apache Arrow dataframes: either from any list endpoint with asDataframe: true, or from the synchronous export endpoint POST /v2/export/sync (Core & RH reference), which additionally lets you pick and rename columns through fieldsOptions.

The killer feature of fieldsOptions is the jump ref (->): the API joins related resources server-side, so a single call returns a flat, analysis-ready dataset that would otherwise require one request per related resource plus manual joins on your side. From a training attendee you can pull the employee's name, their manager's name (two jumps: attendee.user_id->member.manager_id->person.last_name), the training order, its provider, its costs and even custom fields — in one request. Joins are resolved with your permissions applied at every hop, columns come back renamed the way you asked, and there is no N+1 traffic between you and the API.

The response contains a single data field: a base64-encoded Arrow IPC stream that loads with the official open source Apache Arrow libraries — pyarrow (pandas), apache-arrow (npm), arrow-go or Apache.Arrow (NuGet). No Arrow implementation in your language (e.g. PHP)? The same data, filters and pagination are available as plain JSON from the list endpoints:

Paginate your extractions. Set pageSize in the listRequest (200 rows per page is an efficient first choice) and increment pageNumber until a page comes back with fewer rows than pageSize — the sync export response does not include a total. A pageSize of -1 asks the server to stream everything in one response: convenient on small datasets, but slower, heavier and more likely to time out on large ones.
import base64

import pandas as pd
import pyarrow as pa
import requests

API = "https://api.empowill.com"
TOKEN = "<access_token>"  # see Authentication
PAGE_SIZE = 200

headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Company-Id": "my-company",
    "Content-Type": "application/json",
}

# Export training attendees of a given training plan, with renamed columns.
# One call returns a flat dataset: the API joins the employee, their manager,
# the training order and its provider server-side through the -> jump refs.
body = {
    "resourceType": "RESOURCE_TYPE_TRAINING_ORDER_REAL_ATTENDEE",
    "listRequest": {
        "pageSize": PAGE_SIZE,
        "pageNumber": 1,
        "filters": [
            {
                # Resource filter: keep attendees whose order belongs to the plan.
                "resource": {
                    "type": "RESOURCE_TYPE_TRAINING_ORDER_REAL",
                    "fieldRef": "attendee.order_id",
                    "filters": [
                        {
                            "string": {
                                "fieldRef": "training_order.training_plan_id",
                                "op": "STRING_OP_MATCH",
                                "value": "<training-plan-id>",
                            }
                        }
                    ],
                }
            }
        ],
    },
    "fieldsOptions": [
        {"fieldRef": "attendee.user_id"},
        # Jump refs (->) resolve fields on referenced resources.
        {"fieldRef": "attendee.user_id->person.last_name", "fieldName": "Last name"},
        {"fieldRef": "attendee.user_id->person.first_name", "fieldName": "First name"},
        # Two jumps: attendee -> employee -> manager.
        {"fieldRef": "attendee.user_id->member.manager_id->person.last_name", "fieldName": "Manager"},
        # Custom fields are addressed by their id.
        {"fieldRef": "attendee.user_id->member.custom_fields.team", "fieldName": "Team"},
        {"fieldRef": "attendee.order_id->training_order.name", "fieldName": "Training order"},
        {"fieldRef": "attendee.order_id->order_real.training_provider_id->name", "fieldName": "Provider"},
        {"fieldRef": "attendee.order_id->order_real.start", "fieldName": "Start"},
        {"fieldRef": "presence_rate", "fieldName": "Presence"},
    ],
}


def fetch_page(page_number: int) -> pd.DataFrame:
    body["listRequest"]["pageNumber"] = page_number
    response = requests.post(f"{API}/v2/export/sync", headers=headers, json=body)
    response.raise_for_status()
    # The payload is a base64-encoded Apache Arrow IPC stream.
    arrow_bytes = base64.b64decode(response.json()["data"])
    with pa.ipc.open_stream(arrow_bytes) as reader:
        return reader.read_pandas()


# Paginate until a page comes back smaller than PAGE_SIZE.
pages, page_number = [], 1
while True:
    page = fetch_page(page_number)
    pages.append(page)
    if len(page) < PAGE_SIZE:
        break
    page_number += 1

df = pd.concat(pages, ignore_index=True)
print(f"Loaded DataFrame with {len(df)} rows")
print(df.head())
// npm install apache-arrow
import { tableFromIPC } from "apache-arrow";

const API = "https://api.empowill.com";
const TOKEN = "<access_token>"; // see Authentication
const PAGE_SIZE = 200;

// Same body as the Python example: the -> jump refs in fieldsOptions make the
// API join employees, managers, orders and providers server-side, so a single
// request returns a flat, analysis-ready dataset.
const body = {
  resourceType: "RESOURCE_TYPE_TRAINING_ORDER_REAL_ATTENDEE",
  listRequest: { pageSize: PAGE_SIZE, pageNumber: 1, filters: [/* ... */] },
  fieldsOptions: [/* ... */],
};

async function fetchPage(pageNumber: number) {
  body.listRequest.pageNumber = pageNumber;
  const response = await fetch(`${API}/v2/export/sync`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      "Company-Id": "my-company",
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error(`export failed: ${response.status}`);

  // The payload is a base64-encoded Apache Arrow IPC stream.
  const { data } = (await response.json()) as { data: string };
  const arrowBytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
  return tableFromIPC(arrowBytes);
}

// Paginate until a page comes back smaller than PAGE_SIZE.
const rows: Record<string, unknown>[] = [];
for (let pageNumber = 1; ; pageNumber++) {
  const table = await fetchPage(pageNumber);
  rows.push(...table.toArray().map((row) => row.toJSON()));
  if (table.numRows < PAGE_SIZE) break;
}

console.log(`Loaded ${rows.length} rows`);
console.log(rows[0]);
// go get github.com/apache/arrow-go/v18
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"

	"github.com/apache/arrow-go/v18/arrow"
	"github.com/apache/arrow-go/v18/arrow/ipc"
)

const (
	api      = "https://api.empowill.com"
	token    = "<access_token>" // see Authentication
	pageSize = 200
)

// Same body as the Python example: the -> jump refs in fieldsOptions make the
// API join employees, managers, orders and providers server-side, so a single
// request returns a flat, analysis-ready dataset.
var body = map[string]any{
	"resourceType": "RESOURCE_TYPE_TRAINING_ORDER_REAL_ATTENDEE",
	"listRequest":  map[string]any{"pageSize": pageSize, "pageNumber": 1 /* , "filters": ... */},
	"fieldsOptions": []map[string]any{ /* ... */ },
}

// fetchPage returns one page of the export as Arrow records.
func fetchPage(pageNumber int) ([]arrow.Record, error) {
	body["listRequest"].(map[string]any)["pageNumber"] = pageNumber
	raw, _ := json.Marshal(body)

	req, _ := http.NewRequest(http.MethodPost, api+"/v2/export/sync", bytes.NewReader(raw))
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Company-Id", "my-company")
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	// The payload is a base64-encoded Apache Arrow IPC stream;
	// encoding/json transparently base64-decodes []byte fields.
	var payload struct {
		Data []byte `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return nil, err
	}

	reader, err := ipc.NewReader(bytes.NewReader(payload.Data))
	if err != nil {
		return nil, err
	}
	defer reader.Release()

	var records []arrow.Record
	for reader.Next() {
		record := reader.Record()
		record.Retain()
		records = append(records, record)
	}
	return records, reader.Err()
}

func main() {
	// Paginate until a page comes back smaller than pageSize.
	var rows int64
	for pageNumber := 1; ; pageNumber++ {
		records, err := fetchPage(pageNumber)
		if err != nil {
			panic(err)
		}

		var pageRows int64
		for _, record := range records {
			pageRows += record.NumRows()
			// ... read the record columns here ...
			record.Release()
		}
		rows += pageRows

		if pageRows < pageSize {
			break
		}
	}
	fmt.Printf("loaded %d rows\n", rows)
}
// dotnet add package Apache.Arrow
using System.Net.Http.Json;
using Apache.Arrow.Ipc;

const string Api = "https://api.empowill.com";
const string Token = "<access_token>"; // see Authentication
const int PageSize = 200;

var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}");
http.DefaultRequestHeaders.Add("Company-Id", "my-company");

// Same body as the Python example: the -> jump refs in fieldsOptions make the
// API join related resources server-side.
var listRequest = new Dictionary<string, object> { ["pageSize"] = PageSize, ["pageNumber"] = 1 /* , filters... */ };
var body = new Dictionary<string, object>
{
    ["resourceType"] = "RESOURCE_TYPE_TRAINING_ORDER_REAL_ATTENDEE",
    ["listRequest"] = listRequest,
    ["fieldsOptions"] = new object[] { /* ... */ },
};

// Paginate until a page comes back smaller than PageSize.
var rows = 0L;
for (var pageNumber = 1; ; pageNumber++)
{
    listRequest["pageNumber"] = pageNumber;
    var response = await http.PostAsJsonAsync($"{Api}/v2/export/sync", body);
    response.EnsureSuccessStatusCode();

    // The payload is a base64-encoded Apache Arrow IPC stream.
    var payload = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
    var arrowBytes = Convert.FromBase64String(payload!["data"]);

    var pageRows = 0L;
    using var reader = new ArrowStreamReader(new MemoryStream(arrowBytes));
    while (await reader.ReadNextRecordBatchAsync() is { } batch)
    {
        using (batch)
        {
            pageRows += batch.Length;
            // ... read batch.Column(i) here ...
        }
    }

    rows += pageRows;
    if (pageRows < PageSize) break;
}

Console.WriteLine($"loaded {rows} rows");
<?php
// There is no official Apache Arrow implementation for PHP: use the plain
// JSON list endpoints instead — same data, same filters, same pagination.
$api = "https://api.empowill.com";
$token = "<access_token>"; // see Authentication
$pageSize = 200;

// Paginate until a page comes back smaller than $pageSize.
$rows = [];
for ($pageNumber = 1; ; $pageNumber++) {
    $body = [
        "page" => [
            "pageSize" => $pageSize,
            "pageNumber" => $pageNumber,
            // "filters" => [...], "sorts" => [...], "selects" => [...]
        ],
    ];

    $ch = curl_init("$api/v2/users/list");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer $token",
            "Company-Id: my-company",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS => json_encode($body),
    ]);
    $response = json_decode(curl_exec($ch), true);
    curl_close($ch);

    $users = $response["users"] ?? [];
    $rows = array_merge($rows, $users);

    if (count($users) < $pageSize) {
        break;
    }
}

printf("loaded %d rows\n", count($rows));

The same decoding applies to list endpoints called with asDataframe: true: the Arrow stream is then returned in the dataframe.data field of the page response. Use the resource's Schema endpoint (or schemaOnly: true) to discover the field refs available for fieldsOptions.

Gigabyte attachments? No problem.

Signed URLs stream files straight from Google Cloud Storage — resumable, no timeouts.

Browse the Files reference

Files: signed URLs & heavy attachments

API items carry binary attachments — training documents, certificates, medic exams, interview PDFs. They can be very heavy, because the bytes never have to transit through the API: every file endpoint can hand you a short-lived signed Google Cloud Storage URL instead of the content.

import requests

API = "https://api.empowill.com"
headers = {"Authorization": f"Bearer {TOKEN}", "Company-Id": "my-company"}

# 1. Ask for a signed URL instead of the bytes (valid 15 minutes).
doc = f"{API}/v2/files/company/my-company/training/<training-id>/<file-name>"
signed = requests.get(doc, headers={**headers, "Accept": "application/json"}).json()["url"]

# 2. Download straight from Google Cloud Storage: no auth header needed.
downloaded = 0
with requests.get(signed, stream=True) as r, open("document.pdf", "wb") as out:
    r.raise_for_status()
    for chunk in r.iter_content(1 << 20):
        out.write(chunk)
        downloaded += len(chunk)

# Interrupted? The signed URL supports HTTP Range: resume where you stopped.
resume = requests.get(signed, headers={"Range": f"bytes={downloaded}-"}, stream=True)
const API = "https://api.empowill.com";
const headers = { Authorization: `Bearer ${TOKEN}`, "Company-Id": "my-company" };

// 1. Ask for a signed URL instead of the bytes (valid 15 minutes).
const doc = `${API}/v2/files/company/my-company/training/<training-id>/<file-name>`;
const res = await fetch(doc, { headers: { ...headers, Accept: "application/json" } });
const { url: signed } = (await res.json()) as { url: string };

// 2. Download straight from Google Cloud Storage: no auth header needed.
const blob = await (await fetch(signed)).blob();

// Interrupted? The signed URL supports HTTP Range: resume where you stopped.
const rest = await fetch(signed, { headers: { Range: `bytes=${blob.size}-` } });
// 1. Ask for a signed URL instead of the bytes (valid 15 minutes).
doc := api + "/v2/files/company/my-company/training/<training-id>/<file-name>"
req, _ := http.NewRequest(http.MethodGet, doc, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Company-Id", "my-company")
req.Header.Set("Accept", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}
defer resp.Body.Close()

var signed struct {
	URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&signed); err != nil {
	panic(err)
}

// 2. Download straight from Google Cloud Storage: no auth header needed.
out, _ := os.Create("document.pdf")
defer out.Close()

dl, err := http.Get(signed.URL)
if err != nil {
	panic(err)
}
defer dl.Body.Close()
written, _ := io.Copy(out, dl.Body)

// Interrupted? The signed URL supports HTTP Range: resume where you stopped.
retry, _ := http.NewRequest(http.MethodGet, signed.URL, nil)
retry.Header.Set("Range", fmt.Sprintf("bytes=%d-", written))
using System.Net.Http.Json;

const string Api = "https://api.empowill.com";

var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}");
http.DefaultRequestHeaders.Add("Company-Id", "my-company");

// 1. Ask for a signed URL instead of the bytes (valid 15 minutes).
var doc = $"{Api}/v2/files/company/my-company/training/<training-id>/<file-name>";
var request = new HttpRequestMessage(HttpMethod.Get, doc);
request.Headers.Add("Accept", "application/json");
var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var signed = (await response.Content.ReadFromJsonAsync<Dictionary<string, string>>())!["url"];

// 2. Download straight from Google Cloud Storage: no auth header needed.
var gcs = new HttpClient();
await using (var output = File.Create("document.pdf"))
await using (var stream = await gcs.GetStreamAsync(signed))
{
    await stream.CopyToAsync(output);
}

// Interrupted? The signed URL supports HTTP Range: resume where you stopped.
var resume = new HttpRequestMessage(HttpMethod.Get, signed);
resume.Headers.Add("Range", $"bytes={new FileInfo("document.pdf").Length}-");
<?php
$api = "https://api.empowill.com";
$headers = ["Authorization: Bearer $token", "Company-Id: my-company"];

// 1. Ask for a signed URL instead of the bytes (valid 15 minutes).
$doc = "$api/v2/files/company/my-company/training/<training-id>/<file-name>";
$ch = curl_init($doc);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => array_merge($headers, ["Accept: application/json"]),
]);
$signed = json_decode(curl_exec($ch), true)["url"];
curl_close($ch);

// 2. Download straight from Google Cloud Storage: no auth header needed.
$out = fopen("document.pdf", "wb");
$ch = curl_init($signed);
curl_setopt_array($ch, [CURLOPT_FILE => $out]);
curl_exec($ch);
curl_close($ch);
fclose($out);

// Interrupted? The signed URL supports HTTP Range: resume where you stopped.
$out = fopen("document.pdf", "ab");
$ch = curl_init($signed);
curl_setopt_array($ch, [
    CURLOPT_FILE => $out,
    CURLOPT_RESUME_FROM => filesize("document.pdf"), // sends Range: bytes=N-
]);
curl_exec($ch);
curl_close($ch);
fclose($out);

Every file route and its accepted content types are documented in the Files reference.

Our commitments

Empowill builds for the long run. The same care we put into supporting field teams goes into how we run our technology: sober, secure and accountable.

Green tech

Slow tech, on purpose

We choose boring, battle-tested technology — PostgreSQL first — and push it to its best rather than chasing the framework of the week. Fewer moving parts, fewer surprises, software that lasts.

Performance

Lean by design

gRPC on the wire and Apache Arrow for data keep payloads compact and latency at its shortest. Less bytes moved is less energy burned — efficiency is a feature, not an afterthought.

Green tech

Scale to zero

HR moves at a human pace, and our infrastructure respects that: when nobody calls the API, services are fully deprovisioned and consume no server resources. Cloud impact is a real problem; idle compute is the easiest one to fix.

Traceability

Accountable by default

Not a table without audit headers: who created each resource, who last modified it, and when (headers.created_by, headers.updated_at...). Built into the data model, readable from the API references.

Stability

We don't break your integrations

The API follows semantic versioning: v2 contracts don't break. And when a breaking change is truly unavoidable, we inspect real usage first and warn exactly the teams consuming the affected endpoints — never a blanket surprise.

Support

Humans on the line

We stand by the developers and clients who build on Empowill: our engineers take the time to help you debug an integration, shape the right query and serve your use case — helping you ship is part of the product.

ISO 27001 certified
Certification

Security at every level

Empowill is ISO 27001 certified: information security is audited, managed and continuously improved — from infrastructure to processes and people.

ISO 27001 certified GDPR compliant Made in France Referenced by UGAP