The first run is the one that matters. Get it wrong and every member exists twice.
A workspace already has people in it, typed in by hand. Your system has the same people. Nothing links the two, so a naive first push creates a second copy of everyone — and because families share email addresses, an address cannot be used to tell them apart.
So we keep an identity map: your external_id ↔ our member. Once a person is in it, every later run updates rather than adds.
Send the batch with "mode": "dry_run". Nothing is written. For any record that looks like someone already here, you get candidates back.
curl -X POST https://api.theskillsfest.com/v1/sync/members \
-H "Authorization: Bearer $SKILLSFEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "acme-membership",
"mode": "dry_run",
"records": [
{ "external_id": "U-1042", "first_name": "Ada", "last_name": "Lovelace",
"date_of_birth": "2012-04-05", "role": "Student" }
]
}'Confirm the matches. An admin can do this in Settings → API → Sync, or you can post them:
curl -X POST https://api.theskillsfest.com/v1/sync/links \
-H "Authorization: Bearer $SKILLSFEST_KEY" \
-H "Content-Type: application/json" \
-d '{ "source": "acme-membership",
"links": [{ "external_id": "U-1042", "member_id": 1489 }] }'You can ask us to link obvious matches for you with "auto_link": "exact", which links only when the last name, date of birth, and club all agree and exactly one person matches. If several people match, we refuse and hand it back — that means the workspace has its own duplicates, and linking into an unresolved pair strands half of someone’s history.
curl -X POST https://api.theskillsfest.com/v1/sync/members \
-H "Authorization: Bearer $SKILLSFEST_KEY" \
-H "Content-Type: application/json" \
-d '{ "source": "acme-membership", "records": [ ... ] }'Send the same batch as often as you like. Each record comes back as created, updated, unchanged, or failed, and an unchanged record does no work at all. Partial success is normal: one bad row is a failed entry, not a rejected batch.
Send only fields Skills Fest actually stores. Anything else is refused, naming the field — we never accept a write and quietly drop part of it, because an integration that believes it recorded something and didn’t is worse than one that got an error.
| If your system calls it | Send |
|---|---|
a stable record id, e.g. ymms:usuario:1463299 | external_id verbatim, with your source slug alongside it |
| one display name, “FIRST LAST” | first_name and last_name — both required and non-empty, so split before sending |
| the person’s club id | unit_external_id |
| the club’s director or head leader | on the CLUB record, director_external_id — the same id you imported that person under, from the same source. There is no director_email and no name matching: two clubs in different programmes share names and leaders far too often for either to be an identity |
a role like PATHFINDER or DIRECTOR | Student, Teacher, Staff, or UnitLeader. Map it on your side — an unmapped role fails the record rather than guessing whether someone is a child or an adult |
| a regional grouping or conference | parent_org on the club |
active: false | status: "inactive" |
| screening / background-check state | background_check and background_check_not_cleared as booleans you have already decided. We never infer them from a role — see below |
Fields with no Skills Fest equivalent — a church, an age, a city, a member count, a status string — are refused. Send what maps; drop the rest.
Nothing special: send the same external_id with the new unit_external_id. A person belongs to one club here, so the old one stops listing them automatically. Sync the destination club first if it is new.
By default, leaving a person out of a batch does nothing — most callers send changes rather than a census, and a truncated payload must never deactivate anyone. Two ways to retire someone:
"status": "inactive". Simple, explicit, always works."roster": "full", which declares the payload is your complete roster for this source. Anyone linked to it and absent is deactivated — never deleted, and never anyone who isn’t linked to your source.A full-roster run that would deactivate an unusual share of your people stops and tells you the number instead of doing it. Resend with "confirm_deactivations": <that exact number> if it really is intended. It’s an exact count rather than a flag on purpose: a bug that truncates your payload can always set a flag, but it cannot guess how many people it is about to remove.
unit_external_id resolves. An unknown club fails that record rather than filing the person under nothing. And a club whose NAME matches one you already have is refused, not merged — two unrelated clubs can share a name, so a person has to confirm that link in Settings → API → Sync.director_external_id points the other way, at a person, so on a first run that person does not exist yet. Run clubs → members → clubs: the second club pass costs nothing for clubs that have not changed (they come back unchanged) and fills in the directors. A director who cannot be resolved fails that club outright — it will not quietly land without a leader.