Kuali GraphQL Documentation

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 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 the default and active flags 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 are FAX, HM, MBL, or OTH leaves 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 as phone_country_not_dialing_code.
  • Office address: the first <address> in the document, whatever its addressTypeCode. If that address is not WRK, it is still applied and the record carries a non_work_address_applied warning. 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 as office_address_empty; a record with no <address> leaves the office fields as they are.
  • Primary department: primaryDepartment of the first <employment> in the document, across all affiliations. primaryEmployment is not imported and does not affect the choice.
  • Title: the <name> row's title, then primaryTitle, then directoryTitle — 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 without deactivateMissing never 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.error and failure.message say 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. Within counts.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's failure.message and 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"}}}
  • version is required. Version 1 is supported; any other version, or none, is rejected with 422 unsupported_format_version naming what you sent and what is supported. A first line that is not the envelope is 422 invalid_envelope.
  • institutionalId is 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, so 12345 matches 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 with deactivateMissing: true that person counts as absent from the roster; fix the line and resubmit.
  • The envelope may carry "deactivateMissing": true. It defaults to false: 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. With true, 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 with 422 invalid_envelope. The run reports the setting as deactivateMissing.
  • Omitting a key leaves it unchanged, except active. An entry that omits person.active sets the person active, on create and on update, so a person an earlier sweep deactivated returns when a roster carries them again; send "active": false to deactivate. Sending null clears username, email, the other string person keys, and any field on a user attribute page. A list is cleared by sending []: products and affiliations accept only [], while a page field that holds a list or rows accepts null or []. null is invalid for role, active, systemNotifications, products and affiliations, and fails that entry. Nothing else is ever cleared: there is no page-level replace. The field list reports how each field is cleared in clearWith.
  • 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 Texta string
Number, Linear Scalea number
Currencya number in major units: 85000.5
Datean ISO calendar date: "2026-01-15"
Timestamp an ISO 8601 date-time with an offset: "2026-01-15T09:30:00Z"
Timeseconds after midnight: 34200
Yes/NoJSON true or false
Dropdown, Radiosthe option's key
Checkboxesa list of option keys
Country, State, Languageone 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 affiliations key 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 active key 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