Skip to main content

Progress Migration

Base URL: https://api-sls.platzi.com/production-sls-business-domains

Early access. This API is being rolled out with selected partners. Contact your account manager to enable it for your company and get a sandbox environment.

Key features:

  • Migrate the full course history of each user: completion percentage, dates, and exam results
  • Dry-run mode that validates the whole payload without writing anything
  • Per-record error reporting with actionable error codes — one bad record never rejects the batch
  • Non-destructive merge: the import never downgrades progress a user already earned on Platzi
  • Idempotent at two levels (request and record), safe to retry

Workflow

  1. GET /v1/migrations/{company_id}/catalog/courses — map your external courses to platzi_course_id
  2. POST /v1/migrations/{company_id}/users/resolve — verify every user exists with an active license
  3. (Optional) POST .../users/mappings and POST .../catalog/mappings — register your previous-LMS ids so records can reference users and courses by your own ids
  4. POST /v1/migrations/{company_id}/progress/imports with dry_run: true — validate, fix errors
  5. Same request with dry_run: false — run the real import
  6. Poll GET /v1/migrations/{company_id}/progress/imports/{import_id} until the status is terminal
  7. GET .../imports/{import_id}/records?status=error — review rejected records

Import statuses: validated (dry run, terminal) · processingsuccess | partial_success | error

Users must exist with an active license before the import — this API never creates users. Onboard them first with the Users API or a Massive Action (invite_users).


External id mappings (optional)

If keeping your previous-LMS ids is easier than exporting emails and Platzi course ids, register the links once and reference everything by your own ids:

POST /v1/migrations/{company_id}/users/mappings
{ "mappings": [ { "external_user_id": "EMP-001", "email": "ana@empresa.com" } ] }

POST /v1/migrations/{company_id}/catalog/mappings
{ "mappings": [ { "external_course_id": 9101, "platzi_course_id": 1620 } ] }
  • External ids are flexible: numbers and strings are equivalent (9101 and "9101" are the same id), max 128 characters.
  • Upsert semantics: re-sending an external id overwrites its link. GET on the same paths lists what is currently registered.
  • After registering, records can use user.external_user_id instead of user.email, and content.external_course_id instead of content.platzi_course_id (or both — they must resolve to the same entity).

The migration record

One record = the full history of one (user, course) pair. All timestamps are ISO 8601 in UTC.

{
"external_record_id": "lms-rec-84921",
"user": { "email": "ana@empresa.com" },
"content": { "type": "course", "platzi_course_id": 2599, "external_ref": "JS-101" },
"progress": {
"completion_pct": 87.5,
"started_at": "2024-03-01T00:00:00Z",
"last_activity_at": "2024-06-15T10:30:00Z",
"completed_at": null,
"classes": [
{ "platzi_class_id": 259901, "viewed_at": "2024-03-02T00:00:00Z" },
{ "platzi_class_id": 259902, "viewed_at": "2024-03-05T00:00:00Z" }
]
},
"assessment": { "status": "passed", "score_pct": 92, "taken_at": "2024-06-20T00:00:00Z" }
}
FieldRequiredDescription
external_record_idYes1–128 chars, unique per company. Per-record idempotency key: re-sending it never duplicates progress.
user.emailConditionalMust resolve to an active user in the company. Required unless user.external_user_id is sent.
user.external_user_idConditionalYour previous-LMS user id (number or string, max 128 chars), registered beforehand with POST .../users/mappings. If both identifiers are sent, they must resolve to the same user.
content.typeYesOnly course in v1. Learning-path progress is recalculated by Platzi from course records — never imported directly.
content.platzi_course_idConditionalCourse id from the catalog endpoint. Required unless content.external_course_id is sent.
content.external_course_idConditionalYour previous-LMS course id (number or string, max 128 chars), registered beforehand with POST .../catalog/mappings. If both identifiers are sent, they must resolve to the same course.
content.external_refNoFree-form reference, stored for traceability only (never resolved).
progress.completion_pctConditional0–100. Required unless assessment.status is passed (completion is assumed to be 100) or progress.classes is sent (derived from the class count).
progress.classesNoClass-level detail: array of { platzi_class_id, viewed_at? }. Marks those classes as viewed in Platzi. Use it when the course structure matches (e.g. the company mirrored the exact same course); class ids come from the catalog endpoint.
progress.*_at datesNoMissing dates default to the import date.
assessment.statusNopassed | failed | not_taken. Declared by your system — Platzi does not re-evaluate.
assessment.score_pctNo0–100, informational.
assessment.taken_atNoUsed as the certification date when passed.

Business rules

SituationRule
Record with progress.classesEach listed class is marked as viewed (with its original viewed_at date). Classes that don't belong to the course are skipped with a warning; duplicates are ignored. Without class detail, progress is stored at course level only — no synthetic class views are fabricated.
classes sent without completion_pctValid — the percentage is derived from the class count (warning completion_pct_derived). If both are sent and the class detail implies a higher percentage, the higher value is kept (warning completion_pct_adjusted).
Course with exam + assessment.status: "passed"The course is marked certified (approved_course = 1, certification date = taken_at).
Course with exam, exam not passedCertification stays pending; only the completion percentage applies.
Course without examThe assessment block is ignored (warning assessment_ignored); the course completes at 100% of classes.
Exam-only record (passed, no progress)Valid — completion is assumed 100 (warning completion_pct_assumed).
User already has progress on PlatziNon-destructive merge: the maximum is kept per dimension (warning native_progress_kept). The import never downgrades organic progress.
Every migrated recordPersisted with source = "external_migration" and the import_id, so migrated history is always distinguishable and auditable.

Error codes

Request-level errors use the standard error envelope (errors[].error_code): invalid_request, batch_too_large (over 500 records / 100 emails), import_not_found.

Record-level errors (records[].errors[].error_code — the record is rejected, the batch continues):

CodeMeaning
invalid_recordStructural problem; the message says exactly which field.
duplicate_external_record_idThe same external_record_id appears twice in one batch.
user_not_found / user_inactiveEmail does not resolve to an active user in the company.
unsupported_content_typecontent.type is not course.
course_not_in_catalogplatzi_course_id is not in the company catalog.
external_user_id_not_mapped / external_course_id_not_mappedThe external id was never registered with the mappings endpoints.
identifier_mismatchBoth identifiers were sent (email + external id, or course id + external id) but they resolve to different entities.
invalid_completion_pct / invalid_date_formatValue out of range / not ISO 8601.
missing_progressNo progress block and the passed-exam rule does not apply.

Record-level warnings (the record is still applied): assessment_ignored, completion_pct_assumed, completion_pct_derived, completion_pct_adjusted, class_not_in_course, duplicate_class_id, native_progress_kept.


Limits & idempotency

ConcernValue
Max records per import request500
Max emails per resolve request100
Max mappings per request500
Request idempotencyIdempotency-Key header — retrying returns the original result.
Record idempotencyexternal_record_id — re-sending never duplicates progress.

API Reference

Complete request/response schemas for each endpoint: