API
API v1
Crear programas, niveles, cohortes y matrículas desde tu propio sistema.
Permite que una empresa gestione su catálogo académico —programas → niveles → grupos (cortes)— desde su propia aplicación, sin entrar al panel.
- Base:
https://<tu-dominio>/api/v1 - Formato: JSON en petición y respuesta.
- Alcance de datos: la clave identifica a un tenant. Solo ve y escribe datos de
ese tenant; no hace falta (ni se acepta) enviar
tenant_iden ningún sitio.
1. Autenticación
Cabecera Authorization con la clave:
Authorization: Bearer ck_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Las claves se emiten desde el panel de administración (POST /api/admin/api-keys, con
sesión de admin). La clave en claro se muestra una sola vez: no se guarda y no puede
recuperarse. Si se pierde, se revoca y se emite otra.
Una clave lleva permisos explícitos. Escribir no implica leer:
| Permiso | Habilita |
|---|---|
programs:* | listar, crear, editar y archivar programas |
levels:* | ídem niveles |
groups:* | ídem grupos (cortes) |
modules:* | módulos del currículo |
lessons:* | sesiones dentro de un módulo |
students:* | alta y consulta de estudiantes |
enrollments:* | matricular y consultar matrículas |
exams:write | importar exámenes (examen como código) |
attempts:read | leer resultados de evaluaciones |
webhooks:* | registrar y dar de baja destinos de aviso |
Las claves emitidas antes de habilitar estos recursos no ganan los permisos nuevos: emite una clave nueva si necesitas matrículas, resultados o webhooks.
Límite: 120 peticiones por minuto y clave. Al superarlo se responde 429 con
retry_after_seconds.
2. Formato de respuesta
Éxito de un recurso:
{ "data": { "id": "…", "object": "program", "name": "…" } }
Éxito de un listado:
{
"data": [ … ],
"pagination": { "limit": 25, "offset": 0, "total": 42, "has_more": true }
}
Error:
{
"error": {
"code": "validation_error",
"message": "Revisa los campos enviados.",
"fields": [{ "field": "name", "message": "Es obligatorio y debe ser texto." }]
}
}
code | HTTP | Significado |
|---|---|---|
unauthorized | 401 | Clave ausente, mal formada, inexistente o revocada |
forbidden | 403 | Clave válida sin el permiso necesario, o cuenta inactiva |
not_found | 404 | El recurso no existe o no es de tu cuenta |
conflict | 409 | Ya existe un recurso con esa clave natural (incluye existing_id) |
validation_error | 422 | El cuerpo no cumple el contrato; mira fields |
rate_limited | 429 | Límite de peticiones superado |
internal_error | 500 | Fallo del servidor |
Los listados aceptan ?limit= (máx. 100, por defecto 25) y ?offset=.
3. Programas
POST /api/v1/programs
| Campo | Tipo | Obligatorio | Notas |
|---|---|---|---|
name | string | sí | 2–160 caracteres |
slug | string | no | Si se omite, se deriva del nombre. Único dentro de tu cuenta |
description | string | no | |
cover_url | string | no | |
status | enum | no | active (def.) · inactive · archived |
order_index | int | no | Orden de presentación, por defecto 0 |
curl -X POST https://tu-dominio.com/api/v1/programs \
-H "Authorization: Bearer $CK" \
-H "Content-Type: application/json" \
-d '{ "name": "Ascenso de Escala 2026", "description": "Preparación docente" }'
{ "data": { "id": "…", "object": "program", "name": "Ascenso de Escala 2026",
"slug": "ascenso-de-escala-2026", "status": "active" } }
GET /api/v1/programs
Filtro opcional ?status=active.
4. Niveles
Un nivel cuelga de un programa y define la interfaz del aula.
POST /api/v1/levels
| Campo | Tipo | Obligatorio | Notas |
|---|---|---|---|
program_id | uuid | sí | Debe ser un programa de tu cuenta |
name | string | sí | Único dentro del programa |
description | string | no | |
modality | enum | no | presential (def.) · live · recorded |
status | enum | no | active (def.) · inactive · archived |
order_index | int | no | |
image_url | string | no |
⚠️ La modalidad del nivel va en inglés (
presential,live,recorded) y decide cómo se presenta el aula. La del grupo va en español y es solo una etiqueta. No son intercambiables.
curl -X POST https://tu-dominio.com/api/v1/levels \
-H "Authorization: Bearer $CK" -H "Content-Type: application/json" \
-d '{ "program_id": "…", "name": "Primaria", "modality": "live" }'
GET /api/v1/levels?program_id=…
5. Grupos (cortes)
Una cohorte concreta de un nivel: el grupo al que se matriculan los alumnos.
POST /api/v1/groups
| Campo | Tipo | Obligatorio | Notas |
|---|---|---|---|
level_id | uuid | sí | Debe ser un nivel de tu cuenta |
name | string | sí | Único dentro del nivel |
capacity | int | no | Por defecto 0 (sin tope) |
modality | enum | no | presencial (def.) · virtual · semipresencial |
start_date / end_date | YYYY-MM-DD | no | end_date no puede ser anterior |
schedule | objeto | no | Libre. El LMS entiende { "days": [...], "time_start": "18:00", "time_end": "20:00" } |
status | enum | no | active (def.) · inactive · archived |
location_name, location_address, maps_url | string | no | Para cortes presenciales |
order_index | int | no |
curl -X POST https://tu-dominio.com/api/v1/groups \
-H "Authorization: Bearer $CK" -H "Content-Type: application/json" \
-d '{
"level_id": "…",
"name": "Corte Marzo 2026",
"capacity": 40,
"modality": "virtual",
"start_date": "2026-03-02",
"end_date": "2026-06-30",
"schedule": { "days": ["lunes","miércoles"], "time_start": "19:00", "time_end": "21:00" }
}'
GET /api/v1/groups?level_id=…
6. Flujo completo
CK="ck_live_…"
API="https://tu-dominio.com/api/v1"
PROGRAM=$(curl -s -X POST $API/programs -H "Authorization: Bearer $CK" \
-H "Content-Type: application/json" \
-d '{"name":"Ascenso de Escala 2026"}' | jq -r .data.id)
LEVEL=$(curl -s -X POST $API/levels -H "Authorization: Bearer $CK" \
-H "Content-Type: application/json" \
-d "{\"program_id\":\"$PROGRAM\",\"name\":\"Primaria\",\"modality\":\"live\"}" | jq -r .data.id)
curl -s -X POST $API/groups -H "Authorization: Bearer $CK" \
-H "Content-Type: application/json" \
-d "{\"level_id\":\"$LEVEL\",\"name\":\"Corte Marzo\",\"capacity\":40,\"modality\":\"virtual\"}"
7. Reintentos y duplicados
La API no crea duplicados en silencio. Si reintentas una creación que ya se aplicó
—por un timeout de red, por ejemplo— recibes 409 conflict con el existing_id del
recurso que ya existe, y puedes seguir con él:
{ "error": { "code": "conflict", "message": "Ya existe un programa con el slug \"…\".",
"existing_id": "8ebbceda-…" } }
Las claves naturales son: programa → slug (dentro de tu cuenta); nivel → name (dentro
del programa); grupo → name (dentro del nivel).
8. Editar y archivar
Los tres recursos del catálogo aceptan GET, PATCH y DELETE en /{recurso}/{id}.
PATCH solo toca lo que envías. Si mandas { "name": "…" }, la descripción se queda
como estaba: no hay que reenviar el objeto completo.
DELETE archiva, no borra. Un programa cuelga niveles, grupos y matrículas con
historial de notas y pagos; un borrado real desde una integración sería irreversible y se
llevaría por delante datos de personas. La API pone status: "archived" y conserva todo.
Es idempotente y la respuesta te dice cuántos hijos quedan colgando:
{ "data": { "id": "…", "object": "program", "status": "archived",
"archived_levels_pending": 3 } }
9. Módulos y sesiones
POST /api/v1/modules — { level_id, title, group_id?, description?, order_index?, status? }
Un módulo puede ser de la plantilla del nivel (sin group_id) o del currículo propio
de un grupo. Si indicas group_id, ese grupo debe pertenecer al mismo nivel.
POST /api/v1/lessons — { module_id, title, description?, lesson_type?, order_index?, is_preview? }
lesson_type: in_person (def.) · video · document · quiz · assignment · live.
10. Estudiantes y matrículas
POST /api/v1/students
| Campo | Tipo | Obligatorio |
|---|---|---|
full_name | string | sí |
email | string | no |
phone | string | no |
Sin email se crea una cuenta sombra: el LMS admite menores sin correo propio, con una
dirección interna que nunca recibe correo (las notificaciones se redirigen al apoderado).
Con email, la persona podrá iniciar sesión. Si ya existe un estudiante con ese correo en
tu cuenta, la respuesta es 409 con el existente — reintentar no parte en dos el historial.
POST /api/v1/enrollments
| Campo | Tipo | Obligatorio | Notas |
|---|---|---|---|
student_id | uuid | sí | Debe ser de tu cuenta |
group_id | uuid | sí | Debe ser de tu cuenta |
status | enum | no | active (def.) · free_trial · pending · suspended · expired · cancelled |
start_date / end_date | YYYY-MM-DD | no | Si no envías inicio, es hoy |
notes | string | no |
Matricular por API no cobra. Esta vía es para empresas que gestionan el cobro en su propio sistema. El flujo con pasarela sigue siendo el checkout del LMS.
Un estudiante no se matricula dos veces en el mismo grupo: el reintento devuelve 409 con
la matrícula existente.
11. Resultados
GET /api/v1/attempts?exam_id=&student_id=&status=&submitted_since=
Devuelve nota, máximo, estado y fechas de cada entrega — de alumnos y de formularios públicos. No devuelve las respuestas pregunta a pregunta ni la clave de corrección: exponerlas por API filtraría el examen entero a cualquiera con la clave.
12. Webhooks
En vez de preguntar en bucle, el LMS avisa a tu sistema.
POST /api/v1/webhooks — { url, events?, description? }. La URL debe ser https.
Sin events, se suscribe a todos.
| Evento | Cuándo |
|---|---|
enrollment.created | Se crea una matrícula por la API (POST /api/v1/enrollments) |
exam.submitted | Alguien entrega una evaluación, desde el aula o desde un formulario público |
payment.succeeded | Se confirma la renovación de una suscripción |
La respuesta incluye el secreto de firma, una sola vez. Cada envío llega así:
POST https://tu-servidor.com/webhook
X-Webhook-Signature: t=1754899200,v1=<hmac-sha256-hex>
Content-Type: application/json
{ "id": "evt_…", "event": "enrollment.created", "created_at": "…", "data": { … } }
Qué trae data
La envoltura (id, event, created_at) es siempre la misma. Lo que cambia es data.
El aviso trae identificadores y lo esencial, no el detalle completo: viaja hasta un servidor ajeno, así que cuanto menos dato sensible lleve, mejor. Cuando necesites el detalle, usa el id que viene aquí para pedirlo por la API.
enrollment.created
| Campo | Tipo | Qué es |
|---|---|---|
enrollment | objeto | La matrícula completa, con la misma forma que devuelve GET /api/v1/enrollments |
student | objeto | { id, full_name } |
group_id | uuid | Grupo (corte) en el que quedó matriculado |
exam.submitted — se dispara desde dos orígenes y los campos no son idénticos:
en un formulario público no hay alumno registrado ni matrícula, solo un nombre escrito
a mano. Discrimina con source, que solo está presente en el caso público.
| Campo | Aula | Form. público | Qué es |
|---|---|---|---|
attempt_id | ✓ | ✓ | Intento; con él se pide el detalle a /api/v1/attempts |
exam_id | ✓ | ✓ | Evaluación entregada |
score / max_score | ✓ | ✓ | Puntaje obtenido y máximo posible |
needs_review | ✓ | ✓ | true si quedaron preguntas abiertas sin calificar |
submitted_at | ✓ | ✓ | Momento de la entrega (ISO-8601) |
student_id | ✓ | — | Alumno del LMS |
enrollment_id | ✓ | — | Matrícula bajo la que rindió |
respondent_name | — | ✓ | Nombres y apellidos que escribió quien respondió |
source | — | "public_form" | Origen de la entrega |
payment.succeeded — solo renovaciones: el cobro recurrente de una suscripción
ya activa, confirmado por Culqi. El primer cobro de un checkout no dispara este evento.
| Campo | Tipo | Qué es |
|---|---|---|
enrollment_id | uuid | Matrícula que se extendió con el cobro |
student_id | uuid | Alumno |
group_id | uuid | Grupo (corte) |
amount | número | Monto cobrado |
currency | texto | Moneda, PEN por defecto |
culqi_charge_id | texto | Cargo en Culqi, para conciliar |
paid_at | texto | Momento del cobro (ISO-8601) |
El paquete solo crece. Se pueden añadir campos nuevos sin previo aviso, así que ignora los que no conozcas en vez de rechazar el aviso. Los campos de esta tabla no se quitan ni cambian de significado sin una versión nueva de la API.
Verificar la firma
La firma es HMAC-SHA256(secreto, "<timestamp>.<cuerpo crudo>"). El timestamp entra en la
firma para que nadie pueda reenviar un aviso capturado.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verificar(secreto, cabecera, cuerpoCrudo, tolerancia = 300) {
const p = Object.fromEntries(cabecera.split(',').map(x => x.trim().split('=')))
const t = Number.parseInt(p.t, 10)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > tolerancia) return false
const esperada = createHmac('sha256', secreto).update(`${t}.${cuerpoCrudo}`).digest('hex')
const a = Buffer.from(esperada), b = Buffer.from(p.v1 ?? '')
return a.length === b.length && timingSafeEqual(a, b)
}
Verifica sobre el cuerpo crudo, antes de parsear el JSON: reserializar cambia los bytes y la firma dejaría de cuadrar.
Reintentos. Responde 2xx para confirmar. Si no, se reintenta 5 veces con espera
creciente (1 min → 5 min → 30 min → 2 h → 6 h) y después se marca como fallido.
Da de baja un destino con DELETE /api/v1/webhooks/{id}.
13. Importar exámenes
POST /api/v1/exams/import — { lesson_id, document, dry_run?, publish? }
Usa el mismo motor "examen como código" del panel. Es idempotente por el sourceKey
del documento: reejecutar con el mismo sourceKey actualiza el examen en vez de
duplicarlo, que es justo lo que necesita un pipeline que republica su banco de preguntas.
Con dry_run: true devuelve el plan (qué crearía, actualizaría u omitiría) sin escribir.
14. Límites actuales
- No se pueden borrar programas, niveles ni grupos: se archivan.
- Los resultados son de solo lectura y no incluyen el detalle por pregunta.
- El límite de peticiones se aplica por instancia del servidor, así que en picos el máximo real puede ser algo mayor que 120/min.