Skip to main content

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​

  1. POST /v1/massive-actions/{company_id}/presigned-upload-url/ — get upload URL
  2. PUT the file to the presigned URL
  3. POST /v1/massive-actions/{company_id}/ — start the job
  4. 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
}
}
  • phase is starting right after the job is accepted (counters still zero), validating while the file rows are being validated, and onboarding (only for invite_users) while the accounts are being created and invited.
  • progress reports throughput only (how many items have been processed), not outcomes. Success/failure counts are available in summary once the job reaches a terminal status.
  • progress is null once the job reaches a terminal status (use summary instead).
  • The list endpoint (GET /v1/massive-actions/{companyId}/) includes the same progress object on processing jobs, so history views can render a live percentage without one detail call per row.
  • The list endpoint filters by action, status, page and page_size. action accepts several values — repeated (?action=assign_objective&action=update_objectives) or comma separated — so one history can cover several actions; an unknown action answers 400.

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​

ActionDescription
invite_usersInvite (onboard) users in bulk, with optional per-user metadata
assign_users_to_groupAdd users (by email) to a group
unassign_users_from_groupRemove users (by email) from a group
deactivate_usersDeactivate users by email (a flexible-plan month already running is kept until it ends)
delete_usersDelete users by email
resend_invitationsResend pending invitations by email
create_learning_pathsCreate custom learning paths in bulk
assign_users_to_learning_pathAssign users (by email or username) to a learning path
unassign_users_from_learning_pathRemove users (by email or username) from a learning path
create_registration_code_invitesCreate registration-code invitations in bulk
assign_registration_codesAssign existing registration codes in bulk
assign_objectiveAssign one objective (a course or a learning path, with an optional deadline) to users by email or username
update_objectivesAdd, 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.

ColumnRequiredDescription
emailYesUser email. Must be a valid address.
nameNoUser full name.
licensesNoNumber of licenses to assign. Integer >= 0 (defaults to 0).
any other columnNoTreated 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
}
}
FieldDefaultDescription
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_sendfalseSkip sending the invitation email.
skip_mail_validationfalseSkip email deliverability validation.

Job-level validation (rejected with 400 before any row is processed):

Error codeMeaning
unknown_metadata_keyOne 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:

ErrorMeaning
missing_emailRow has no email value.
invalid_emailEmail is not a valid address.
invalid_licenseslicenses is not an integer >= 0.
invalid_metadataA metadata value fails the company's field validation.
invalid_rowThe row could not be parsed.
duplicate_emailThe 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.

ColumnRequiredDescription
rutaYesLearning path title. Must be unique within the company catalog.
curso_1 … curso_20At least oneCourse 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"
}
}
FieldDefaultDescription
step_titleProgramaTitle of the single step that contains all courses
stateactiveCatalog state: draft, active, or archived

Per-row result fields: ruta, learning_path_id (on success), error (on failure)

Common row errors:

ErrorMeaning
missing_rutaRow has no path title
missing_course_idsRow has no valid course IDs
title_already_existsA path with that title already exists
courses_not_availableOne 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.

ColumnRequiredDescription
identificadorYesThe 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
}
}
FieldRequiredDescription
learning_path_idYesThe 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:

ErrorMeaning
invalid_identifierThe row's identifier is empty or malformed
user_not_foundNo 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_foundThe 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.

ColumnRequiredDescription
identificadorYesThe 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"
}
}
FieldRequiredDescription
catalog_typeYescourse or learning_path.
content_idYesThe course or learning path every row is assigned. One job targets one objective.
deadlineNoISO 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 reachResult
Whole accountEvery row is processed like an administrator's upload
Own groupsRows naming someone outside the manager's groups fail with out_of_scope; the rest are assigned
Own groups, no groups assignedThe 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:

ErrorMeaning
invalid_identifierThe row's identifier is empty or malformed
user_not_foundNo user in the company matches the identifier
objective_content_not_foundThe 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
ColumnRequiredDescription
identificadorYesThe user's email address, or their username for users who registered without one (usuario and email are accepted as synonyms).
tipoYescourse or learning_path (curso and ruta are accepted too).
contenido_idYesThe course or learning path ID.
fecha_limiteNoISO 8601 deadline. A bare date (2026-12-31) means the end of that day in UTC. Empty means no deadline. Ignored for eliminar.
accionYesagregar, editar or eliminar (see below).
accionUser already has the objectiveUser does not have it
agregarIts deadline is set to the row'sIt is assigned with the row's deadline
editarIts deadline is set to the row'sThe row fails with objective_not_assigned
eliminarIt is removedThe 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 }
]
}
}
FieldRequiredDescription
assign_contentsNoEvery 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 }
}
}
CountMeaning
assignedObjectives assigned by agregar rows
updatedDeadlines changed (by editar, or by agregar on an objective the user already had with another deadline)
unchangededitar or agregar rows whose deadline was already the one in the file
removedObjectives 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:

ErrorMeaning
missing_action / invalid_actionThe accion cell is empty or not one of the three actions
invalid_catalog_typeThe tipo cell is not a course or a learning path
invalid_content_idThe contenido_id cell is not a positive integer
invalid_deadlineThe fecha_limite cell is not an ISO 8601 date
invalid_identifierThe row's identifier is empty or malformed
user_not_foundNo user in the company matches the identifier
objective_not_assignededitar / eliminar on an objective the user does not have
objective_content_not_foundThe company has no such content in its catalog
content_not_declaredagregar 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>/