Para desarrolladores

API REST de constancias DC-3

Emite, consulta y anula constancias DC-3 desde tu propio sistema (nómina, LMS, ERP) y recibe avisos firmados por webhook. La constancia es la misma que sale del panel: mismo folio, mismo PDF oficial, misma verificación pública.

Cómo empezar. Necesitas una cuenta (crear cuenta gratis) y una API key, que genera el administrador en Panel › Integraciones. La llave se muestra una sola vez; guárdala como una contraseña. Todos los ejemplos usan $KEY como esa llave.

Índice

  1. Conceptos: base, autenticación, límites
  2. Códigos de respuesta
  3. Verificar un folio (público)
  4. Listar constancias (filtros)
  5. Emitir una constancia
  6. Emitir en lote (207)
  7. Obtener una constancia
  8. Descargar PDF o DOCX
  9. Anular una constancia
  10. Catálogos (claves SIRCE y del DC-3)
  11. Cumplimiento, empresas, portal, ACE
  12. Idempotency-Key
  13. Webhooks: eventos, payloads y firma HMAC
  14. El objeto constancia
  15. Compatibilidad y cambios

1. Conceptos

Basehttps://capacitatotal.mx/api.php/v1 — sin rewrite: la ruta va después de api.php.
FormatoJSON UTF-8 en cuerpo y respuesta. Manda Content-Type: application/json. Las fechas son AAAA-MM-DD.
AutenticaciónAuthorization: Bearer dc3_… en todas las rutas salvo GET /v1/verificar/{folio}. La llave pertenece a UN capacitador: sólo ve y crea lo suyo.
Permisos (scopes)Una llave nace con constancias:read (todo GET) y constancias:write (POST). Sin el permiso: 403.
Límites de tráficoPor llave: 120 peticiones por minuto (si tu integración necesita más, pídelo por contacto). Sin autenticar, por IP: 100 por minuto; /v1/verificar: 60 por minuto. Al pasarte: 429.
Cupo del planLa constancia emitida por API cuenta igual que la del panel. El plan Gratis incluye 2 constancias de prueba; al agotarlo la emisión responde 403 con el motivo en texto.
ErroresSiempre JSON: {"error": "texto para una persona"}. El texto dice qué campo falló y cómo corregirlo; no hay códigos internos que descifrar.
# Tu primera llamada: lista tu constancia más reciente (no emite nada)
curl -s "https://capacitatotal.mx/api.php/v1/constancias?limit=1" \
  -H "Authorization: Bearer $KEY"

2. Códigos de respuesta

CódigoCuándo
200Consulta correcta; anulación hecha.
201Constancia emitida; webhook o liga de portal creados.
207Lote procesado: cada ítem trae su propio resultado (ok true/false). Uno malo no detiene a los demás.
400Dato mal formado: tipo incorrecto, texto más largo que su tope, clave de catálogo inexistente, JSON inválido, Idempotency-Key inválida.
401Sin cabecera Authorization, llave desconocida, llave revocada o cuenta suspendida (el texto distingue las tres últimas).
403La llave no tiene el permiso; la capacidad no viene en el plan (portal, ACE); cupo del plan agotado; correo de la cuenta sin confirmar; el capacitador de la llave todavía no tiene RFC (se captura una sola vez en el panel, en Mi cuenta; no afecta a las llaves de los demás capacitadores de la cuenta). El texto dice cuál de todos es.
404Folio, curso, empresa o catálogo que no existe (o no es tuyo); versión de API desconocida.
405Método no soportado en esa ruta.
409Ya estaba hecho (constancia ya anulada), carrera al crear, u otra petición en curso con la misma Idempotency-Key.
422Falta un campo obligatorio o el valor no es válido para el negocio: CURP inválida, fechas incoherentes, área que no cuadra, Idempotency-Key reutilizada con otro cuerpo.
429Límite de tráfico excedido. Espera y reintenta.
500Falla nuestra. El cuerpo sigue siendo JSON ({"error":"error interno del servidor"}); el detalle queda en nuestro registro.
503Reintenta en unos segundos: otra emisión de la misma cuenta está tomando el cupo, o el servidor no tiene la tabla de idempotencia. No se emitió nada.

3. Verificar un folio (público)

GET/v1/verificar/{folio}sin autenticación · 60/min por IP

Lo mismo que ve cualquiera al escanear el QR, legible por máquina: autenticidad (integridad del contenido + firma RSA), estado, y los datos impresos con la CURP enmascarada. Acepta el folio actual, el folio del sistema anterior o el código de verificación. Nunca devuelve el RFC ni el código secreto.

curl -s "https://capacitatotal.mx/api.php/v1/verificar/DC3-3KSF-BE3H-JPZF0"
HTTP/1.1 200 OK
{
  "folio": "DC3-3KSF-BE3H-JPZF0",
  "autentico": false,
  "estado": "anulada",
  "reemplazada": true,
  "sustituida_por": "DC3-JA9Z-5N7G-GYZ61",
  "sustituye_a": null,
  "reemplazo_vigente": "DC3-JA9Z-5N7G-GYZ61",
  "integridad_ok": true,
  "firmada": true,
  "firma_valida": true,
  "trabajador": "LOPEZ MARTINEZ MARIA",
  "curp_mascara": "LOMA**********RR06",
  "curso": "NOM-009-STPS-2011 Trabajos en altura",
  "duracion_horas": 8,
  "fec_inicio": "2026-08-24",
  "fec_termino": "2026-08-25",
  "fecha_emision": "2026-09-02",
  "empresa": "CONSTRUCTORA DEL NORTE SA DE CV",
  "capacitador": "CAPACITACION DE PRUEBA SC",
  "registro_ace": "CAPD-850101-ABC-0013",
  "verificado_at": "2026-09-02T15:58:32-06:00"
}

autentico es true sólo si la constancia está activa, su contenido no fue alterado y la firma es válida. Una constancia anulada conserva integridad y firma válidas (el papel es auténtico) pero autentico es false; si fue corregida, sustituida_por trae el folio de la versión vigente. Folio inexistente: 404 {"error":"no encontrada"}.

4. Listar constancias

GET/v1/constanciasconstancias:read
ParámetroFiltra por
curpCURP del trabajador (18 caracteres). Sólo forma; sirve incluso para localizar una CURP mal capturada y anularla.
curso_idId del curso (el que devuelve la emisión).
empresa_rfcRFC de la empresa.
estadoactiva o anulada.
desde, hastaFecha de emisión, inclusivas, AAAA-MM-DD. Una fecha imposible (2026-13-99) responde 422, no una lista vacía.
limit, offsetPaginación: limit 1-200 (50 por omisión). total es el universo completo del filtro, no la página.
curl -s "https://capacitatotal.mx/api.php/v1/constancias?curp=PEGJ900515HDFRRN09" \
  -H "Authorization: Bearer $KEY"
HTTP/1.1 200 OK
{
  "total": 1, "count": 1, "limit": 50, "offset": 0,
  "constancias": [
    {
      "folio": "DC3-JA9Z-5N7G-GYZ61",
      "fecha_emision": "2026-09-02",
      "estado": "activa",
      "empresa_rfc": "CNO050101AB2",
      "empresa": "CONSTRUCTORA DEL NORTE SA DE CV",
      "links": {
        "pdf": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/pdf",
        "docx": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/docx",
        "verificar": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61",
        "verificar_json": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61&format=json"
      },
      "curp": "PEGJ900515HDFRRN09",
      "trabajador": "PEREZ GARCIA JUAN",
      "curso_id": 4923,
      "curso": "NOM-009-STPS-2011 Trabajos en altura",
      "fec_inicio": "2026-08-24",
      "fec_termino": "2026-08-25",
      "duracion_horas": 8
    }
  ]
}

Otros ejemplos que funcionan: ?curso_id=4923&limit=1, ?desde=2026-09-01&hasta=2026-09-30, ?estado=anulada. Se pueden combinar.

5. Emitir una constancia

POST/v1/constanciasconstancias:write · acepta Idempotency-Key

Una llamada hace todo: da de alta (o reutiliza) la empresa por RFC, el curso por nombre o por curso_id, y al trabajador por CURP dentro de esa empresa; después emite, firma y numera la constancia. Los datos que ya existen no se sobrescriben con vacíos.

CampoObligatorioNotas
empresa.rfcsíLlave de la empresa dentro de tu cuenta. Si el RFC no tiene la forma del SAT, la constancia se emite igual y se te avisa en avisos.
empresa.razon_socialsi la empresa es nuevaHasta 250 caracteres. Opcionales: representante_legal, representante_trabajadores (200).
curso_id o curso.nombresí (uno de los dos)Con curso.nombre nuevo hace falta curso.duracion_horas (1-9999). curso.area_clave: clave de areas_tematicas; sin ella la casilla sale en blanco y se avisa.
trabajador.curpsíSe valida el dígito verificador: una CURP mal capturada es 422.
trabajador.nombre, primer_apellidosísegundo_apellido y puesto opcionales (120 / 200 caracteres).
trabajador.ocupacion_arearecomendadoCódigo del reverso del DC-3, por ejemplo "04.6" (catálogo ocupacion_areas). También acepta el nombre exacto. Si código y nombre no cuadran entre sí, 422: no elegimos por ti.
trabajador.ocupacion_claveopcionalOcupación específica del CNO (clave de ocupaciones). Si viene, es la que se imprime.
trabajador.sirce.*opcionalDatos que el reporte SIRCE pide y el DC-3 no imprime: escolaridad_clave, documento_probatorio_clave, institucion_clave, estado_clave + municipio_clave (van juntos). Claves en catálogos.
fec_inicio, fec_terminosíAAAA-MM-DD; inicio no puede ser posterior a término.
duracion_horasopcionalSi no viene, se usa la del curso.
notificar.emailopcionalCorreo al que se envía la constancia (si el correo saliente está configurado). Se valida antes de emitir.
curl -s -X POST "https://capacitatotal.mx/api.php/v1/constancias" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rh-2026-0001" \
  -d '{
    "empresa": {
      "rfc": "CNO050101AB2",
      "razon_social": "CONSTRUCTORA DEL NORTE SA DE CV",
      "representante_legal": "MARIA LOPEZ RUIZ"
    },
    "curso": {"nombre": "NOM-009-STPS-2011 Trabajos en altura", "duracion_horas": 8, "area_clave": 1},
    "trabajador": {
      "curp": "PEGJ900515HDFRRN09",
      "nombre": "JUAN", "primer_apellido": "PEREZ", "segundo_apellido": "GARCIA",
      "puesto": "OPERADOR",
      "ocupacion_area": "04.6",
      "sirce": {"escolaridad_clave": 3, "documento_probatorio_clave": 2, "institucion_clave": 1,
                "estado_clave": 9, "municipio_clave": 5}
    },
    "fec_inicio": "2026-08-24",
    "fec_termino": "2026-08-25",
    "notificar": {"email": "juan.perez@example.com"}
  }'
HTTP/1.1 201 Created
{
  "folio": "DC3-JA9Z-5N7G-GYZ61",
  "codigo_verificacion": "22ORpeF5WTsmpYhVyt06OF0Q",
  "empresa_id": 4753,
  "curso_id": 4923,
  "fecha_emision": "2026-09-02",
  "reactivado": false,
  "links": {
    "pdf": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/pdf",
    "docx": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/docx",
    "verificar": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61",
    "verificar_json": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61&format=json"
  },
  "avisos": [
    "La empresa no tiene capturado su número de trabajadores, así que no podemos decirte si le corresponde la firma del representante de los trabajadores: el formato la pide sólo por encima de 50."
  ]
}
Lee avisos. La constancia ya está emitida y firmada: es inmutable. Los avisos dicen qué casilla del DC-3 salió en blanco o qué dato conviene corregir en la ficha para las próximas; no se rechaza la emisión para no romper integraciones en marcha. Guarda codigo_verificacion como secreto: es lo único que no es público.
reactivado: la persona volvió al padrón. Emitirle una DC-3 a alguien es declarar que sigue en la empresa, así que si su ficha estaba dada de baja, esta emisión la devuelve a activa —igual que por el panel y que por la importación— y te lo decimos: reactivado vale true y la frase entra también en avisos. Normalmente vale false. Si te sale true sin esperarlo, revisa la CURP que mandaste: puede que sea la de otra persona. Y si alguna vez no pudiéramos reactivar la ficha, la constancia no se pierde: la respuesta sigue siendo 201 con reactivado en false y un aviso que te dice sólo lo que sabemos de esa ficha — que seguía dada de baja y hay que reactivarla desde Personas, o que estaba activa y no había baja que deshacer, o que no pudimos comprobarla. Nunca te mandamos a corregir una baja que no existe. No repitas la petición: la constancia ya está emitida.

Segunda emisión reutilizando el curso (curso_id) y la empresa ya dadas de alta:

curl -s -X POST "https://capacitatotal.mx/api.php/v1/constancias" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"empresa":{"rfc":"CNO050101AB2"},"curso_id":4923,
       "trabajador":{"curp":"LOMA880202MDFPRR06","nombre":"MARIA","primer_apellido":"LOPEZ","segundo_apellido":"MARTINEZ","ocupacion_area":"04.6"},
       "fec_inicio":"2026-08-24","fec_termino":"2026-08-25"}'

Errores típicos, tal como salen:

422 {"error":"falta el campo 'trabajador'"}
422 {"error":"CURP inválida: XXXX000000XXXXXX00"}
422 {"error":"trabajador.ocupacion_area: '01.3 GANADERIA' no cuadra. El código 01.3 es «Pesca y acuacultura», no lo que dice el texto, y no elegimos por ti cuál de los dos querías: corrige uno y vuelve a mandarlo. (El sistema anterior de muchos despachos traía la ganadería con el código de la pesca.)"}
400 {"error":"trabajador.sirce: no existe el municipio 999 en el estado 9 (consulta GET /v1/catalogos/municipios?estado=9)"}
400 {"error":"cuerpo JSON inválido"}

6. Emitir en lote

POST/v1/constancias/loteconstancias:write · acepta Idempotency-Key

Cuerpo {"constancias": [ …hasta 50 objetos iguales al de emitir… ]}. Cada ítem se procesa por separado con las mismas reglas y el mismo cupo; la respuesta es siempre 207 con un resultado por índice. Un ítem con error no impide los demás.

curl -s -X POST "https://capacitatotal.mx/api.php/v1/constancias/lote" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: lote-2026-0001" \
  -d '{"constancias":[
        {"empresa":{"rfc":"CNO050101AB2"},"curso_id":4923,"trabajador":{"curp":"LOMA880202MDFPRR06","nombre":"MARIA","primer_apellido":"LOPEZ"},"fec_inicio":"2026-08-27","fec_termino":"2026-08-27"},
        {"empresa":{"rfc":"CNO050101AB2"},"curso_id":4923,"trabajador":{"curp":"XXXX000000XXXXXX00","nombre":"ERROR","primer_apellido":"CURP"},"fec_inicio":"2026-08-27","fec_termino":"2026-08-27"}
      ]}'
HTTP/1.1 207 Multi-Status
{
  "total": 2, "creadas": 1, "fallidas": 1,
  "resultados": [
    {"indice": 0, "ok": true, "folio": "DC3-1WXA-QKKF-NMNQG", "codigo_verificacion": "kr5Yoo6zAVmZkPvIv0e7VTmX",
     "empresa_id": 4753, "curso_id": 4923, "fecha_emision": "2026-09-02", "reactivado": false,
     "links": { "pdf": "…", "docx": "…", "verificar": "…", "verificar_json": "…" },
     "avisos": ["La empresa no tiene capturado su número de trabajadores, …"]},
    {"indice": 1, "ok": false, "error": "CURP inválida: XXXX000000XXXXXX00", "status": 422}
  ]
}

Con Idempotency-Key, repetir un lote que creó al menos una constancia devuelve el mismo 207 (los ítems fallidos siguen fallidos: corrígelos y mándalos en un lote nuevo con otra llave). Un 207 que no creó ninguna (por ejemplo, todas rechazadas por cupo) no se guarda: el reintento con la misma llave vuelve a procesar, para que pagar y reintentar funcione.

7. Obtener una constancia

GET/v1/constancias/{folio}constancias:read

Devuelve el objeto constancia: lo impreso (sale del contenido firmado, no de las fichas), ids, estado, motivo de anulación y enlaces.

curl -s "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61" -H "Authorization: Bearer $KEY"
HTTP/1.1 200 OK
{
  "folio": "DC3-JA9Z-5N7G-GYZ61",
  "estado": "activa",
  "fecha_emision": "2026-09-02",
  "periodo": {"inicio": "2026-08-24", "fin": "2026-08-25"},
  "duracion_horas": 8,
  "trabajador": "PEREZ GARCIA JUAN",
  "empresa": "CONSTRUCTORA DEL NORTE SA DE CV",
  "curso": "NOM-009-STPS-2011 Trabajos en altura",
  "links": {
    "pdf": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/pdf",
    "docx": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/docx",
    "verificar": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61",
    "verificar_json": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61&format=json"
  },
  "curp": "PEGJ900515HDFRRN09",
  "puesto": "OPERADOR",
  "ocupacion": "04.6 Procesos industriales",
  "area_tematica": "Producción",
  "empresa_id": 4753,
  "empresa_rfc": "CNO050101AB2",
  "empresa_razon_social": "CONSTRUCTORA DEL NORTE SA DE CV",
  "curso_id": 4923,
  "capacitador_id": 2661,
  "verify_url": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61",
  "pdf_url": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/pdf",
  "anulada_motivo": null,
  "sustituida_por": null
}

8. Descargar el PDF o el DOCX

GET/v1/constancias/{folio}/pdfconstancias:read
GET/v1/constancias/{folio}/docxconstancias:read

El documento se genera al momento a partir del contenido firmado (por eso no hay HEAD). Respuesta binaria con Content-Disposition: attachment y el nombre DC3_<folio>.pdf. Folio ajeno o inexistente: 404.

curl -s "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/pdf" \
  -H "Authorization: Bearer $KEY" -o DC3_DC3-JA9Z-5N7G-GYZ61.pdf

9. Anular una constancia

POST/v1/constancias/{folio}/anularconstancias:write

El DC-3 no vence; la única forma de retirar una constancia con un error es anularla. La página pública pasa a decir «anulada» (o «reemplazada por…» si mandas sustituida_por), su contenido firmado no se toca y dispara el webhook constancia.anulada (hoy sólo cuando se anula por la API; el botón «Anular» del panel todavía no lo dispara).

CampoNotas
motivoObligatorio, hasta 255 caracteres. Queda registrado y viaja en el webhook.
sustituida_porOpcional: folio de la constancia nueva (ya emitida, activa, tuya) que corrige a ésta. Con él, la verificación pública muestra la corrección como corrección y no como una anulación seca.
curl -s -X POST "https://capacitatotal.mx/api.php/v1/constancias/DC3-3KSF-BE3H-JPZF0/anular" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"motivo":"Error de captura en el segundo apellido","sustituida_por":"DC3-JA9Z-5N7G-GYZ61"}'
HTTP/1.1 200 OK
{
  "folio": "DC3-3KSF-BE3H-JPZF0",
  "estado": "anulada",
  … el objeto constancia completo …
  "anulada_motivo": "Error de captura en el segundo apellido",
  "sustituida_por": "DC3-JA9Z-5N7G-GYZ61"
}

# repetirla:
HTTP/1.1 409 Conflict
{"error":"la constancia ya estaba anulada (motivo registrado: Error de captura en el segundo apellido); la sustituye DC3-JA9Z-5N7G-GYZ61"}

# sin motivo:
HTTP/1.1 422
{"error":"falta el campo 'motivo'"}
Anular no devuelve cupo en el plan Gratis (las constancias de prueba cuentan aunque se anulen); en los planes de pago la anulada deja de contar en el mes.

10. Catálogos

GET/v1/catalogosconstancias:read
GET/v1/catalogos/{nombre}constancias:read

Los catálogos oficiales (STPS, INEGI, CNO) con los que se llenan las claves de la emisión. Son iguales para todas las cuentas. El índice dice, para cada uno, qué campo del cuerpo se llena con qué columna.

CatálogoDevuelveSe usa en
escolaridadclave, nombretrabajador.sirce.escolaridad_clave
documento_probatorioclave, nombretrabajador.sirce.documento_probatorio_clave
institucionclave, nombretrabajador.sirce.institucion_clave
estadosclave, nombretrabajador.sirce.estado_clave
municipios?estado=9estado_clave, clave, nombretrabajador.sirce.municipio_clave (con su estado)
areas_tematicasclave, codigo, nombrecurso.area_clave = clave
ocupacion_areascodigo, nombre, divisiontrabajador.ocupacion_area = codigo («04.6»)
ocupaciones?q=soldadorclave, codigo, nombre (hasta 50)trabajador.ocupacion_clave = clave
curl -s "https://capacitatotal.mx/api.php/v1/catalogos/municipios?estado=9" -H "Authorization: Bearer $KEY"
HTTP/1.1 200 OK
{
  "catalogo": "municipios",
  "uso": "trabajador.sirce.municipio_clave = clave (junto con estado_clave); requiere ?estado=",
  "total": 16,
  "items": [
    {"estado_clave": 9, "clave": 1, "nombre": "ALVARO OBREGON"},
    {"estado_clave": 9, "clave": 2, "nombre": "AZCAPOTZALCO"},
    …
  ]
}

# sin estado:      422 {"error":"El catálogo 'municipios' requiere ?estado=<clave del estado> (ver /v1/catalogos/estados)."}
# nombre desconocido: 404 {"error":"catálogo desconocido: 'nada'. Los que existen: escolaridad, documento_probatorio, institucion, estados, municipios, areas_tematicas, ocupacion_areas, ocupaciones"}

escolaridad: 1 Ninguno · 2 Primaria · 3 Secundaria · 4 Bachillerato · 5 Carrera técnica · 6 Licenciatura · 7 Especialidad · 8 Maestría · 9 Doctorado

documento_probatorio: 1 Título · 2 Certificado · 3 Diploma · 4 Otro · 5 Ninguno

institucion: 1 Pública · 2 Privada · 3 Ninguna

11. Cumplimiento, empresas, portal y ACE

GET/v1/cumplimientoconstancias:read

Resumen de cumplimiento de todas tus empresas: cobertura de plantilla, constancias por año, situación del SIRCE del año cerrado y nivel de riesgo.

HTTP/1.1 200 OK
{
  "empresas": 1, "score_promedio": 100, "en_riesgo": 0,
  "detalle": [
    {"empresa_id": 4753, "empresa": "CONSTRUCTORA DEL NORTE SA DE CV", "plantilla": 2, "con_constancia": 2, "cobertura_pct": 100,
     "constancias_total": 2, "constancias_por_anio": {"2026": 2},
     "sirce": {"anio_cerrado": 2025, "constancias": 0, "fecha_limite": "2026-03-25", "dias_para_limite": -161, "dias_habiles": 60},
     "score_cumplimiento": 100, "riesgo_inspeccion": "bajo", "alertas": []}
  ]
}
GET/v1/empresas/{id}/cumplimientoconstancias:read

El mismo bloque, para una empresa (el detalle[] de arriba sin el envoltorio). {id} es empresa_id; una empresa que no es tuya responde 404.

GET/v1/empresas/{id}/panelconstancias:read

Lo que ve el portal de esa empresa: cumplimiento, por_nom (trabajadores y cursos por NOM), recapacitacion, recapacitacion_resumen y recapacitacion_config.

recapacitacion_resumen dice a cuántas PERSONAS dejó fuera la lista de recapacitacion por estar dadas de baja en la empresa (bajas) y con qué frase decirlo junto al número (bajas_texto, vacía cuando no hay ninguna). A quien ya no trabaja ahí no «le toca recapacitación», así que la lista lo excluye; sin este bloque una lista más corta —o vacía— no traía con qué explicarse.

HTTP/1.1 200 OK
{
  "cumplimiento": { … },
  "por_nom": [{"nom": "NOM-009", "trabajadores": 2, "cursos": ["NOM-009-STPS-2011 Trabajos en altura"]}],
  "recapacitacion": [],
  "recapacitacion_resumen": {"bajas": 1, "bajas_texto": "1 persona dada de baja no se cuenta"},
  "recapacitacion_config": {"cursos": 1, "con_periodo": 1}
}
POST/v1/empresas/{id}/portal-linkconstancias:write · plan con portal

Genera una liga del portal de empresa (donde tu cliente descarga y verifica sus constancias). Si tu plan no incluye el portal: 403 {"error":"Portal de empresas no viene en tu plan."}.

curl -s -X POST "https://capacitatotal.mx/api.php/v1/empresas/4753/portal-link" -H "Authorization: Bearer $KEY"

HTTP/1.1 201 Created
{"portal_url": "https://capacitatotal.mx/portal.php?t=viDWGGMEifwtTNToTqB6ba9LjCwZcTumdzGy6HuP29Q"}
GET/v1/ace/{registro}constancias:read · plan con ACE

Situación de un registro de agente capacitador externo según la copia local del padrón de la STPS (nunca consulta en vivo a la STPS). Si el registro no se ha cotejado todavía, lo dice.

HTTP/1.1 200 OK
{"rfc": "CAPD850101ABC0013", "estado": "sin_padron", "activo": false,
 "mensaje": "Todavía no has comprobado este registro contra el padrón de la STPS. Puedes hacerlo desde «Mi registro ACE»."}

12. Idempotency-Key

Emitir no es reversible: se numera, se firma y descuenta cupo. Si tu sistema reintenta una petición que sí llegó (timeout de red, proceso reiniciado), saldrían dos constancias iguales. Para evitarlo, manda en POST /v1/constancias y POST /v1/constancias/lote la cabecera:

Idempotency-Key: rh-2026-0001      # letras, dígitos y . _ : -  (hasta 128); una por operación tuya
SituaciónRespuesta
Primera vezSe procesa normal. Si emitió (201 o 207), la respuesta queda guardada 24 horas bajo esa llave y tu API key.
Misma llave, mismo cuerpo (byte a byte), dentro de 24 hLa respuesta original, con la cabecera Idempotent-Replayed: true. No se emite nada.
Misma llave, otro cuerpo u otra ruta422. Una llave identifica una operación.
Misma llave mientras la primera sigue en curso409: espera su respuesta.
La primera falló (4xx/5xx)No se guarda: el reintento con la misma llave vuelve a intentarlo.
Llave mal formada400 antes de tocar nada.
# segunda vez, mismo cuerpo:
HTTP/1.1 201 Created
Idempotent-Replayed: true
{"folio":"DC3-JA9Z-5N7G-GYZ61", …exactamente la misma respuesta… }

# misma llave, otro cuerpo:
HTTP/1.1 422
{"error":"Esta Idempotency-Key ya se usó con otro cuerpo (o en otra ruta). Una llave identifica UNA operación: usa una llave nueva para una petición distinta. No se creó nada."}

13. Webhooks

POST/v1/webhooksconstancias:write

Registra una URL https pública (puerto 443 u 8443; nada de http, localhost ni IP privadas) y recibirás un POST firmado por cada evento al que te suscribas. También se registran desde Panel › Integraciones, donde además hay un botón «Probar».

curl -s -X POST "https://capacitatotal.mx/api.php/v1/webhooks" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/webhooks/rh","eventos":["constancia.creada","constancia.anulada"]}'

HTTP/1.1 201 Created
{
  "id": 100,
  "secret": "whsec_TjDpagKHdLMREaKQWBwc-ocSeXGFK994",
  "eventos": ["constancia.creada", "constancia.anulada"],
  "nota": "Guarda el secret (se muestra una vez). Firma: X-DC3-Signature = v1=HMAC_SHA256(secret, X-DC3-Delivery + \".\" + X-DC3-Timestamp + \".\" + cuerpo). Cómo verificarla: https://capacitatotal.mx/api-docs#webhooks"
}

# evento inexistente: 400 {"error":"'eventos' sólo acepta: constancia.creada, constancia.anulada, lote.completado"}
# http o red interna: 422 {"error":"La URL del webhook no es válida. Debe ser una URL https pública."}

Sin eventos te suscribes a los tres. El secret se muestra una sola vez.

Eventos

EventoCuándo se dispara
constancia.creadaAl emitir una constancia por la API (individual o en lote). Las emitidas desde el panel hoy no disparan este evento.
constancia.anuladaAl anular por POST /v1/constancias/{folio}/anular. Trae motivo y, cuando aplique, sustituida_por.
lote.completadoCuando termina de generarse el ZIP de un lote del panel (importación de Excel o grupo). Trae lote_id, total y fallidas.
webhook.testEl botón «Probar» del panel. Mismas cabeceras y misma firma; cuerpo {"event":"webhook.test","mensaje":"…","endpoint_id":N}.
Los webhooks registrados antes de septiembre de 2026 sólo están suscritos a constancia.creada y lote.completado. Para recibir constancia.anulada, registra un endpoint nuevo con los tres eventos.

Cabeceras de cada entrega

POST /webhooks/rh HTTP/1.1
Content-Type: application/json
User-Agent: CapacitaTotal-DC3-Webhook/1
X-DC3-Event: constancia.creada
X-DC3-Delivery: 531
X-DC3-Timestamp: 1788386135
X-DC3-Signature: v1=881d58dddfb440b0b9adcecb8384eff51d5adddd8ee931acf83107e417782629

Responde 2xx en menos de 10 segundos (el cuerpo de tu respuesta se descarta). Cualquier otra cosa se reintenta: hasta 8 intentos con espera creciente (2, 4, 8… minutos, tope 6 horas). Tras 20 fallos seguidos el endpoint se desactiva; registra uno nuevo cuando lo hayas arreglado. X-DC3-Delivery es el id de la entrega: si recibes la misma dos veces (reintento tras un 2xx que no nos llegó), descártala por ese id.

Payload de constancia.creada

Las claves evento, folio, occurred_at y nombre (nombre de pila del trabajador) son las de siempre. Todo lo demás es el objeto constancia completo, añadido en septiembre de 2026: una integración que sólo lee folio y nombre sigue funcionando sin cambios.

{
  "evento": "constancia.creada",
  "folio": "DC3-JA9Z-5N7G-GYZ61",
  "occurred_at": "2026-09-02T15:58:11-06:00",
  "nombre": "JUAN",
  "estado": "activa",
  "fecha_emision": "2026-09-02",
  "periodo": {"inicio": "2026-08-24", "fin": "2026-08-25"},
  "duracion_horas": 8,
  "trabajador": "PEREZ GARCIA JUAN",
  "empresa": "CONSTRUCTORA DEL NORTE SA DE CV",
  "curso": "NOM-009-STPS-2011 Trabajos en altura",
  "links": {
    "pdf": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/pdf",
    "docx": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/docx",
    "verificar": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61",
    "verificar_json": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61&format=json"
  },
  "curp": "PEGJ900515HDFRRN09",
  "puesto": "OPERADOR",
  "ocupacion": "04.6 Procesos industriales",
  "area_tematica": "Producción",
  "empresa_id": 4753,
  "empresa_rfc": "CNO050101AB2",
  "empresa_razon_social": "CONSTRUCTORA DEL NORTE SA DE CV",
  "curso_id": 4923,
  "capacitador_id": 2661,
  "verify_url": "https://capacitatotal.mx/verifica.php?f=DC3-JA9Z-5N7G-GYZ61",
  "pdf_url": "https://capacitatotal.mx/api.php/v1/constancias/DC3-JA9Z-5N7G-GYZ61/pdf",
  "anulada_motivo": null,
  "sustituida_por": null
}

Payload de constancia.anulada

{
  "evento": "constancia.anulada",
  "folio": "DC3-3KSF-BE3H-JPZF0",
  "occurred_at": "2026-09-02T15:58:27-06:00",
  "motivo": "Error de captura en el segundo apellido",
  "sustituida_por": "DC3-JA9Z-5N7G-GYZ61",
  "estado": "anulada",
  "fecha_emision": "2026-09-02",
  "periodo": {"inicio": "2026-08-24", "fin": "2026-08-25"},
  "duracion_horas": 8,
  "trabajador": "LOPEZ MARTINEZ MARIA",
  "empresa": "CONSTRUCTORA DEL NORTE SA DE CV",
  "curso": "NOM-009-STPS-2011 Trabajos en altura",
  "links": { "pdf": "…", "docx": "…", "verificar": "…", "verificar_json": "…" },
  "curp": "LOMA880202MDFPRR06",
  "puesto": null,
  "ocupacion": "04.6 Procesos industriales",
  "area_tematica": "Producción",
  "empresa_id": 4753,
  "empresa_rfc": "CNO050101AB2",
  "empresa_razon_social": "CONSTRUCTORA DEL NORTE SA DE CV",
  "curso_id": 4923,
  "capacitador_id": 2661,
  "verify_url": "https://capacitatotal.mx/verifica.php?f=DC3-3KSF-BE3H-JPZF0",
  "pdf_url": "https://capacitatotal.mx/api.php/v1/constancias/DC3-3KSF-BE3H-JPZF0/pdf",
  "anulada_motivo": "Error de captura en el segundo apellido"
}

Payload de lote.completado

{"evento": "lote.completado", "lote_id": 812, "occurred_at": "2026-09-02T16:10:03-06:00", "total": 48, "fallidas": 0}

Verificar la firma

La firma es HMAC-SHA256 con tu secret sobre la cadena X-DC3-Delivery + "." + X-DC3-Timestamp + "." + cuerpo, en hexadecimal y con el prefijo v1=. Usa el cuerpo crudo (los bytes tal cual llegaron, no el JSON re-serializado), compara en tiempo constante y rechaza timestamps de más de 5 minutos. Los tres fragmentos siguientes se probaron contra entregas reales.

PHP
$secret    = getenv('DC3_WEBHOOK_SECRET');            // el whsec_… que te dio el registro
$body      = file_get_contents('php://input');
$delivery  = $_SERVER['HTTP_X_DC3_DELIVERY']  ?? '';
$timestamp = $_SERVER['HTTP_X_DC3_TIMESTAMP'] ?? '';
$firma     = $_SERVER['HTTP_X_DC3_SIGNATURE'] ?? '';   // "v1=<hex>"

$esperada = 'v1=' . hash_hmac('sha256', $delivery . '.' . $timestamp . '.' . $body, $secret);
$valida   = hash_equals($esperada, $firma) && abs(time() - (int) $timestamp) < 300;
if (!$valida) { http_response_code(401); exit; }

$evento = json_decode($body, true);   // $evento['evento'], $evento['folio'], …
http_response_code(200);
Python
import hashlib, hmac, os, time

def webhook_valido(headers: dict, body_bytes: bytes) -> bool:
    secret = os.environ["DC3_WEBHOOK_SECRET"]
    delivery = headers["X-DC3-Delivery"]
    timestamp = headers["X-DC3-Timestamp"]
    firma = headers["X-DC3-Signature"]          # "v1=<hex>"

    mensaje = f"{delivery}.{timestamp}.".encode("utf-8") + body_bytes
    esperada = "v1=" + hmac.new(secret.encode("utf-8"), mensaje, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperada, firma) and abs(time.time() - int(timestamp)) < 300

# Flask:  webhook_valido(request.headers, request.get_data())   # get_data() = cuerpo crudo
Node.js
const crypto = require('crypto');

function webhookValido(headers, rawBody /* Buffer con el cuerpo crudo */) {
  const secret = process.env.DC3_WEBHOOK_SECRET;
  const delivery = headers['x-dc3-delivery'];
  const timestamp = headers['x-dc3-timestamp'];
  const firma = headers['x-dc3-signature'];               // "v1=<hex>"

  const mensaje = Buffer.concat([Buffer.from(`${delivery}.${timestamp}.`, 'utf8'), rawBody]);
  const esperada = 'v1=' + crypto.createHmac('sha256', secret).update(mensaje).digest('hex');
  return esperada.length === firma.length
    && crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(firma))
    && Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
}

// Express: app.post('/webhooks/dc3', express.raw({ type: 'application/json' }), (req, res) => {
//   if (!webhookValido(req.headers, req.body)) return res.sendStatus(401);
//   const evento = JSON.parse(req.body.toString('utf8'));
//   res.sendStatus(200);
// });

14. El objeto constancia

Es la misma forma en GET /v1/constancias/{folio}, en la respuesta de anular y dentro de los webhooks constancia.creada y constancia.anulada, para que escribas un solo lector. Lo impreso sale del contenido firmado al emitir (lo que dice el papel), no de las fichas vivas.

ClaveContenido
folioFolio público, impreso en el documento y en el QR.
estadoactiva o anulada.
fecha_emision, periodo.inicio, periodo.fin, duracion_horasFechas AAAA-MM-DD y horas del curso.
trabajadorTexto, en el orden del formato: apellido paterno, materno, nombre(s).
empresa, cursoTexto: razón social y nombre del curso tal como se imprimieron.
links.pdf, links.docxDescargas (con tu API key).
links.verificar, verify_urlLa página pública de verificación: la misma URL que lleva el QR.
links.verificar_jsonLa misma verificación, en JSON y sin autenticación.
curp, puesto, ocupacion, area_tematicaLo impreso en esas casillas (null si quedó en blanco).
empresa_id, empresa_rfc, empresa_razon_social, curso_id, capacitador_idIds para tus cruces y el RFC del patrón.
pdf_urlIgual que links.pdf.
anulada_motivoTexto del motivo cuando está anulada; si no, null.
sustituida_porFolio de la constancia que la corrige, cuando aplique; si no, null.

La respuesta de emitir es más corta a propósito (folio, código de verificación, ids y enlaces): lo que hace falta para archivar. Si necesitas el objeto completo tras emitir, pídelo con GET /v1/constancias/{folio} o recíbelo por webhook.

15. Compatibilidad y cambios

La API es un contrato: nunca se quita ni se renombra una clave existente; lo nuevo se añade. Tu lector debe ignorar las claves que no conozca.

FechaCambio (todo aditivo)
2026-09Webhook constancia.creada con el objeto constancia completo (antes: solo folio y nombre). Evento nuevo constancia.anulada. Ruta POST /v1/constancias/{folio}/anular. Filtros curp, curso_id, estado en el listado y columnas nuevas por fila. Bloque trabajador.sirce en la emisión. Cabecera Idempotency-Key. Rutas /v1/catalogos. links.verificar_json. Claves nuevas en GET /v1/constancias/{folio}. La respuesta de POST /v1/webhooks añade eventos y corrige el texto de la nota (la firma siempre incluyó el id de entrega). Motivo nuevo de 403 al emitir: perfil del capacitador sin completar (mismo código, texto propio). reactivado en la respuesta de emitir (y en cada ítem del lote): la emisión devuelve a activa a una persona dada de baja, como ya hacían el panel y la importación. Bloque recapacitacion_resumen en GET /v1/empresas/{id}/panel.
2026-08trabajador.ocupacion_area en la emisión; avisos en la respuesta de emitir.

¿Falta algo para tu integración? Escríbenos desde contacto: la API crece con lo que los integradores necesitan.

Preguntas frecuentes

¿Necesito ser programador para usar la API?

Sí, o tener a alguien que lo sea. La API es para conectar otro software (nómina, LMS, ERP, un sistema de RH) con la plataforma. Si emites constancias desde el panel, no necesitas nada de esto.

¿Qué pasa si mi sistema reintenta una emisión por un fallo de red?

Manda la cabecera Idempotency-Key con un identificador tuyo por operación. La misma llave con el mismo cuerpo, dentro de 24 horas, devuelve la respuesta original (mismo folio) y no emite una segunda constancia.

¿Los webhooks llegan a una URL http o a una IP interna?

No. Sólo se entregan a URL https públicas (puertos 443 u 8443). Cada envío va firmado con HMAC-SHA256 y trae un identificador de entrega para que puedas descartar repeticiones.

¿La constancia emitida por API es igual a la del panel?

Es la misma constancia: mismo folio, mismo PDF oficial, misma firma verificable y misma página pública de verificación. Cuenta igual para el cupo del plan.

¿Puedo probar sin pagar?

Sí. Una cuenta del plan Gratis puede generar su API key y usar todas las rutas; el cupo de constancias del plan aplica igual por API que por el panel.

Conecta tu sistema hoy

Crea tu cuenta, genera tu API key en Integraciones y emite tu primera DC-3 desde tu propio software.

Empieza gratis
¿Dudas? Escríbenos