Node.js
Employee synchronization with the ssm.ro API — Node.js Guide
This guide shows how to synchronize employees from an internal HR platform with the ssm.ro API, both in batch mode (daily run) and in real time (a single employee created/modified).
Synchronization logic:
- Check if marca is in the exclusion list → ignore
- Check if the employee already exists in ssm.ro based on
marca - If it exists → update
- If it doesn't exist and the status is not
rez(terminated) → create - If it doesn't exist and the status is
rez→ ignore (we don't create terminated contracts)
Configuration
const API_BASE = 'https://www.appssm.ro/api/v1';
const TOKEN = process.env.SSM_API_TOKEN; // Bearer token received from ssm.ro
const ORGANIZATIE = process.env.SSM_ORGANIZATIE; // organization subdomain (e.g., "demo-organization")Set the environment variables before running the script:
SSM_API_TOKEN=tokenul_tau SSM_ORGANIZATIE=organizatia_ta node sync.jsExclusion list (ignore list)
Some employees may be excluded from synchronization based on internal HR rules — for example technical administrators, test accounts, manually managed employees, or people with special confidentiality status.
/**
* Set of marca values explicitly excluded from synchronization.
*
* Typical reasons for exclusion:
* - employee managed manually directly in ssm.ro (no HR source)
* - technical or test account that does not represent a real person
* - person with special status who should not be synchronized automatically
* - employee with incorrect HR data, pending manual correction
*
* Add or remove marca values from this set whenever HR rules change.
*/
const IGNORED_MARCA = new Set([
// 'M00001', // example: technical account
// 'M00999', // example: manually managed employee
]);
/**
* Returns true if marca is in the exclusion list.
*/
function isIgnored(marca) {
return IGNORED_MARCA.has(String(marca));
}Status mapping
The HR platform may use different internal names than the values accepted by the API. The function below performs the conversion.
Values accepted by the API: activ, suspendat, rez
/**
* Converts the internal HR status → the status accepted by the API.
* activ concediu / activ detasat are treated as suspendat (active contract, but temporarily unavailable).
* reziliat / rez → rez (terminated contract).
*/
function mapStatus(hrStatus) {
const s = hrStatus?.toLowerCase().trim() ?? '';
if (s === 'activ') return 'activ';
if (['activ concediu', 'activ detasat', 'suspendat'].includes(s)) return 'suspendat';
if (['rez', 'reziliat'].includes(s)) return 'rez';
// fallback — unknown status treated as suspendat
console.warn(`Unknown status: "${hrStatus}", treated as suspendat`);
return 'suspendat';
}Post (risk group) mapping from the COR job title
In HR platforms, the post field is not usually available — it represents the job's risk group, used in ssm.ro to determine the SSM training category applicable to the employee (e.g., work at height, electrical risk, office). Each COR job title (cor) belongs to a risk group; most job titles share the same default group.
/**
* Post = the job's risk group, used for SSM training.
* Determines the training category applied to the employee in the platform
* (e.g., general training, electrical risk, work at height, etc.).
*
* Most COR job titles use the default post (DEFAULT_POST).
* Add exceptions only for job titles with a distinct risk group.
*/
const DEFAULT_POST = 'Lucratori';
const COR_TO_POST = {
// 'cor (lowercase)' → 'post / risk group'
//
// Posts are managed in the platform by SSM inspectors and can be created:
// - manually, by the inspector, through the platform interface
// - automatically via API, if the submitted post does not yet exist — the automatic creation option must be enabled by the super user in the organization's platform settings
// COR job titles (cor) are assigned to posts:
// - manually by the inspector, through association in the platform
// - automatically via API, on the first import of the job title — if the post does not exist, the job title is placed
// temporarily under the internal group "_FUNCTII_NEALOCATE_" until manually assigned
// Recommendation: align the post values below with those existing in the platform
// to avoid creating duplicates or unexpected groups.
//
'medic medicina muncii': 'Medici medicina muncii',
'inspector protectia muncii': 'Lucratori desemnati SSM',
'electrician': 'Lucratori cu risc electric',
'sudor': 'Lucratori cu risc chimic si termic',
'operator macara': 'Lucratori la inaltime',
'administrator retele de calcul': 'Lucratori birou',
// add the job titles specific to your organization
};
/**
* Returns the post (SSM risk group) corresponding to the COR job title.
* If the job title does not have an explicit mapping, DEFAULT_POST is used.
*/
function getPost(cor) {
if (!cor) return DEFAULT_POST;
return COR_TO_POST[cor.toLowerCase().trim()] ?? DEFAULT_POST;
}Employee field mapping
Adapt this function to the object structure in your HR platform.
/**
* Converts the internal HR object → the payload expected by the API.
*
* Example internal HR fields (left) → API fields (right):
* employee.id → marca
* employee.lastName → nume
* employee.firstName → prenume
* employee.dept → departament
* employee.deptCode → codDepartament
* employee.jobTitle → cor (COR job title from HR)
* employee.managerId → marcaSuperior
* employee.manager2Id → marcaSuperior2
* employee.substituteId→ marcaInlocuitor
* employee.birthDate → dataNasterii (format: YYYY-MM-DD)
* employee.status → status (passed through mapStatus)
* employee.phone → telefon
* employee.cnp → cnp (13 digits, unique within the organization)
* employee.language → limba (ro, en)
* employee.qualification → calificare
* employee.citizenship → cetatenie
* employee.nationality → nationalitate
* employee.startDate → dataIncepereActivitate (format: YYYY-MM-DD)
*
* post (SSM risk group) is derived from cor via getPost(),
* since HR doesn't usually expose it as a separate field.
*/
function mapEmployee(employee) {
return {
organizatie: ORGANIZATIE,
marca: String(employee.id),
nume: employee.lastName,
prenume: employee.firstName,
email: employee.email ?? null,
// departament is created automatically in the platform if it doesn't already exist.
// Note: automatic department creation must be enabled by the super user in the organization's platform settings.
// If the organization has departments with identical names (e.g., "Production" in multiple locations),
// send codDepartament (the unique external code from HR) to precisely identify the correct department
// and avoid misassigning the employee.
// The department is identified by codDepartament: if you send the same codDepartament
// with a different name (departament), the existing name is UPDATED (renamed),
// without creating a new department — a rename in HR is automatically propagated to the platform.
departament: employee.dept ?? null,
codDepartament: employee.deptCode ?? null,
cor: employee.jobTitle ?? null, // COR job title, not the numeric code
// post = SSM risk group, derived from the COR job title; not sourced from HR
post: getPost(employee.jobTitle),
marcaSuperior: employee.managerId ? String(employee.managerId) : null,
marcaSuperior2: employee.manager2Id ? String(employee.manager2Id) : null,
marcaInlocuitor: employee.substituteId ? String(employee.substituteId) : null,
adresa: employee.address ?? null,
localitate: employee.city ?? null,
judet: employee.county ?? null,
dataNasterii: employee.birthDate ?? null, // format: YYYY-MM-DD
locatieFizica: employee.officeLocation ?? null,
status: mapStatus(employee.status),
echipaPSI: employee.psiTeam ? 'Da' : 'Nu',
telefon: employee.phone ?? null,
cnp: employee.cnp ?? null, // 13 digits, unique within the organization
limba: employee.language ?? null, // accepted values: ro, en
calificare: employee.qualification ?? null,
cetatenie: employee.citizenship ?? null,
nationalitate: employee.nationality ?? null,
dataIncepereActivitate: employee.startDate ?? null, // format: YYYY-MM-DD
};
}API functions
API change — September 2026
Contact lookup now goes through GET /contacts?organizatie=&marca=. The legacy route
GET /contacts/{marca} is deprecated — see Contacts.
const headers = () => ({
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
});
/**
* Returns the contact data or null if it doesn't exist.
* The badge number is sent as a query parameter, encoded with encodeURIComponent() —
* the legacy /contacts/{marca} route is deprecated.
*/
async function getContact(marca) {
const res = await fetch(
`${API_BASE}/contacts?organizatie=${ORGANIZATIE}&marca=${encodeURIComponent(marca)}`,
{ headers: headers() }
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`GET /contacts?marca=${marca} → ${res.status}`);
return res.json();
}
/**
* Creates a new contact. Throws an error if marca already exists.
*/
async function createContact(payload) {
const res = await fetch(`${API_BASE}/contacts?organizatie=${ORGANIZATIE}`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`POST /contacts → ${res.status}: ${body.error ?? 'unknown error'}`);
}
return res.json();
}
/**
* Updates an existing contact identified by marca.
* Returns null if no differences were detected (204 No Content).
*/
async function updateContact(payload) {
const res = await fetch(`${API_BASE}/contacts/update?organizatie=${ORGANIZATIE}`, {
method: 'PATCH',
headers: headers(),
body: JSON.stringify(payload),
});
if (res.status === 204) return null;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`PATCH /contacts/update → ${res.status}: ${body.error ?? 'unknown error'}`);
}
return res.json();
}Single employee synchronization
Used for real-time events (employee created or modified in HR).
async function syncEmployee(hrEmployee) {
const payload = mapEmployee(hrEmployee);
const marca = payload.marca;
// The employee is explicitly excluded from synchronization by internal HR rules.
if (isIgnored(marca)) {
console.log(`[${marca}] Ignored — excluded from synchronization (IGNORED_MARCA)`);
return { marca, action: 'skipped', reason: 'ignored' };
}
const existing = await getContact(marca);
if (existing) {
const result = await updateContact(payload);
if (result === null) {
console.log(`[${marca}] No changes (204)`);
return { marca, action: 'no_change' };
}
console.log(`[${marca}] Updated`);
return { marca, action: 'updated' };
} else {
if (payload.status === 'rez') {
console.log(`[${marca}] Ignored — terminated contact, not created`);
return { marca, action: 'skipped', reason: 'terminated' };
}
await createContact(payload);
console.log(`[${marca}] Created`);
return { marca, action: 'created' };
}
}Daily batch synchronization
Receives an array of unsynchronized employees and processes them sequentially.
async function syncAll(hrEmployees) {
const summary = { created: 0, updated: 0, skipped: 0, no_change: 0, errors: 0 };
for (const employee of hrEmployees) {
try {
const result = await syncEmployee(employee);
summary[result.action] = (summary[result.action] ?? 0) + 1;
} catch (err) {
const marca = employee.id ?? '?';
console.error(`[${marca}] Error: ${err.message}`);
summary.errors++;
}
}
console.log('\n--- Synchronization summary ---');
console.log(`Created: ${summary.created}`);
console.log(`Updated: ${summary.updated}`);
console.log(`No change: ${summary.no_change}`);
console.log(`Skipped: ${summary.skipped}`);
console.log(`Errors: ${summary.errors}`);
// Send/log the execution report to the client.
// Always if there are errors; optional for runs without issues.
//
// Implementation examples:
// - email to the HR team: sendReportEmail(summary, errorDetails)
// - POST to an internal webhook: await fetch(REPORT_WEBHOOK_URL, { method: 'POST', body: JSON.stringify(summary) })
// - write to a log file: appendFileSync('./sync.log', JSON.stringify({ date: new Date(), ...summary }) + '\n')
// - send to Slack / Teams: await notifyChannel(summary)
//
// if (summary.errors > 0) {
// await sendReportEmail(summary); // always on errors
// } else {
// // await sendReportEmail(summary); // uncomment if you want a report on success too
// }
return summary;
}Usage examples
Daily batch (JSON file)
// sync.js
import { readFileSync } from 'fs';
// employees.json — list exported from HR for the current day
const employees = JSON.parse(readFileSync('./employees.json', 'utf8'));
syncAll(employees).then((summary) => {
if (summary.errors > 0) process.exit(1);
});Run:
SSM_API_TOKEN=abc123 SSM_ORGANIZATIE=demo-organization node sync.jsReal-time event (single employee)
// Called from a webhook, queue consumer, or any HR event system
async function onEmployeeChanged(hrEmployee) {
try {
const result = await syncEmployee(hrEmployee);
console.log('Sync result:', result);
} catch (err) {
console.error('Sync failed:', err.message);
// re-throw to allow retry from the messaging system
throw err;
}
}
// Direct call example
onEmployeeChanged({
id: 'M00212',
lastName: 'Popescu',
firstName: 'Ion',
email: 'ion.popescu@firma.ro',
dept: 'Resurse Umane',
deptCode: 'RU-01',
jobTitle: 'Specialist resurse umane',
managerId: 'M00100',
birthDate: '1985-06-15',
status: 'activ',
psiTeam: false,
phone: '+40722333444',
cnp: '1850615221144',
language: 'ro',
qualification: 'Economist',
citizenship: 'Romana',
nationality: 'Romania',
startDate: '2020-01-15',
});API response codes
| Code | Meaning |
|---|---|
| 200 | Success — returns the contact data |
| 204 | Success — no changes detected (PATCH only) |
| 400 | Invalid or missing organizatie |
| 401 | Missing or invalid token |
| 404 | Contact not found (GET only) |
| 422 | Invalid data — the response includes { "error": "..." } with details |
| 500 | Internal server error |