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.
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.
rate limiting — no quotas, no throttling, no surprise invoice
typical response time on standard calls
rows per export, streamed as Apache Arrow dataframes
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.
from OAuth client to your first successful call
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.
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 API
Authentication, companies, users, contracts, roles & permissions, notes, custom fields, imports & exports, billing, actions, configurations.
Training API
Training catalog, providers, plans, requests, orders, sessions, attendees, certifications, medic exams, clearances.
Interview & Career API
Campaigns, interviews, interview types & templates, goals, jobs, skill frameworks, people reviews, succession plans.
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.
-
Ask an administrator to create an OAuth client from the Empowill console (Settings >
API). You get a
client_idand aclient_secretbound to a role that defines its permissions. - Exchange those credentials for an access token (see Authentication).
-
Call any endpoint with the token in the
Authorizationheader 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.
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.
-
Send the access token on every request:
Authorization: Bearer <access_token>. -
Available scopes:
offline(allow refresh token generation) andopenid(token hints and additional openid capacities). -
A client can access one or several customer tenants depending on its given accesses; the active tenant is
selected with the
Company-Idheader. -
The permissions of an API client come from the role attached to it. Calls outside the role's permissions
return
permission_deniederrors.
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
-
All time values are exchanged as
RFC 3339
timestamps (e.g.
2026-06-11T09:30:00Z) and always stored in UTC on the server. -
Unsetting a time value: send the sentinel value of January 1st of year one,
"0001-01-01T00:00:00.000Z". This clears the field instead of setting it. -
Rendering in a local timezone (PDFs, exports, notifications) is controlled by the
X-Timezoneheader, never by the stored value.
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": []
}
{"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.
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
pageSize— max elements per page. Defaults to20, capped at10000;-1disables pagination.pageNumber— 1-based page index, defaults to1.-
Responses include a
pageobject withtotal(matching rows in database),totalPagesandpaginationParameters(the values actually applied, useful to discover server defaults).
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:
string— match / contain / begin-with comparisons, with case, accent and word-order insensitive options.stringIn— value is one of a list (optionally including null).number,time,boolean— typed comparisons (equal, greater than, lesser than...).null— field has no value.-
Advanced searches:
resource(filter on a referenced resource's own fields),users/people/population(people-scoped helpers),graphSelf(hierarchies like the management chain),existsIn,ltreeSearch.
Search, sorts and selects
search— free-text search split on spaces, applied over the fields declared searchable on the resource.sorts— ordered list of{ref, desc}; first entry has priority.selects— return only the listed field refs instead of full objects.
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:
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.
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.
-
Toggle with one header. Send
Accept: application/jsonon any fileGETand you receive{"url": "<signed URL>"}(valid 15 minutes) instead of the bytes. -
Resumable downloads. The signed URL is served by GCS: it needs no
Authorizationheader and supports HTTPRange, so interrupted transfers of large files resume where they stopped — with GCS-grade bandwidth, free of API timeouts. -
Direct uploads. The same header on a file
POSTwith a JSON body ({"contentType": "...", "name": "..."}) returns a signedPUTURL: push heavy files straight to GCS, the declared content type is bound into the signature.
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.
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.
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.
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.
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.
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.
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.
Security at every level
Empowill is ISO 27001 certified: information security is audited, managed and continuously improved — from infrastructure to processes and people.