Massive Actions
Base URL: https://api-sls.platzi.com/production-sls-business-domains
Key Features:
- Upload a file directly to S3 via a presigned URL
- Start an asynchronous job and poll for progress
- Per-row error reporting with aggregate status
Workflow
POST /v1/massive-actions/{company_id}/presigned-upload-url/— get upload URLPUTthe file to the presigned URLPOST /v1/massive-actions/{company_id}/— start the job- Poll
GET /v1/massive-actions/{company_id}/{job_id}/until status is terminal
Job statuses: pending → processing → success | partial_success | error
While a job is processing, the job detail response includes a progress object you can use to render a progress bar:
{
"progress": {
"phase": "onboarding",
"total": 200000,
"completed": 154200,
"percent": 77.1
}
}
phaseisstartingright after the job is accepted (counters still zero),validatingwhile the file rows are being validated, andonboarding(only forinvite_users) while the accounts are being created and invited.progressreports throughput only (how many items have been processed), not outcomes. Success/failure counts are available insummaryonce the job reaches a terminal status.progressisnullonce the job reaches a terminal status (usesummaryinstead).- The list endpoint (
GET /v1/massive-actions/{companyId}/) includes the sameprogressobject onprocessingjobs, so history views can render a live percentage without one detail call per row. - The list endpoint filters by
action,status,pageandpage_size.actionaccepts several values — repeated (?action=assign_objective&action=update_objectives) or comma separated — so one history can cover several actions; an unknown action answers400.
For invite_users, the job stays processing until every accepted user has actually been onboarded (account created, licenses assigned, invitation sent). The final summary counts each file row exactly once: total = success + failed, where failed includes invalid rows, duplicate emails, and users whose onboarding failed.
Supported actions
| Action | Description |
|---|---|
invite_users | Invite (onboard) users in bulk, with optional per-user metadata |
assign_users_to_group | Add users (by email) to a group |
unassign_users_from_group | Remove users (by email) from a group |
deactivate_users | Deactivate users by email (a flexible-plan month already running is kept until it ends) |
delete_users | Delete users by email |
resend_invitations | Resend pending invitations by email |
create_learning_paths | Create custom learning paths in bulk |
assign_users_to_learning_path | Assign users (by email or username) to a learning path |
unassign_users_from_learning_path | Remove users (by email or username) from a learning path |
create_registration_code_invites | Create registration-code invitations in bulk |
assign_registration_codes | Assign existing registration codes in bulk |
assign_objective | Assign one objective (a course or a learning path, with an optional deadline) to users by email or username |
update_objectives | Add, re-date and remove objectives in bulk: one user, one content and one action per row |
Invite users in bulk
Action: invite_users
Accepted file types: CSV (.csv)
File format: one row per user to invite.
| Column | Required | Description |
|---|---|---|
email | Yes | User email. Must be a valid address. |
name | No | User full name. |
licenses | No | Number of licenses to assign. Integer >= 0 (defaults to 0). |
| any other column | No | Treated as a metadata field. For companies with configured metadata fields, the column header must match one of them (by key or label). For companies without configured metadata fields, extra columns are accepted and ignored. |
The email, name, and licenses headers are reserved and matched case-insensitively. Every other column is interpreted as user metadata. Metadata is optional per row — a company configured for metadata can still upload rows that leave some (or all) metadata cells empty. If the company has no metadata fields configured, any extra columns in the file are silently ignored (no metadata is stored) and the rows are still invited.
Emails are case-insensitive. When the file contains two or more rows with the same email, only the first row is invited — no extra licenses are reserved and no duplicate invitation email is sent. Duplicate rows are reported in the job's error samples with the duplicate_email code and counted in summary.failed.
Use GET /v3/users/{company_id}/metadata-fields to discover which metadata fields a company accepts before building the file.
Request payload (optional):
{
"action": "invite_users",
"file_key": "<from presigned-upload-url>",
"payload": {
"metadata_columns": ["area", "sede"],
"group_ids": [12],
"skip_mail_send": false,
"skip_mail_validation": false
}
}
| Field | Default | Description |
|---|---|---|
metadata_columns | [] | Names of the metadata columns present in the uploaded file. Sent as a hint so the request can be rejected up front when a configured company doesn't accept one of them. Ignored when the company has no metadata fields configured. |
group_ids | [] | Groups every invited user is added to. Each id must be an active group of the company, otherwise the request is rejected with unknown_group before any row is processed. |
skip_mail_send | false | Skip sending the invitation email. |
skip_mail_validation | false | Skip email deliverability validation. |
Job-level validation (rejected with 400 before any row is processed):
| Error code | Meaning |
|---|---|
unknown_metadata_key | One or more declared metadata columns don't match any configured field (by key or label). Only applies to companies with metadata fields configured. |
Per-row result fields: email, error (on failure).
Common row errors:
| Error | Meaning |
|---|---|
missing_email | Row has no email value. |
invalid_email | Email is not a valid address. |
invalid_licenses | licenses is not an integer >= 0. |
invalid_metadata | A metadata value fails the company's field validation. |
invalid_row | The row could not be parsed. |
duplicate_email | The email already appeared in an earlier row of the same file; only the first row was invited. |
Job-level failure reasons (whole roster rejected after validation; surfaced in error_detail.reason): no_valid_users, empty_csv, not_flexible_plan, plan_expired, not_enough_licenses.
Create learning paths in bulk
Action: create_learning_paths
Accepted file types: CSV (.csv)
File format (wide): one row per learning path.
| Column | Required | Description |
|---|---|---|
ruta | Yes | Learning path title. Must be unique within the company catalog. |
curso_1 … curso_20 | At least one | Course IDs (integers) assigned to the path, in column order. Empty cells are ignored. |
Request payload (optional):
{
"action": "create_learning_paths",
"file_key": "<from presigned-upload-url>",
"payload": {
"step_title": "Programa",
"state": "active"
}
}
| Field | Default | Description |
|---|---|---|
step_title | Programa | Title of the single step that contains all courses |
state | active | Catalog state: draft, active, or archived |
Per-row result fields: ruta, learning_path_id (on success), error (on failure)
Common row errors:
| Error | Meaning |
|---|---|
missing_ruta | Row has no path title |
missing_course_ids | Row has no valid course IDs |
title_already_exists | A path with that title already exists |
courses_not_available | One or more course IDs are not in the company catalog |
Template file: see assets/templates/create_learning_paths_template.csv in the repository.
Assign or remove learning-path users in bulk
Actions: assign_users_to_learning_path, unassign_users_from_learning_path
Accepted file types: CSV (.csv)
File format: one row per user.
| Column | Required | Description |
|---|---|---|
identificador | Yes | The user's email address, or their username for users who registered without one. The columns usuario and email are accepted as synonyms. |
Request payload (required):
{
"action": "unassign_users_from_learning_path",
"file_key": "<from presigned-upload-url>",
"payload": {
"learning_path_id": 42
}
}
| Field | Required | Description |
|---|---|---|
learning_path_id | Yes | The learning path every row is assigned to (or removed from). One job targets one path. |
The job is rejected up front (learning_path_not_found) when the learning
path is not available to the company — for removals this means it must
already be in the company catalog.
Removing a user preserves their course progress: re-assigning the path later restores them where they left off. Users who were not assigned to the path are reported as failed rows rather than silently skipped.
Common row errors:
| Error | Meaning |
|---|---|
invalid_identifier | The row's identifier is empty or malformed |
user_not_found | No user in the company matches the identifier |
user_not_in_learning_path | (Removal only) the user was not assigned to the path |
learning_path_not_found | The learning path is no longer available |
Assign an objective in bulk
Action: assign_objective
Accepted file types: CSV (.csv)
File format: one row per user. Same file as the learning-path actions.
| Column | Required | Description |
|---|---|---|
identificador | Yes | The user's email address, or their username for users who registered without one. The columns usuario and email are accepted as synonyms. |
Request payload (required):
{
"action": "assign_objective",
"file_key": "<from presigned-upload-url>",
"payload": {
"catalog_type": "course",
"content_id": 2345,
"deadline": "2026-12-31T23:59:59-05:00"
}
}
| Field | Required | Description |
|---|---|---|
catalog_type | Yes | course or learning_path. |
content_id | Yes | The course or learning path every row is assigned. One job targets one objective. |
deadline | No | ISO 8601 completion deadline applied to every row. Omit it to assign without a deadline. |
The job is rejected up front when the content is not available to the
company (course_not_found or learning_path_not_found), before any row
runs.
The action only adds: users who already had the objective keep it, with the deadline of this upload, and nobody is removed from it. Any member of the company can be assigned, whatever the state of their license.
Managers. assign_objective is the one massive action open to the
manager role; every other action answers 403 manager_forbidden. A manager
needs objectives.view and the assign permission of the content
(courses.assign or learning_paths.assign) switched on, or the job is
refused with 403 permission_disabled. How far the upload reaches follows
objectives.view:
objectives.view reach | Result |
|---|---|
| Whole account | Every row is processed like an administrator's upload |
| Own groups | Rows naming someone outside the manager's groups fail with out_of_scope; the rest are assigned |
| Own groups, no groups assigned | The job is refused with 403 no_group_scope |
A manager can read the detail of the jobs they started, but not the job list or other users' jobs.
Common row errors:
| Error | Meaning |
|---|---|
invalid_identifier | The row's identifier is empty or malformed |
user_not_found | No user in the company matches the identifier |
objective_content_not_found | The content stopped being available while the job was running |
out_of_scope | (Managers only) the user is not in the manager's groups — also reported for identifiers that match nobody |
Edit objectives in bulk
Action: update_objectives
Accepted file types: CSV (.csv)
File format: one row per user and content. A user holds at most one objective per content, so the user and the content are enough to name it.
identificador,tipo,contenido_id,fecha_limite,accion
ana@empresa.com,course,2345,2026-12-31T23:59:59-05:00,agregar
juan.perez,course,2345,2027-01-15T23:59:59-05:00,editar
juan.perez,learning_path,88,,editar
maria@empresa.com,course,2345,,eliminar
| Column | Required | Description |
|---|---|---|
identificador | Yes | The user's email address, or their username for users who registered without one (usuario and email are accepted as synonyms). |
tipo | Yes | course or learning_path (curso and ruta are accepted too). |
contenido_id | Yes | The course or learning path ID. |
fecha_limite | No | ISO 8601 deadline. A bare date (2026-12-31) means the end of that day in UTC. Empty means no deadline. Ignored for eliminar. |
accion | Yes | agregar, editar or eliminar (see below). |
accion | User already has the objective | User does not have it |
|---|---|---|
agregar | Its deadline is set to the row's | It is assigned with the row's deadline |
editar | Its deadline is set to the row's | The row fails with objective_not_assigned |
eliminar | It is removed | The row fails with objective_not_assigned |
GET /v3/lp-builder/{company_id}/objectives/assignments/ returns the company's current objectives with these same columns, so a file can be downloaded, edited and uploaded back.
Request payload:
{
"action": "update_objectives",
"file_key": "<from presigned-upload-url>",
"payload": {
"assign_contents": [
{ "catalog_type": "course", "content_id": 2345 }
]
}
}
| Field | Required | Description |
|---|---|---|
assign_contents | No | Every distinct content named by an agregar row (at most 10). Leave it empty when the file only edits or removes. |
The job is rejected up front when a declared content is not available to the company (course_not_found or learning_path_not_found). An agregar row whose content was not declared fails with content_not_declared.
Rows run in parallel: two rows for the same user and content are applied in no particular order, so keep one row per user and content.
When the job finishes, error_detail.outcome_counts tells what the rows did:
{
"error_detail": {
"outcome_counts": { "assigned": 12, "updated": 30, "unchanged": 4, "removed": 3 }
}
}
| Count | Meaning |
|---|---|
assigned | Objectives assigned by agregar rows |
updated | Deadlines changed (by editar, or by agregar on an objective the user already had with another deadline) |
unchanged | editar or agregar rows whose deadline was already the one in the file |
removed | Objectives removed by eliminar rows |
outcome_counts is written right after the job reaches its terminal status; poll the detail once more if it is not there yet.
Managers. Open to the manager role like assign_objective, with objectives.view switched on. Each row needs the assign permission of its content (courses.assign or learning_paths.assign) — the same one editing or deleting an objective by hand asks for — or it fails with permission_disabled; a declared agregar content the manager cannot assign refuses the whole job with 403 permission_disabled. How far the file reaches follows objectives.view, as for assign_objective.
Common row errors:
| Error | Meaning |
|---|---|
missing_action / invalid_action | The accion cell is empty or not one of the three actions |
invalid_catalog_type | The tipo cell is not a course or a learning path |
invalid_content_id | The contenido_id cell is not a positive integer |
invalid_deadline | The fecha_limite cell is not an ISO 8601 date |
invalid_identifier | The row's identifier is empty or malformed |
user_not_found | No user in the company matches the identifier |
objective_not_assigned | editar / eliminar on an objective the user does not have |
objective_content_not_found | The company has no such content in its catalog |
content_not_declared | agregar on a content missing from assign_contents |
permission_disabled | (Managers only) the assign permission of the row's content is off |
out_of_scope | (Managers only) the user is not in the manager's groups — also reported for identifiers that match nobody |
Failed rows in error_detail.sample_errors carry row_number and content_id next to the identifier. row_number is the line of the uploaded file (the header being line 1), unless the row has a fila column: a positive integer there is reported instead, so a client that uploads a cleaned-up copy of a file can point at the line of the original.
Example: start a bulk learning-path job
# 1. Presigned URL
POST /v1/massive-actions/123/presigned-upload-url/
{
"action": "create_learning_paths",
"filename": "rutas.csv",
"content_type": "text/csv"
}
# 2. PUT file to presigned_url
# 3. Start job
POST /v1/massive-actions/123/
{
"action": "create_learning_paths",
"file_key": "<file_key from step 1>",
"payload": { "step_title": "Programa" }
}
# 4. Poll
GET /v1/massive-actions/123/<job_id>/