Pushing students from your SIS
Your student information system is the source of truth for who your students are. This guide shows how to push them into CareerOS on a schedule so the two stay in step without anyone uploading a spreadsheet.
Before you start
You need a token CareerOS has enabled for writing, scoped to a single
university. A read token will authenticate and then return 403 with
WRITE_NOT_ENABLED — ask support@thecareeros.com to enable writes on your
token, or issue you one. Consortium-scoped tokens cannot write at all.
Which verb
PUT /v1/students | Start here. Idempotent upsert — safe to replay, creates what is missing, updates what changed. This is what a nightly sync wants. Requires the complete student. |
POST /v1/students | Create only. An existing student comes back rejected rather than updated. Requires the complete student. |
PATCH /v1/students | Update only. Never creates. Everything except email may be omitted, and an omitted field is left untouched — use it to correct a name, set the alumni flag, or move someone to another cohort. |
What “complete” means. POST and PUT both create, so both require
email, first_name, last_name, cohort_name and is_alumni. A cohort
requires name, display_name, program, degree, start_date, end_date,
intake_year and intake_term. That is the same set the roster spreadsheet
your advisors upload makes mandatory — only campus and study mode are optional
in both. A half-populated record is invisible to the filters advisors work
from, which is why it is not accepted.
Two things to get right
Send student-grain rows. One object per person. If your extract has one row per course per term — most do — group it first. The example below shows the grouping. A student is identity plus a cohort: there is no per-programme structure to fill in.
Students are addressed by email. It is unique across CareerOS, so you need
no identifier of ours. If you also have a durable student number, send it as
external_id: it then takes precedence when matching, so a student whose
address changes stays one account rather than becoming two. Without it, an
email change looks like a new person.
What happens to a created student
They get an invited account: real, findable by your advisors, but with no
login identity yet and no email sent. The account does not expire: the
person can claim it at their first sign-in, whenever that is. invitations_sent in the response is
always 0. Your advisors invite them from the CareerOS app when they are ready,
and the person claims the account at first login. Pushing your whole roster does
not email your whole roster.
Alumni
Alumni are students here: the same /v1/students resource and the same
account, with is_alumni: true. Two fields are involved, and they do
different jobs.
| Field | Where | What it means |
|---|---|---|
graduation_date | on the student | The date this person actually graduated. Send it only for people who have finished. |
is_alumni | on the student | Whether CareerOS treats the account as an alumni account today. |
How is_alumni is normally set
CareerOS derives it from graduation_date: a date before today means alumnus,
and with no date a past cohort end_date counts instead. That derivation runs
at four moments only — when the student signs in, when their graduation date
is set in the app, and when they are enrolled in or moved to a cohort.
A partner write is not one of those moments. Pushing a past
graduation_date on its own stores the date and leaves the flag untouched.
The person becomes an alumnus the next time they sign in, and a historical
roster of graduates who never sign in stays is_alumni: false for good,
invisible to the alumni views your advisors use.
Sending it yourself
That gap is what is_alumni on the write is for. If your SIS knows who has
graduated, send it and the flag is set on the spot:
{ "email": "alex.doe@example.edu", "first_name": "Alex", "last_name": "Doe",
"cohort_name": "MBA 2020", "is_alumni": true,
"graduation_date": "2020-06-15" }
| You send | What happens |
|---|---|
true | Account flagged as alumni immediately, on create and on update. |
false | Flag cleared immediately. |
| field omitted | Refused on create. On PATCH it leaves the flag alone, so CareerOS’s own derivation still owns it. |
Every change is written to the student’s alumni history with reason
partner_api_push, next to the changes made in the app, so an advisor can see
where a flag came from.
When it disagrees with the date
The value is applied as sent — we do not second-guess your SIS — and the row’s
notes tell you:
is_alumni=true was applied, but graduation_date 2027-06-15 implies false.
CareerOS re-derives is_alumni from the date whenever this student's cohort or
graduation date next changes, which would overwrite it.
Read that as a warning that the value will not last: the next derivation
recomputes the flag from the date. One asymmetry to know about. The sign-in
derivation only ever promotes false to true; it never clears a true. A
wrong true therefore survives sign-ins and is corrected only by a cohort or
graduation-date change. Fix the SIS record rather than relying on that.
Only real graduation dates
Send graduation_date only when the person has genuinely graduated. For a
student still studying, leave it null. CareerOS stores no expected completion
date on a student — the cohort’s end_date carries when the class is due to
finish — so sending an expected date as graduation_date would mark a current
student as an alumnus at their next sign-in.
The separate
/v1/alumniresource is a different thing: a directory of alumni contacts your students browse for outreach. It is read-only, and it is not where you record that someone graduated.
Managing cohorts
Everything your advisors can do to a cohort in the app, you can do here —
POST, PUT and PATCH /v1/cohorts — so you never need to send us a roster
spreadsheet.
curl -X PUT 'https://api.partners.thecareeros.com/v1/cohorts' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"cohorts":[{
"name":"MSCDS27",
"display_name":"MSc Data Science 2027",
"program":"MSc Data Science",
"degree":"Master of Science",
"intake_year":2025,
"intake_term":"Fall",
"end_date":"2027-06-30",
"campus_name":"Madrid"
}]}'
A cohort is addressed by name within your university, and name is frozen
once created — roster matching keys on it, so a rename would fork one cohort
into two. Send a new cohort instead.
Two things to know:
- Omitting a field leaves it alone; sending
nullclears it. That is what makes a narrowPATCHsafe. - If two of your cohorts already share a name, a write to that name is
refused with
NAME_AMBIGUOUSrather than picking one. Contact us and we’ll resolve the duplicate.
Push cohorts before students — this is now required, not advice. A student
write carries only cohort_name; degree, the dates, campus, intake and
templates live on the cohort itself. A student naming a cohort that does not
exist is refused with COHORT_NOT_FOUND rather than creating an empty one,
because a student write has nothing to build a complete cohort from.
Every student must land in a cohort. cohort_name is required on create,
and a student outside every cohort appears on no advisor’s roster, gets no
résumé templates and receives nothing targeted at their year.
Moving students between cohorts
cohort_users is unique per student, so naming a different cohort moves
them rather than adding a second membership. Send only what changes:
curl -X PATCH 'https://api.partners.thecareeros.com/v1/students' \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"students":[{"email":"alex.doe@example.edu","cohort_name":"MSCDS28"}]}'
To move students into a cohort that does not exist yet, create it first with
POST /v1/cohorts and then patch them — the example script does exactly this
with --reassign. A cohort’s name is frozen, so “renaming” a cohort is
really: create the new one, move the students, and tell us to retire the old
one.
What lives on the cohort, not the student
CareerOS models a student’s academic context through their cohort, not as fields repeated on every student record. So:
degreegoes on the cohort — set it once via/v1/cohortsand every student placed there inherits it. Sending it on a student is dropped.- Expected graduation is the cohort’s
end_date, plusgraduation_yearon the student’s programme. There is no separate anticipated-graduation field on a student, and we are not planning to add one — a student’s owngraduation_datemeans they have actually graduated, and the cohort carries when everyone else is due to finish. - Courses and grades are not stored yet. We hold no transcript model today. If it turns out to matter for your use case, tell us what you need it for and we will look at it properly.
Reading the response
A 200 means the batch was processed, not that every row succeeded. Rows
are independent so one bad record never blocks a sync. Always read results:
{
"counts": { "created": 1, "updated": 1, "unchanged": 0, "rejected": 1 },
"cohorts_created": 1,
"invitations_sent": 0,
"dropped_fields": ["person_birth_date", "person_gender"],
"results": [
{ "email": "a.mugisha@ughe.rw", "status": "created", "id": "7d9e1000-…", "cohort_id": "3f2a1000-…" },
{ "email": "j.uwase@ughe.rw", "status": "updated", "id": "8e0f2111-…", "cohort_id": null },
{ "email": "p.habimana@ughe.rw", "status": "rejected", "code": "INVALID_PHONE",
"message": "\"(079) 172-4400\" is not in E.164 form — include the country code, e.g. +250788000000" }
]
}
Rows are keyed on email, not on your external_id — match results back to
your records on the address you sent.
A row may also carry notes: something we accepted but did not apply as sent.
There are two today. One is an is_alumni that disagrees with graduation_date
(see Alumni). The other is a changed address: if you push a student by external_id
with an email the account does not hold, we record it as a secondary address
but do not make it a login or the contact address — a login address is only
granted after the student verifies it. Contact
support@thecareeros.com to complete a domain
migration.
dropped_fields names data you sent that a student record does not hold —
dates of birth, gender, courses, grades, and the academic detail degree,
specialization and anticipated_completion_date. Sending them is not an
error; they are reported rather than ignored so you can see they were not
saved. See What lives on the cohort, not the student
for where degree belongs.
Rejection codes
| Code | What to do |
|---|---|
INVALID_EMAIL, MISSING_NAME | Fix the record in your SIS. |
INVALID_PHONE | Add the country code, or send null. |
EMAIL_BELONGS_TO_ANOTHER_UNIVERSITY | That address is already someone else’s account. Never adopted — confirm the address is right. |
EMAIL_BELONGS_TO_EXISTING_ACCOUNT | That address already belongs to a CareerOS user who is not on your roster (including self-serve accounts with no university). Contact support; do not retry. |
EMAIL_BELONGS_TO_NON_STUDENT | The address belongs to a staff account. |
EXTERNAL_ID_CONFLICT | Your external_id is already mapped to a different person than this email. We will not merge them; resolve which is correct. |
NOT_FOUND | PATCH only — no student matches. Use PUT if it should be created. |
ALREADY_EXISTS | You used POST for someone who exists. Nothing was written — use PUT. |
MISSING_COHORT | No cohort_name was sent. It is required on create; every student belongs to a cohort. |
COHORT_NOT_FOUND | The cohort_name matches no cohort at your university. Create it with POST /v1/cohorts first — a student write cannot build one. |
COHORT_NAME_AMBIGUOUS | Two of your cohorts share that cohort_name, so we will not guess. Contact support to resolve the duplicate. |
UNKNOWN_TEMPLATE | A resume_template_names entry does not exist at your university. Only checked when the cohort is being created. |
CONCURRENT_WRITE | Two writes raced. Retry the row. |
ACCOUNT_DELETED | The account was erased and cannot be recreated through the API. |
Cohort writes have their own: NAME_AMBIGUOUS (two of your cohorts share that
name), CAMPUS_NOT_FOUND (check GET /v1/campuses), UNKNOWN_TEMPLATE,
INVALID_NAME, INVALID_DATE_RANGE — which is also checked against the date
already stored, so a PATCH sending only start_date cannot invert a range.
A runnable example
Reads an SIS extract at enrollment grain, groups it to student grain, and upserts it in batches. Standard library only.
The field names below (visible_student_id, person_is_alum, degree_name
and so on) are one SIS’s vocabulary — rename them to match your own export. The
shape is what generalises: group to one object per person, resolve a single
programme name, pass your SIS’s alumni flag through as is_alumni, and send an
actual graduation date only for people who have actually graduated.
It builds both payloads from one extract and pushes cohorts first, then students. The mapping it assumes:
| CareerOS | From your extract |
|---|---|
cohort.name (frozen) | programme + intake year, e.g. Global Nursing Leadership 2023 |
cohort.display_name | full degree_name + intake year |
cohort.program | program_name, falling back to the subject in degree_name when that is a generic tier |
cohort.degree | the award half of degree_name — everything before " in " |
cohort.start_date | earliest academic_term_start_date for that class |
cohort.end_date | degree_anticipated_completion_date — when the class is expected to finish |
cohort.intake_year / intake_term | year of start_date, and the season named in academic_term_name |
student.cohort_name | the cohort name above |
student.is_alumni | person_is_alum, forwarded unchanged |
student.graduation_date | degree_graduation_date, only once the person has actually finished |
Confirm this mapping with us before your first real run. cohort.name is
frozen once created, so the field you key it on is the one decision here that
cannot be undone later — it lives in a single function, cohort_key(), so
swapping it is a one-line change. If your SIS holds a real class or intake code,
send that instead.
Two of those rows are deliberate. cohort.end_date takes the anticipated
date, because it describes when the class finishes rather than when one person
did — an individual’s real completion date belongs on
student.graduation_date. And graduation_date is sent only when the alumni
flag and a past date agree: a date is what CareerOS later derives the flag
from, so a wrong date is harder to undo than a wrong flag. See
Alumni for what happens when the two disagree.
The script also refuses to send what CareerOS would reject. A cohort missing any
required field is reported (INCOMPLETE COHORT ...: no end_date, intake_term)
and the students in it are held back rather than sent to a certain
COHORT_NOT_FOUND, so a dry run tells you what your extract cannot yet supply.
#!/usr/bin/env python3
"""Push UGHE students and alumni from a Populi extract into CareerOS.
Runs on UGHE infrastructure. Standard library only, Python 3.8+.
export CAREEROS_TOKEN='<your token>'
python3 careeros_sync.py sis_extract.json --dry-run # inspect, send nothing
python3 careeros_sync.py sis_extract.json # push
# move people to another cohort, changing nothing else
python3 careeros_sync.py sis_extract.json \
--reassign alex@ughe.org="Global Nursing Leadership 2024"
Two calls are made, in this order: cohorts first, then students. Both are
idempotent upserts -- nothing is ever deleted.
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from collections import defaultdict
from datetime import date
from html import unescape
BASE = "https://api.partners.thecareeros.com"
COHORTS_API = f"{BASE}/v1/cohorts"
STUDENTS_API = f"{BASE}/v1/students"
BATCH_SIZE = 200
# Tier labels that name no actual programme; the subject inside degree_name is
# used instead. Extend as your catalogue grows.
GENERIC_PROGRAM_NAMES = {"graduate", "undergraduate", "postgraduate"}
# CareerOS accepts only these four. A term we cannot map is left unset.
TERM_SEASONS = {"fall": "Fall", "winter": "Winter", "spring": "Spring", "summer": "Summer"}
ALLOWED_IN_PHONE = set("0123456789+ -().")
# Mandatory on create, mirroring the advisor roster template whose every
# column is required except campus and study mode.
REQUIRED_COHORT_FIELDS = ("name", "display_name", "program", "degree",
"start_date", "end_date", "intake_year", "intake_term")
# ─────────────────────────────────────────────────────────────────────────────
# THE ONE MAPPING TO CONFIRM
# ─────────────────────────────────────────────────────────────────────────────
def cohort_key(programme, intake_year, row):
"""The cohort's frozen identity -- CareerOS cannot rename it afterwards.
Default: "<Programme> <Intake year>". Intake year is used because it is
derivable from every row, while graduation dates are often null. If Populi
holds a real class code (e.g. MBBS'29), return it here instead."""
return f"{programme} {intake_year}".strip() if intake_year else None
# ─────────────────────────────────────────────────────────────────────────────
# Field helpers
# ─────────────────────────────────────────────────────────────────────────────
def to_e164(raw):
"""CareerOS requires E.164. A local number cannot be repaired by guessing a
dial code, so send None rather than something wrong. Unexpected characters
make the whole value untrustworthy -- never strip them."""
if not raw or not set(raw) <= ALLOWED_IN_PHONE:
return None
compact = "".join(c for c in raw if c.isdigit() or c == "+")
return compact if compact.startswith("+") and 8 <= len(compact) <= 18 else None
def award(degree_title):
""""Master of Science in Health Professions Education" -> "Master of Science"."""
return degree_title.split(" in ", 1)[0].strip()
def subject(degree_title):
parts = degree_title.split(" in ", 1)
return parts[1].strip() if len(parts) == 2 else degree_title
def intake_term(term_name):
"""Season out of academic_term_name; None when the term is "Semester N"."""
lowered = unescape(term_name or "").lower()
for token, label in TERM_SEASONS.items():
if token in lowered:
return label
return None
def has_graduated(row):
"""Requires the SIS alum flag AND a past date. A date alone is often the
expected completion in the wrong column, and a past graduation date is what
turns an account into an alumni account."""
grad = row.get("degree_graduation_date")
if not grad or not row.get("person_is_alum"):
return False
return date.fromisoformat(grad) <= date.today()
def stage(row):
if has_graduated(row):
return "completed"
return "active" if row.get("course_student_status") == "ENROLLED" else "upcoming"
def programme_of(row, degree_title):
name = unescape(row.get("program_name") or "").strip()
if not name or name.lower() in GENERIC_PROGRAM_NAMES:
return subject(degree_title)
return name
# ─────────────────────────────────────────────────────────────────────────────
# Enrollment grain -> student grain
# ─────────────────────────────────────────────────────────────────────────────
def group(rows):
"""Enrollment grain -> students + the cohorts they need.
One object per person: CareerOS stores a student as identity + cohort, so
the per-course rows collapse entirely. Cohort dates are aggregated across
every contributing row, since a cohort is shared by many students."""
students, cohorts = {}, {}
# Earliest term per (student, degree) is the intake; computed first because
# the cohort key depends on it.
intakes = defaultdict(lambda: None)
for row in rows:
degree_title = unescape(row.get("degree_name") or "").strip()
if not degree_title:
continue
start = row.get("academic_term_start_date")
slot = (str(row["visible_student_id"]), degree_title)
if start and (intakes[slot] is None or start < intakes[slot]):
intakes[slot] = start
for row in rows:
key = str(row["visible_student_id"])
degree_title = unescape(row.get("degree_name") or "").strip()
if not degree_title:
continue
programme = programme_of(row, degree_title)
start = intakes[(key, degree_title)]
year = start[:4] if start else ""
name = cohort_key(programme, year, row)
label = f"{degree_title} {year}".strip() if year else degree_title
if key not in students:
students[key] = {
"email": row["person_contact_primary_email"].strip().lower(),
"first_name": unescape(row["student_first_name"]).strip(),
"last_name": unescape(row["student_last_name"]).strip(),
# Required, and flat: the cohort the student belongs to.
"cohort_name": name,
# Required: CareerOS re-derives this only at sign-in, so a
# pushed roster of graduates would otherwise never show as alumni.
"is_alumni": bool(row.get("person_is_alum")),
"external_id": key,
"phone": to_e164(row.get("person_contact_primary_phone")),
# Only a real completion date, and only once they have finished.
# An expected date here would make a current student an alumnus.
"graduation_date": None,
"cohort_display_name": label if name else None,
}
if has_graduated(row):
students[key]["graduation_date"] = row.get("degree_graduation_date")
if not name:
continue
cohort = cohorts.setdefault(name, {
"name": name,
"display_name": label,
"program": programme,
"degree": award(degree_title),
"start_date": start,
# When the class is expected to finish -- not any one person's
# actual graduation date, which belongs on the student.
"end_date": row.get("degree_anticipated_completion_date"),
"intake_year": int(year) if year.isdigit() else None,
"intake_term": intake_term(row.get("academic_term_name")),
})
if start and (cohort["start_date"] is None or start < cohort["start_date"]):
cohort["start_date"] = start
# An expected completion date that precedes the intake is a source-data
# error; CareerOS refuses the range, so drop the date rather than the cohort.
for cohort in cohorts.values():
if cohort["end_date"] and cohort["start_date"] and cohort["end_date"] <= cohort["start_date"]:
print(f" warning: {cohort['name']}: end_date {cohort['end_date']} precedes "
f"start_date {cohort['start_date']}; clearing it")
cohort["end_date"] = None
# Every field below is mandatory on create. A cohort missing one cannot be
# built, and a student naming it would be refused COHORT_NOT_FOUND -- so say
# which field is missing and hold those students back.
complete, blocked = [], set()
for cohort in cohorts.values():
gaps = [f for f in REQUIRED_COHORT_FIELDS if not cohort.get(f)]
if gaps:
print(f" INCOMPLETE COHORT {cohort['name']}: no {', '.join(gaps)}")
blocked.add(cohort["name"])
else:
complete.append(cohort)
sendable = []
for student in students.values():
if not student["cohort_name"] or student["cohort_name"] in blocked:
print(f" BLOCKED {student['email']}: cohort "
f"{student['cohort_name'] or '(none)'} cannot be created")
continue
sendable.append(student)
return sendable, complete
# ─────────────────────────────────────────────────────────────────────────────
# Transport
# ─────────────────────────────────────────────────────────────────────────────
def push(url, payload, token, method="PUT"):
request = urllib.request.Request(
url, method=method, data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response)
except urllib.error.HTTPError as err:
body = err.read().decode()
print(f" HTTP {err.code}: {body}", file=sys.stderr)
raise
def reassign(students, token):
"""Move people to a different cohort without resending the whole record.
PATCH never creates, so every field except email may be omitted and is left
untouched. The cohort must already exist -- push it with /v1/cohorts first,
which is how "create a new cohort and move students into it" is done."""
rows = [{"email": e, "cohort_name": c} for e, c in students]
result = push(STUDENTS_API, {"students": rows}, token, method="PATCH")
for row in result["results"]:
if row["status"] == "rejected":
print(f" REJECTED {row['email']}: {row['code']} - {row['message']}")
else:
print(f" {row['email']} -> cohort {row['cohort_id']}")
return result
def main():
parser = argparse.ArgumentParser(description="Push a Populi extract into CareerOS.")
parser.add_argument("extract", help="Path to the SIS extract JSON")
parser.add_argument("--dry-run", action="store_true",
help="Print the payloads and send nothing")
parser.add_argument("--reassign", metavar="EMAIL=COHORT", nargs="+",
help="Move existing students to a cohort and do nothing else")
args = parser.parse_args()
if args.reassign:
pairs = [tuple(item.split("=", 1)) for item in args.reassign]
reassign(pairs, os.environ["CAREEROS_TOKEN"])
return
with open(args.extract, encoding="utf-8") as handle:
rows = json.load(handle)
students, cohorts = group(rows)
print(f"{len(rows)} rows -> {len(students)} students, {len(cohorts)} cohorts\n")
if args.dry_run:
print(json.dumps({"cohorts": cohorts, "students": students}, indent=2))
return
token = os.environ["CAREEROS_TOKEN"]
# Cohorts first: degree, dates and campus live on the cohort, so they must
# exist before students are placed into them.
print("Cohorts:")
for start in range(0, len(cohorts), BATCH_SIZE):
result = push(COHORTS_API, {"cohorts": cohorts[start:start + BATCH_SIZE]}, token)
print(f" {result['counts']}")
for row in result["results"]:
if row["status"] == "rejected":
print(f" REJECTED {row['name']}: {row['code']} - {row['message']}")
print("Students:")
totals = defaultdict(int)
for start in range(0, len(students), BATCH_SIZE):
result = push(STUDENTS_API, {"students": students[start:start + BATCH_SIZE]}, token)
for name, count in result["counts"].items():
totals[name] += count
if result.get("dropped_fields"):
print(f" not stored: {', '.join(result['dropped_fields'])}")
# A 200 does not mean every row landed. Rows are keyed on email.
for row in result["results"]:
if row["status"] == "rejected":
print(f" REJECTED {row['email']}: {row['code']} - {row['message']}")
elif row.get("notes"):
print(f" NOTE {row['email']}: {' '.join(row['notes'])}")
print(f" {dict(totals)}")
if __name__ == "__main__":
main()
Run it with your token in the environment:
export CAREEROS_TOKEN='<your token>'
python3 push_students.py
Going to production
- Dry run against a handful of students first, and read every rejection.
- Keep batches at 200–500. Over 500 in one request returns
413and writes nothing. - Respect the write rate limit: 60 requests/minute. A nightly sync of a few thousand students is a handful of batched calls, well inside it.
- Retry
429and5xxwith backoff.PUTis idempotent, so a retried batch is safe. - Log the
resultsarray. It is your record of what changed, and it is how you spot an extract slowly drifting.