Fontes Portal API exposes two endpoints for submitting salary rows. They differ in how they handle existing data for the same (survey, participant) pair. Choosing the right one for your workflow avoids accidental data loss.
Merge (POST /v1/salary-rows) |
Replace (POST /v1/salary-rows/replace) |
|
|---|---|---|
| Semantics | Upsert by employee code |
Delete then insert |
| Safe to resend? | Yes — identical re-sends are no-ops | No — a partial re-send removes rows you didn't include |
| Deletes rows? | Never | Yes, for every affected (survey, participant) pair — unless the payload is empty (rows: []), then skipped |
| Default choice? | Yes | No — use deliberately |
| Idempotency | Required header on all POST calls | Required header on all POST calls |
POST /v1/salary-rows upserts each row by the employee code — the same
natural key the Excel upload uses. When your data spans multiple legal entities,
the composite key (code, groupLegalNames) is used instead. If a row with that
key already exists for the participant, the fields you send are updated in place
and fields you omit are left untouched (a partial merge). If no matching row
exists, a new one is inserted. Rows already on the server that are not in your
payload are never removed.
Rows that fail validation are not dropped — they are stored too (flagged
invalid) and additionally listed in validationIssues, exactly as replace and the
Excel upload behave. Nothing you submit is silently discarded.
codeis required on every row — it is the key the merge matches on.
Worked example — two successive calls with merge
Call 1: Submit rows for Alice and Bob.
{
"customerRegNo": "12345678",
"rows": [
{ "code": "E-1001", "monthlyGrossSalary": "2000" },
{ "code": "E-1002", "monthlyGrossSalary": "2500" }
]
}
After call 1: both Alice (E-1001) and Bob (E-1002) have a row.
Call 2: Submit an updated salary for Alice only (Bob's row unchanged).
{
"customerRegNo": "12345678",
"rows": [
{ "code": "E-1001", "monthlyGrossSalary": "2100" }
]
}
After call 2: Alice's salary is updated to 2100. Bob's row is untouched, and any fields on Alice's row that you didn't send are left as they were.
POST /v1/salary-rows/replace first deletes every existing salary row for each
eligible (survey, participant) pair represented in the payload, then inserts the
rows you supplied. The deletion scope is per participant within the survey — if
Alice has rows in two surveys, and your payload only covers one survey, only
the rows in that survey are deleted.
This mirrors the portal's Excel upload exactly: all submitted rows are stored,
including any that fail validation (they are persisted flagged as invalid and
additionally listed in validationIssues). Importing a given dataset via the API
therefore leaves the participant in the same state as uploading the equivalent
Excel file.
Worked example — two successive calls with replace
Call 1: Submit rows for Alice and Bob (same payload as above).
After call 1: both Alice and Bob have a row.
Call 2: Submit an updated salary for Alice only.
After call 2: Alice's salary is updated to 2100. Bob's row is deleted.
This is the key behavioural difference. If call 2 was intended as "update Alice only", replace was the wrong choice — it removed Bob's row as a side effect.
To stop an empty request from wiping a participant's data, replace skips the
destructive delete only when the payload contains no rows at all (rows: []).
The call returns 200 OK with each affected (survey, participant) pair reported
as "skipped": true / "skippedReason": "no_rows", and nothing is deleted.
Any non-empty payload proceeds: existing rows are deleted and the submitted
rows are stored. Note that rows failing validation are still stored (flagged
invalid) rather than dropped — they are also reported in validationIssues so you
can fix and resend them. This matches the Excel upload, where invalid rows are
saved and surfaced in the portal for correction.
There is no way to bulk-clear a participant's rows by sending an empty
payload — rows: [] is a safe no-op (skipped, nothing deleted). To deliberately
reset all rows for a customer, contact api-support@fontes.ee; there is no
self-service bulk-delete endpoint.
Both endpoints fan out to every survey the customer participates in (in its division, not yet in distribution). If a survey's data has already been approved or the customer is not filling it, that survey is left untouched and reported in the response as skipped — it is not silently omitted:
{
"surveyId": 150,
"surveyName": "Palgauuring 2026",
"skipped": true,
"skippedReason": "participant_data_confirmed",
"acceptedRows": 0,
"savedRows": 0,
"deletedRows": 0
}
skippedReason is one of:
participant_data_confirmed — the data is approved/locked; your changes were ignored.participant_not_filling — the customer opted out of this survey.no_rows — a replace call with an empty rows array (safety guard).Every result block also includes surveyName alongside surveyId so you can
show the user exactly which survey a notice refers to.
Use merge when:
Use replace when:
Replace is destructive. Do not use it as your default. If you accidentally send a partial payload with replace, rows not included will be permanently deleted for every affected participant in that survey.