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

  1. Pedid una cuenta a ab serveis. Dará de alta vuestra organización e invitará a la primera persona administradora.
  2. 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 por ap_test_ y solo se muestra una vez: guardadla en vuestro gestor de secretos, nunca en el código.
  3. 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 con POST /invitations/accept.
  • Las claves de API siguen sirviendo para integraciones: una clave de administración actúa como admin y las demás como batch_manager.
  • Si montáis vuestro propio frontal con sesiones de persona: POST /sessions devuelve una cookie y un csrf_token, que hay que enviar en la cabecera X-CSRF-Token en las peticiones que cambian algo; AccesPub-Mode: test para 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).

  1. 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}.
  2. 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).
  3. Subid los EPUB y enviad los metadatos y los datos comerciales desde vuestro catálogo (PATCH …/metadata, PATCH …/commercial).
  4. Cuando un título pase a needs_review, mostrad en vuestro frontal un botón «Revisar en AccesPub». Cada título trae portal_url (su ficha en el portal) y review_url (su pantalla de revisión); el lote, su portal_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 abre review_url y la persona inicia sesión.
  5. 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, consultad GET /jobs/{id} hasta ver completed.

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 id de cada persona en AccesPub (lo devuelve POST /invitations, y GET /users los 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_id guarda 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.

  1. Pedid los pendientes: GET /jobs/{id}/review-items?status=pending (paginado: hasta 100 por página, seguid next_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-decision con un error por decisión en errors ("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-items muestra en decided_by y decided_at quié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, reviewer es 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) y all_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 con on_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-review marca lo pendiente de imágenes, tablas e idiomas en línea como decorativo, de maquetación o conservado. Con "confirm_decorative": true y reviewer, 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}/metadata muestra cada campo con su origen (epub u override) 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) y title_placeholder (el EPUB no tenía título).
  • Los avisos no bloquean, igual que en el dashboard, pero conviene resolverlos.
  • null quita 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 ES o FR BE CH, o regiones ONIX como WORLD) 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}/commercial devuelve lo efectivo, de dónde sale cada valor y las comprobaciones (issues); con algún error, POST /onix-full responde 422.
  • apply_to_batch en 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) y GET /batches/{id}/files/onix_full.
  • El coste lo fija el contrato de la organización (billing en el GET). Con claves ap_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 2xx en 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 con GET /jobs/{id}.
  • Probadla con POST /webhook-endpoints/{id}/test (envía webhook.test en 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 https públicas: nada de localhost ni 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 billing desde 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 402 y 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_required y credits_available. Para finalizar solo algunos, indicad job_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 claves ap_test_ nunca consumen.

9. Versiones y cambios

  • La versión va en la ruta (/v1). Dentro de v1 solo 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) y Sunset (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.

Volver arriba