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@careeros.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, group it first; a people extract, like the one the example below reads, is already at that grain. 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 the student’s official graduation date: the graduation_date you send, otherwise the end_date of the cohort they are in. A date before today means alumnus; with neither, the flag is left as it is. Cohorts whose end date is being confirmed with your advisors are skipped until it is confirmed.

The derivation runs every night for every student, in both directions, and also when they are enrolled in or moved to a cohort and on a write that sends graduation_date or cohort_name without is_alumni. A historical roster of graduates who never sign in is promoted by your write itself, as long as you send their graduation_date.

Sending it yourself

If your SIS knows who has graduated, send is_alumni and the flag is set on the spot, in either direction:

{ "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 may not last: a later derivation recomputes the flag from the date. Fix the SIS record rather than relying on the flag.

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 as soon as that date passes.

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": 0,
  "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": "3f2a1000-…" },
    { "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 applied differently than you might expect, or a change worth your attention. There are four today.

dropped_fields names data you sent that a student record does not hold. It is a closed list of eleven names, not “anything we don’t recognise”: birth_date, person_birth_date, gender, person_gender, grades, courses, top_courses, degree, specialization, anticipated_completion_date and cohort_display_name. Sending one of those is not an error; it is reported rather than ignored so you can see it was not saved. Any other unrecognised field refuses the whole request with INVALID_BODY, so send the documented fields and nothing else. 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 a Populi people extract — one row per person, with the cohort carried in a tag and one JSON object per degree — and upserts it in batches. Standard library only.

The field names below (person_id, is_alum, degree_info, tags and so on) are one SIS’s vocabulary — rename them to match your own export. The shape is what generalises: one object per person, one cohort per person, the programme resolved from your degree record, your SIS’s alumni flag passed through as is_alumni, and an actual graduation date sent 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
student.external_idperson_id — the SIS primary key, never reissued
student.cohort_name / cohort.name (frozen)the tag starting with Cohort , verbatim, e.g. Cohort 11-MGHD
cohort.programdegree_info.name, e.g. Master of Science in Global Health Delivery
cohort.degreethe award half of that name — everything before " in "
cohort.abbreviation_namedegree_info.abbrv
cohort.display_nameprogramme + expected completion year
cohort.end_datelatest degree_anticipated_completion_date among the cohort’s members
cohort.start_date, intake_year, intake_termderived: end_date minus the programme length in degree_info
student.is_alumniis_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_tag(), and the identifier in student_key(), so swapping either is a one-line change.

Three of those rows are deliberate. A tag is matched to a degree through the abbreviation after its dash (Cohort 11-MGHD → MGHD), and a person on two programmes at once is placed in the one that runs longest — CareerOS holds one cohort per person, so the other is reported and not recorded. 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). And the intake row is an estimate: a people extract carries no term dates, so the script derives the start from the programme length and prints each derivation as DERIVED. If your SIS holds the real cohort start date, send that instead and derive nothing.

The script refuses to send what CareerOS would reject, and says why. A person with no cohort tag or no email is listed under BLOCKED; a cohort missing a required field under INCOMPLETE COHORTS, with its students held back rather than sent to a certain COHORT_NOT_FOUND. Run --dry-run first: the report goes to stderr and the exact payloads to stdout.

#!/usr/bin/env python3
"""Push students and alumni from a Populi people extract into CareerOS.

Reads the v2 extract (one row per person, cohort in `tags`), builds cohorts
and students, and upserts them. Runs on your infrastructure. Standard library
only, Python 3.8+.

    export CAREEROS_TOKEN='<your token>'
    python3 careeros_sync.py extract.json --dry-run          # report + payloads, send nothing
    python3 careeros_sync.py extract.json --phone-country 250 # push, local numbers as Rwandan
    python3 careeros_sync.py extract.json --reassign alex.doe@example.edu="Cohort 12-MGHD"

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 re
import sys
import urllib.error
import urllib.request
from collections import Counter, 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

# CareerOS accepts only these four seasons.
SEASON_BY_MONTH = {12: "Winter", 1: "Winter", 2: "Winter",
                   3: "Spring", 4: "Spring", 5: "Spring",
                   6: "Summer", 7: "Summer", 8: "Summer",
                   9: "Fall", 10: "Fall", 11: "Fall"}

ALLOWED_IN_PHONE = set("0123456789+ -().")

# Mandatory on create, mirroring the advisor roster template.
REQUIRED_COHORT_FIELDS = ("name", "display_name", "program", "degree",
                         "start_date", "end_date", "intake_year", "intake_term")

# Extract fields CareerOS does not store; listed once in the dry-run report.
NOT_STORED = ("middle_name", "gender", "birth_date", "home_city", "home_state",
              "home_country", "specialization_name", "specialization_abbrv",
              "course_offering_name", "course_grade", "active_roles")


# ─────────────────────────────────────────────────────────────────────────────
# THE TWO MAPPINGS TO CONFIRM
# ─────────────────────────────────────────────────────────────────────────────

def student_key(row):
    """The durable identifier sent as external_id; it must never be reissued.
    person_id is Populi's own primary key. visible_student_id is the printed
    student number, which is not unique across SIS components."""
    return str(row["person_id"])


def cohort_tag(tag):
    """Which tags name a cohort: here the ones starting with "Cohort ", e.g.
    "Cohort 11-MGHD". The tag itself becomes the frozen cohort name."""
    return tag.lower().startswith("cohort ")


# ─────────────────────────────────────────────────────────────────────────────
# Field helpers
# ─────────────────────────────────────────────────────────────────────────────

def split_pipe(value):
    """Populi joins one value per degree with " | ". None -> []."""
    if value is None or value == "":
        return []
    return [part.strip() for part in str(value).split(" | ")]


def split_degree_info(value):
    """degree_info is one JSON object per degree joined with " | ". Descriptions
    may themselves contain a pipe, so split only where a new object begins."""
    if not value:
        return []
    return [json.loads(part) for part in re.split(r"\s\|\s(?=\{)", value)]


def to_e164(raw, default_country=None):
    """CareerOS requires E.164. A local number is only completed when
    --phone-country says which dial code applies; otherwise it is dropped
    rather than guessed. Unexpected characters make the value untrustworthy."""
    if not raw or not set(raw) <= ALLOWED_IN_PHONE:
        return None
    compact = "".join(c for c in raw if c.isdigit() or c == "+")
    if not compact.startswith("+") and default_country:
        compact = "+" + default_country + compact.lstrip("0")
    return compact if compact.startswith("+") and 8 <= len(compact) <= 18 else None


def award(degree_title):
    """"Master of Science in Global Health Delivery" -> "Master of Science"."""
    return degree_title.split(" in ", 1)[0].strip()


def shift_months(day, months):
    """day minus `months` months, clamped to the end of the resulting month."""
    year, month = divmod(day.year * 12 + day.month - 1 - months, 12)
    month += 1
    for last in (31, 30, 29, 28):
        try:
            return date(year, month, min(day.day, last))
        except ValueError:
            continue
    raise ValueError(day)


def derive_start(end, degree):
    """Populi's people extract carries no intake date. The programme length in
    degree_info is the only signal, so the start is end_date minus length --
    an estimate, reported as DERIVED so you can confirm or supply the real one."""
    length, unit = degree.get("length"), (degree.get("length_unit") or "").lower()
    if not length or not end:
        return None
    months = int(length) * (12 if unit.startswith("year") else 1)
    return shift_months(date.fromisoformat(end), months)


def has_graduated(row, grad):
    """Requires the SIS alum flag AND a past date. A past graduation date is
    what turns an account into an alumni account, so a wrong date is worse
    than a wrong flag."""
    if not grad or not row.get("is_alum"):
        return False
    return date.fromisoformat(grad) <= date.today()


# ─────────────────────────────────────────────────────────────────────────────
# Person rows -> students + cohorts
# ─────────────────────────────────────────────────────────────────────────────

def degrees_of(row):
    """One dict per degree, pairing the pipe-delimited columns positionally."""
    infos = split_degree_info(row.get("degree_info"))
    statuses = split_pipe(row.get("degree_status"))
    ends = split_pipe(row.get("degree_anticipated_completion_date"))
    grads = split_pipe(row.get("degree_graduation_date"))
    out = []
    for i, info in enumerate(infos):
        out.append({
            "info": info,
            "abbrv": unescape(info.get("abbrv") or "").strip(),
            "name": unescape(info.get("name") or "").strip(),
            "status": statuses[i] if i < len(statuses) else None,
            "end": ends[i] or None if i < len(ends) else None,
            "graduated": grads[i] or None if i < len(grads) else None,
        })
    return out


def pair_tags_to_degrees(tags, degrees):
    """"Cohort 11-MGHD" belongs to the degree whose abbrv is "MGHD"."""
    pairs, orphans = [], []
    for tag in tags:
        suffix = tag.split("-", 1)[1].strip().lower() if "-" in tag else ""
        match = next((d for d in degrees if d["abbrv"].lower() == suffix), None)
        if match:
            pairs.append((tag, match))
        else:
            orphans.append(tag)
    return pairs, orphans


def group(rows, phone_country=None):
    students, cohorts, report = [], {}, defaultdict(list)
    cohort_ends = defaultdict(list)

    for row in rows:
        key = student_key(row)
        email = (row.get("contact_primary_email") or "").strip().lower()
        label = f"{key} {email or '(no email)'}"
        if not email:
            report["blocked"].append(f"{label}: no contact_primary_email")
            continue

        tags = [unescape(t).strip() for t in (row.get("tags") or "").split(",") if t.strip()]
        cohort_tags = [t for t in tags if cohort_tag(t)]
        degrees = degrees_of(row)
        pairs, orphans = pair_tags_to_degrees(cohort_tags, degrees)
        for tag in orphans:
            report["warnings"].append(f"{label}: tag {tag!r} matches no degree abbrv; ignored")
        if degrees and not cohort_tags:
            report["warnings"].append(f"{label}: has a degree but no Cohort tag")

        if not pairs:
            why = "no Cohort tag" if not cohort_tags else "Cohort tag matches no degree"
            report["blocked"].append(f"{label}: {why} (is_alum={row.get('is_alum')})")
            continue

        # One account, one cohort: the programme still running longest is the
        # current one. Others are reported, since CareerOS has nowhere to keep them.
        pairs.sort(key=lambda p: (p[1]["end"] or "", p[1]["status"] == "active"), reverse=True)
        tag, degree = pairs[0]
        for other_tag, _ in pairs[1:]:
            report["not_recorded"].append(f"{label}: also in {other_tag!r}")

        grad = degree["graduated"]
        students.append({
            "email": email,
            "first_name": unescape(row["first_name"]).strip(),
            "last_name": unescape(row["last_name"]).strip(),
            "cohort_name": tag,
            "is_alumni": bool(row.get("is_alum")),
            "external_id": key,
            "phone": to_e164(row.get("contact_primary_phone"), phone_country),
            "graduation_date": grad if has_graduated(row, grad) else None,
        })

        if tag not in cohorts:
            cohorts[tag] = {
                "name": tag,
                "display_name": None,
                "program": degree["name"],
                "degree": award(degree["name"]),
                "abbreviation_name": degree["abbrv"] or None,
                "start_date": None,
                "end_date": None,
                "intake_year": None,
                "intake_term": None,
                "_degree": degree["info"],
            }
        if degree["end"]:
            cohort_ends[tag].append(degree["end"])

    # A cohort finishes when its last member is expected to; intake, year and
    # term are derived from that and the programme length.
    complete, blocked = [], set()
    for tag, cohort in cohorts.items():
        end = max(cohort_ends[tag]) if cohort_ends[tag] else None
        start = derive_start(end, cohort.pop("_degree"))
        cohort["end_date"] = end
        cohort["start_date"] = start.isoformat() if start else None
        cohort["intake_year"] = start.year if start else None
        cohort["intake_term"] = SEASON_BY_MONTH[start.month] if start else None
        cohort["display_name"] = f"{cohort['program']} {end[:4]}" if end else cohort["program"]
        if start:
            report["derived"].append(
                f"{tag}: start_date {cohort['start_date']}, intake {cohort['intake_term']} "
                f"{cohort['intake_year']} = end_date minus programme length")
        gaps = [f for f in REQUIRED_COHORT_FIELDS if not cohort.get(f)]
        if gaps:
            report["incomplete_cohorts"].append(f"{tag}: no {', '.join(gaps)}")
            blocked.add(tag)
        else:
            complete.append(cohort)

    sendable = []
    for student in students:
        if student["cohort_name"] in blocked:
            report["blocked"].append(f"{student['external_id']} {student['email']}: "
                                     f"cohort {student['cohort_name']!r} cannot be created")
        else:
            sendable.append(student)
    return sendable, complete, report


def load_rows(path):
    with open(path, encoding="utf-8") as handle:
        data = json.load(handle)
    return data["rows"] if isinstance(data, dict) else data


def print_report(rows, students, cohorts, report):
    alumni = sum(1 for s in students if s["is_alumni"])
    err = sys.stderr
    print(f"{len(rows)} people -> {len(students)} sendable students "
          f"({alumni} alumni), {len(cohorts)} cohorts\n", file=err)
    for section, title in (("blocked", "BLOCKED (not sent)"),
                           ("incomplete_cohorts", "INCOMPLETE COHORTS"),
                           ("derived", "DERIVED (confirm with Populi)"),
                           ("not_recorded", "NOT RECORDED (second programme)"),
                           ("warnings", "WARNINGS")):
        if report.get(section):
            print(title, file=err)
            for line in report[section]:
                print(f"  {line}", file=err)
            print(file=err)
    present = [f for f in NOT_STORED if any(r.get(f) not in (None, "") for r in rows)]
    if present:
        print(f"NOT STORED by CareerOS: {', '.join(present)}\n", file=err)


# ─────────────────────────────────────────────────────────────────────────────
# 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:
        print(f"  HTTP {err.code}: {err.read().decode()}", file=sys.stderr)
        raise


def reassign(pairs, token):
    """Move people to another cohort, changing nothing else. The cohort must
    already exist -- push it with /v1/cohorts first."""
    rows = [{"email": e, "cohort_name": c} for e, c in pairs]
    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']}")


def main():
    parser = argparse.ArgumentParser(description="Push a Populi people extract into CareerOS.")
    parser.add_argument("extract", help="Path to the extract JSON ({rows: [...]} or a list)")
    parser.add_argument("--dry-run", action="store_true",
                        help="Report to stderr, payloads as JSON to stdout, send nothing")
    parser.add_argument("--phone-country", metavar="CODE",
                        help="Dial code for numbers without one, e.g. 250. Omit to drop them")
    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:
        reassign([tuple(item.split("=", 1)) for item in args.reassign], os.environ["CAREEROS_TOKEN"])
        return

    rows = load_rows(args.extract)
    students, cohorts, report = group(rows, args.phone_country)
    print_report(rows, students, cohorts, report)

    if args.dry_run:
        print(json.dumps({"cohorts": cohorts, "students": students}, indent=2))
        return

    token = os.environ["CAREEROS_TOKEN"]
    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 = Counter()
    for start in range(0, len(students), BATCH_SIZE):
        result = push(STUDENTS_API, {"students": students[start:start + BATCH_SIZE]}, token)
        totals.update(result["counts"])
        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 careeros_sync.py extract.json --dry-run
python3 careeros_sync.py extract.json --phone-country 250

Going to production