AccesPub API Guía práctica · v0.10.0 Documentación
API v1 · contrato 0.10.0 · 01/11/2026

Guía práctica de la API de AccesPub

La API hace por lotes lo mismo que la web de AccesPub hace con un EPUB: analiza, corrige todo lo automático, deja para una persona lo que requiere criterio y entrega el EPUB accesible con su informe y su ONIX. Esta guía recorre, una a una, las 71 acciones disponibles: qué hace cada una, quién puede usarla y el código para ejecutarla.

Si no programasLee el título y «Para qué sirve» de cada acción: explica qué ocurre en AccesPub. El código es la orden exacta que ejecutará tu equipo técnico.
Si programasElige arriba curl, Python o JavaScript: todos los ejemplos cambian a la vez. Se pueden copiar y ejecutar tal cual tras el paso 1.
Datos de ejemploLa editorial Ediciones Vela Blanca, su lote Novedades primavera 2027 y sus libros son ficticios. Sustitúyelos por los vuestros.
Organización
La cuenta de la editorial: créditos, espacio, personas y claves. La da de alta ab serveis.
Clave de API
La «contraseña» de una integración. ap_test_ para pruebas (sin coste) y ap_live_ para producción.
Lote
Un grupo de EPUB que se procesan juntos, con opciones y valores comunes.
Título (job)
Cada EPUB subido. Tiene un estado que avanza del análisis a la entrega.
Revisión
Las decisiones que requieren criterio humano: textos alternativos, tablas e idiomas.
Finalizar
Generar el EPUB definitivo con su informe y su ONIX. Consume el crédito.
Crédito
Unidad de cobro: 1 por título hasta 150 imágenes, la primera vez que se finaliza.
Salidas
EPUB accesible, informe HTML, ONIX de accesibilidad y ONIX comercial.
Recorrido de un título en la API Crear lote, subir EPUB, análisis automático, revisión humana, metadatos y ONIX opcionales, finalizar (consume crédito) y descargar. 1. Crearlote 2. Subirun EPUB por petición 3. Análisisautomático 4. Revisiónhumana (+ IA) 5. Metadatosy ONIX (opcional) 6. Finalizarconsume crédito 7. DescargarEPUB, informe, ONIX queuedprocessingneeds_reviewreadyfinalizingcompleted Estado del título en cada momento (GET /jobs/{job_id}). Si no hay nada que revisar, pasa de processing a ready.

Ninguna acción coincide con la búsqueda.

Conectar con AccesPub

Antes de la primera petición hacen falta tres cosas: que la organización exista, una clave de API y un entorno preparado para lanzar los ejemplos. Esta sección es el punto de partida de todo lo demás.

1Paso 0. Alta de la organización (lo hace ab serveis)

La organización es la cuenta de la editorial o de la plataforma en AccesPub: tiene sus créditos, su espacio, sus personas y sus claves. No se crea por la API: la da de alta ab serveis desde su consola interna, junto con el plan y los límites, e invita a la primera persona administradora.

  1. Pedid la cuenta a ab serveis (info@abserveis.net). En el ejemplo, la organización se llama Ediciones Vela Blanca.
  2. ab serveis da de alta la organización e invita a la primera administradora (Marta Ibáñez, marta@velablanca.example). Le llega un enlace para crear su contraseña.
  3. Se crea la primera clave de API, de una de estas dos formas:
    • Marta entra en el portal (https://api.accespub.app/portal) → Organización → Claves de API → Crear clave; o
    • ab serveis la crea desde la consola y se la hace llegar por un canal seguro (es la única forma de obtener una clave de administración, que además puede gestionar personas y otras claves).
  4. Guardad la clave en un gestor de secretos. Solo se muestra entera una vez; después solo se ve su final (ap_test_…7Hx2).
Dos tipos de clave, un solo servidor. ap_test_… trabaja con datos de prueba separados, sin consumir créditos y con propuestas de IA ficticias: es la que conviene para empezar. ap_live_… trabaja con los datos reales y consume créditos.

2Paso 1. Preparar el entorno para los ejemplos

Todos los ejemplos de esta guía leen la dirección de la API y la clave de dos variables. Prepáralas una vez y cada ejemplo se podrá copiar y ejecutar tal cual. Elige arriba el lenguaje que prefieras: curl (terminal, sin programar), Python o JavaScript (Node.js).

curl
# macOS / Linux (terminal). En Windows, usa Git Bash o WSL.
export BASE="https://api.accespub.app/v1"
export ACCESPUB_KEY="ap_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"   # tu clave de pruebas
# Solo para gestionar personas y claves: una clave de administración (ver paso 0)
export ACCESPUB_ADMIN_KEY="ap_test_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"

# Los ejemplos usan también estas variables. Se rellenan a medida que la API
# devuelve identificadores (cada ejemplo indica cuál guardar):
# export BATCH_ID=…   JOB_ID=…   ITEM_ID=…   USER_ID=…   KEY_ID=…
Python
# Requiere Python 3.9+ y la librería requests:  pip install requests
import os, uuid, json, time, requests

BASE = "https://api.accespub.app/v1"
KEY = os.environ["ACCESPUB_KEY"]          # export ACCESPUB_KEY=ap_test_…
H = {"Authorization": f"Bearer {KEY}"}    # cabecera común a todas las peticiones
# Solo para gestionar personas y claves (clave de administración, ver paso 0):
HA = {"Authorization": f"Bearer {os.environ.get('ACCESPUB_ADMIN_KEY', '')}"}

# Los ejemplos van guardando los identificadores que devuelve la API en
# variables (BATCH_ID, JOB_ID…). Ejecútalos en orden en la misma sesión.
JavaScript
// Node.js 18 o superior (sin dependencias). Guarda los ejemplos en un
// fichero .mjs y ejecútalo con:  ACCESPUB_KEY=ap_test_… node guia.mjs
import { readFile, writeFile } from "node:fs/promises";

const BASE = "https://api.accespub.app/v1";
const H = { Authorization: `Bearer ${process.env.ACCESPUB_KEY}` };
// Solo para gestionar personas y claves (clave de administración, ver paso 0):
const HA = { Authorization: `Bearer ${process.env.ACCESPUB_ADMIN_KEY}` };

// Los ejemplos van guardando los identificadores que devuelve la API en
// variables (BATCH_ID, JOB_ID…). Usa "let" si repites un ejemplo.

3Paso 2. Comprobar la conexión: ¿quién soy?

GET/me

La primera petición de prueba. Devuelve con qué identidad se está conectando (una clave o una persona), su rol, el modo (prueba o real) y la organización. Si responde, la clave funciona.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/me" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/me", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/me`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "actor": {"type": "api_key", "id": "key_01JQ7V4D6F8H0J2K4M6N8P0R2T", "name": "Integración catálogo"},
  "role": "batch_manager",
  "mode": "test",
  "organization": {"id": "org_01JQ7V2A9C4E6G8J0K2M4P6R8T", "name": "Ediciones Vela Blanca"},
  "user": null,
  "batch_id": null
}

A tener en cuenta

  • Una clave normal actúa con el rol batch_manager; una clave de administración, como admin.
  • Si la clave no es válida: 401 unauthorized. Revisa que la cabecera sea Authorization: Bearer ap_…, con un espacio después de Bearer.

Organización, créditos y claves

Datos de la cuenta: créditos disponibles, espacio ocupado, límites y consumo. Y la gestión de las claves de API.

4Ver la organización, los créditos y los límites

GET/organization

Muestra el plan, cuántos créditos quedan, el espacio usado y los límites (tamaño máximo de un EPUB, títulos por lote, propuestas de IA por minuto…). Útil antes de subir un lote grande.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s "$BASE/organization" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/organization", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/organization`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "org_01JQ7V2A9C4E6G8J0K2M4P6R8T",
  "name": "Ediciones Vela Blanca",
  "plan": "api_professional",
  "mode": "live",
  "credits": {"total": 100, "used": 12, "available": 88},
  "storage": {"used_bytes": 734003200, "quota_bytes": 5368709120},
  "limits": {"max_epub_bytes": 52428800, "max_large_epub_bytes": 209715200,
             "max_jobs_per_batch": 50, "max_batch_bytes": 1073741824,
             "ai_suggestions_per_minute": 30}
}

5Consultar el consumo de créditos por mes

GET/organization/usage

Cuántos títulos se han finalizado y cuántos créditos se han gastado cada mes. Por defecto, los últimos 12 meses (months, de 1 a 24).

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s "$BASE/organization/usage?months=6" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/organization/usage?months=6", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/organization/usage?months=6`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "data": [
    {"month": "2027-03", "jobs_finalized": 9, "credits_used": 10},
    {"month": "2027-02", "jobs_finalized": 2, "credits_used": 2}
  ]
}

6Listar las claves de API

GET/api-keys

Las claves de la organización, sin el secreto: nombre, modo, final visible, cuándo se creó y cuándo se usó por última vez. Sirve para detectar claves que ya no se usan.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s "$BASE/api-keys" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/api-keys", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/api-keys`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "data": [
    {"id": "key_01JQ7V4D6F8H0J2K4M6N8P0R2T", "label": "Integración catálogo", "mode": "test",
     "prefix": "ap_test_…7Hx2", "created_at": "2027-03-02T09:14:00Z", "last_used_at": "2027-03-04T11:02:41Z"}
  ]
}

7Crear una clave nueva

POST/api-keys

Crea otra clave, por ejemplo una live para producción o una por cada sistema que se conecte. Solo puede hacerlo una administradora (o una clave de administración). El secreto completo solo aparece en esta respuesta.

Quién puede: Administración

curl
curl -s "$BASE/api-keys" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode": "live", "label": "Producción - catálogo"}'

# Guarda el identificador que devuelve (campo "id"):
export KEY_ID=key_01JQ7V9R3T5V7X9Z1B3D5F7H9K
Python
r = requests.post(
    f"{BASE}/api-keys",
    headers=HA,
    json={"mode": "live", "label": "Producción - catálogo"},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
KEY_ID = data["id"]
JavaScript
const r = await fetch(`${BASE}/api-keys`, {
  method: "POST",
  headers: { ...HA, "Content-Type": "application/json" },
  body: JSON.stringify({mode: "live", label: "Producción - catálogo"}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
const KEY_ID = data.id;
Respuesta de ejemplo
{
  "id": "key_01JQ7V9R3T5V7X9Z1B3D5F7H9K",
  "label": "Producción - catálogo",
  "mode": "live",
  "prefix": "ap_live_…Qm4s",
  "created_at": "2027-03-05T10:00:00Z",
  "last_used_at": null,
  "secret": "ap_live_Zp3…Qm4s"
}

A tener en cuenta

  • Las claves creadas por la API nunca son de administración. Las de administración las crea ab serveis.
  • Guarda secret en el gestor de secretos en ese momento: no se puede volver a consultar.
  • En los ejemplos, $ACCESPUB_ADMIN_KEY es una clave de administración (o usa la sesión de una administradora).

8Revocar una clave

DELETE/api-keys/{key_id}

Anula una clave al momento (por ejemplo, si se ha filtrado o el sistema que la usaba se retira). Las peticiones que lleguen con ella responderán 401.

Quién puede: Administración

curl
curl -s -X DELETE "$BASE/api-keys/$KEY_ID" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"
Python
r = requests.delete(f"{BASE}/api-keys/{KEY_ID}", headers=HA)
print(r.status_code)
JavaScript
const r = await fetch(`${BASE}/api-keys/${KEY_ID}`, {
  method: "DELETE",
  headers: { ...HA },
});
console.log(r.status);

Respuesta: 204 (sin contenido)

Personas, roles y sesiones

Las personas que revisan firman sus decisiones, por eso cada una tiene su cuenta. Son las mismas del portal. Roles: admin (todo), batch_manager (lotes, subir, finalizar, descargar), reviewer (ver, revisar y descargar) y external_reviewer (solo revisa el lote al que se le invita, sin contraseña).

9Listar las personas de la organización

GET/users

Todas las personas: activas, invitadas pendientes de aceptar y dadas de baja, con su rol y su último acceso.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s "$BASE/users" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/users", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/users`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "data": [
    {"id": "usr_01JQ7V3B5D7F9H1K3M5N7P9R1T", "name": "Marta Ibáñez", "email": "marta@velablanca.example",
     "role": "admin", "status": "active", "batch_id": null,
     "created_at": "2027-03-01T08:00:00Z", "last_login_at": "2027-03-05T08:31:10Z"},
    {"id": "usr_01JQ7W1F3H5J7K9M1N3P5Q7S9T", "name": "Jordi Puig", "email": "jordi@velablanca.example",
     "role": "reviewer", "status": "invited", "batch_id": null,
     "created_at": "2027-03-05T10:20:00Z", "last_login_at": null}
  ]
}

10Invitar a una persona del equipo

POST/invitations

Da de alta a alguien con un rol. Recibe por correo un enlace (válido 72 horas) para crear su contraseña; el enlace también se devuelve en access_url por si hay que hacérselo llegar a mano.

Quién puede: Administración

curl
curl -s "$BASE/invitations" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "jordi@velablanca.example", "role": "reviewer", "name": "Jordi Puig"}'

# Guarda el identificador que devuelve (campo "id"):
export USER_ID=usr_01JQ7W1F3H5J7K9M1N3P5Q7S9T
Python
r = requests.post(
    f"{BASE}/invitations",
    headers=HA,
    json={"email": "jordi@velablanca.example", "role": "reviewer", "name": "Jordi Puig"},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
USER_ID = data["id"]
JavaScript
const r = await fetch(`${BASE}/invitations`, {
  method: "POST",
  headers: { ...HA, "Content-Type": "application/json" },
  body: JSON.stringify({email: "jordi@velablanca.example", role: "reviewer", name: "Jordi Puig"}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
const USER_ID = data.id;
Respuesta de ejemplo
{
  "id": "usr_01JQ7W1F3H5J7K9M1N3P5Q7S9T",
  "name": "Jordi Puig",
  "email": "jordi@velablanca.example",
  "role": "reviewer",
  "status": "invited",
  "access_url": "https://api.accespub.app/portal/invitacion?token=…",
  "email_sent": true
}

A tener en cuenta

  • Modelo mixto: con "send_email": false no se envía el correo de «crea tu contraseña»; la persona entrará siempre desde vuestra aplicación con un enlace de acceso (ver «Pedir un enlace de acceso al portal»). Guardad su id en vuestro sistema.
  • Un correo solo puede pertenecer a una persona activa o invitada: si no, 409 email-in-use.
  • Con una clave de API, el correo de quien revisa tiene que ser el de una persona activa: dad de alta a cada revisor antes de enviar sus decisiones.

11Invitar a una revisora externa para un lote

POST/invitations

Para una colaboradora de fuera (por ejemplo, una correctora) que solo debe revisar un lote. Entra con un enlace reutilizable, válido 30 días, sin contraseña, y solo ve ese lote.

Quién puede: Administración

curl
curl -s "$BASE/invitations" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "lucia@correctoras.example",
  "role": "external_reviewer",
  "name": "Lucía Ferrer",
  "batch_id": "'"$BATCH_ID"'"
}'
Python
r = requests.post(
    f"{BASE}/invitations",
    headers=HA,
    json={
        "email": "lucia@correctoras.example",
        "role": "external_reviewer",
        "name": "Lucía Ferrer",
        "batch_id": BATCH_ID
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/invitations`, {
  method: "POST",
  headers: { ...HA, "Content-Type": "application/json" },
  body: JSON.stringify({
    email: "lucia@correctoras.example",
    role: "external_reviewer",
    name: "Lucía Ferrer",
    batch_id: BATCH_ID
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "usr_01JQ7W2G4J6L8N0Q2S4U6W8Y0A",
  "name": "Lucía Ferrer",
  "email": "lucia@correctoras.example",
  "role": "external_reviewer",
  "status": "active",
  "batch_id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q",
  "access_url": "https://api.accespub.app/portal/revision?token=…",
  "email_sent": true
}

A tener en cuenta

  • Necesita un lote creado (ver «Crear un lote»). El acceso se retira dándola de baja.

12Aceptar una invitación y crear la contraseña

POST/invitations/accept

Lo que hace la persona invitada al abrir su enlace. Solo es necesario por API si construís vuestra propia pantalla de alta; si no, se hace en el portal. Sirve también para restablecer una contraseña.

Quién puede: sin clave

curl
curl -s "$BASE/invitations/accept" \
  -H "Content-Type: application/json" \
  -d '{"token": "TOKEN_DEL_ENLACE", "password": "una-contraseña-larga", "name": "Jordi Puig"}'
Python
r = requests.post(
    f"{BASE}/invitations/accept",
    json={"token": "TOKEN_DEL_ENLACE", "password": "una-contraseña-larga", "name": "Jordi Puig"},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/invitations/accept`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({token: "TOKEN_DEL_ENLACE", password: "una-contraseña-larga", name: "Jordi Puig"}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "usr_01JQ7W1F3H5J7K9M1N3P5Q7S9T",
  "name": "Jordi Puig",
  "email": "jordi@velablanca.example",
  "role": "reviewer",
  "status": "active"
}

A tener en cuenta

  • El token es el valor de token= del enlace. Mínimo 10 caracteres de contraseña. Enlace usado o caducado: 400 invalid-token.

13Cambiar el rol de una persona

PATCH/users/{user_id}

Por ejemplo, convertir a un revisor en gestor de lotes. Los roles posibles aquí son admin, batch_manager y reviewer.

Quién puede: Administración

curl
curl -s -X PATCH "$BASE/users/$USER_ID" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"role": "batch_manager"}'
Python
r = requests.patch(
    f"{BASE}/users/{USER_ID}",
    headers=HA,
    json={"role": "batch_manager"},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/users/${USER_ID}`, {
  method: "PATCH",
  headers: { ...HA, "Content-Type": "application/json" },
  body: JSON.stringify({role: "batch_manager"}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"id": "usr_01JQ7W1F3H5J7K9M1N3P5Q7S9T", "name": "Jordi Puig", "role": "batch_manager", "status": "active"}

A tener en cuenta

  • No se puede quitar el rol de administración a la última administradora (409 last-admin).

POST/users/{user_id}/access-link

Genera un enlace nuevo para esa persona e invalida el anterior: reenvía una invitación caducada, permite restablecer una contraseña olvidada o renueva el enlace de una revisora externa.

Quién puede: Administración

curl
curl -s -X POST "$BASE/users/$USER_ID/access-link" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"
Python
r = requests.post(f"{BASE}/users/{USER_ID}/access-link", headers=HA)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/users/${USER_ID}/access-link`, {
  method: "POST",
  headers: { ...HA },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "usr_01JQ7W1F3H5J7K9M1N3P5Q7S9T",
  "email": "jordi@velablanca.example",
  "access_url": "https://api.accespub.app/portal/invitacion?token=…",
  "email_sent": true
}

POST/users/{user_id}/sign-in-link

Para que quien revisa salte de vuestra aplicación al portal sin volver a iniciar sesión. Vuestro servidor lo pide justo cuando la persona pulsa «Revisar en AccesPub» y redirige su navegador a url: el portal abre su sesión y la lleva a la pantalla indicada en next (por ejemplo, el review_url del título). Sus decisiones quedan firmadas por ella.

Quién puede: AdministraciónGestión de lotes

curl
curl -s "$BASE/users/$USER_ID/sign-in-link" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"next": "https://api.accespub.app/portal/titulos/job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7/revision"}'
Python
r = requests.post(
    f"{BASE}/users/{USER_ID}/sign-in-link",
    headers=H,
    json={
        "next": "https://api.accespub.app/portal/titulos/job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7/revision"
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/users/${USER_ID}/sign-in-link`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({next: "https://api.accespub.app/portal/titulos/job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7/revision"}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "url": "https://api.accespub.app/portal/entrar?token=…",
  "expires_at": "2027-03-05T11:05:00Z",
  "user_id": "usr_01JQ7W1F3H5J7K9M1N3P5Q7S9T",
  "next": "/portal/titulos/job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7/revision"
}

A tener en cuenta

  • Hay que pedir a ab serveis que lo active para vuestra organización: AccesPub confía en vuestro inicio de sesión para identificar a vuestra gente. Sin activar: 403 sign-in-links-disabled.
  • Un solo uso y 5 minutos. Pedidlo en el momento del clic y siempre desde vuestro servidor: la clave de API nunca debe llegar al navegador. Con sesión de persona en lugar de clave: 403.
  • Una clave que no es de administración no puede pedirlo para una administradora (403).
  • Persona de otra organización: 404; dada de baja: 409. Una persona invitada que aún no tiene contraseña queda activa al usarlo.
  • next: una ruta del portal (/portal/…) o una dirección completa del portal (review_url, portal_url). Sin next, la lista de lotes.
  • Cada enlace queda registrado (persona, clave, fecha y destino).

16Dar de baja a una persona

DELETE/users/{user_id}

Quita el acceso y cierra sus sesiones. Su nombre se conserva en las decisiones que firmó (es parte del registro de la revisión). Su correo se puede volver a invitar más adelante.

Quién puede: Administración

curl
curl -s -X DELETE "$BASE/users/$USER_ID" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"
Python
r = requests.delete(f"{BASE}/users/{USER_ID}", headers=HA)
print(r.status_code)
JavaScript
const r = await fetch(`${BASE}/users/${USER_ID}`, {
  method: "DELETE",
  headers: { ...HA },
});
console.log(r.status);

Respuesta: 204 (sin contenido)

17Iniciar sesión como persona (frontales propios)

POST/sessions

Solo para quien construya su propio frontal en el que cada persona entra con su correo y contraseña (como hace el portal). Devuelve una cookie de sesión y un csrf_token que hay que enviar en la cabecera X-CSRF-Token en cada petición que cambie algo. Las integraciones de servidor a servidor usan claves de API y no necesitan sesiones.

Quién puede: sin clave

curl
curl -s "$BASE/sessions" \
  -c cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"email": "jordi@velablanca.example", "password": "una-contraseña-larga"}'

# Guarda el csrf_token de la respuesta para las peticiones que cambian algo:
export CSRF=…
Python
s = requests.Session()   # guarda la cookie de sesión
r = s.post(
    f"{BASE}/sessions",
    json={"email": "jordi@velablanca.example", "password": "una-contraseña-larga"},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
CSRF = data["csrf_token"]
JavaScript
const r = await fetch(`${BASE}/sessions`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({email: "jordi@velablanca.example", password: "una-contraseña-larga"}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
const CSRF = data.csrf_token;
const COOKIE = r.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
Respuesta de ejemplo
{
  "user": {"id": "usr_01JQ7W1F3H5J7K9M1N3P5Q7S9T", "name": "Jordi Puig", "role": "reviewer", "status": "active"},
  "organization": {"id": "org_01JQ7V2A9C4E6G8J0K2M4P6R8T", "name": "Ediciones Vela Blanca"},
  "csrf_token": "c5f1…",
  "expires_at": "2027-03-05T18:40:00Z"
}

A tener en cuenta

  • La sesión se renueva con la actividad: caduca tras 12 horas sin usarse y, como máximo, 7 días después de iniciarse. (Las claves de API no tienen sesión.)
  • Con sesión, el modo se elige con la cabecera AccesPub-Mode: test (por defecto, real).
  • Tras 5 intentos fallidos en 15 minutos: 429 con Retry-After.

POST/sessions/link

La revisora externa entra con el token de su enlace, sin contraseña. La sesión queda limitada a su lote.

Quién puede: sin clave

curl
curl -s "$BASE/sessions/link" \
  -c cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"token": "TOKEN_DEL_ENLACE_DE_LOTE"}'

# Guarda el csrf_token de la respuesta para las peticiones que cambian algo:
export CSRF=…
Python
s = requests.Session()   # guarda la cookie de sesión
r = s.post(f"{BASE}/sessions/link", json={"token": "TOKEN_DEL_ENLACE_DE_LOTE"})
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
CSRF = data["csrf_token"]
JavaScript
const r = await fetch(`${BASE}/sessions/link`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({token: "TOKEN_DEL_ENLACE_DE_LOTE"}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
const CSRF = data.csrf_token;
const COOKIE = r.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
Respuesta de ejemplo
{
  "user": {"id": "usr_01JQ7W2G4J6L8N0Q2S4U6W8Y0A", "name": "Lucía Ferrer", "role": "external_reviewer",
           "batch_id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q"},
  "csrf_token": "9a2e…",
  "expires_at": "2027-03-05T18:40:00Z"
}

19Cambiar la propia contraseña

POST/me/password

La persona con sesión cambia su contraseña. Se cierran sus demás sesiones abiertas.

Quién puede: persona con sesión

curl
curl -s "$BASE/me/password" \
  -b cookies.txt -H "X-CSRF-Token: $CSRF" \
  -H "Content-Type: application/json" \
  -d '{"current_password": "una-contraseña-larga", "new_password": "otra-contraseña-más-larga"}'
Python
r = s.post(
    f"{BASE}/me/password",
    headers={"X-CSRF-Token": CSRF},
    json={
        "current_password": "una-contraseña-larga",
        "new_password": "otra-contraseña-más-larga"
    },
)
print(r.status_code)
JavaScript
const r = await fetch(`${BASE}/me/password`, {
  method: "POST",
  headers: { Cookie: COOKIE, "X-CSRF-Token": CSRF, "Content-Type": "application/json" },
  body: JSON.stringify({current_password: "una-contraseña-larga", new_password: "otra-contraseña-más-larga"}),
});
console.log(r.status);

Respuesta: 204 (sin contenido)

20Cerrar la sesión

DELETE/sessions/current

Termina la sesión actual.

Quién puede: persona con sesión

curl
curl -s -X DELETE "$BASE/sessions/current" \
  -b cookies.txt -H "X-CSRF-Token: $CSRF"
Python
r = s.delete(f"{BASE}/sessions/current", headers={"X-CSRF-Token": CSRF})
print(r.status_code)
JavaScript
const r = await fetch(`${BASE}/sessions/current`, {
  method: "DELETE",
  headers: { Cookie: COOKIE, "X-CSRF-Token": CSRF },
});
console.log(r.status);

Respuesta: 204 (sin contenido)

Lotes

Un lote agrupa los EPUB que se procesan juntos (por ejemplo, las novedades de una temporada). Al crearlo se decide qué hacer con lo que requiere revisión y se pueden fijar valores comunes. Límites por defecto: 50 EPUB y 1 GB por lote.

21Crear un lote

POST/batches

Crea un lote vacío en el que después se suben los EPUB. Opciones principales: on_pending (wait: esperar a la revisión humana, único camino a la conformidad; finalize: entregar sin declarar conformidad), auto_finalize (finalizar cada título solo al tomar su última decisión) y defaults (editorial, idioma de reserva, propuestas de IA automáticas y datos comerciales comunes).

Quién puede: AdministraciónGestión de lotes

curl
curl -s "$BASE/batches" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "name": "Novedades primavera 2027",
  "external_id": "LOTE-2027-03",
  "on_pending": "wait",
  "auto_finalize": false,
  "defaults": {
    "publisher": "Ediciones Vela Blanca",
    "fallback_language": "es",
    "ai_prefill": true,
    "commercial": {
      "pub_status": "04",
      "supplier_name": "Distribuciones Norte",
      "markets": [{"territory": "ES", "currency": "EUR", "tax_percent": "4"}]
    }
  }
}'

# Guarda el identificador que devuelve (campo "id"):
export BATCH_ID=bat_01JQ7VB3K9R2XW4D5M6N7P8Q
Python
r = requests.post(
    f"{BASE}/batches",
    headers=H | {"Idempotency-Key": str(uuid.uuid4())},
    json={
        "name": "Novedades primavera 2027",
        "external_id": "LOTE-2027-03",
        "on_pending": "wait",
        "auto_finalize": False,
        "defaults": {
            "publisher": "Ediciones Vela Blanca",
            "fallback_language": "es",
            "ai_prefill": True,
            "commercial": {
                "pub_status": "04",
                "supplier_name": "Distribuciones Norte",
                "markets": [{"territory": "ES", "currency": "EUR", "tax_percent": "4"}]
            }
        }
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
BATCH_ID = data["id"]
JavaScript
const r = await fetch(`${BASE}/batches`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({
    name: "Novedades primavera 2027",
    external_id: "LOTE-2027-03",
    on_pending: "wait",
    auto_finalize: false,
    defaults: {
      publisher: "Ediciones Vela Blanca",
      fallback_language: "es",
      ai_prefill: true,
      commercial: {
        pub_status: "04",
        supplier_name: "Distribuciones Norte",
        markets: [{territory: "ES", currency: "EUR", tax_percent: "4"}]
      }
    }
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
const BATCH_ID = data.id;
Respuesta de ejemplo
{
  "id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q",
  "name": "Novedades primavera 2027",
  "status": "open",
  "on_pending": "wait",
  "auto_finalize": false,
  "ai_prefill": true,
  "external_id": "LOTE-2027-03",
  "portal_url": "https://api.accespub.app/portal/lotes/bat_01JQ7VB3K9R2XW4D5M6N7P8Q?mode=test",
  "created_at": "2027-03-05T10:30:00Z",
  "created_by": {"type": "api_key", "id": "key_01JQ7V4D6F8H0J2K4M6N8P0R2T", "name": "Integración catálogo"},
  "counts": {"jobs": 0, "queued": 0, "processing": 0, "needs_review": 0, "ready": 0, "completed": 0, "failed": 0},
  "review": {"total": 0, "decided": 0, "pending": 0},
  "credits": {"required": 0, "charged": 0, "pending": 0}
}

A tener en cuenta

  • Idempotency-Key: si la conexión se corta y repites la petición con la misma clave en 24 h, no se crea un segundo lote.
  • Todo lo de defaults es opcional. ai_prefill: true pide a la IA una propuesta de texto alternativo para cada imagen al terminar el análisis (solo propone; decide una persona).

22Listar los lotes

GET/batches

Los lotes de la organización, del más reciente al más antiguo. Se puede filtrar por estado y paginar (ver «Paginación» en la referencia).

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s "$BASE/batches?status=needs_review&limit=25" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/batches?status=needs_review&limit=25", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/batches?status=needs_review&limit=25`, {
  headers: { ...H },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "data": [
    {"id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q", "name": "Novedades primavera 2027", "status": "needs_review",
     "counts": {"jobs": 2, "needs_review": 1, "ready": 1}}
  ],
  "next_cursor": null
}

23Ver el estado de un lote

GET/batches/{batch_id}

Estado agregado del lote y contadores: cuántos títulos hay en cola, en revisión, listos, finalizados o con error, el progreso de la revisión y los créditos que costará finalizarlo.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/batches/$BATCH_ID" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/batches/{BATCH_ID}", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q",
  "name": "Novedades primavera 2027",
  "status": "needs_review",
  "counts": {"jobs": 2, "queued": 0, "processing": 0, "needs_review": 1, "ready": 1, "completed": 0, "failed": 0},
  "review": {"total": 31, "decided": 9, "pending": 22},
  "size_bytes": 18350080,
  "credits": {"required": 3, "charged": 0, "pending": 3}
}

A tener en cuenta

  • Estados del lote: open, processing, needs_review (algún título tiene pendientes), ready (todos se pueden finalizar), finalizing, completed, cancelled.

24Cambiar el nombre, los revisores o las opciones de un lote

PATCH/batches/{batch_id}

Renombra el lote, cambia qué hacer con lo pendiente, activa la finalización automática o asigna revisores (identificadores de persona).

Quién puede: AdministraciónGestión de lotes

curl
curl -s -X PATCH "$BASE/batches/$BATCH_ID" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Novedades primavera 2027 (revisado)",
  "auto_finalize": true,
  "reviewer_user_ids": ["'"$USER_ID"'"]
}'
Python
r = requests.patch(
    f"{BASE}/batches/{BATCH_ID}",
    headers=H,
    json={
        "name": "Novedades primavera 2027 (revisado)",
        "auto_finalize": True,
        "reviewer_user_ids": [USER_ID]
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}`, {
  method: "PATCH",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({
    name: "Novedades primavera 2027 (revisado)",
    auto_finalize: true,
    reviewer_user_ids: [USER_ID]
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q", "name": "Novedades primavera 2027 (revisado)", "auto_finalize": true, "status": "needs_review"}

25Borrar un lote

DELETE/batches/{batch_id}

Cancela lo que esté en cola y borra el lote con todos sus ficheros. No devuelve créditos ya consumidos. Las decisiones de revisión y los informes se conservan según el contrato.

Quién puede: AdministraciónGestión de lotes

curl
curl -s -X DELETE "$BASE/batches/$BATCH_ID" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.delete(f"{BASE}/batches/{BATCH_ID}", headers=H)
print(r.status_code)
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}`, {
  method: "DELETE",
  headers: { ...H },
});
console.log(r.status);

Respuesta: 204 (sin contenido)

A tener en cuenta

  • No se puede deshacer. Descarga antes lo que necesites.

Subir títulos y seguir su estado

Cada EPUB subido es un título (en la API, un job o trabajo) dentro de un lote. Se sube un EPUB por petición y AccesPub lo analiza y corrige en segundo plano.

Recorrido habitual: queued → processing → needs_review → ready → finalizing → completed. Si no hay nada que revisar, pasa de processing a ready. Si no se puede procesar: failed.

26Subir un EPUB al lote

POST/batches/{batch_id}/jobs

Envía el fichero EPUB. La respuesta llega enseguida (202) con el título en cola: el análisis sigue en segundo plano. Opcionalmente, external_id (vuestro identificador, que se devuelve siempre) e isbn (si el EPUB no lo declara).

Quién puede: AdministraciónGestión de lotes

curl
curl -s "$BASE/batches/$BATCH_ID/jobs" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "file=@el-faro-de-las-gaviotas.epub" \
  -F "external_id=VB-0412" \
  -F "isbn=9788412345674"

# Guarda el identificador que devuelve (campo "id"):
export JOB_ID=job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7
Python
r = requests.post(
    f"{BASE}/batches/{BATCH_ID}/jobs",
    headers=H | {"Idempotency-Key": str(uuid.uuid4())},
    files={"file": open("el-faro-de-las-gaviotas.epub", "rb")},
    data={"external_id": "VB-0412", "isbn": "9788412345674"},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JOB_ID = data["id"]
JavaScript
const form = new FormData();
form.append("file", new Blob([await readFile("el-faro-de-las-gaviotas.epub")]), "el-faro-de-las-gaviotas.epub");
form.append("external_id", "VB-0412");
form.append("isbn", "9788412345674");
const r = await fetch(`${BASE}/batches/${BATCH_ID}/jobs`, {
  method: "POST",
  headers: { ...H, "Idempotency-Key": crypto.randomUUID() },
  body: form,
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
const JOB_ID = data.id;
Respuesta de ejemplo
{
  "id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7",
  "batch_id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q",
  "status": "queued",
  "filename": "el-faro-de-las-gaviotas.epub",
  "size_bytes": 9175040,
  "external_id": "VB-0412",
  "isbn": "9788412345674",
  "title": null
}

A tener en cuenta

  • Máximo 50 MB por EPUB (si es mayor: 413 file-too-large; ver «Subidas grandes»).
  • No se admiten EPUB de diseño fijo (422 fixed-layout-not-supported).
  • Lote lleno (50 EPUB o 1 GB): 409 batch-limit-reached. Espacio de la organización agotado: 409 storage-quota-exceeded.

27Consultar el estado de un título (y esperar a que termine)

GET/jobs/{job_id}

Devuelve el estado del título y, cuando termina el análisis, el título del libro, el progreso de la revisión, las imágenes que tiene y los créditos que costará (billing). Sin avisos automáticos (ver «Avisos automáticos (webhooks)»), se consulta cada 10–30 segundos mientras esté en queued, processing o finalizing: el mismo ejemplo sirve para esperar al análisis y a la finalización.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/jobs/$JOB_ID" \
  -H "Authorization: Bearer $ACCESPUB_KEY"

# Esperar mientras esté en cola, analizándose o finalizándose (consulta cada 15 s):
while true; do
  ESTADO=$(curl -s "$BASE/jobs/$JOB_ID" -H "Authorization: Bearer $ACCESPUB_KEY" \
           | grep -o '"status": *"[a-z_]*"' | head -1 | cut -d'"' -f4)
  echo "Estado: $ESTADO"
  case "$ESTADO" in queued|processing|finalizing) sleep 15 ;; *) break ;; esac
done
Python
# Esperar mientras esté en cola, analizándose o finalizándose (consulta cada 15 s)
while True:
    job = requests.get(f"{BASE}/jobs/{JOB_ID}", headers=H).json()
    print("Estado:", job["status"])
    if job["status"] not in ("queued", "processing", "finalizing"):
        break
    time.sleep(15)

print(job["title"], job["review"], job["billing"])
JavaScript
// Esperar mientras esté en cola, analizándose o finalizándose (consulta cada 15 s)
let job;
while (true) {
  job = await (await fetch(`${BASE}/jobs/${JOB_ID}`, { headers: H })).json();
  console.log("Estado:", job.status);
  if (!["queued", "processing", "finalizing"].includes(job.status)) break;
  await new Promise((ok) => setTimeout(ok, 15000));
}
console.log(job.title, job.review, job.billing);
Respuesta de ejemplo
{
  "id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7",
  "status": "needs_review",
  "title": "El faro de las gaviotas",
  "isbn": "9788412345674",
  "epub_version": "2.0",
  "review": {"total": 22, "decided": 0, "pending": 22},
  "billing": {"images": 38, "images_per_credit": 150, "credits_required": 1, "credits_charged": 0},
  "notices": [{"code": "date_ambiguous", "field": "dc.date", "value": "05/06/2020",
               "detail": "La fecha del EPUB no se puede interpretar."}],
  "review_flags": [],
  "conformance": null,
  "error": null,
  "portal_url": "https://api.accespub.app/portal/titulos/job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7?mode=test",
  "review_url": "https://api.accespub.app/portal/titulos/job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7/revision?mode=test"
}

A tener en cuenta

  • billing.credits_required: 1 crédito hasta 150 imágenes; uno más por cada bloque de 150 adicional. Se sabe antes de revisar: si no compensa, se borra el título y no se cobra.
  • notices: datos que conviene completar (ver «Metadatos»). No bloquean.
  • portal_url y review_url: la ficha y la pantalla de revisión del título en el portal (ver «Enviar a revisar al portal»). Los de prueba llevan ?mode=test.

28Listar los títulos de un lote

GET/batches/{batch_id}/jobs

Todos los títulos del lote con su estado. Se puede filtrar (por ejemplo, solo los que necesitan revisión).

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/batches/$BATCH_ID/jobs?status=needs_review" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/batches/{BATCH_ID}/jobs?status=needs_review", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}/jobs?status=needs_review`, {
  headers: { ...H },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "data": [
    {"id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "status": "needs_review", "title": "El faro de las gaviotas",
     "external_id": "VB-0412", "review": {"total": 22, "decided": 0, "pending": 22}}
  ],
  "next_cursor": null
}

29Corregir el ISBN o el identificador de un título, o asignarle revisores

PATCH/jobs/{job_id}

Cambia datos del título que no están en el EPUB: vuestro identificador, el ISBN-13 o las personas que lo revisan (si no se indican, las del lote).

Quién puede: AdministraciónGestión de lotes

curl
curl -s -X PATCH "$BASE/jobs/$JOB_ID" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"isbn": "978-84-1234-567-4", "external_id": "VB-0412", "assignee_user_ids": ["'"$USER_ID"'"]}'
Python
r = requests.patch(
    f"{BASE}/jobs/{JOB_ID}",
    headers=H,
    json={"isbn": "978-84-1234-567-4", "external_id": "VB-0412", "assignee_user_ids": [USER_ID]},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}`, {
  method: "PATCH",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({isbn: "978-84-1234-567-4", external_id: "VB-0412", assignee_user_ids: [USER_ID]}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "isbn": "9788412345674", "external_id": "VB-0412", "status": "needs_review"}

A tener en cuenta

  • Guiones y espacios del ISBN se ignoran.

30Ver el informe del análisis (JSON)

GET/jobs/{job_id}/report

El informe completo en formato de datos: correcciones automáticas aplicadas, problemas que requieren atención, resultado de EPUBCheck y metadatos de accesibilidad. Es lo mismo que muestra el informe del dashboard, para integrarlo en vuestros sistemas. (El informe para personas, en HTML, está en «Descargas».)

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/jobs/$JOB_ID/report" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/report", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/report`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "schema_version": "1.0",
  "title": "El faro de las gaviotas",
  "language": "es",
  "fixes_applied": ["…"],
  "issues": ["…"],
  "epubcheck": {"errors": 0, "warnings": 2},
  "accessibility_metadata": {"…": "…"}
}

A tener en cuenta

  • Respuesta resumida. El esquema completo está en report.schema.json de la documentación técnica.
  • Mientras se analiza: 409 job-not-ready con Retry-After.

31Volver a procesar un título

POST/jobs/{job_id}/reprocess

Repite el análisis del EPUB original (por ejemplo, tras una mejora de AccesPub). Las decisiones ya tomadas se conservan y se vuelven a aplicar solas. No consume créditos.

Quién puede: AdministraciónGestión de lotes

curl
curl -s -X POST "$BASE/jobs/$JOB_ID/reprocess" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
Python
r = requests.post(
    f"{BASE}/jobs/{JOB_ID}/reprocess",
    headers=H | {"Idempotency-Key": str(uuid.uuid4())},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/reprocess`, {
  method: "POST",
  headers: { ...H, "Idempotency-Key": crypto.randomUUID() },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "status": "queued"}

32Quitar un título del lote

DELETE/jobs/{job_id}

Borra el título y sus ficheros. Si no se había finalizado, no consume créditos: es la forma de descartar un libro que costaría más créditos de lo previsto.

Quién puede: AdministraciónGestión de lotes

curl
curl -s -X DELETE "$BASE/jobs/$JOB_ID" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.delete(f"{BASE}/jobs/{JOB_ID}", headers=H)
print(r.status_code)
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}`, { method: "DELETE", headers: { ...H } });
console.log(r.status);

Respuesta: 204 (sin contenido)

33Pedir permiso para un EPUB de más de 50 MB

POST/large-upload-requests

Para EPUB grandes (suele deberse a imágenes sin optimizar, fuentes o vídeo incrustados). ab serveis revisa la petición; si la aprueba, el lote admite ese fichero, hasta 200 MB, durante 7 días.

Quién puede: AdministraciónGestión de lotes

curl
curl -s "$BASE/large-upload-requests" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "batch_id": "'"$BATCH_ID"'",
  "filename": "atlas-de-las-nubes.epub",
  "size_bytes": 87031808,
  "reason": "Libro ilustrado con fotografías a alta resolución."
}'
Python
r = requests.post(
    f"{BASE}/large-upload-requests",
    headers=H,
    json={
        "batch_id": BATCH_ID,
        "filename": "atlas-de-las-nubes.epub",
        "size_bytes": 87031808,
        "reason": "Libro ilustrado con fotografías a alta resolución."
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/large-upload-requests`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({
    batch_id: BATCH_ID,
    filename: "atlas-de-las-nubes.epub",
    size_bytes: 87031808,
    reason: "Libro ilustrado con fotografías a alta resolución."
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "lur_01JQ8B3D5F7H9K1M3N5P7Q9S1U",
  "batch_id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q",
  "filename": "atlas-de-las-nubes.epub",
  "size_bytes": 87031808,
  "status": "pending",
  "valid_until": null
}

34Ver las solicitudes de subida grande

GET/large-upload-requests

Estado de cada solicitud: pending, approved (con su fecha límite en valid_until), rejected o expired.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s "$BASE/large-upload-requests" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/large-upload-requests", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/large-upload-requests`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"data": [{"id": "lur_01JQ8B3D5F7H9K1M3N5P7Q9S1U", "filename": "atlas-de-las-nubes.epub",
           "status": "approved", "valid_until": "2027-03-12T10:00:00Z"}]}

Revisión humana

AccesPub corrige solo todo lo automático, pero hay decisiones que requieren criterio: el texto alternativo de las imágenes, si una tabla es de datos o de maquetación, y los idiomas. La IA puede proponer, pero decide siempre una persona, y cada decisión queda firmada. Sin esta revisión, el EPUB no declara conformidad con WCAG 2.1 AA.

35Enviar a revisar al portal (modelo mixto)

En el modelo mixto, vuestro sistema hace por la API lo mecánico y las personas revisan en el portal de AccesPub, que ya resuelve todo lo que requiere criterio. Cada título trae dos direcciones del portal: portal_url (su ficha) y review_url (su pantalla de revisión). Las acciones siguientes de esta sección son para quien haga la revisión en su propio frontal.

  1. Cread el lote con "on_pending": "wait" y "auto_finalize": true: cada título se finaliza solo cuando se toma su última decisión en el portal.
  2. Cuando el título esté en needs_review (aviso job.needs_review), mostrad en vuestro frontal un botón «Revisar en AccesPub» que abra su review_url en una pestaña nueva.
  3. Al pulsar el botón, vuestro servidor pide un enlace de acceso para esa persona (ver «Pedir un enlace de acceso al portal») y redirige su navegador: entra en la revisión sin contraseña. Sin enlace de acceso, el botón abre review_url y, si la persona no tiene la sesión abierta, el portal se la pide y la devuelve a la misma pantalla.
  4. Vuestro sistema recibe el aviso job.completed (ver «Avisos automáticos (webhooks)») y descarga las salidas. Sin avisos, lo ve en completed con «Consultar el estado de un título».
<a href="https://api.accespub.app/portal/titulos/job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7/revision"
   target="_blank" rel="noopener">Revisar en AccesPub</a>
El portal no se puede incrustar en otra web (por seguridad no admite marcos ni iframe): abridlo en una pestaña o ventana nueva.

36Listar lo que hay que revisar

GET/jobs/{job_id}/review-items

Los elementos pendientes de un título, con su contexto en el libro (texto anterior y posterior) para decidir con criterio. Tres tipos: image, table y language. Se puede filtrar por tipo y por estado (pending, decided, all).

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/jobs/$JOB_ID/review-items?status=pending&limit=100" \
  -H "Authorization: Bearer $ACCESPUB_KEY"

# Guarda el identificador que devuelve (campo "data[0].id"):
export ITEM_ID=img_0004
Python
r = requests.get(
    f"{BASE}/jobs/{JOB_ID}/review-items?status=pending&limit=100",
    headers=H,
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
ITEM_ID = data["data"][0]["id"]
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/review-items?status=pending&limit=100`, {
  headers: { ...H },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
const ITEM_ID = data.data[0].id;
Respuesta de ejemplo
{
  "data": [
    {"id": "img_0004", "kind": "image", "status": "pending",
     "document": "OEBPS/cap02.xhtml",
     "context_before": "Al amanecer, el faro seguía encendido.",
     "context_after": "Las gaviotas volvían al acantilado.",
     "image": {"src": "images/faro.jpg", "current_alt": null, "decorative_guess": false, "is_svg": false,
               "image_url": "https://api.accespub.app/v1/jobs/job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7/review-items/img_0004/image"},
     "suggestion": {"text": "Faro blanco sobre un acantilado al amanecer, con gaviotas volando.",
                    "language": "es", "provider": "google", "source": "prefill"},
     "hint": {"suggest": "informative", "reasons": ["caption"], "group": null, "group_size": 1}},
    {"id": "tbl_0001", "kind": "table", "status": "pending",
     "table": {"html_preview": "<table>…</table>", "layout_guess": false, "first_row_as_headers_guess": true}},
    {"id": "book_language", "kind": "language", "status": "pending",
     "language": {"scope": "book", "declared": ["es", "en"], "proposed": ["es"],
                  "candidates": ["es", "en", "ca"], "reasons": ["multiple_declared"]}}
  ],
  "next_cursor": null
}

A tener en cuenta

  • Hasta 100 elementos por página: sigue next_cursor hasta que sea null.
  • suggestion: la última propuesta de la IA, si se pidió. No se aplica sola.
  • hint orienta (pie de figura, portada, filete decorativo, imágenes casi iguales…) pero no decide.
  • Mientras se analiza: 409 job-not-ready.

37Descargar la imagen que se revisa

GET/jobs/{job_id}/review-items/{item_id}/image

La imagen del elemento, para mostrarla a quien revisa (reducida a 1024 px como máximo).

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s -o imagen-a-revisar.jpg "$BASE/jobs/$JOB_ID/review-items/$ITEM_ID/image" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/review-items/{ITEM_ID}/image", headers=H)
open("imagen-a-revisar.jpg", "wb").write(r.content)
print(r.status_code, "→ guardado en imagen-a-revisar.jpg")
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/review-items/${ITEM_ID}/image`, {
  headers: { ...H },
});
await writeFile("imagen-a-revisar.jpg", Buffer.from(await r.arrayBuffer()));
console.log(r.status, "→ guardado en imagen-a-revisar.jpg");

Respuesta: La imagen (image/jpeg, image/png, image/gif o image/svg+xml).

38Pedir a la IA una propuesta para una imagen o tabla

POST/jobs/{job_id}/review-items/{item_id}/suggestion

La IA propone un texto alternativo (imagen) o un título (tabla), escrito en el idioma del libro. La persona la lee, la acepta o la corrige, y la envía como decisión.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s -X POST "$BASE/jobs/$JOB_ID/review-items/$ITEM_ID/suggestion" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.post(f"{BASE}/jobs/{JOB_ID}/review-items/{ITEM_ID}/suggestion", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/review-items/${ITEM_ID}/suggestion`, {
  method: "POST",
  headers: { ...H },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "item_id": "img_0004",
  "kind": "image",
  "text": "Faro blanco sobre un acantilado al amanecer, con gaviotas volando.",
  "language": "es",
  "provider": "google",
  "created_at": "2027-03-05T11:02:00Z"
}

A tener en cuenta

  • Con una clave ap_test_ la propuesta es un texto fijo de prueba (sin coste ni IA real).
  • Límite por minuto (limits.ai_suggestions_per_minute): si se supera, 429.
  • SVG o contenido bloqueado por los filtros de la IA: 422 ai-blocked (hay que escribirlo a mano).

39Preparar propuestas de la IA para todas las imágenes y tablas

POST/jobs/{job_id}/ai-prefill

Propuestas masivas: en lugar de pedirlas una a una, la IA prepara en segundo plano un texto alternativo para cada imagen pendiente y un título para cada tabla pendiente. Aparecen en la lista de revisión como suggestion y tampoco se aplican solas: una persona las valida, corrige o marca como decorativas (o de maquetación).

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s -X POST "$BASE/jobs/$JOB_ID/ai-prefill" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.post(f"{BASE}/jobs/{JOB_ID}/ai-prefill", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/ai-prefill`, {
  method: "POST",
  headers: { ...H },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "status": "running",
  "total": 38, "queued": 30, "running": 2, "done": 6, "failed": 0, "skipped": 0,
  "images": 35, "tables": 3,
  "paused": false,
  "billing": {"mode": "included", "credits_charged": 0}
}

A tener en cuenta

  • Se hace solo si el lote se creó con defaults.ai_prefill: true.
  • Cómo se cobra lo fija el contrato (billing.mode): incluido en el crédito del título o de pago. Las tablas no cuentan en los bloques de pago. Si no está activado para la organización: 403.

40Ver el progreso de las propuestas masivas

GET/jobs/{job_id}/ai-prefill

Cuántas propuestas están hechas, en cola o han fallado. Cuando status es done, ya se pueden validar todas.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/jobs/$JOB_ID/ai-prefill" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/ai-prefill", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/ai-prefill`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"status": "done", "total": 38, "queued": 0, "running": 0, "done": 37, "failed": 1, "skipped": 0,
 "images": 35, "tables": 3}

41Enviar las decisiones de revisión

POST/jobs/{job_id}/decisions

Envía lo que ha decidido una persona, hasta 200 decisiones por petición, firmadas con su nombre y correo. Este ejemplo reúne los tipos posibles: imagen con texto alternativo, imagen decorativa, tabla de datos con título, idioma en línea, idioma de un documento e idioma principal del libro.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/jobs/$JOB_ID/decisions" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "reviewer": {"name": "Jordi Puig", "email": "jordi@velablanca.example"},
  "decisions": [
    {
      "item_id": "img_0004",
      "decision": {
        "kind": "image",
        "decorative": false,
        "alt": "Faro blanco sobre un acantilado al amanecer, con gaviotas volando.",
        "from_suggestion": true
      }
    },
    {"item_id": "img_0005", "decision": {"kind": "image", "decorative": true}},
    {
      "item_id": "tbl_0001",
      "decision": {
        "kind": "table",
        "layout": false,
        "caption": "Horario de mareas en marzo",
        "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"}
    },
    {
      "item_id": "book_language",
      "decision": {"kind": "language", "action": "replace", "languages": ["es"]}
    }
  ]
}'
Python
r = requests.post(
    f"{BASE}/jobs/{JOB_ID}/decisions",
    headers=H | {"Idempotency-Key": str(uuid.uuid4())},
    json={
        "reviewer": {"name": "Jordi Puig", "email": "jordi@velablanca.example"},
        "decisions": [
            {
                "item_id": "img_0004",
                "decision": {
                    "kind": "image",
                    "decorative": False,
                    "alt": "Faro blanco sobre un acantilado al amanecer, con gaviotas volando.",
                    "from_suggestion": True
                }
            },
            {"item_id": "img_0005", "decision": {"kind": "image", "decorative": True}},
            {
                "item_id": "tbl_0001",
                "decision": {
                    "kind": "table",
                    "layout": False,
                    "caption": "Horario de mareas en marzo",
                    "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"}
            },
            {
                "item_id": "book_language",
                "decision": {"kind": "language", "action": "replace", "languages": ["es"]}
            }
        ]
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/decisions`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({
    reviewer: {name: "Jordi Puig", email: "jordi@velablanca.example"},
    decisions: [
      {
        item_id: "img_0004",
        decision: {
          kind: "image",
          decorative: false,
          alt: "Faro blanco sobre un acantilado al amanecer, con gaviotas volando.",
          from_suggestion: true
        }
      },
      {item_id: "img_0005", decision: {kind: "image", decorative: true}},
      {
        item_id: "tbl_0001",
        decision: {
          kind: "table",
          layout: false,
          caption: "Horario de mareas en marzo",
          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"}
      },
      {
        item_id: "book_language",
        decision: {kind: "language", action: "replace", languages: ["es"]}
      }
    ]
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "saved": 6,
  "review": {"total": 22, "decided": 6, "pending": 16},
  "job_status": "needs_review"
}

A tener en cuenta

  • Todo o nada: si una decisión no es válida, responde 422 invalid-decision con un error por decisión ("pointer": "/decisions/2/decision/caption") y no guarda ninguna.
  • Con clave de API, reviewer.email tiene que ser el de una persona activa de la organización (si no, 422 reviewer-not-registered). Con sesión, firma la persona de la sesión.
  • from_suggestion: true registra que el texto parte de una propuesta de la IA.
  • Enviar otra decisión para el mismo elemento la sustituye (queda el historial). Cuando ya no queda nada pendiente, job_status pasa a ready.

42Ver el historial de decisiones de un título

GET/jobs/{job_id}/decisions

Todas las decisiones tomadas, también las sustituidas, con quién y cuándo. Es el registro de la revisión humana y se conserva aunque se borren los ficheros.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/jobs/$JOB_ID/decisions" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/decisions", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/decisions`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "data": [
    {"item_id": "img_0004", "kind": "image",
     "decision": {"kind": "image", "decorative": false, "alt": "Faro blanco sobre…", "from_suggestion": true},
     "reviewer": {"name": "Jordi Puig", "email": "jordi@velablanca.example"},
     "decided_at": "2027-03-05T11:10:22Z"}
  ]
}

43Saltar la revisión de lo que quede pendiente

POST/jobs/{job_id}/skip-review

Como «Saltar revisión» del dashboard: lo pendiente de imágenes, tablas e idiomas en línea pasa a decorativo, de maquetación o se conserva. Con confirm_decorative: true y una persona que lo firma, cuenta como revisado; sin confirmar, el EPUB sale igual pero sin declarar conformidad.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/jobs/$JOB_ID/skip-review" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "confirm_decorative": true,
  "reviewer": {"name": "Jordi Puig", "email": "jordi@velablanca.example"}
}'
Python
r = requests.post(
    f"{BASE}/jobs/{JOB_ID}/skip-review",
    headers=H | {"Idempotency-Key": str(uuid.uuid4())},
    json={
        "confirm_decorative": True,
        "reviewer": {"name": "Jordi Puig", "email": "jordi@velablanca.example"}
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/skip-review`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({
    confirm_decorative: true,
    reviewer: {name: "Jordi Puig", email: "jordi@velablanca.example"}
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"skipped": 0, "confirmed": 16, "saved": 16, "review": {"total": 22, "decided": 22, "pending": 0}, "job_status": "ready"}

A tener en cuenta

  • Usadlo solo cuando una persona haya comprobado que lo restante es realmente decorativo: queda firmado.
  • Los idiomas de documento y del libro no se tocan.

Metadatos del libro

Lo mismo que se corrige a mano en la pantalla de exportación del dashboard: título, autoría, fecha, descripción, materias, resumen de accesibilidad… Los cambios se guardan aparte y se aplican al finalizar (sobreviven aunque se vuelva a procesar).

44Ver los metadatos y lo que conviene completar

GET/jobs/{job_id}/metadata

Cada campo del libro con su origen (epub: viene del EPUB; override: indicado por la API) y los avisos de lo que falta o es dudoso.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/jobs/$JOB_ID/metadata" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/metadata", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/metadata`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "dc": {
    "title": {"value": "El faro de las gaviotas", "source": "epub"},
    "creator": {"value": "Elena Roca", "source": "epub"},
    "date": {"value": "05/06/2020", "source": "epub"},
    "description": {"value": null, "source": "epub"}
  },
  "page_source_isbn": null,
  "notices": [{"code": "date_ambiguous", "field": "dc.date", "value": "05/06/2020"}]
}

A tener en cuenta

  • Avisos posibles: date_ambiguous (fecha que no se puede interpretar), page_source_missing (hay números de página sin la edición impresa de la que proceden), title_placeholder (el EPUB no tenía título), epubcheck_errors (errores que AccesPub no puede corregir; ver «Ayuda»).

45Completar o corregir metadatos

PATCH/jobs/{job_id}/metadata

Cambia solo los campos enviados. null quita el valor indicado y vuelve al del EPUB.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -X PATCH "$BASE/jobs/$JOB_ID/metadata" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "dc": {
    "title": "El faro de las gaviotas",
    "creator": "Elena Roca",
    "date": "2020-06-05",
    "publisher": "Ediciones Vela Blanca"
  },
  "page_source_isbn": "9788412345681",
  "accessibility_summary": "Libro con texto alternativo en todas las ilustraciones y navegación por capítulos."
}'
Python
r = requests.patch(
    f"{BASE}/jobs/{JOB_ID}/metadata",
    headers=H,
    json={
        "dc": {
            "title": "El faro de las gaviotas",
            "creator": "Elena Roca",
            "date": "2020-06-05",
            "publisher": "Ediciones Vela Blanca"
        },
        "page_source_isbn": "9788412345681",
        "accessibility_summary": "Libro con texto alternativo en todas las ilustraciones y navegación por capítulos."
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/metadata`, {
  method: "PATCH",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({
    dc: {
      title: "El faro de las gaviotas",
      creator: "Elena Roca",
      date: "2020-06-05",
      publisher: "Ediciones Vela Blanca"
    },
    page_source_isbn: "9788412345681",
    accessibility_summary: "Libro con texto alternativo en todas las ilustraciones y navegación por capítulos."
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"dc": {"date": {"value": "2020-06-05", "source": "override"}, "…": "…"}, "notices": []}

A tener en cuenta

  • Todo o nada: con algún campo no válido, 422 y no se guarda nada.
  • En un título ya finalizado, lo devuelve a ready: hay que finalizar de nuevo (sin coste).
  • El idioma del libro no se cambia aquí, sino en la revisión (elemento book_language).

46Pedir a la IA una sinopsis y materias

POST/jobs/{job_id}/metadata/suggestion

Si el libro no tiene descripción o materias, la IA propone una sinopsis, códigos Thema (comprobados contra la lista oficial), una pista BISAC y palabras clave, leyendo el comienzo del libro. No se aplica sola ni consume créditos.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -X POST "$BASE/jobs/$JOB_ID/metadata/suggestion" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.post(f"{BASE}/jobs/{JOB_ID}/metadata/suggestion", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/metadata/suggestion`, {
  method: "POST",
  headers: { ...H },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "description": "En un pueblo pesquero del norte, la última farera…",
  "thema": [{"code": "FBA", "label": "Ficción moderna y contemporánea", "confidence": "high", "reason": "…"},
            {"code": "1DSE", "label": "España", "confidence": "medium", "reason": "…"}],
  "bisac_hint": "Ficción literaria",
  "keywords": ["faro", "mar", "memoria familiar"],
  "rejected_codes": [],
  "language": "es",
  "provider": "google"
}

47Guardar la descripción y las materias aceptadas

PATCH/jobs/{job_id}/metadata

Lo que una persona acepte de la propuesta (o escriba ella misma) se guarda con este cambio. Las materias sustituyen a todas las del EPUB y van también al ONIX de accesibilidad.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -X PATCH "$BASE/jobs/$JOB_ID/metadata" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "dc": {"description": "En un pueblo pesquero del norte, la última farera…"},
  "subjects": {
    "thema": ["FBA", "1DSE"],
    "bisac": ["FIC019000"],
    "keywords": ["faro", "mar", "memoria familiar"]
  }
}'
Python
r = requests.patch(
    f"{BASE}/jobs/{JOB_ID}/metadata",
    headers=H,
    json={
        "dc": {"description": "En un pueblo pesquero del norte, la última farera…"},
        "subjects": {
            "thema": ["FBA", "1DSE"],
            "bisac": ["FIC019000"],
            "keywords": ["faro", "mar", "memoria familiar"]
        }
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/metadata`, {
  method: "PATCH",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({
    dc: {description: "En un pueblo pesquero del norte, la última farera…"},
    subjects: {
      thema: ["FBA", "1DSE"],
      bisac: ["FIC019000"],
      keywords: ["faro", "mar", "memoria familiar"]
    }
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"dc": {"description": {"value": "En un pueblo pesquero…", "source": "override"}}, "subjects": {"thema": ["FBA", "1DSE"], "…": "…"}}

A tener en cuenta

  • BISAC: 3 letras y 6 cifras. "subjects": null vuelve a las materias del EPUB.

Finalizar

Finalizar aplica las decisiones y los metadatos, escribe los metadatos de accesibilidad, valida con EPUBCheck y genera las salidas definitivas. Es el momento en que se consume el crédito (solo la primera vez; volver a finalizar no cuesta).

48Finalizar un título

POST/jobs/{job_id}/finalize

Genera el EPUB final del título. Si queda algo por revisar, responde 409 pending-review, salvo que se pida allow_pending: true (se entrega sin declarar conformidad).

Quién puede: AdministraciónGestión de lotes

curl
curl -s "$BASE/jobs/$JOB_ID/finalize" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"allow_pending": false}'
Python
r = requests.post(
    f"{BASE}/jobs/{JOB_ID}/finalize",
    headers=H | {"Idempotency-Key": str(uuid.uuid4())},
    json={"allow_pending": False},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/finalize`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({allow_pending: false}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "status": "finalizing"}

A tener en cuenta

  • Después, espera con el ejemplo «Consultar el estado de un título» hasta que esté en completed.
  • Sin créditos: 402 insufficient-credits y no se cobra nada.
  • Si se cambia una decisión o un metadato después, vuelve a ready y se finaliza de nuevo sin coste.

49Finalizar todos los títulos listos de un lote

POST/batches/{batch_id}/finalize

Finaliza de una vez los títulos del lote que no tienen pendientes (o solo los indicados en job_ids). Todo o nada con los créditos: si no hay saldo para todos, no se finaliza ninguno.

Quién puede: AdministraciónGestión de lotes

curl
curl -s "$BASE/batches/$BATCH_ID/finalize" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"job_ids": ["'"$JOB_ID"'"], "allow_pending": false}'
Python
r = requests.post(
    f"{BASE}/batches/{BATCH_ID}/finalize",
    headers=H | {"Idempotency-Key": str(uuid.uuid4())},
    json={"job_ids": [JOB_ID], "allow_pending": False},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}/finalize`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({job_ids: [JOB_ID], allow_pending: false}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q", "status": "finalizing", "credits": {"required": 1, "charged": 0, "pending": 1}}

A tener en cuenta

  • Sin job_ids, todos los que cumplan la condición. Ninguno listo: 409 nothing-to-finalize.
  • Sin saldo suficiente: 402 con credits_required y credits_available.

Descargas

Con el título finalizado (completed) ya se pueden descargar sus salidas, una a una o todas las del lote juntas. Los ficheros finales se conservan 30 días tras finalizar.

50Descargar el EPUB accesible

GET/jobs/{job_id}/files/epub

El EPUB final, remediado. Con naming=isbn, el fichero se llama como el ISBN (9788412345674.epub), como piden las plataformas de distribución.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -o 9788412345674.epub "$BASE/jobs/$JOB_ID/files/epub?naming=isbn" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/files/epub?naming=isbn", headers=H)
open("9788412345674.epub", "wb").write(r.content)
print(r.status_code, "→ guardado en 9788412345674.epub")
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/files/epub?naming=isbn`, {
  headers: { ...H },
});
await writeFile("9788412345674.epub", Buffer.from(await r.arrayBuffer()));
console.log(r.status, "→ guardado en 9788412345674.epub");

Respuesta: El fichero EPUB (application/epub+zip).

A tener en cuenta

  • Sin finalizar: 409 job-not-finalized. Caducado (más de 30 días): 410 files-expired.
  • Con naming=isbn y sin ISBN-13 válido: 409 isbn-missing.

51Descargar el informe de accesibilidad (HTML)

GET/jobs/{job_id}/files/report_html

El informe para personas: qué se ha corregido, qué se ha revisado y quién, y el resultado de la validación. En español por defecto; también en inglés, catalán o francés (lang).

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -o informe.html "$BASE/jobs/$JOB_ID/files/report_html?lang=es" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/files/report_html?lang=es", headers=H)
open("informe.html", "wb").write(r.content)
print(r.status_code, "→ guardado en informe.html")
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/files/report_html?lang=es`, {
  headers: { ...H },
});
await writeFile("informe.html", Buffer.from(await r.arrayBuffer()));
console.log(r.status, "→ guardado en informe.html");

Respuesta: El informe (text/html).

52Descargar el ONIX 3.1 de accesibilidad

GET/jobs/{job_id}/files/onix_a11y

El ONIX con los metadatos de accesibilidad del libro, para enviarlo a distribuidores y tiendas.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -o 9788412345674-onix.xml "$BASE/jobs/$JOB_ID/files/onix_a11y?naming=isbn" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/files/onix_a11y?naming=isbn", headers=H)
open("9788412345674-onix.xml", "wb").write(r.content)
print(r.status_code, "→ guardado en 9788412345674-onix.xml")
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/files/onix_a11y?naming=isbn`, {
  headers: { ...H },
});
await writeFile("9788412345674-onix.xml", Buffer.from(await r.arrayBuffer()));
console.log(r.status, "→ guardado en 9788412345674-onix.xml");

Respuesta: El ONIX (application/xml).

53Descargar el EPUB original

GET/jobs/{job_id}/files/original

El fichero tal como se subió (disponible aunque el título no esté finalizado, mientras no caduque).

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -o original.epub "$BASE/jobs/$JOB_ID/files/original" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/files/original", headers=H)
open("original.epub", "wb").write(r.content)
print(r.status_code, "→ guardado en original.epub")
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/files/original`, { headers: { ...H } });
await writeFile("original.epub", Buffer.from(await r.arrayBuffer()));
console.log(r.status, "→ guardado en original.epub");

Respuesta: El EPUB original (application/epub+zip).

54Descargar todas las salidas de un lote

GET/batches/{batch_id}/files/epubs

De una vez, para los títulos finalizados del lote: epubs (.zip con los EPUB), reports (.zip con los informes), onix_a11y (un único ONIX con un producto por título) u onix_full (ONIX comercial). Se puede limitar a algunos títulos con job_ids (separados por comas).

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -o novedades-primavera-2027.zip "$BASE/batches/$BATCH_ID/files/epubs?naming=isbn" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/batches/{BATCH_ID}/files/epubs?naming=isbn", headers=H)
open("novedades-primavera-2027.zip", "wb").write(r.content)
print(r.status_code, "→ guardado en novedades-primavera-2027.zip")
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}/files/epubs?naming=isbn`, {
  headers: { ...H },
});
await writeFile("novedades-primavera-2027.zip", Buffer.from(await r.arrayBuffer()));
console.log(r.status, "→ guardado en novedades-primavera-2027.zip");

Respuesta: Un .zip con los EPUB finales.

A tener en cuenta

  • Para los demás, cambia epubs por reports, onix_a11y u onix_full en la dirección.

55Ver el manifiesto del lote

GET/batches/{batch_id}/manifest

Un resumen del lote para control: para cada título, su estado, si declara conformidad, el estado de cada salida y qué falta (por ejemplo, el ISBN para el ONIX). En JSON o en CSV (para abrir en una hoja de cálculo).

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -o manifiesto.csv "$BASE/batches/$BATCH_ID/manifest?format=csv" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/batches/{BATCH_ID}/manifest?format=csv", headers=H)
open("manifiesto.csv", "wb").write(r.content)
print(r.status_code, "→ guardado en manifiesto.csv")
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}/manifest?format=csv`, {
  headers: { ...H },
});
await writeFile("manifiesto.csv", Buffer.from(await r.arrayBuffer()));
console.log(r.status, "→ guardado en manifiesto.csv");
Respuesta de ejemplo
{
  "batch_id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q",
  "generated_at": "2027-03-06T09:00:00Z",
  "jobs": [
    {"job_id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "external_id": "VB-0412",
     "filename": "el-faro-de-las-gaviotas.epub", "title": "El faro de las gaviotas", "isbn": "9788412345674",
     "status": "completed", "pending": 0, "conformance": "wcag21aa",
     "outputs": {"epub": "ready", "report_html": "ready", "onix_a11y": "ready"}, "missing": []}
  ]
}

A tener en cuenta

  • La respuesta de ejemplo es la versión JSON (format=json).
  • Estados de una salida: pending, ready, ready_with_warnings (se entrega, pero falta algo: ver missing), missing_data, invalid, not_applicable, expired.

ONIX comercial

Además del ONIX de accesibilidad, AccesPub 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 lector, cubierta… Los valores comunes se fijan en el lote y cada título puede cambiarlos (lo del título manda).

Un mercado es un territorio (países ISO como ES o FR BE CH, o WORLD) con su moneda, precio, tipo de precio, impuesto, disponibilidad, proveedor y fecha de venta. Cada mercado se convierte en un bloque de venta del ONIX.

56Fijar los datos comerciales comunes del lote

PATCH/batches/{batch_id}/commercial

Valores que comparten todos los títulos del lote: estado, sello, distribuidor, mercados (sin precio, que es de cada título)… También se pueden indicar al crear el lote (defaults.commercial).

Quién puede: AdministraciónGestión de lotes

curl
curl -s -X PATCH "$BASE/batches/$BATCH_ID/commercial" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "imprint": "Vela Blanca Narrativa",
  "pub_status": "04",
  "city": "Barcelona",
  "country": "ES",
  "supplier_name": "Distribuciones Norte",
  "markets": [
    {"territory": "ES", "currency": "EUR", "price_type": "02", "tax_percent": "4"},
    {"territory": "MX CO PE CL", "currency": "USD", "price_type": "01"},
    {
      "territory": "WORLD",
      "countries_excluded": "ES MX CO PE CL",
      "currency": "EUR",
      "price_type": "02"
    }
  ]
}'
Python
r = requests.patch(
    f"{BASE}/batches/{BATCH_ID}/commercial",
    headers=H,
    json={
        "imprint": "Vela Blanca Narrativa",
        "pub_status": "04",
        "city": "Barcelona",
        "country": "ES",
        "supplier_name": "Distribuciones Norte",
        "markets": [
            {"territory": "ES", "currency": "EUR", "price_type": "02", "tax_percent": "4"},
            {"territory": "MX CO PE CL", "currency": "USD", "price_type": "01"},
            {
                "territory": "WORLD",
                "countries_excluded": "ES MX CO PE CL",
                "currency": "EUR",
                "price_type": "02"
            }
        ]
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}/commercial`, {
  method: "PATCH",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({
    imprint: "Vela Blanca Narrativa",
    pub_status: "04",
    city: "Barcelona",
    country: "ES",
    supplier_name: "Distribuciones Norte",
    markets: [
      {territory: "ES", currency: "EUR", price_type: "02", tax_percent: "4"},
      {territory: "MX CO PE CL", currency: "USD", price_type: "01"},
      {
        territory: "WORLD",
        countries_excluded: "ES MX CO PE CL",
        currency: "EUR",
        price_type: "02"
      }
    ]
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "batch_id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q",
  "values": {"imprint": "Vela Blanca Narrativa", "pub_status": "04", "markets": ["…"]},
  "titles": [{"job_id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "title": "El faro de las gaviotas",
              "ready": false, "issues": [{"code": "market_unpriced", "level": "warning"}]}],
  "billing": {"mode": "paid", "credits_per_title": 1}
}

A tener en cuenta

  • Códigos ONIX: pub_status lista 64 (04 activo, 02 próximamente), price_type lista 58 (02 PVP con impuestos, 01 sin impuestos, 04 precio fijo con impuestos), availability lista 65 (20 disponible).

57Ver los datos comunes del lote y el estado de cada título

GET/batches/{batch_id}/commercial

Los valores comunes y, para cada título, si está listo para generar su ONIX comercial y qué le falta.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/batches/$BATCH_ID/commercial" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/batches/{BATCH_ID}/commercial", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}/commercial`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"batch_id": "bat_01JQ7VB3K9R2XW4D5M6N7P8Q", "values": {"…": "…"}, "titles": [{"job_id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "ready": true, "issues": [], "generated_at": null}]}

58Completar los datos comerciales de un título

PATCH/jobs/{job_id}/commercial

Lo propio del título: subtítulo, número en la colección, fecha de publicación, autoría con su rol, cubierta y los precios de cada mercado. Lo que no se indique se toma del lote. Si el título trae sus propios mercados, sustituyen a los del lote.

Quién puede: AdministraciónGestión de lotes

curl
curl -s -X PATCH "$BASE/jobs/$JOB_ID/commercial" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "subtitle": "Una novela del Cantábrico",
  "collection": "Mareas",
  "collection_number": "7",
  "publishing_date": "2027-04-23",
  "copyright_year": "2027",
  "copyright_owner": "Elena Roca",
  "audience": "01",
  "age_from": "14",
  "contributors": [{"name": "Elena Roca", "role": "aut"}, {"name": "Joan Vidal", "role": "ill"}],
  "cover_url": "https://www.velablanca.example/cubiertas/9788412345674.jpg",
  "markets": [
    {
      "territory": "ES",
      "currency": "EUR",
      "price_type": "02",
      "tax_percent": "4",
      "price_amount": "9.99"
    },
    {
      "territory": "MX CO PE CL",
      "currency": "USD",
      "price_type": "01",
      "price_amount": "7.99"
    }
  ],
  "apply_to_batch": ["collection"]
}'
Python
r = requests.patch(
    f"{BASE}/jobs/{JOB_ID}/commercial",
    headers=H,
    json={
        "subtitle": "Una novela del Cantábrico",
        "collection": "Mareas",
        "collection_number": "7",
        "publishing_date": "2027-04-23",
        "copyright_year": "2027",
        "copyright_owner": "Elena Roca",
        "audience": "01",
        "age_from": "14",
        "contributors": [{"name": "Elena Roca", "role": "aut"}, {"name": "Joan Vidal", "role": "ill"}],
        "cover_url": "https://www.velablanca.example/cubiertas/9788412345674.jpg",
        "markets": [
            {
                "territory": "ES",
                "currency": "EUR",
                "price_type": "02",
                "tax_percent": "4",
                "price_amount": "9.99"
            },
            {
                "territory": "MX CO PE CL",
                "currency": "USD",
                "price_type": "01",
                "price_amount": "7.99"
            }
        ],
        "apply_to_batch": ["collection"]
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/commercial`, {
  method: "PATCH",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({
    subtitle: "Una novela del Cantábrico",
    collection: "Mareas",
    collection_number: "7",
    publishing_date: "2027-04-23",
    copyright_year: "2027",
    copyright_owner: "Elena Roca",
    audience: "01",
    age_from: "14",
    contributors: [{name: "Elena Roca", role: "aut"}, {name: "Joan Vidal", role: "ill"}],
    cover_url: "https://www.velablanca.example/cubiertas/9788412345674.jpg",
    markets: [
      {
        territory: "ES",
        currency: "EUR",
        price_type: "02",
        tax_percent: "4",
        price_amount: "9.99"
      },
      {territory: "MX CO PE CL", currency: "USD", price_type: "01", price_amount: "7.99"}
    ],
    apply_to_batch: ["collection"]
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "title": {"subtitle": "Una novela del Cantábrico", "cover_url": "https://www.velablanca.example/cubiertas/9788412345674.jpg", "…": "…"},
  "batch": {"imprint": "Vela Blanca Narrativa", "…": "…"},
  "effective": {"imprint": "Vela Blanca Narrativa", "collection": "Mareas", "markets": ["…"], "…": "…"},
  "sources": {"imprint": "batch", "collection": "title"},
  "isbn": "9788412345674",
  "publisher": "Ediciones Vela Blanca",
  "issues": [],
  "ready": true,
  "onix_full": null,
  "billing": {"mode": "paid", "credits_per_title": 1, "cost_now": 1}
}

A tener en cuenta

  • Roles de autoría: aut (autor/a), trl (traducción), ill (ilustración), edt (edición). Sin contributors, se usa la autoría del EPUB.
  • cover_url: dirección pública y estable que lleve directamente a la imagen (JPEG o PNG, idealmente de 1000 px o más en el lado corto). Viaja dentro del ONIX y las plataformas la descargarán de ahí, así que no debe cambiar ni caducar.
  • apply_to_batch guarda además esos campos comunes como valores del lote.
  • Cambiar los datos después no obliga a finalizar de nuevo ni vuelve a cobrar.

59Comprobar si el título está listo para el ONIX comercial

GET/jobs/{job_id}/commercial

Lo que se usará al generar el ONIX (effective), de dónde sale cada valor y las comprobaciones: los errores impiden generarlo y los avisos no.

Quién puede: AdministraciónGestión de lotesRevisiónRevisión externa (su lote)

curl
curl -s "$BASE/jobs/$JOB_ID/commercial" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/commercial", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/commercial`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "ready": false,
  "issues": [
    {"code": "isbn_missing", "level": "error", "field": "isbn"},
    {"code": "cover_missing", "level": "warning", "field": "cover_url"}
  ],
  "billing": {"mode": "paid", "credits_per_title": 1, "cost_now": 1}
}

A tener en cuenta

  • Errores: not_finalized, isbn_missing, publisher_missing, markets_missing, xsd_invalid. Avisos: market_unpriced (se envía como «precio por anunciar»), date_missing, cover_missing.

60Generar el ONIX comercial de un título

POST/jobs/{job_id}/onix-full

Comprueba los datos y activa el ONIX comercial del título. Según el contrato puede consumir créditos, una sola vez por título; con claves de prueba, nunca. Después se descarga siempre con los datos al día.

Quién puede: AdministraciónGestión de lotes

curl
curl -s -X POST "$BASE/jobs/$JOB_ID/onix-full" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.post(f"{BASE}/jobs/{JOB_ID}/onix-full", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/onix-full`, {
  method: "POST",
  headers: { ...H },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "generated": [{"job_id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "credits_charged": 1, "generated_at": "2027-03-06T09:20:00Z"}],
  "credits_charged": 1
}

A tener en cuenta

  • Faltan datos: 422 onix-incomplete con las comprobaciones de cada título. Sin saldo: 402. Desactivado para la organización: 403.

61Generar el ONIX comercial de todo el lote

POST/batches/{batch_id}/onix-full

Para todos los títulos terminados del lote (o los de job_ids). Todo o nada: si alguno no está listo o no hay saldo para todos, no se genera ninguno. Los ya generados no se cobran de nuevo.

Quién puede: AdministraciónGestión de lotes

curl
curl -s "$BASE/batches/$BATCH_ID/onix-full" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"job_ids": ["'"$JOB_ID"'"]}'
Python
r = requests.post(
    f"{BASE}/batches/{BATCH_ID}/onix-full",
    headers=H,
    json={"job_ids": [JOB_ID]},
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/batches/${BATCH_ID}/onix-full`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({job_ids: [JOB_ID]}),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"generated": [{"job_id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "credits_charged": 0}], "credits_charged": 0}

62Descargar el ONIX comercial

GET/jobs/{job_id}/files/onix_full

El ONIX 3.1 completo del título, construido con los datos de ese momento. Para el lote entero, GET /batches/{batch_id}/files/onix_full (un solo fichero con un producto por título).

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s -o 9788412345674-onix-comercial.xml "$BASE/jobs/$JOB_ID/files/onix_full?naming=isbn" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/files/onix_full?naming=isbn", headers=H)
open("9788412345674-onix-comercial.xml", "wb").write(r.content)
print(r.status_code, "→ guardado en 9788412345674-onix-comercial.xml")
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/files/onix_full?naming=isbn`, {
  headers: { ...H },
});
await writeFile("9788412345674-onix-comercial.xml", Buffer.from(await r.arrayBuffer()));
console.log(r.status, "→ guardado en 9788412345674-onix-comercial.xml");

Respuesta: El ONIX comercial (application/xml).

A tener en cuenta

  • Sin generar: 409. Si se cambian los datos, basta con volver a descargarlo.

Pedir ayuda a ab serveis

Para un EPUB con errores que AccesPub no puede corregir (aviso epubcheck_errors) o que no se ha podido procesar (failed). Es opcional: la editorial también puede corregir el EPUB y volver a subirlo.

63Pedir ayuda con un título

POST/jobs/{job_id}/support-requests

ab serveis recibe el mensaje con el registro de errores de EPUBCheck y responde en la propia solicitud. Con share_epub: true se le autoriza a descargar el EPUB original para diagnosticarlo.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s "$BASE/jobs/$JOB_ID/support-requests" \
  -H "Authorization: Bearer $ACCESPUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "message": "EPUBCheck sigue dando RSC-005 en el capítulo 3 después de la remediación.",
  "contact_email": "pau@velablanca.example",
  "share_epub": true
}'
Python
r = requests.post(
    f"{BASE}/jobs/{JOB_ID}/support-requests",
    headers=H,
    json={
        "message": "EPUBCheck sigue dando RSC-005 en el capítulo 3 después de la remediación.",
        "contact_email": "pau@velablanca.example",
        "share_epub": True
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/support-requests`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({
    message: "EPUBCheck sigue dando RSC-005 en el capítulo 3 después de la remediación.",
    contact_email: "pau@velablanca.example",
    share_epub: true
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "sup_01JQ8A2C4E6G8J0K2M4P6R8T0V",
  "job_id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7",
  "status": "open",
  "share_epub": true,
  "resolution": null,
  "response": null,
  "created_at": "2027-03-06T10:00:00Z"
}

A tener en cuenta

  • Respuestas posibles (resolution): fix_planned (AccesPub lo contemplará), publisher_fix (lo corrige la editorial), manual_fix_quote (ab serveis puede corregirlo con presupuesto aparte), fixed, closed.
  • Como máximo 3 solicitudes abiertas por título.

64Ver las solicitudes de ayuda de un título

GET/jobs/{job_id}/support-requests

Las solicitudes del título con su estado y la respuesta de ab serveis.

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s "$BASE/jobs/$JOB_ID/support-requests" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/jobs/{JOB_ID}/support-requests", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/jobs/${JOB_ID}/support-requests`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"data": [{"id": "sup_01JQ8A2C4E6G8J0K2M4P6R8T0V", "status": "answered", "resolution": "fix_planned",
           "response": "Es un caso que AccesPub corregirá en la próxima versión."}]}

65Ver todas las solicitudes de ayuda de la organización

GET/support-requests

Todas las solicitudes, de la más reciente a la más antigua. Se pueden filtrar por estado (open, answered, closed).

Quién puede: AdministraciónGestión de lotesRevisión

curl
curl -s "$BASE/support-requests?status=open" \
  -H "Authorization: Bearer $ACCESPUB_KEY"
Python
r = requests.get(f"{BASE}/support-requests?status=open", headers=H)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/support-requests?status=open`, { headers: { ...H } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"data": [{"id": "sup_01JQ8A2C4E6G8J0K2M4P6R8T0V", "job_id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "status": "open"}]}

Avisos automáticos (webhooks)

En lugar de consultar el estado cada poco, AccesPub avisa a vuestro sistema cuando cambia algo: un título necesita revisión, está listo, se ha finalizado o ha fallado; un lote ha terminado; ab serveis ha respondido; quedan pocos créditos… Cada aviso es un POST firmado a una dirección https vuestra. Las direcciones se gestionan con una clave de administración (o en el portal: Organización → Avisos automáticos) y cada modo tiene las suyas: las de una clave ap_test_ solo reciben lo de los datos de prueba.

AvisoCuándo llega
job.needs_reviewEl análisis ha terminado y hay elementos para revisar (trae review_url).
job.readyYa no queda nada pendiente: el título se puede finalizar.
job.completedEl título se ha finalizado: ya se pueden descargar las salidas.
job.failedNo se ha podido analizar o finalizar (ver error).
job.ai_prefill_doneHan terminado las propuestas de la IA del título.
batch.completedTodos los títulos del lote están finalizados o con error.
large_upload_request.resolvedab serveis ha aprobado o rechazado una subida grande.
support_request.answeredab serveis ha respondido una solicitud de ayuda.
credits.lowEl saldo baja del umbral (10 créditos, salvo que se acuerde otro) y otra vez al agotarse. Solo datos reales.

66Dar de alta una dirección que reciba los avisos

POST/webhook-endpoints

Indicad la dirección de vuestro sistema y los avisos que queréis recibir (sin events, todos). La respuesta trae el secreto de firma (whsec_…): guardadlo en vuestro sistema, porque solo se muestra ahora y sirve para comprobar que cada aviso viene de AccesPub.

Quién puede: Administración

curl
curl -s "$BASE/webhook-endpoints" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://erp.velablanca.example/accespub/avisos",
  "description": "ERP de producción",
  "events": ["job.needs_review", "job.completed", "job.failed", "batch.completed", "credits.low"]
}'

# Guarda el identificador que devuelve (campo "id"):
export WEBHOOK_ID=whe_01JQ8C4E6G8J0K2M4N6P8R0S2T
Python
r = requests.post(
    f"{BASE}/webhook-endpoints",
    headers=HA,
    json={
        "url": "https://erp.velablanca.example/accespub/avisos",
        "description": "ERP de producción",
        "events": [
            "job.needs_review",
            "job.completed",
            "job.failed",
            "batch.completed",
            "credits.low"
        ]
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
WEBHOOK_ID = data["id"]
JavaScript
const r = await fetch(`${BASE}/webhook-endpoints`, {
  method: "POST",
  headers: { ...HA, "Content-Type": "application/json" },
  body: JSON.stringify({
    url: "https://erp.velablanca.example/accespub/avisos",
    description: "ERP de producción",
    events: ["job.needs_review", "job.completed", "job.failed", "batch.completed", "credits.low"]
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
const WEBHOOK_ID = data.id;
Respuesta de ejemplo
{
  "id": "whe_01JQ8C4E6G8J0K2M4N6P8R0S2T",
  "url": "https://erp.velablanca.example/accespub/avisos",
  "description": "ERP de producción",
  "mode": "test",
  "events": ["job.needs_review", "job.completed", "job.failed", "batch.completed", "credits.low"],
  "enabled": true,
  "disabled_reason": null,
  "failing_since": null,
  "created_at": "2027-03-05T10:00:00Z",
  "last_delivery": null,
  "secret": "whsec_k3J9yQm1vH7pX2bN8cR5tW0aZ4eL6uF8"
}

A tener en cuenta

  • Solo direcciones https públicas: nada de localhost, IP privadas ni redes internas (422). Para probar en vuestro ordenador, usad un túnel (por ejemplo, ngrok o Cloudflare Tunnel).
  • Como máximo 5 direcciones por modo (409).
  • Con una clave normal (no de administración): 403.

67Recibir los avisos y comprobar la firma

Lo que tiene que hacer vuestro sistema con cada aviso: comprobar la firma con el secreto (cabeceras webhook-id, webhook-timestamp y webhook-signature, según Standard Webhooks), responder enseguida con un 2xx y descartar los repetidos por su id. Los ejemplos de Python y JavaScript son receptores completos, sin dependencias; el de curl simula un aviso firmado para probar vuestro receptor en local.

curl
# Simula un aviso firmado contra vuestro receptor local (necesita openssl).
SECRET="whsec_k3J9yQm1vH7pX2bN8cR5tW0aZ4eL6uF8"      # el de la dirección
ID="evt_prueba_$(date +%s)"; TS=$(date +%s)
BODY='{"id":"'$ID'","type":"webhook.test","created_at":"2027-03-05T10:00:00Z","mode":"test","data":{"message":"hola"}}'
KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 -d | od -An -tx1 | tr -d ' \n')
SIG=$(printf '%s' "$ID.$TS.$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$KEY_HEX -binary | base64)

curl -s -i http://localhost:8000/accespub/avisos \
  -H "Content-Type: application/json" \
  -H "webhook-id: $ID" -H "webhook-timestamp: $TS" -H "webhook-signature: v1,$SIG" \
  -d "$BODY"
# → HTTP/1.0 204 (con una firma incorrecta, 401)
Python
# receptor.py: recibe los avisos de AccesPub (Python 3.9+, sin dependencias)
import base64, hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["ACCESPUB_WEBHOOK_SECRET"]       # whsec_… (al dar de alta la dirección)
vistos = set()                                       # en producción, en vuestra base de datos

def verificar(headers, body: bytes) -> bool:
    msg_id, ts = headers.get("webhook-id", ""), headers.get("webhook-timestamp", "0")
    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.get("webhook-signature", "").split())

class Receptor(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))   # el cuerpo tal cual llega
        if not verificar(self.headers, body):
            self.send_response(401); self.end_headers(); return
        self.send_response(204); self.end_headers()  # responder enseguida…
        evento = json.loads(body)
        if evento["id"] in vistos:                   # …y descartar repetidos
            return
        vistos.add(evento["id"])
        print(evento["type"], evento["data"].get("id"), evento["data"].get("status"))
        # p. ej. job.needs_review → botón «Revisar en AccesPub» con evento["data"]["review_url"]
        #        job.completed    → descargar las salidas del título

HTTPServer(("", 8000), Receptor).serve_forever()
JavaScript
// receptor.mjs: recibe los avisos de AccesPub (Node 18+, sin dependencias)
import http from "node:http";
import crypto from "node:crypto";

const SECRET = process.env.ACCESPUB_WEBHOOK_SECRET;  // whsec_… (al dar de alta la dirección)
const vistos = new Set();                            // en producción, en vuestra base de datos

function verificar(headers, body) {
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
  if (!id || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64");
  const esperada = Buffer.from(crypto.createHmac("sha256", key)
    .update(`${id}.${ts}.`).update(body).digest("base64"));
  return (headers["webhook-signature"] || "").split(" ").some((s) => {
    const firma = Buffer.from(s.split(",")[1] || "");
    return firma.length === esperada.length && crypto.timingSafeEqual(firma, esperada);
  });
}

http.createServer((req, res) => {
  const partes = [];
  req.on("data", (c) => partes.push(c));
  req.on("end", () => {
    const body = Buffer.concat(partes);              // el cuerpo tal cual llega
    if (!verificar(req.headers, body)) { res.writeHead(401).end(); return; }
    res.writeHead(204).end();                        // responder enseguida…
    const evento = JSON.parse(body);
    if (vistos.has(evento.id)) return;               // …y descartar repetidos
    vistos.add(evento.id);
    console.log(evento.type, evento.data.id, evento.data.status);
  });
}).listen(8000);

A tener en cuenta

  • Comprobad la firma con el cuerpo tal cual llega (sin volver a convertirlo a JSON): cualquier cambio de espacios la invalida.
  • Responded en menos de 10 segundos y haced el trabajo pesado después (en una cola). Cualquier respuesta que no sea 2xx, también una redirección, se reintenta: 9 intentos con esperas crecientes durante unas 25 horas.
  • Un aviso puede llegar dos veces (mismo webhook-id) y no siempre en orden: ante la duda, consultad el estado actual con «Consultar el estado de un título».
  • Cuerpo de cada aviso: id, type, created_at, mode y data (el título, el lote, la solicitud… tal como los devuelve la API en ese momento).

68Probar una dirección

POST/webhook-endpoints/{webhook_id}/test

Envía en el momento un aviso de prueba (webhook.test) y devuelve lo que ha respondido vuestro sistema. Un solo intento, sin reintentos.

Quién puede: Administración

curl
curl -s -X POST "$BASE/webhook-endpoints/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"
Python
r = requests.post(f"{BASE}/webhook-endpoints/{WEBHOOK_ID}/test", headers=HA)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/webhook-endpoints/${WEBHOOK_ID}/test`, {
  method: "POST",
  headers: { ...HA },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "whd_01JQ8C5F7H9K1M3N5P7Q9S1U3W",
  "event_id": "evt_01JQ8C5F7G8J0K2M4N6P8R0S2T",
  "event_type": "webhook.test",
  "status": "delivered",
  "attempts": 1,
  "response_status": 204,
  "error": null,
  "delivered_at": "2027-03-05T10:01:00Z"
}

A tener en cuenta

  • Si no llega: status: "failed" con el motivo en error (http_500, timeout, connection_error, tls_error, dns_error…).

69Ver las direcciones y su último envío

GET/webhook-endpoints

Las direcciones del modo de la clave, si están activas y cómo fue su último envío. Incluye la lista de avisos que se pueden pedir (event_types).

Quién puede: Administración

curl
curl -s "$BASE/webhook-endpoints" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"
Python
r = requests.get(f"{BASE}/webhook-endpoints", headers=HA)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/webhook-endpoints`, { headers: { ...HA } });
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "data": [{
    "id": "whe_01JQ8C4E6G8J0K2M4N6P8R0S2T",
    "url": "https://erp.velablanca.example/accespub/avisos",
    "enabled": true,
    "events": ["job.needs_review", "job.completed", "job.failed", "batch.completed", "credits.low"],
    "last_delivery": {"at": "2027-03-05T10:01:00Z", "status": "delivered", "response_status": 204}
  }],
  "event_types": ["job.needs_review", "job.ready", "job.completed", "job.failed", "job.ai_prefill_done",
                  "batch.completed", "large_upload_request.resolved", "support_request.answered", "credits.low"]
}

70Ver los últimos envíos de una dirección

GET/webhook-endpoints/{webhook_id}/deliveries

Los envíos de los últimos 30 días, del más reciente al más antiguo, con sus intentos y lo que respondió vuestro sistema. Se pueden filtrar por estado: pending, retrying, delivered o failed.

Quién puede: Administración

curl
curl -s "$BASE/webhook-endpoints/$WEBHOOK_ID/deliveries?limit=20" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"

# Guarda el identificador que devuelve (campo "data[0].id"):
export DELIVERY_ID=whd_01JQ8D2B4D6F8H0K2M4N6P8R0S
Python
r = requests.get(
    f"{BASE}/webhook-endpoints/{WEBHOOK_ID}/deliveries?limit=20",
    headers=HA,
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
DELIVERY_ID = data["data"][0]["id"]
JavaScript
const r = await fetch(`${BASE}/webhook-endpoints/${WEBHOOK_ID}/deliveries?limit=20`, {
  headers: { ...HA },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
const DELIVERY_ID = data.data[0].id;
Respuesta de ejemplo
{
  "data": [{
    "id": "whd_01JQ8D2B4D6F8H0K2M4N6P8R0S",
    "event_id": "evt_01JQ8D2B4C6E8G0J2L4N6Q8S0U",
    "event_type": "job.completed",
    "status": "retrying",
    "attempts": 2,
    "response_status": 503,
    "error": "http_503",
    "next_attempt_at": "2027-03-06T09:25:00Z"
  }]
}

71Ver un envío con el aviso que se envió

GET/webhook-endpoints/{webhook_id}/deliveries/{delivery_id}

El envío y el aviso completo (event), tal como se envió.

Quién puede: Administración

curl
curl -s "$BASE/webhook-endpoints/$WEBHOOK_ID/deliveries/$DELIVERY_ID" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"
Python
r = requests.get(
    f"{BASE}/webhook-endpoints/{WEBHOOK_ID}/deliveries/{DELIVERY_ID}",
    headers=HA,
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/webhook-endpoints/${WEBHOOK_ID}/deliveries/${DELIVERY_ID}`, {
  headers: { ...HA },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "whd_01JQ8D2B4D6F8H0K2M4N6P8R0S",
  "event_type": "job.completed",
  "status": "retrying",
  "event": {
    "id": "evt_01JQ8D2B4C6E8G0J2L4N6Q8S0U",
    "type": "job.completed",
    "created_at": "2027-03-06T09:20:00Z",
    "mode": "test",
    "data": {"id": "job_01JQ7VC8T1Y3Z5A7B9C1D3E5F7", "status": "completed", "conformance": "wcag21aa", "…": "…"}
  }
}

72Volver a enviar un aviso

POST/webhook-endpoints/{webhook_id}/deliveries/{delivery_id}/retry

Para un aviso que no llegó (o que vuestro sistema perdió): se envía de nuevo en unos segundos, con el mismo webhook-id.

Quién puede: Administración

curl
curl -s -X POST "$BASE/webhook-endpoints/$WEBHOOK_ID/deliveries/$DELIVERY_ID/retry" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"
Python
r = requests.post(
    f"{BASE}/webhook-endpoints/{WEBHOOK_ID}/deliveries/{DELIVERY_ID}/retry",
    headers=HA,
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/webhook-endpoints/${WEBHOOK_ID}/deliveries/${DELIVERY_ID}/retry`, {
  method: "POST",
  headers: { ...HA },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{"id": "whd_01JQ8D2B4D6F8H0K2M4N6P8R0S", "status": "pending", "next_attempt_at": "2027-03-06T09:30:05Z"}

A tener en cuenta

  • Con la dirección desactivada: 409 (reactivadla antes).

73Cambiar los avisos de una dirección, desactivarla o reactivarla

PATCH/webhook-endpoints/{webhook_id}

Se puede cambiar url, events y description, y desactivarla ("enabled": false) o reactivarla (true). Si una dirección falla sin interrupción 5 días, AccesPub la desactiva (disabled_reason: "failing") y avisa por correo a la administración; cuando vuelva a funcionar, se reactiva así.

Quién puede: Administración

curl
curl -s -X PATCH "$BASE/webhook-endpoints/$WEBHOOK_ID" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "events": ["job.needs_review", "job.ready", "job.completed", "job.failed", "batch.completed"],
  "enabled": true
}'
Python
r = requests.patch(
    f"{BASE}/webhook-endpoints/{WEBHOOK_ID}",
    headers=HA,
    json={
        "events": ["job.needs_review", "job.ready", "job.completed", "job.failed", "batch.completed"],
        "enabled": True
    },
)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/webhook-endpoints/${WEBHOOK_ID}`, {
  method: "PATCH",
  headers: { ...HA, "Content-Type": "application/json" },
  body: JSON.stringify({
    events: ["job.needs_review", "job.ready", "job.completed", "job.failed", "batch.completed"],
    enabled: true
  }),
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "whe_01JQ8C4E6G8J0K2M4N6P8R0S2T",
  "enabled": true,
  "disabled_reason": null,
  "events": ["job.needs_review", "job.ready", "job.completed", "job.failed", "batch.completed"]
}

74Cambiar el secreto de firma

POST/webhook-endpoints/{webhook_id}/rotate-secret

Genera un secreto nuevo (solo se muestra ahora). Durante 24 horas cada aviso lleva dos firmas, con el secreto nuevo y con el anterior, para que podáis cambiarlo en vuestro sistema sin perder avisos.

Quién puede: Administración

curl
curl -s -X POST "$BASE/webhook-endpoints/$WEBHOOK_ID/rotate-secret" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"
Python
r = requests.post(f"{BASE}/webhook-endpoints/{WEBHOOK_ID}/rotate-secret", headers=HA)
data = r.json()
print(r.status_code, json.dumps(data, indent=2, ensure_ascii=False))
JavaScript
const r = await fetch(`${BASE}/webhook-endpoints/${WEBHOOK_ID}/rotate-secret`, {
  method: "POST",
  headers: { ...HA },
});
const data = await r.json();
console.log(r.status, JSON.stringify(data, null, 2));
Respuesta de ejemplo
{
  "id": "whe_01JQ8C4E6G8J0K2M4N6P8R0S2T",
  "secret": "whsec_Q8n2Lr5Tz1Kc7Vb4Xm9Hd3Jf6Wp0Ys2A",
  "previous_secret_valid_until": "2027-03-07T10:00:00Z"
}

75Quitar una dirección

DELETE/webhook-endpoints/{webhook_id}

Deja de enviar avisos a esa dirección y borra su historial de envíos.

Quién puede: Administración

curl
curl -s -X DELETE "$BASE/webhook-endpoints/$WEBHOOK_ID" \
  -H "Authorization: Bearer $ACCESPUB_ADMIN_KEY"
Python
r = requests.delete(f"{BASE}/webhook-endpoints/{WEBHOOK_ID}", headers=HA)
print(r.status_code)
JavaScript
const r = await fetch(`${BASE}/webhook-endpoints/${WEBHOOK_ID}`, {
  method: "DELETE",
  headers: { ...HA },
});
console.log(r.status);

Respuesta: 204 (sin contenido)

Recorrido completo en un solo programa

Los pasos principales encadenados en Python: crear un lote, subir un EPUB, esperar al análisis, revisar en el terminal (la persona escribe cada texto alternativo), finalizar y descargar. Para probarlo sin coste, usa una clave ap_test_.

Python · recorrido.py
# Recorrido completo: un EPUB de principio a fin (Python).
# pip install requests  ·  ACCESPUB_KEY=ap_test_… python recorrido.py
import os, time, uuid, requests

BASE = "https://api.accespub.app/v1"
H = {"Authorization": f"Bearer {os.environ['ACCESPUB_KEY']}"}
REVISOR = {"name": "Jordi Puig", "email": "jordi@velablanca.example"}  # persona activa de la organización

def idem():
    return H | {"Idempotency-Key": str(uuid.uuid4())}

def esperar(job_id, mientras=("queued", "processing", "finalizing")):
    while True:
        job = requests.get(f"{BASE}/jobs/{job_id}", headers=H).json()
        if job["status"] not in mientras:
            return job
        time.sleep(15)

# 1. Lote
lote = requests.post(f"{BASE}/batches", headers=idem(),
                     json={"name": "Novedades primavera 2027", "on_pending": "wait"}).json()

# 2. Subir el EPUB
with open("el-faro-de-las-gaviotas.epub", "rb") as f:
    job = requests.post(f"{BASE}/batches/{lote['id']}/jobs", headers=idem(),
                        files={"file": f}, data={"external_id": "VB-0412"}).json()

# 3. Esperar al análisis
job = esperar(job["id"])
print("Estado:", job["status"], "· pendientes:", job["review"]["pending"],
      "· créditos:", job["billing"]["credits_required"])

# 4. Revisión humana: aquí cada persona decide. En este ejemplo, se muestra
#    cada imagen con su contexto y se escribe el texto alternativo en el terminal.
cursor = None
while job["status"] == "needs_review":
    params = {"status": "pending", "limit": 100} | ({"cursor": cursor} if cursor else {})
    pagina = requests.get(f"{BASE}/jobs/{job['id']}/review-items", headers=H, params=params).json()
    decisiones = []
    for item in pagina["data"]:
        if item["kind"] == "image":
            print("\nImagen:", item["image"]["src"], "\nContexto:", item.get("context_before", "")[:120])
            alt = input("Texto alternativo (vacío = decorativa): ").strip()
            decisiones.append({"item_id": item["id"], "decision":
                               {"kind": "image", "decorative": not alt, **({"alt": alt} if alt else {})}})
        elif item["kind"] == "table":
            titulo = input(f"\nTítulo de la tabla {item['id']} (vacío = maquetación): ").strip()
            decisiones.append({"item_id": item["id"], "decision":
                               {"kind": "table", "layout": not titulo, **({"caption": titulo} if titulo else {})}})
        else:
            decisiones.append({"item_id": item["id"], "decision": {"kind": "language", "action": "keep"}})
    if decisiones:
        r = requests.post(f"{BASE}/jobs/{job['id']}/decisions", headers=idem(),
                          json={"reviewer": REVISOR, "decisions": decisiones})
        r.raise_for_status()
    cursor = pagina["next_cursor"]
    job = requests.get(f"{BASE}/jobs/{job['id']}", headers=H).json()

# 5. Finalizar (consume el crédito) y esperar
requests.post(f"{BASE}/jobs/{job['id']}/finalize", headers=idem(), json={}).raise_for_status()
job = esperar(job["id"])
print("Conformidad:", job["conformance"])

# 6. Descargar el EPUB accesible y el informe
for kind, nombre in (("epub", "final.epub"), ("report_html", "informe.html")):
    r = requests.get(f"{BASE}/jobs/{job['id']}/files/{kind}", headers=H)
    open(nombre, "wb").write(r.content)
    print("Guardado:", nombre)

Referencia rápida

Errores

Los errores llegan en formato estándar (RFC 9457): un type que identifica el problema, un title legible y un detail con el caso concreto. En los errores de validación, errors señala cada campo.

{
  "type": "https://api.accespub.app/problems/invalid-decision",
  "title": "Alguna decisión no es válida",
  "status": 422,
  "detail": "No se ha guardado ninguna decisión.",
  "errors": [{"pointer": "/decisions/2/decision/caption", "detail": "falta el título de la tabla"}]
}
CódigoSignificadoQué hacer
400, 422Petición o fichero no válidoCorregir lo que indica errors o detail
401, 403Clave incorrecta o rol sin permisoRevisar la clave y el rol
402Sin créditos suficientesRecargar créditos (no se ha cobrado nada)
409El título o el lote no está en el estado adecuadoEsperar, revisar o finalizar antes
410Ficheros caducados (30 días)Volver a subir el EPUB
413EPUB demasiado grandeReducirlo o pedir una subida grande
429Demasiadas peticionesEsperar los segundos de Retry-After
502, 503IA o servidor no disponiblesReintentar tras Retry-After

Reintentos seguros (Idempotency-Key)

Las operaciones que crean o cobran (crear lote, subir, decidir, finalizar…) admiten la cabecera Idempotency-Key con un valor único (un UUID). Si la conexión falla y se repite la petición con el mismo valor en 24 horas, se recibe la respuesta original: no se duplica nada ni se cobra dos veces. Los ejemplos de esta guía ya la incluyen.

Paginación

Las listas devuelven hasta limit elementos (25 por defecto, 100 como máximo) y un next_cursor. Para la página siguiente, se repite la petición con ?cursor= y ese valor, hasta que next_cursor sea null.

Prueba y producción

Un solo servidor; el modo lo decide la clave. Con ap_test_ los datos están separados, no se consumen créditos y las propuestas de IA son textos fijos. Cuando la integración funcione, basta con cambiar a una clave ap_live_.

Límites por defecto

EPUB50 MB (hasta 200 MB con subida grande aprobada)
Lote50 EPUB y 1 GB
Espacio de la organización5 GB
Propuestas de IASegún contrato, por minuto (limits.ai_suggestions_per_minute)
Decisiones por petición200
Conservación de ficheros30 días tras finalizar

Los de vuestra organización, en GET /organization.

Créditos

  • 1 crédito por título al finalizarlo por primera vez, si tiene hasta 150 imágenes; uno más por cada bloque adicional de 150.
  • Se sabe antes de revisar (billing.credits_required en el título). Lo que no se finaliza no consume.
  • Volver a procesar o volver a finalizar un título ya cobrado no consume más.
  • En lotes, todo o nada: sin saldo para todos, no se finaliza ninguno.
  • El ONIX comercial y las propuestas masivas de IA se cobran según el contrato (billing en cada respuesta).

Estados de un título

queuedEn cola
processingAnalizando y corrigiendo
needs_reviewHay decisiones para una persona
readyNada pendiente: se puede finalizar
finalizingGenerando el EPUB final
completedSalidas disponibles
failedNo se ha podido procesar (ver error)

Avisos automáticos (webhooks)

AccesPub avisa a una dirección https vuestra con un POST firmado (Standard Webhooks, HMAC-SHA256): job.needs_review, job.ready, job.completed, job.failed, job.ai_prefill_done, batch.completed, large_upload_request.resolved, support_request.answered y credits.low. Responded 2xx en menos de 10 segundos; si no, se reintenta durante unas 25 horas. Un aviso puede llegar dos veces (mismo webhook-id). Ver la sección Avisos automáticos (webhooks).

Versiones

La versión va en la dirección (/v1). Dentro de v1 solo se añaden campos y operaciones; si algo se retirara, se avisaría con al menos 6 meses de antelación. Conviene ignorar los campos que no se conozcan.