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/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, 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.
| 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 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 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 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/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. - The cohort’s label is
display_nameon the cohort.cohort_display_nameon a student is dropped — a student write never creates or renames a cohort. - 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": 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.
- An
is_alumnithat disagrees withgraduation_date— see Alumni. - A changed address. If you push a student by
external_idwith an email the account does not hold, we record it as a secondary address — a real sign-in address from the moment it is written, since you’re asserting your own student — but it does not become the contact address. Contact support@careeros.com to complete a domain migration. - A changed
external_id. Pushing a student by email with a differentexternal_idre-keys them: the new value is stored and the old one stops matching. The row reportsupdatedand names both values, so a re-key is never silent. - An address taken over from an unconfirmed request. If another CareerOS user asked to add the address you pushed to their own account and never confirmed it from that mailbox, your record wins: the request is cancelled and the address goes to your student. The note does not name the other user.
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
| 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 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:
| CareerOS | From your extract |
|---|---|
student.external_id | person_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.program | degree_info.name, e.g. Master of Science in Global Health Delivery |
cohort.degree | the award half of that name — everything before " in " |
cohort.abbreviation_name | degree_info.abbrv |
cohort.display_name | programme + expected completion year |
cohort.end_date | latest degree_anticipated_completion_date among the cohort’s members |
cohort.start_date, intake_year, intake_term | derived: end_date minus the programme length in degree_info |
student.is_alumni | 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_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
- 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.