CareerOS Partners API

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/studentsStart 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/studentsCreate only. An existing student comes back rejected rather than updated. Requires the complete student.
PATCH /v1/studentsUpdate 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.

FieldWhereWhat it means
graduation_dateon the studentThe date this person actually graduated. Send it only for people who have finished.
is_alumnion the studentWhether 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 sendWhat happens
trueAccount flagged as alumni immediately, on create and on update.
falseFlag cleared immediately.
field omittedRefused 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/alumni resource 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:

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:

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

CodeWhat to do
INVALID_EMAIL, MISSING_NAMEFix the record in your SIS.
INVALID_PHONEAdd the country code, or send null.
EMAIL_BELONGS_TO_ANOTHER_UNIVERSITYThat address is already someone else’s account. Never adopted — confirm the address is right.
EMAIL_BELONGS_TO_EXISTING_ACCOUNTThat 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_STUDENTThe address belongs to a staff account.
EXTERNAL_ID_CONFLICTYour external_id is already mapped to a different person than this email. We will not merge them; resolve which is correct.
NOT_FOUNDPATCH only — no student matches. Use PUT if it should be created.
ALREADY_EXISTSYou used POST for someone who exists. Nothing was written — use PUT.
MISSING_COHORTNo cohort_name was sent. It is required on create; every student belongs to a cohort.
COHORT_NOT_FOUNDThe cohort_name matches no cohort at your university. Create it with POST /v1/cohorts first — a student write cannot build one.
COHORT_NAME_AMBIGUOUSTwo of your cohorts share that cohort_name, so we will not guess. Contact support to resolve the duplicate.
UNKNOWN_TEMPLATEA resume_template_names entry does not exist at your university. Only checked when the cohort is being created.
CONCURRENT_WRITETwo writes raced. Retry the row.
ACCOUNT_DELETEDThe 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:

CareerOSFrom your extract
cohort.name (frozen)programme + intake year, e.g. Global Nursing Leadership 2023
cohort.display_namefull degree_name + intake year
cohort.programprogram_name, falling back to the subject in degree_name when that is a generic tier
cohort.degreethe award half of degree_name — everything before " in "
cohort.start_dateearliest academic_term_start_date for that class
cohort.end_datedegree_anticipated_completion_date — when the class is expected to finish
cohort.intake_year / intake_termyear of start_date, and the season named in academic_term_name
student.cohort_namethe cohort name above
student.is_alumniperson_is_alum, forwarded unchanged
student.graduation_datedegree_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