Guides
Bulk import an HR feed
Bulk-import your institution's people and organizational units from a Classic HR feed or the platform-native roster format (or a JSON groups batch).
Overview
POST /api/v0/imports/hr-feed/people
accepts an unmodified Classic hrmanifest
document (schema version 2.0) and treats it
as the authoritative active roster: people are created or updated, and (once a later run omits
them) deactivated. The feed carries 90 attributes. The 31 under
Applied fields
are read; every other attribute is accepted but never stored, logged, or reported on.
The same endpoint also accepts the native roster format
(application/x-ndjson), which names the platform's own person fields and any
published user attribute page directly. Everything below applies to both formats unless it
says otherwise.
Before your first import
-
Classic feeds only: the Research User Attributes
page must be installed. Every Classic roster is rejected without it — including one that carries
only names, emails, and phones — with
422 user_attributes_page_required. The check runs before the payload is read, so it cannot depend on what the roster contains. - Enforce Unique Person Institutional IDs must be enabled in Identity Settings.
Applied fields
| Attribute | Element | Destination |
|---|---|---|
principalId |
record |
institutional ID |
principalName |
record |
username |
active |
record |
active |
affiliationType |
affiliation |
affiliation |
firstName |
name |
first name |
middleName |
name |
middle name |
lastName |
name |
last name |
prefix |
name |
prefix |
suffix |
name |
suffix |
title |
name |
title |
emailAddress |
email |
|
phoneNumber |
phone |
phone |
primaryDepartment |
employment |
Research User Attributes — Primary Department |
unitNumber |
appointment |
Research User Attributes — Appointments → Unit |
appointmentType |
appointment |
Research User Attributes — Appointments → Appointment Type |
jobCode |
appointment |
Research User Attributes — Appointments → Job Code |
salary |
appointment |
Research User Attributes — Appointments → Salary |
startDate |
appointment |
Research User Attributes — Appointments → Start Date |
endDate |
appointment |
Research User Attributes — Appointments → End Date |
addressLine1 |
address |
Research User Attributes — Office Address 1 |
addressLine2 |
address |
Research User Attributes — Office Address 2 |
city |
address |
Research User Attributes — Office City |
stateOrProvince |
address |
Research User Attributes — Office State |
postalCode |
address |
Research User Attributes — Office Postal Code |
country |
address |
Research User Attributes — Office Country |
addressTypeCode |
address |
not stored — reported when the applied address is not WRK |
citizenshipType |
kcExtendedAttributes |
Research User Attributes — Citizenship Type |
primaryTitle |
kcExtendedAttributes |
title, when the name carries none |
directoryTitle |
kcExtendedAttributes |
title, when the name and primaryTitle carry none |
phoneType |
phone |
not stored — selects the WRK row for phone |
country |
phone |
phone, prepended when it is a dialing code |
On the Research User Attributes page, the import changes only the fields listed above. The page's other fields — ORCID, NSF ID, eRA Commons username, office phone, and the rest — keep whatever value they have.
Which row is applied
A person can carry more than one <name>, <email>, <phone>, <address>, or <employment>.
At most one row per element is applied.
-
Name and email:
the first row in the document.
nameCode,emailType, and thedefaultandactiveflags are not imported and do not affect the choice, so put the row you want applied first. -
Phone:
the first row with
phoneType="WRK". The number is stored as the person's phone. A record whose only numbers areFAX,HM,MBL, orOTHleaves the phone unset — those numbers have no destination on the person record. -
Phone country:
the schema does not constrain
phone/@country— it is a plain string with no length, pattern, or list of allowed values. When it is one to three digits, with or without a leading+, it is prepended to the number as+<code> <number>. Any other value leaves the number unchanged, and the run reports the first such value once asphone_country_not_dialing_code. -
Office address:
the first
<address>in the document, whatever itsaddressTypeCode. If that address is notWRK, it is still applied and the record carries anon_work_address_appliedwarning. The six office fields are written together: an attribute the applied address omits clears that field. An address with none of the six attributes writes nothing and is reported asoffice_address_empty; a record with no<address>leaves the office fields as they are. -
Primary department:
primaryDepartmentof the first<employment>in the document, across all affiliations.primaryEmploymentis not imported and does not affect the choice. -
Title:
the
<name>row'stitle, thenprimaryTitle, thendirectoryTitle— the first one present is applied. -
Appointments:
every
<appointment>becomes a row, in document order, replacing the rows the person had. A record with no<appointments>element leaves the stored rows untouched. - No qualifying row leaves the destination unset. It is never an error.
Limits
- 250 MB and 100,000 records per submission
- native roster: at most 1,000 distinct user attribute pages and 10,000 distinct fields (person keys, page fields and row fields) named across one payload; a payload over either limit fails before anything is written
- one roster import per institution at a time
- results are retained 90 days
Retired fields
The Classic migration mapping marks these "do not migrate". They are accepted but never stored, logged, or reported.
| Attribute | Element |
|---|---|
campus |
affiliation |
employeeStatus |
employment |
employeeType |
employment |
baseSalaryAmount |
employment |
employeeId |
employment |
primaryEmployment |
employment |
addressLine3 |
address |
default |
address |
active |
address |
nameCode |
name |
default |
name |
active |
name |
extension |
phone |
default |
phone |
active |
phone |
emailType |
email |
default |
email |
active |
email |
jobTitle |
appointment |
preferedJobTitle |
appointment |
Not yet mapped
The migration mapping does not mention these, so no decision has been made about them. Until one is, they are handled exactly like retired fields: accepted, and never stored, logged, or reported.
| Attribute | Element |
|---|---|
statusEmailRecipient |
hrmanifest |
reportDate |
hrmanifest |
entityId |
record |
default |
affiliation |
active |
affiliation |
county |
kcExtendedAttributes |
ageByFiscalYear |
kcExtendedAttributes |
race |
kcExtendedAttributes |
educationLevel |
kcExtendedAttributes |
degree |
kcExtendedAttributes |
major |
kcExtendedAttributes |
handicapped |
kcExtendedAttributes |
handicapType |
kcExtendedAttributes |
veteran |
kcExtendedAttributes |
veteranType |
kcExtendedAttributes |
visa |
kcExtendedAttributes |
visaType |
kcExtendedAttributes |
visaCode |
kcExtendedAttributes |
visaRenewalDate |
kcExtendedAttributes |
officeLocation |
kcExtendedAttributes |
secondaryOfficeLocation |
kcExtendedAttributes |
school |
kcExtendedAttributes |
yearGraduated |
kcExtendedAttributes |
directoryDepartment |
kcExtendedAttributes |
vacationAccrual |
kcExtendedAttributes |
onSabbatical |
kcExtendedAttributes |
idProvided |
kcExtendedAttributes |
idVerified |
kcExtendedAttributes |
multiCampusPrincipalId |
kcExtendedAttributes |
multiCampusPrincipalName |
kcExtendedAttributes |
salaryAnniversaryDate |
kcExtendedAttributes |
degreeCode |
degree |
degree |
degree |
graduationYear |
degree |
fieldOfStudy |
degree |
specialization |
degree |
school |
degree |
schoolId |
degree |
schoolIdCode |
degree |
Run statuses
A run reports accepted, validating, applying, or
sweeping
while it is still working (sweeping
only for a run that sweeps), and finishes in one of:
-
completed— every record applied and, if the run swept, the sweep ran clean (a native run withoutdeactivateMissingnever sweeps) -
completed_with_failures— the run finished, but at least one record failed or a deactivation did -
completed_deactivations_withheld— applied, but the deactivation sweep exceeded its threshold and is waiting on your confirmation -
completed_deactivations_refused,completed_deactivations_stale— the sweep was refused outright, or the approved candidate set had drifted by the time you confirmed it -
failed— the run stopped and will not resume;failure.errorandfailure.messagesay why. How much applied depends on the cause: a rejected payload means nothing did, but a sweep failure happens only after every record already landed — the deactivation pass is what failed, not the roster -
failed_dependency_unavailable— attribute writes failed consecutively past the configured threshold, so the run halted instead of failing every remaining record one at a time. Withincounts.processed, records from chunks completed before the halt applied fully and resubmitting re-applies them as no-ops; the halting chunk's own failing records had their person data applied but not their attributes, so resubmitting re-attempts just those writes. The streak can come from either of two different causes — the destination page (or its reference data) being unreachable, or a value the destination keeps rejecting on every record — so check the run'sfailure.messageand the per-record results to see which one it was before resubmitting
Native roster format
Send Content-Type: application/x-ndjson
to the same endpoint. The body is newline-delimited JSON: the first line is the envelope, every
later line is one person.
{"format": "kuali-roster", "version": 1, "recordCount": 1}
{"institutionalId": "P-10044", "person": {"username": "edgar", "firstName": "Edgar", "role": "user"}, "pages": {"<pageId>": {"kuali_officeCity": "Ann Arbor"}}}
-
versionis required. Version 1 is supported; any other version, or none, is rejected with422 unsupported_format_versionnaming what you sent and what is supported. A first line that is not the envelope is422 invalid_envelope. -
institutionalIdis required and is the only key a person is matched on. Unknown ids create people; known ids update them. A JSON integer is read as its decimal string, so12345matches the person whose institutional ID is"12345"; a float, boolean, object, or blank value fails that entry. An entry that cannot be read at all has no institutional ID to match, so withdeactivateMissing: truethat person counts as absent from the roster; fix the line and resubmit. -
The envelope may carry
"deactivateMissing": true. It defaults tofalse: the run then creates and updates people and stamps them as seen but never deactivates anyone, and finishes as a run with no deactivation candidates. Withtrue, people previously loaded by an import who are absent from this roster are deactivated, subject to the same threshold and confirmation as Classic. Any other value is rejected with422 invalid_envelope. The run reports the setting asdeactivateMissing. -
Omitting a key leaves it unchanged, except
active. An entry that omitsperson.activesets the person active, on create and on update, so a person an earlier sweep deactivated returns when a roster carries them again; send"active": falseto deactivate. Sendingnullclearsusername,email, the other string person keys, and any field on a user attribute page. A list is cleared by sending[]:productsandaffiliationsaccept only[], while a page field that holds a list or rows acceptsnullor[].nullis invalid forrole,active,systemNotifications,productsandaffiliations, and fails that entry. Nothing else is ever cleared: there is no page-level replace. The field list reports how each field is cleared inclearWith. - Each line may be at most 1 MB; the 250 MB and 100,000-entry limits apply as for Classic.
Person keys
username, email, firstName, middleName, lastName, prefix, suffix, title, phone, officeAddress, alternateId,
ssoId
(strings; an SSO ID is not written while the person is or becomes inactive);
active
(boolean, active when omitted); systemNotifications
(all
or daily); role
(user, admin, or external
— any other value fails that entry); products
(list of strings, replaced whole); affiliations
(list of {"type", "organizationalUnit"}, replaced whole). A person always needs a
username or an email. password
and approved
are never accepted. A person created by a roster has no local password, receives no email, and
gets the default role unless the entry carries one. Every entry whose role or product access
changed — including a person created with one — is flagged in the results with
roleChanged
and productsChanged. A roster submitted with an OAuth token may not carry
role
or ssoId:
the run fails as forbidden_field
before anything is written.
User attribute pages
pages
is keyed by page id, and each page by field key. Your institution's pages, field keys, types,
allowed options, and where to find reference ids are listed by
GET /api/v0/imports/roster/fields
(institution administrators only), which always reflects the current published pages. Each type
takes exactly one shape:
| Field type | Send |
|---|---|
| Text, Textarea, Email, Url, Rich Text | a string |
| Number, Linear Scale | a number |
| Currency | a number in major units: 85000.5 |
| Date | an ISO calendar date: "2026-01-15" |
| Timestamp |
an ISO 8601 date-time with an offset: "2026-01-15T09:30:00Z"
|
| Time | seconds after midnight: 34200 |
| Yes/No | JSON true or false |
| Dropdown, Radios | the option's key |
| Checkboxes | a list of option keys |
| Country, State, Language | one of the dropdown's own values |
| Form, Group, and Person pickers | the target record's id (a list of ids for a multi-select) |
| Repeater, Table | a list of row objects keyed by the row's field keys; rows are replaced whole and keep their order |
References are by id only
— never by name, code, serial number, or unit number. An id must name a record the field could
offer someone editing the page by hand: a document in the field's dataset that passes its filter
and is neither a draft nor deleted, a group in the field's Blueprint that is not deactivated, or
an active person. Any other id leaves the field unchanged and adds a
unmatched_reference
warning; for a multi-select, one unmatched id leaves the whole field unchanged. A value of the
wrong shape is dropped with an invalid_value
warning; the rest of the entry still applies. Values are stored as sent: a page's conditional
visibility rules are not evaluated.
Never importable: Private Data fields; read-only, calculated, data-link and data-lookup fields; rules; terms; attachments and signatures; layout elements; rows nested inside rows; fields of a type the import does not support (the field list marks each one not importable).
Whole-payload rejections
A page, field, or person key your institution's configuration does not recognise — or one that
is never importable, or password/approved
— or a payload naming more distinct pages or fields than the limits above — fails the whole run
before anyone is created, updated, or deactivated: the run ends failed
with failure.error
unknown_field, naming the key in failure.field, the reason in failure.reason, and the first entry that carried it in failure.firstIndex.
Differences from Classic
- An unknown field rejects a native payload, but a drifted Classic destination is only skipped and reported once per run: native field names are yours, Classic's are fixed by a schema you did not write.
-
An omitted
affiliationskey leaves a native person's affiliations unchanged, whereas a Classic record requires at least one affiliation and replaces the set. -
People loaded by either format are one roster: a run that sweeps (every Classic run, and a
native run with
deactivateMissing: true) deactivates people a run in the other format loaded and the new run omits. -
A native run does not deactivate anyone unless it asks to, and an omitted
activekey sets a person active; a Classic record without an active flag is also set active, and a Classic run always sweeps.
Per-record failure reasons
GET /api/v0/imports/:runId/results
streams one outcome line per submitted record. Every line also carries attributes
— written, unchanged, skipped, or failed,
this record's own attribute-write outcome — and a warnings
list of field/value/reason
entries for this record's own attributes; a run-level warning is never on a per-record line, only
on the run itself. A failed record names one of:
-
missing_required_field— a required imported attribute was blank or absent -
entry_too_large— native only: the entry's line exceeded 1 MB invalid_value— a supplied value didn't match its required pattern-
duplicate_in_payload— the same institutional ID appeared twice in this submission -
held_by_other_person— the username is already assigned to a different person -
ambiguous_institutional_id— two existing people already hold this institutional ID -
identity_rejected— the record failed a save-time validation not listed above -
dependency_unavailable— the person was applied but their attribute write failed after retries -
section_create_rejected— the person's attribute section document could not be created -
validation_rejected— the attribute write failed a save-time field validation on the section document -
write_rejected— the attribute section document write failed for another reason -
unit_number_field_disabled,parent_not_found,parent_cycle— group-batch specific