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_id en 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:

PermisoHabilita
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:writeimportar exámenes (examen como código)
attempts:readleer 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." }]
  }
}
codeHTTPSignificado
unauthorized401Clave ausente, mal formada, inexistente o revocada
forbidden403Clave válida sin el permiso necesario, o cuenta inactiva
not_found404El recurso no existe o no es de tu cuenta
conflict409Ya existe un recurso con esa clave natural (incluye existing_id)
validation_error422El cuerpo no cumple el contrato; mira fields
rate_limited429Límite de peticiones superado
internal_error500Fallo del servidor

Los listados aceptan ?limit= (máx. 100, por defecto 25) y ?offset=.


3. Programas

POST /api/v1/programs

CampoTipoObligatorioNotas
namestring2–160 caracteres
slugstringnoSi se omite, se deriva del nombre. Único dentro de tu cuenta
descriptionstringno
cover_urlstringno
statusenumnoactive (def.) · inactive · archived
order_indexintnoOrden 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

CampoTipoObligatorioNotas
program_iduuidDebe ser un programa de tu cuenta
namestringÚnico dentro del programa
descriptionstringno
modalityenumnopresential (def.) · live · recorded
statusenumnoactive (def.) · inactive · archived
order_indexintno
image_urlstringno

⚠️ 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

CampoTipoObligatorioNotas
level_iduuidDebe ser un nivel de tu cuenta
namestringÚnico dentro del nivel
capacityintnoPor defecto 0 (sin tope)
modalityenumnopresencial (def.) · virtual · semipresencial
start_date / end_dateYYYY-MM-DDnoend_date no puede ser anterior
scheduleobjetonoLibre. El LMS entiende { "days": [...], "time_start": "18:00", "time_end": "20:00" }
statusenumnoactive (def.) · inactive · archived
location_name, location_address, maps_urlstringnoPara cortes presenciales
order_indexintno
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

CampoTipoObligatorio
full_namestring
emailstringno
phonestringno

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

CampoTipoObligatorioNotas
student_iduuidDebe ser de tu cuenta
group_iduuidDebe ser de tu cuenta
statusenumnoactive (def.) · free_trial · pending · suspended · expired · cancelled
start_date / end_dateYYYY-MM-DDnoSi no envías inicio, es hoy
notesstringno

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.

EventoCuándo
enrollment.createdSe crea una matrícula por la API (POST /api/v1/enrollments)
exam.submittedAlguien entrega una evaluación, desde el aula o desde un formulario público
payment.succeededSe 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

CampoTipoQué es
enrollmentobjetoLa matrícula completa, con la misma forma que devuelve GET /api/v1/enrollments
studentobjeto{ id, full_name }
group_iduuidGrupo (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.

CampoAulaForm. públicoQué es
attempt_idIntento; con él se pide el detalle a /api/v1/attempts
exam_idEvaluación entregada
score / max_scorePuntaje obtenido y máximo posible
needs_reviewtrue si quedaron preguntas abiertas sin calificar
submitted_atMomento de la entrega (ISO-8601)
student_idAlumno del LMS
enrollment_idMatrícula bajo la que rindió
respondent_nameNombres 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.

CampoTipoQué es
enrollment_iduuidMatrícula que se extendió con el cobro
student_iduuidAlumno
group_iduuidGrupo (corte)
amountnúmeroMonto cobrado
currencytextoMoneda, PEN por defecto
culqi_charge_idtextoCargo en Culqi, para conciliar
paid_attextoMomento 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.