Guía de integración de la API de AccesPub
Versión 0.10 (01/11/2026). Todo lo de esta guía está disponible en
https://api.accespub.app/v1, también los webhooks (§6). La referencia completa está en /docs/referencia y el contrato, en openapi.yaml.
La API hace por lotes lo mismo que el dashboard de AccesPub hace con un EPUB: analiza, corrige todo lo automático, deja para una persona lo que requiere criterio y entrega el EPUB final con su informe y su ONIX de accesibilidad.
Hay dos formas de trabajar con AccesPub:
- Solo portal, sin programar: todo se hace en el portal de AccesPub (api.accespub.app/portal), con su ayuda en cuatro idiomas.
- Modelo mixto, para integrarlo con vuestros sistemas: vuestro sistema hace por la API lo mecánico (crear lotes, subir los EPUB, enviar metadatos y datos comerciales, descargar las salidas) y la revisión humana se hace en el portal, al que vuestro frontal enlaza (ver «Modelo mixto»).
El portal usa esta misma API: no tiene funciones que la API no tenga. Si necesitáis hacer también la revisión en vuestro propio frontal, habladlo antes con ab serveis.
1. Primeros pasos
- Pedid una cuenta a ab serveis. Dará de alta vuestra organización e invitará a la primera persona administradora.
- Cread las claves que necesitéis en el portal (Organización → Claves de API)
o con
POST /api-keys("mode": "test"para pruebas). La clave de prueba empieza porap_test_y solo se muestra una vez: guardadla en vuestro gestor de secretos, nunca en el código. - Probad la clave:
export ACCESPUB_KEY=ap_test_...
curl -s https://api.accespub.app/v1/organization \
-H "Authorization: Bearer $ACCESPUB_KEY"
Las claves ap_test_ no consumen créditos y sus datos están separados de los
reales. Para producción, cread una clave ap_live_.
Un EPUB de principio a fin
Hay un ejemplo completo y probado en Python
(con revisión interactiva en el terminal) y en JavaScript para
Node.js. Paso a paso con curl:
# 1. Crear un lote
curl -s https://api.accespub.app/v1/batches \
-H "Authorization: Bearer $ACCESPUB_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"name": "Prueba", "on_pending": "wait"}'
# → {"id": "bat_01JAB2C3D4", "status": "open", ...}
# 2. Subir un EPUB (uno por petición)
curl -s https://api.accespub.app/v1/batches/bat_01JAB2C3D4/jobs \
-H "Authorization: Bearer $ACCESPUB_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "file=@el-agua-que-viaja.epub" \
-F "external_id=SKU-1234"
# → 202 {"id": "job_01JAB2C9XY", "status": "queued", ...}
# 3. Esperar al análisis (consultando el estado cada 10-30 s)
curl -s https://api.accespub.app/v1/jobs/job_01JAB2C9XY \
-H "Authorization: Bearer $ACCESPUB_KEY"
# → "status": "needs_review", "review": {"total": 22, "pending": 22}
# 4. Revisar (ver §4) y 5. finalizar (o crear el lote con "auto_finalize": true)
curl -s -X POST https://api.accespub.app/v1/jobs/job_01JAB2C9XY/finalize \
-H "Authorization: Bearer $ACCESPUB_KEY" \
-H "Idempotency-Key: $(uuidgen)"
# 6. Descargar el EPUB final
curl -s -o final.epub \
https://api.accespub.app/v1/jobs/job_01JAB2C9XY/files/epub \
-H "Authorization: Bearer $ACCESPUB_KEY"
Personas y roles
Cada persona que revisa tiene su cuenta. Las invita un administrador:
curl -s https://api.accespub.app/v1/invitations \
-H "Authorization: Bearer $ACCESPUB_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"email": "laura@editorial.example", "role": "reviewer", "name": "Laura Pons"}'
# → {"id": "usr_…", "status": "invited", "access_url": "https://…/invitacion?token=…", "email_sent": true}
| Rol | Qué puede hacer |
|---|---|
admin |
Todo, incluidas personas y claves |
batch_manager |
Lotes, subidas, revisión, finalizar y descargar |
reviewer |
Ver, revisar y descargar (no crea lotes ni finaliza) |
external_reviewer |
Solo revisar el lote al que se le invita (batch_id), sin contraseña: entra con su enlace, válido 30 días |
- El enlace llega por correo y también se os devuelve en
access_url(es personal). La persona crea su contraseña conPOST /invitations/accept. - Las claves de API siguen sirviendo para integraciones: una clave de
administración actúa como
adminy las demás comobatch_manager. - Si montáis vuestro propio frontal con sesiones de persona:
POST /sessionsdevuelve una cookie y uncsrf_token, que hay que enviar en la cabeceraX-CSRF-Tokenen las peticiones que cambian algo;AccesPub-Mode: testpara los datos de prueba. Es lo mismo que usa el portal. - Las sesiones de persona (portal o
POST /sessions) se renuevan con la actividad: caducan tras 12 horas sin usarse y, como máximo, 7 días después de iniciarse. Las claves de API no tienen sesión: cada petición lleva la clave y basta. - Dar de baja a una persona (
DELETE /users/{id}) cierra sus sesiones; su nombre sigue en las decisiones que firmó.
Modelo mixto: vuestro sistema y el portal
Vuestro sistema orquesta por la API y las personas revisan en el portal de AccesPub, que ya resuelve lo que requiere criterio (imagen con su contexto, propuestas de la IA que no se aplican solas, tablas, idiomas y la firma de cada decisión).
- Cread el lote con
"on_pending": "wait","auto_finalize": true(cada título se finaliza solo cuando se toma su última decisión en el portal) y, si queréis las propuestas de la IA preparadas,"defaults": {"ai_prefill": true}. - Dad de alta a quienes revisan con
POST /invitations(con una clave de administración). Si van a entrar siempre desde vuestra aplicación, añadid"send_email": false: no reciben el correo de «crea tu contraseña» y no la necesitan (ver el paso 4). - Subid los EPUB y enviad los metadatos y los datos comerciales desde vuestro
catálogo (
PATCH …/metadata,PATCH …/commercial). - Cuando un título pase a
needs_review, mostrad en vuestro frontal un botón «Revisar en AccesPub». Cada título traeportal_url(su ficha en el portal) yreview_url(su pantalla de revisión); el lote, suportal_url. Los de prueba ya llevan?mode=test. Al pulsarlo, vuestro servidor pide un enlace de acceso para esa persona y la redirige: entra en la revisión sin contraseña (ver más abajo). Sin enlace de acceso, el botón abrereview_urly la persona inicia sesión. - Al terminar la revisión, el título se finaliza solo; vuestro sistema recibe el
aviso
job.completed(webhooks, §6) y descarga las salidas. Sin webhooks, consultadGET /jobs/{id}hasta vercompleted.
Con webhooks (§6), vuestro sistema no tiene que consultar el estado: recibe
job.needs_review (para mostrar el botón «Revisar en AccesPub»), job.completed y
batch.completed.
curl -s https://api.accespub.app/v1/jobs/job_01JAB2C9XY \
-H "Authorization: Bearer $ACCESPUB_KEY"
# → "status": "needs_review",
# "portal_url": "https://api.accespub.app/portal/titulos/job_01JAB2C9XY",
# "review_url": "https://api.accespub.app/portal/titulos/job_01JAB2C9XY/revision"
Entrar al portal sin volver a iniciar sesión
Vuestra aplicación ya sabe quién es la persona (tiene su propio inicio de sesión). Con un enlace de acceso, AccesPub abre el portal con la sesión de esa persona sin pedirle contraseña:
# En vuestro servidor, al pulsar «Revisar en AccesPub» (usr_… = la persona en AccesPub)
curl -s https://api.accespub.app/v1/users/usr_01JAB2D4EF/sign-in-link \
-H "Authorization: Bearer $ACCESPUB_KEY" -H "Content-Type: application/json" \
-d '{"next": "https://api.accespub.app/portal/titulos/job_01JAB2C9XY/revision"}'
# → 201 {"url": "https://api.accespub.app/portal/entrar?token=…",
# "expires_at": "…", "user_id": "usr_01JAB2D4EF", "next": "/portal/titulos/job_01JAB2C9XY/revision"}
# Redirigid el navegador de la persona a "url".
- Hay que pedir que se active para vuestra organización (ab serveis lo activa):
AccesPub confía en vuestro inicio de sesión para identificar a vuestra gente. Sin
activar, la respuesta es
403 sign-in-links-disabled. - Un solo uso y 5 minutos: pedidlo en el momento del clic, siempre desde vuestro servidor (la clave de API nunca va al navegador).
- Las decisiones quedan firmadas por esa persona. Una clave que no es de administración no puede pedir enlaces para una administradora.
-
Guardad en vuestro sistema el
idde cada persona en AccesPub (lo devuelvePOST /invitations, yGET /userslos lista). -
Abrid el portal en una pestaña o ventana nueva: no se puede incrustar en otra web (por seguridad, no admite marcos ni
iframe). - Quien revisa necesita una cuenta de vuestra organización. Si no tiene la sesión abierta, el portal le pide entrar y le devuelve a la misma pantalla.
- La revisión puntual de alguien externo (por ejemplo, una correctora) se hace con
un revisor externo (
external_reviewer), que entra con el enlace de su lote.
2. Ciclo de vida de un trabajo
Cada EPUB subido es un trabajo dentro de un lote.
Recorrido habitual: queued → processing → needs_review → ready →
finalizing → completed. Si no hay nada que revisar, pasa directamente de
processing a ready. Una decisión nueva sobre un título terminado lo devuelve a
ready, y POST /jobs/{id}/reprocess lo vuelve a poner en cola.
| Estado | Qué significa | Qué hacer |
|---|---|---|
queued |
En cola | Esperar |
processing |
Analizando y corrigiendo | Esperar |
needs_review |
Hay decisiones que tiene que tomar una persona | Revisar (§4) |
ready |
Nada pendiente | Finalizar |
finalizing |
Generando el EPUB final | Esperar |
completed |
Salidas disponibles | Descargar (§5) |
failed |
No se ha podido procesar; ver error |
Corregir el EPUB y volver a subirlo |
El lote tiene un estado agregado (GET /batches/{id}) y contadores por estado.
3. Lotes y límites
- Un EPUB por petición. Si una subida falla, se repite solo ese fichero.
- Límites por defecto: 50 MB por EPUB, 50 EPUB y 1 GB por lote, 5 GB ocupados
por organización. Se consultan en
GET /organization(limits,storage). - EPUB de más de 50 MB: antes de subirlo, pedid una subida grande
(
POST /large-upload-requests). Suele deberse a imágenes sin optimizar o a fuentes o vídeo incrustados. Si ab serveis la aprueba, el lote admite ese fichero, hasta 200 MB, durante 7 días. - No se admiten EPUB de diseño fijo (
422 fixed-layout-not-supported). external_idguarda vuestro identificador del título y se devuelve en todas las respuestas, webhooks y en el manifiesto.
4. Revisión humana
AccesPub no declara la conformidad con WCAG 2.1 AA si una persona no ha revisado lo que requiere criterio: texto alternativo de imágenes, tablas e idiomas.
- Pedid los pendientes:
GET /jobs/{id}/review-items?status=pending(paginado: hasta 100 por página, seguidnext_cursor). Cada elemento trae su contexto en el libro (texto anterior y posterior) y, en las imágenes, una URL para mostrarla (…/image, reducida a 1024 px). Hay tres tipos:
| Tipo | Qué se decide | Decisión |
|---|---|---|
image |
Texto alternativo, o que es decorativa | {"kind": "image", "decorative": false, "alt": "…"} |
table |
Si es de datos (con título) o de maquetación | {"kind": "table", "layout": false, "caption": "…", "first_row_as_headers": true} |
language, scope: inline |
Conservar o quitar un xml:lang del texto |
{"kind": "language", "action": "keep"} o "remove" |
language, scope: document |
El idioma de un documento | {"kind": "language", "action": "keep"} o "replace" con "new_lang": "ca" |
language, scope: book (id: book_language) |
El idioma principal del libro y su orden en el OPF | {"kind": "language", "action": "replace", "languages": ["es", "en"]} (el principal primero) o "keep" |
El idioma del libro aparece pendiente cuando el OPF declara varios idiomas, cuando
su idioma no coincide con el del texto o cuando no tiene ninguno válido
(language.reasons). Trae los idiomas declarados (declared), una propuesta
(proposed) y los que se pueden elegir (candidates). Si no hay nada que
confirmar, llega ya decidido y podéis cambiarlo igual.
Algunos idiomas de documento llegan ya decididos (decision.auto: true):
AccesPub los asignó con alta confianza. Podéis cambiarlos igual que los demás.
2. Si queréis, pedid una propuesta a la IA (imágenes y tablas):
POST /jobs/{id}/review-items/{item_id}/suggestion. Llega escrita en el
idioma del elemento. La propuesta no se aplica sola: mostradla a la persona
para que la acepte o la cambie, y enviadla con "from_suggestion": true. Con
claves ap_test_ la propuesta es un texto fijo de prueba (sin coste). Hay un
límite por minuto (limits.ai_suggestions_per_minute).
Propuestas masivas (0.7; tablas desde la 0.11). Para no pedirlas una a una, cread el lote con
"defaults": {"ai_prefill": true} o llamad a POST /jobs/{id}/ai-prefill: la IA
prepara en segundo plano una propuesta para cada imagen pendiente (salvo los SVG) y un
título para cada tabla pendiente (images y tables en el estado; las tablas no
cuentan en los bloques de pago).
GET /jobs/{id}/ai-prefill da el progreso (status: running, done…; done,
queued, failed). Cada propuesta aparece en review-items como si se hubiera
pedido a mano y tampoco se aplica sola: la persona la valida, la corrige o
marca la imagen como decorativa. La cola reparte el ritmo entre las
organizaciones (las propuestas pedidas a mano pasan delante) y tiene un tope
diario (limits.ai_prefill_per_day). Según vuestro contrato está incluido en el
crédito del título (hace falta saldo para finalizarlo) o se cobra aparte, por bloque
de imágenes y al pedirlo (lo no generado se devuelve); billing en el estado lo
indica. Cada imagen trae además hint: si parece informativa (pie de figura,
portada) o decorativa (filete, espaciador…), con sus motivos, y el grupo de
imágenes casi iguales (por ejemplo, cabeceras de capítulo) para validarlas seguidas.
3. Enviad las decisiones con la persona que las toma (hasta 200 por petición):
curl -s https://api.accespub.app/v1/jobs/job_01JAB2C9XY/decisions \
-H "Authorization: Bearer $ACCESPUB_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"reviewer": {"name": "Laura Pons", "email": "laura@editorial.example"},
"decisions": [
{"item_id": "img_0004", "decision": {"kind": "image", "decorative": false,
"alt": "Esquema del ciclo del agua: evaporación, condensación y lluvia.",
"from_suggestion": true}},
{"item_id": "img_0005", "decision": {"kind": "image", "decorative": true}},
{"item_id": "9f3c…", "decision": {"kind": "table", "layout": false,
"caption": "Temperaturas medias por mes", "first_row_as_headers": true}},
{"item_id": "lang_0001", "decision": {"kind": "language", "action": "keep"}},
{"item_id": "dlang_0002", "decision": {"kind": "language", "action": "replace",
"new_lang": "ca"}}
]
}'
# → {"saved": 5, "review": {"total": 22, "decided": 9, "pending": 13},
# "job_status": "needs_review"}
- Todo o nada. Si alguna decisión no es válida, la respuesta es
422 invalid-decisioncon un error por decisión enerrors("pointer": "/decisions/3/decision/alt") y no se guarda ninguna. Corregidla y reenviad la petición entera. - Cada decisión se guarda al momento con revisor y fecha. La revisión puede repartirse entre varias personas y durar semanas.
- Enviar otra decisión para el mismo elemento la sustituye; queda el historial
(
GET /jobs/{id}/decisions).review-itemsmuestra endecided_byydecided_atquién tomó la decisión vigente. - Si volvéis a procesar un título, las decisiones se aplican de nuevo solas.
- Quién firma. Con la sesión de una persona, el revisor es esa persona. Con
una clave de API,
revieweres obligatorio y su correo tiene que ser el de una persona activa de vuestra organización (o revisor externo de ese lote); si no,422 reviewer-not-registered. Dad de alta a cada persona que revise (ver «Personas y roles»). - Vuestra organización responde de la identidad y la competencia de sus
revisores (así consta en el contrato). AccesPub marca en
review_flags, sin bloquear, los patrones anómalos:fast_decisions(10 decisiones o más a menos de 3 segundos por elemento desde el fin del análisis) yall_ai_suggestions_accepted(5 propuestas de la IA o más, todas aceptadas sin cambios). - Si no queréis revisar: finalizad con
allow_pending: true(o cread el lote conon_pending: finalize). El EPUB se entrega con todo lo automático aplicado, pero sin declarar conformidad, y el informe lista lo pendiente. - Saltar la revisión (como en el dashboard):
POST /jobs/{id}/skip-reviewmarca lo pendiente de imágenes, tablas e idiomas en línea como decorativo, de maquetación o conservado. Con"confirm_decorative": trueyreviewer, una persona confirma que es así: son decisiones con revisor y cuentan para la conformidad. Sin confirmar, el EPUB sale igual pero sin conformidad.
Completar metadatos
Lo mismo que se corrige a mano en la pantalla de exportación del dashboard:
curl -s -X PATCH https://api.accespub.app/v1/jobs/job_01JAB2C9XY/metadata \
-H "Authorization: Bearer $ACCESPUB_KEY" -H "Content-Type: application/json" \
-d '{"dc": {"title": "El agua que viaja", "creator": "Laura Pons", "date": "2020-06-05"},
"page_source_isbn": "9788400000001",
"accessibility_summary": "Texto propio de la editorial…"}'
GET /jobs/{id}/metadatamuestra cada campo con su origen (epubuoverride) y los avisos (notices), que también salen en el trabajo:date_ambiguous(la fecha del EPUB no se puede interpretar),page_source_missing(hay lista de páginas sin la edición impresa de la que proceden) ytitle_placeholder(el EPUB no tenía título).- Los avisos no bloquean, igual que en el dashboard, pero conviene resolverlos.
nullquita un valor y vuelve al del EPUB. Los cambios se aplican al finalizar y se conservan aunque reproceséis el título.
Materias y propuesta de la IA
subjects sustituye a todas las materias del EPUB. Thema y BISAC se escriben en el
OPF con su refinamiento EPUB 3 (authority + term) y van al ONIX de
accesibilidad junto con las palabras clave:
curl -s -X PATCH https://api.accespub.app/v1/jobs/job_01JAB2C9XY/metadata \
-H "Authorization: Bearer $ACCESPUB_KEY" -H "Content-Type: application/json" \
-d '{"dc": {"description": "Una novela sobre tres generaciones de una familia de Valencia…"},
"subjects": {"thema": ["FBA", "1DSE"], "bisac": ["FIC019000"],
"keywords": ["familia", "memoria", "Valencia"]}}'
Si faltan la descripción o las materias, POST /jobs/{id}/metadata/suggestion
devuelve una propuesta de la IA (sinopsis, Thema comprobados contra la lista
oficial, pista BISAC en texto y palabras clave) a partir de unos 4.000 caracteres
del libro. No se aplica sola ni consume créditos: enviad con PATCH lo que una
persona acepte. Con una clave ap_test_ la propuesta es fija y no se llama a la IA.
ONIX comercial
Además del ONIX de accesibilidad, la API genera el ONIX 3.1 completo de cada título, con los datos comerciales que no están en el EPUB: precios por mercado, estado y fecha de publicación, sello, colección, autoría con su rol, público…
# Valores comunes del lote (también al crearlo, en defaults.commercial)
curl -s -X PATCH https://api.accespub.app/v1/batches/bat_01JAB2C3D4/commercial \
-H "Authorization: Bearer $ACCESPUB_KEY" -H "Content-Type: application/json" \
-d '{"pub_status": "04", "supplier_name": "De Marque",
"markets": [{"territory": "CA", "currency": "CAD", "price_type": "01"},
{"territory": "FR BE CH", "currency": "EUR", "price_type": "04", "tax_percent": "5.5"}]}'
# Datos del título (lo del título manda; los mercados del título sustituyen a los del lote)
curl -s -X PATCH https://api.accespub.app/v1/jobs/job_01JAB2C9XY/commercial \
-H "Authorization: Bearer $ACCESPUB_KEY" -H "Content-Type: application/json" \
-d '{"subtitle": "Un essai", "publishing_date": "2026-11-15",
"markets": [{"territory": "CA", "currency": "CAD", "price_type": "01", "price_amount": "24.95"},
{"territory": "FR BE CH", "currency": "EUR", "price_type": "04", "tax_percent": "5.5", "price_amount": "14.99"}]}'
# Comprobar, generar (puede consumir créditos, una vez por título) y descargar
curl -s https://api.accespub.app/v1/jobs/job_01JAB2C9XY/commercial -H "Authorization: Bearer $ACCESPUB_KEY"
curl -s -X POST https://api.accespub.app/v1/jobs/job_01JAB2C9XY/onix-full -H "Authorization: Bearer $ACCESPUB_KEY"
curl -s -o onix.xml "https://api.accespub.app/v1/jobs/job_01JAB2C9XY/files/onix_full?naming=isbn" \
-H "Authorization: Bearer $ACCESPUB_KEY"
- Un mercado es un territorio (países ISO como
ESoFR BE CH, o regiones ONIX comoWORLD) con su moneda, precio, tipo de precio, impuesto, disponibilidad, proveedor y fecha de venta. Varias monedas en un mismo territorio: repetid el territorio. GET /jobs/{id}/commercialdevuelve lo efectivo, de dónde sale cada valor y las comprobaciones (issues); con algúnerror,POST /onix-fullresponde422.apply_to_batchen el PATCH del título copia campos comunes al lote (los mercados, sin precio).- Cambiar los datos después no obliga a finalizar de nuevo ni vuelve a cobrar:
cada descarga se construye con los datos al día. Para todo el lote:
POST /batches/{id}/onix-full(todo o nada) yGET /batches/{id}/files/onix_full. - El coste lo fija el contrato de la organización (
billingen el GET). Con clavesap_test_no consume créditos.
Finalizar
Finalizar genera el EPUB definitivo y consume sus créditos (sección 8). Por defecto es un paso
explícito (POST /jobs/{id}/finalize, o POST /batches/{id}/finalize para todos
los títulos listos), para que decidáis cuándo se gasta el crédito y podáis repasar
antes. Si preferís que ocurra solo, cread el lote con "auto_finalize": true: cada
título se finaliza al tomar su última decisión. Excepción: los títulos que consumen
más de un crédito no se finalizan solos hasta que alguien confirma el aviso en el
portal o pedís /finalize explícitamente.
Si cambiáis una decisión de un título ya finalizado, vuelve a ready y hay que
finalizarlo de nuevo (sin coste).
5. Salidas
| Salida | Por título | Por lote |
|---|---|---|
| EPUB final | GET /jobs/{id}/files/epub |
GET /batches/{id}/files/epubs (.zip) |
| Informe HTML | GET /jobs/{id}/files/report_html |
GET /batches/{id}/files/reports (.zip) |
| Informe JSON | GET /jobs/{id}/report |
— |
| ONIX 3.1 de accesibilidad | GET /jobs/{id}/files/onix_a11y |
GET /batches/{id}/files/onix_a11y (un mensaje con un <Product> por título) |
| ONIX 3.1 comercial (completo) | GET /jobs/{id}/files/onix_full |
GET /batches/{id}/files/onix_full (los títulos con el ONIX comercial generado) |
| Manifiesto | — | GET /batches/{id}/manifest (JSON, o CSV con ?format=csv o Accept: text/csv) |
El manifiesto dice, para cada título, el estado de cada salida (ready,
ready_with_warnings, expired…) y qué falta (por ejemplo, el ISBN para el
ONIX). El informe HTML sale en español por defecto; ?lang=en, ca o fr para
otro idioma. Un título
incompleto no bloquea el lote: se entrega lo que está listo.
Los ficheros finales se conservan 30 días tras finalizar. Las decisiones y los informes, más tiempo, para poder volver a procesar sin revisar de nuevo.
Lo que está en revisión no caduca. Si un lote real con títulos por revisar o por finalizar lleva 30 días sin actividad, AccesPub envía un recordatorio por correo a las personas con rol admin y batch_manager de la organización; a los 60 días, el último. El plazo máximo para terminar un lote lo fija el contrato.
6. Webhooks
AccesPub avisa a vuestro sistema cuando cambia algo, en una URL vuestra (https):
así no hace falta consultar GET /jobs/{id} cada poco. Las direcciones se dan de
alta con una clave de administración (POST /webhook-endpoints) o en el portal
(Organización → Avisos automáticos). Cada modo tiene las suyas: las de una clave
ap_test_ solo reciben los eventos de los datos de prueba.
curl -s https://api.accespub.app/v1/webhook-endpoints \
-H "Authorization: Bearer $ACCESPUB_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://erp.velablanca.example/accespub/avisos",
"events": ["job.needs_review", "job.completed", "batch.completed", "credits.low"]}'
# → 201 {"id": "whe_…", "secret": "whsec_…", …} ← guardad el secreto: solo se ve ahora
| Evento | Cuándo | data |
|---|---|---|
job.needs_review |
El análisis ha terminado y hay elementos para revisar | Job |
job.ready |
Ya no queda nada pendiente: listo para finalizar | Job |
job.completed |
Un título se ha finalizado y sus salidas están listas | Job |
job.failed |
No se ha podido analizar (status: failed) o finalizar (vuelve a su estado, con error) |
Job |
job.ai_prefill_done |
Han terminado las propuestas de la IA del título (imágenes y tablas) | Job con ai_prefill |
batch.completed |
Todos los títulos del lote están finalizados o con error | Batch |
large_upload_request.resolved |
ab serveis ha aprobado o rechazado una subida grande | LargeUploadRequest |
support_request.answered |
ab serveis ha respondido una solicitud de ayuda | SupportRequest |
credits.low |
El saldo baja del umbral de la organización (10 por defecto) y otra vez al agotarse; solo live |
CreditsLow |
Sin events, la dirección recibe todos. Con un título finalizado automáticamente
(auto_finalize) no llega job.ready: llega job.completed.
Cada aviso es un POST con este cuerpo y las cabeceras webhook-id,
webhook-timestamp y webhook-signature (Standard Webhooks):
{"id": "evt_01JC5YB8Q0VZ3K2M4N6P8R0S2T", "type": "job.needs_review",
"created_at": "2026-11-03T09:14:05Z", "mode": "live",
"data": {"id": "job_01JAB2C9XY", "status": "needs_review",
"review_url": "https://api.accespub.app/portal/titulos/job_01JAB2C9XY/revision", …}}
Verificad la firma antes de confiar en el contenido (con el cuerpo tal cual llega, sin volver a serializarlo):
import base64, hashlib, hmac, time
def verificar(secret: str, headers: dict, body: bytes) -> bool:
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > 300: # más de 5 minutos: rechazar
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
firma = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
esperada = base64.b64encode(firma).decode()
return any(hmac.compare_digest(esperada, s.partition(",")[2])
for s in headers["webhook-signature"].split())
import crypto from "node:crypto";
function verificar(secret, headers, rawBody) { // rawBody: Buffer
const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const esperada = crypto.createHmac("sha256", key)
.update(`${id}.${ts}.`).update(rawBody).digest("base64");
return headers["webhook-signature"].split(" ").some((s) => {
const firma = Buffer.from(s.split(",")[1] || "");
return firma.length === Buffer.byteLength(esperada)
&& crypto.timingSafeEqual(firma, Buffer.from(esperada));
});
}
- Responded con un
2xxen menos de 10 segundos y procesad el aviso después. Cualquier otra respuesta (también una redirección) se reintenta con esperas crecientes durante unas 25 horas (9 intentos). - Un mismo aviso puede llegar dos veces (mismo
webhook-id): usadlo para descartar repetidos. Tampoco está garantizado el orden: ante la duda, consultad el estado actual conGET /jobs/{id}. - Probadla con
POST /webhook-endpoints/{id}/test(envíawebhook.testen el momento y devuelve lo que ha respondido vuestro sistema). - Envíos:
GET /webhook-endpoints/{id}/deliveries(30 días) y, para volver a enviar uno,POST /webhook-endpoints/{id}/deliveries/{delivery_id}/retry. - Rotar el secreto:
POST /webhook-endpoints/{id}/rotate-secret. Durante 24 horas cada aviso lleva dos firmas (nuevo y anterior); basta con que una coincida. - Si una dirección falla sin interrupción 5 días, se desactiva y se avisa por
correo a la administración. Se reactiva con
PATCH /webhook-endpoints/{id}{"enabled": true}o desde el portal. - Por seguridad, solo direcciones
httpspúblicas: nada delocalhostni redes internas. Para probar en local, usad un túnel (por ejemplo, ngrok o Cloudflare Tunnel).
7. Errores, reintentos y límites
Los errores siguen RFC 9457 (application/problem+json):
{
"type": "https://api.accespub.app/problems/file-too-large",
"title": "El EPUB supera el límite de tamaño",
"status": 413,
"detail": "atlas-de-dinosaurios.epub ocupa 64,3 MB; el límite es 50 MB."
}
| Código | Ejemplos de type |
Qué hacer |
|---|---|---|
| 400, 422 | invalid-request, invalid-decision, not-an-epub, fixed-layout-not-supported, ai-blocked |
Corregir la petición o el fichero; escribir el texto a mano |
| 401, 403 | unauthorized, forbidden |
Revisar la clave o el rol |
| 402 | insufficient-credits |
Recargar créditos |
| 409 | job-not-ready, job-not-finalized, pending-review, nothing-to-finalize, batch-limit-reached, storage-quota-exceeded |
Esperar, revisar, crear otro lote o liberar espacio |
| 410 | files-expired |
Los ficheros caducaron (30 días tras finalizar): volved a subir el EPUB |
| 413 | file-too-large |
Reducir el EPUB o pedir una subida grande |
| 429 | rate-limited |
Esperar lo que indique Retry-After |
| 502, 503 | ai-error, ai-unavailable, server-storage-full |
Reintentar tras Retry-After |
Idempotencia. En las operaciones que crean o cobran (crear lote, subir,
decidir, finalizar), enviad Idempotency-Key con un valor único. Si la conexión
falla y repetís la petición con la misma clave en 24 horas, recibís la respuesta
original sin duplicar nada ni cobrar dos veces.
8. Créditos
- Se consume un crédito por título al finalizarlo por primera vez, si tiene hasta 150 imágenes. Cada bloque adicional de 150 imágenes (o parte) consume un crédito más: 151–300 imágenes, 2 créditos; 301–450, 3; etc. Se cuentan todas las imágenes del contenido, también las que ya traen un texto alternativo correcto.
- El número de imágenes y los créditos del título están en
billingdesde que termina el análisis (billing.images,billing.credits_required), antes de revisar nada. Si no os compensa, borrad el título: lo que no se finaliza no consume. - En el portal, un título de más de un crédito muestra un aviso antes de empezar la revisión: quien gestiona lotes lo confirma o borra el título.
- Volver a procesar o volver a finalizar un título ya cobrado no consume más.
- Los títulos fallidos o borrados antes de finalizar no consumen.
- Sin créditos, finalizar responde
402y no se cobra nada. - Lotes: todo o nada. Si pedís finalizar 20 títulos y quedan 8 créditos, no se
finaliza ninguno; el error indica
credits_requiredycredits_available. Para finalizar solo algunos, indicadjob_ids. - Una versión nueva de un EPUB, aunque tenga el mismo ISBN, es un trabajo nuevo y consume otro crédito.
- Consumo por mes:
GET /organization/usage. Las clavesap_test_nunca consumen.
9. Versiones y cambios
- La versión va en la ruta (
/v1). Dentro dev1solo se añaden campos y operaciones; nunca se quitan ni cambian de significado. Ignorad los campos que no conozcáis. - Si algo se va a retirar, las respuestas llevarán las cabeceras
Deprecation(RFC 9745) ySunset(RFC 8594) con la fecha, y se avisará por correo con al menos 6 meses de antelación.
10. Material de apoyo
- Referencia completa, generada desde el contrato: /docs/referencia.
- Contrato OpenAPI 3.1: openapi.yaml. Se importa
directamente en Postman, Insomnia o Bruno, y sirve para generar clientes
(
openapi-generator,openapi-typescript…). - Ejemplos completos: quickstart.py (Python, con revisión interactiva) y quickstart.mjs (Node.js 18+, sin dependencias).
- Ayuda del portal para quien revisa: /portal/ayuda.
- EPUB de prueba: usad los vuestros con una clave
ap_test_(no consumen créditos). Si necesitáis ejemplos con casos concretos (imágenes, tablas, varios idiomas, diseño fijo), pedídselos a ab serveis. - Contacto técnico: info@abserveis.net.