sayhii Organization API
The sayhii Organization API lets your own systems push employee roster and department-hierarchy updates into sayhii programmatically—so your people and org structure stay in sync without manual uploads or spreadsheet wrangling.
Base URL:
https://api.sayhii.ioAuthorization Header:Authorization: Bearer <keyId>.<secret>Roster Source Header:X-Sayhii-Roster-Source: <source-key>(required on every call) Org Context: The API key determines your organization; the roster-source header determines which of your organization's declared push sources the call acts as.
This API isn't self-serve yet. Contact your sayhii account team (support@sayhii.io) to have it enabled for your organization, then generate a key from API Keys.
Only org admins with the API-key permission can create keys. Share keys only with trusted developers or services, and scope them appropriately. Keys are managed in the sayhii Portal—for more information see API Keys.
Push Sources
Every roster or department call acts on behalf of one of your organization's declared
push sources—a named identifier for a system that's allowed to push data (e.g. your
HRIS, or a nightly export job). A key is never tied to a single source: one key can act
as any source it's scoped for, chosen per-request via the X-Sayhii-Roster-Source
header. Sources are declared and managed from the Integrations
page (not the API Keys page); a call naming a source that hasn't been declared for your
org is rejected outright—sources are never auto-created.
Quick Start
Check your organization's current roster status in a few lines of code:
- curl
- python
- node.js
curl -H "Authorization: Bearer $API_KEY" \
-H "X-Sayhii-Roster-Source: $ROSTER_SOURCE" \
https://api.sayhii.io/v1/roster/status
import os, requests
API_KEY = os.environ.get("SAYHII_API_KEY", "YOUR_KEY_ID.YOUR_SECRET")
ROSTER_SOURCE = os.environ.get("SAYHII_ROSTER_SOURCE", "YOUR_SOURCE_KEY")
resp = requests.get(
"https://api.sayhii.io/v1/roster/status",
headers={
"Authorization": f"Bearer {API_KEY}",
"X-Sayhii-Roster-Source": ROSTER_SOURCE,
},
)
resp.raise_for_status()
print(resp.json())
// Node 18+ has global fetch
const API_KEY = process.env.SAYHII_API_KEY || 'YOUR_KEY_ID.YOUR_SECRET';
const ROSTER_SOURCE = process.env.SAYHII_ROSTER_SOURCE || 'YOUR_SOURCE_KEY';
const res = await fetch('https://api.sayhii.io/v1/roster/status', {
headers: {
Authorization: `Bearer ${API_KEY}`,
'X-Sayhii-Roster-Source': ROSTER_SOURCE,
},
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
console.log(await res.json());
Roster
The Roster endpoints let you push employee records into sayhii and read back what's currently on file.
POST /v1/roster/sync
Push a batch of employee records in upsert mode—perfect for syncing your HRIS or
directory. Requires the roster:write scope.
- Body:
{ "employees": RosterRecordInput[] } - Batch Size: Maximum 500 employees per call. Larger snapshots must be split across multiple calls.
- Matching: Records are matched primarily by
employeeIdwithin your push source's own namespace. IfemployeeIddoesn't match an existing record, sayhii falls back to matching byemail—but only against a record already owned by this same push source. A row is rejected as aconflict(not written) if the email fallback would match a record owned by a different push source, or matches more than one existing record ambiguously. - Response: Always
200, with a per-row result for every input record (see Outcomes below)—there is no partial-success status code.
Sync Outcomes
Each row in the response's results array reports one of four outcomes:
| Outcome | Meaning |
|---|---|
applied | The record was fully written. |
pending | The record was written, but its managerEmployeeId and/or departmentCode reference hasn't resolved yet (see pendingFields). It resolves automatically on a future sync once the reference becomes known—no action needed. |
conflict | Nothing was written. The record's email matched an existing user owned by a different push source, or matched more than one existing user ambiguously. |
error | The row was rejected outright—e.g. missing employeeId/email, an invalid status, or a duplicate employeeId elsewhere in the same batch. |
A request is only rejected wholesale (4xx, no rows processed) for envelope-level
problems—an invalid JSON body, a missing employees array, or a batch over 500 entries.
Anything that parses as a valid array of rows proceeds to per-row processing, even if
every row ends up error.
Example
- curl
- python
- node.js
curl -X POST -H "Authorization: Bearer $API_KEY" \
-H "X-Sayhii-Roster-Source: $ROSTER_SOURCE" \
-H "Content-Type: application/json" \
-d '{
"employees": [{
"employeeId": "123",
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Doe",
"title": "Marketing Manager",
"managerEmployeeId": "100",
"departmentCode": "MKT",
"status": "active"
}]
}' \
https://api.sayhii.io/v1/roster/sync
import os, requests, json
API_KEY = os.environ.get("SAYHII_API_KEY", "YOUR_KEY_ID.YOUR_SECRET")
ROSTER_SOURCE = os.environ.get("SAYHII_ROSTER_SOURCE", "YOUR_SOURCE_KEY")
payload = {
"employees": [{
"employeeId": "123",
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Doe",
"title": "Marketing Manager",
"managerEmployeeId": "100",
"departmentCode": "MKT",
"status": "active",
}]
}
resp = requests.post(
"https://api.sayhii.io/v1/roster/sync",
headers={
"Authorization": f"Bearer {API_KEY}",
"X-Sayhii-Roster-Source": ROSTER_SOURCE,
"Content-Type": "application/json",
},
data=json.dumps(payload),
)
print(resp.status_code, resp.json())
const API_KEY = process.env.SAYHII_API_KEY || 'YOUR_KEY_ID.YOUR_SECRET';
const ROSTER_SOURCE = process.env.SAYHII_ROSTER_SOURCE || 'YOUR_SOURCE_KEY';
const payload = {
employees: [{
employeeId: '123',
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Doe',
title: 'Marketing Manager',
managerEmployeeId: '100',
departmentCode: 'MKT',
status: 'active',
}],
};
const res = await fetch('https://api.sayhii.io/v1/roster/sync', {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'X-Sayhii-Roster-Source': ROSTER_SOURCE,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
console.log(res.status, await res.json());
Example Response
{
"syncId": "b1f2c3d4-...",
"receivedAt": "2026-09-08T15:04:05.000Z",
"results": [
{
"employeeId": "123",
"outcome": "pending",
"userId": "5c9e...",
"pendingFields": ["manager"],
"detail": "applied with manager unresolved, pending a future sync"
}
],
"summary": { "total": 1, "applied": 0, "error": 0, "conflict": 0, "pending": 1 }
}
GET /v1/roster/users
Get a paginated list of employee records already pushed for your push source. Requires
the roster:read scope.
Query Parameters
- cursor (
string, optional): Opaque pagination cursor returned by a previous call. - limit (
number, optional): Maximum number of records to return (default 50, max 500). - pending (
boolean, optional): Whentrue, only returns records with an unresolvedpendingFieldsentry.
Response
- results (
RosterUserResponse[], required): Array of employee records. - cursor (
string, optional): Pass ascursoron the next call to fetch the next page. Omitted when there are no more results.
- curl
- python
- node.js
curl -H "Authorization: Bearer $API_KEY" \
-H "X-Sayhii-Roster-Source: $ROSTER_SOURCE" \
"https://api.sayhii.io/v1/roster/users?limit=100"
import os, requests
API_KEY = os.environ.get("SAYHII_API_KEY", "YOUR_KEY_ID.YOUR_SECRET")
ROSTER_SOURCE = os.environ.get("SAYHII_ROSTER_SOURCE", "YOUR_SOURCE_KEY")
resp = requests.get(
"https://api.sayhii.io/v1/roster/users",
params={"limit": 100},
headers={
"Authorization": f"Bearer {API_KEY}",
"X-Sayhii-Roster-Source": ROSTER_SOURCE,
},
)
resp.raise_for_status()
print(len(resp.json().get("results", [])), "users returned")
const API_KEY = process.env.SAYHII_API_KEY || 'YOUR_KEY_ID.YOUR_SECRET';
const ROSTER_SOURCE = process.env.SAYHII_ROSTER_SOURCE || 'YOUR_SOURCE_KEY';
const url = new URL('https://api.sayhii.io/v1/roster/users');
url.searchParams.set('limit', '100');
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${API_KEY}`,
'X-Sayhii-Roster-Source': ROSTER_SOURCE,
},
});
const { results = [] } = await res.json();
console.log(`${results.length} users returned`);
GET /v1/roster/users/{employeeId}
Look up a single employee record by employeeId, within your push source's
namespace. Requires the roster:read scope. Returns 404 if the employeeId doesn't
exist, or exists only under a different push source.
- curl
- python
- node.js
curl -H "Authorization: Bearer $API_KEY" \
-H "X-Sayhii-Roster-Source: $ROSTER_SOURCE" \
https://api.sayhii.io/v1/roster/users/123
import os, requests
API_KEY = os.environ.get("SAYHII_API_KEY", "YOUR_KEY_ID.YOUR_SECRET")
ROSTER_SOURCE = os.environ.get("SAYHII_ROSTER_SOURCE", "YOUR_SOURCE_KEY")
employee_id = "123"
resp = requests.get(
f"https://api.sayhii.io/v1/roster/users/{employee_id}",
headers={
"Authorization": f"Bearer {API_KEY}",
"X-Sayhii-Roster-Source": ROSTER_SOURCE,
},
)
print(resp.status_code, resp.json())
const API_KEY = process.env.SAYHII_API_KEY || 'YOUR_KEY_ID.YOUR_SECRET';
const ROSTER_SOURCE = process.env.SAYHII_ROSTER_SOURCE || 'YOUR_SOURCE_KEY';
const employeeId = '123';
const res = await fetch(`https://api.sayhii.io/v1/roster/users/${employeeId}`, {
headers: {
Authorization: `Bearer ${API_KEY}`,
'X-Sayhii-Roster-Source': ROSTER_SOURCE,
},
});
console.log(res.status, await res.json());
Departments
The Department endpoints let you push and read your organization's department
hierarchy, and let a departmentCode referenced from a roster sync resolve correctly.
This endpoint only maintains the department tree and its code mappings—it never resolves
or writes an individual employee's departmentCode (that happens as part of
roster sync).
POST /v1/departments/sync
Create or update department nodes in upsert mode. Requires the department:write
scope.
- Body:
{ "departments": [{ "code", "name", "parentCode" }] } - Batch Size: Maximum 500 nodes per call.
- Each node is matched on
codewithin your push source's namespace. parentCodemay reference another node in the same payload (forward references are fine) or an already-mapped code from a previous sync. OmitparentCodeentirely to leave a node's current parent untouched; passnullexplicitly to make it a root node.- This endpoint only ever creates or updates nodes—it never deletes tree structure. A node's absence from a payload is not a delete.
- Response: Always
200, with a per-node result (appliedorerror—there's nopending/conflictstate for departments).
Example
- curl
- python
- node.js
curl -X POST -H "Authorization: Bearer $API_KEY" \
-H "X-Sayhii-Roster-Source: $ROSTER_SOURCE" \
-H "Content-Type: application/json" \
-d '{
"departments": [
{ "code": "ENG", "name": "Engineering" },
{ "code": "ENG-PLAT", "name": "Platform", "parentCode": "ENG" }
]
}' \
https://api.sayhii.io/v1/departments/sync
import os, requests, json
API_KEY = os.environ.get("SAYHII_API_KEY", "YOUR_KEY_ID.YOUR_SECRET")
ROSTER_SOURCE = os.environ.get("SAYHII_ROSTER_SOURCE", "YOUR_SOURCE_KEY")
payload = {
"departments": [
{"code": "ENG", "name": "Engineering"},
{"code": "ENG-PLAT", "name": "Platform", "parentCode": "ENG"},
]
}
resp = requests.post(
"https://api.sayhii.io/v1/departments/sync",
headers={
"Authorization": f"Bearer {API_KEY}",
"X-Sayhii-Roster-Source": ROSTER_SOURCE,
"Content-Type": "application/json",
},
data=json.dumps(payload),
)
print(resp.status_code, resp.json())
const API_KEY = process.env.SAYHII_API_KEY || 'YOUR_KEY_ID.YOUR_SECRET';
const ROSTER_SOURCE = process.env.SAYHII_ROSTER_SOURCE || 'YOUR_SOURCE_KEY';
const payload = {
departments: [
{ code: 'ENG', name: 'Engineering' },
{ code: 'ENG-PLAT', name: 'Platform', parentCode: 'ENG' },
],
};
const res = await fetch('https://api.sayhii.io/v1/departments/sync', {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'X-Sayhii-Roster-Source': ROSTER_SOURCE,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
console.log(res.status, await res.json());
GET /v1/departments/nodes
Get a paginated list of department nodes mapped for your push source. Requires the
department:read scope. Same cursor/limit query parameters as
GET /v1/roster/users.
GET /v1/departments/nodes/{code}
Look up a single department node by code. Requires the department:read scope.
Returns 404 if the code doesn't exist, or exists only under a different push source.
GET /v1/departments/pending-codes
Read-only list of department codes referenced by a roster sync (via departmentCode)
that haven't been mapped to a real node yet—the same codes that produce a pending
roster-sync outcome. Requires the department:read scope. Not paginated. An admin
resolves these from sayhii's department hierarchy management screens, not via this API.
- curl
- python
- node.js
curl -H "Authorization: Bearer $API_KEY" \
-H "X-Sayhii-Roster-Source: $ROSTER_SOURCE" \
https://api.sayhii.io/v1/departments/pending-codes
import os, requests
API_KEY = os.environ.get("SAYHII_API_KEY", "YOUR_KEY_ID.YOUR_SECRET")
ROSTER_SOURCE = os.environ.get("SAYHII_ROSTER_SOURCE", "YOUR_SOURCE_KEY")
resp = requests.get(
"https://api.sayhii.io/v1/departments/pending-codes",
headers={
"Authorization": f"Bearer {API_KEY}",
"X-Sayhii-Roster-Source": ROSTER_SOURCE,
},
)
print(resp.json())
const API_KEY = process.env.SAYHII_API_KEY || 'YOUR_KEY_ID.YOUR_SECRET';
const ROSTER_SOURCE = process.env.SAYHII_ROSTER_SOURCE || 'YOUR_SOURCE_KEY';
const res = await fetch('https://api.sayhii.io/v1/departments/pending-codes', {
headers: {
Authorization: `Bearer ${API_KEY}`,
'X-Sayhii-Roster-Source': ROSTER_SOURCE,
},
});
console.log(await res.json());
Status
GET /v1/roster/status
A snapshot summary of your push source's roster and/or department state. Requires
roster:read and/or department:read—each section of the response is included only if
your key holds the matching scope, so a read-only integration can call this with
whichever single scope it has.
{
"users": {
"total": 812,
"active": 790,
"terminated": 22,
"pendingDepartment": 3,
"pendingManager": 1
},
"departments": {
"total": 41,
"pendingCodes": 2
}
}
Schemas
RosterRecordInput
| Field | Type | Required | Notes |
|---|---|---|---|
| employeeId | string | ✅ | Stable identifier within your push source's namespace. |
| string | ✅ | Matching fallback; see Matching. | |
| status | "active" | "terminated" | ✅ | |
| firstName | string | ||
| lastName | string | ||
| title | string | ||
| managerEmployeeId | string | null | This same push source's employeeId namespace, not email. null clears the link; omit to leave it untouched. | |
| departmentCode | string | null | Connection-scoped, see Departments. null clears it; omit to leave it untouched. | |
| terminatedAt | string | null | ISO-8601 date/time. |
RosterUserResponse
| Field | Type | Notes |
|---|---|---|
| employeeId | string | |
| string | ||
| firstName | string | |
| lastName | string | |
| status | "active" | "terminated" | |
| terminatedAt | string | null | |
| departmentCode | string | null | |
| managerEmployeeId | string | null | |
| pendingFields | ("department" | "manager")[] | Always present, [] when nothing is pending. |
DepartmentSyncNodeInput
| Field | Type | Required | Notes |
|---|---|---|---|
| code | string | ✅ | Unique within your push source's namespace. |
| name | string | ✅ | |
| parentCode | string | null | Omit to leave the current parent untouched; null for a root node. |
DepartmentNodeResponse
| Field | Type | Notes |
|---|---|---|
| code | string | |
| name | string | |
| parentCode | string | null | null if this node is a root, or if the parent has no code mapped for your push source. |
| departmentId | string | sayhii's internal department identifier. |
| createdAt | string | ISO-8601. |
Errors
RequestError
| Field | Type | Description |
|---|---|---|
| message | string | Human-readable description. |
Common Status Codes
| Code | Meaning |
|---|---|
| 200 | Success (sync endpoints always return 200; check per-row outcome for partial results) |
| 400 | Bad request—malformed body, batch over 500 entries, or a missing/unrecognized X-Sayhii-Roster-Source header |
| 401 | Unauthorized—missing/malformed Authorization header, or an unknown, revoked, or expired key |
| 403 | Your key doesn't hold the scope this endpoint requires |
| 404 | Not found (read endpoints only) |
| 429 | Rate limit exceeded—see Rate Limits |
Rate Limits
Each key has a rate class set at creation:
| Rate Class | Limit |
|---|---|
standard (default) | 60 requests/minute |
burst | 300 requests/minute |
A 429 response means you've exceeded your key's limit for the current one-minute
window. Use exponential backoff and retry.
Best Practices
- Batch Size: Keep sync requests at or under 500 entries per call.
- Idempotency: Re-sending the same snapshot is safe—both sync endpoints are full upsert, not append.
- Handle
pending: Apendingroster-sync outcome isn't an error—resend the same employee (or the referenced department/manager) in a later sync once it's known, or checkGET /v1/departments/pending-codesto see which department codes still need mapping. - Retries: Use exponential backoff for
429and5xxresponses. - Security: Provide keys only to trusted parties. Keys are org-scoped and can act as any push source they're issued scope for—prefer one key per integration.
Changelog & Support
This is a versioned API (/v1/...); a breaking change ships as a new version
(/v2/...) alongside the existing one rather than changing this contract in place.
For help or questions: support@sayhii.io