# OCR Platform — Guía de integración completa (API v1)

> **Uso con IA:** Adjunta este documento a tu asistente de código e indica tu stack (Laravel, Node, etc.), variables de entorno y casos de uso. Sustituye `YOUR_API_KEY` por tu clave de test en local.
>
> **Instrucción para el asistente:** al generar `custom_instruction` o `response_schema`, sigue estrictamente la sección [5. Prompts y JSON Schema](#5-prompts-y-json-schema). Un schema que no cumpla esas reglas responde `422`.

## 1. Resumen del sistema

OCR Platform es un microservicio interno REST para:

1. **OCR estructurado** — subir PDF/imagen, procesar con Docling, obtener markdown/json/text.
2. **Extracción con IA** — sobre el OCR, el LLM devuelve JSON validado según un schema que defines.

- **API base:** `https://ocr.789.com.mx/v1`
- **Health (sin auth):** `https://ocr.789.com.mx/health/live`
- **App URL:** `https://ocr.789.com.mx`
- **Versión:** API v1 · guía 1.5.1 (2026-09-29). Si tu copia es más vieja, descárgala de nuevo en `https://ocr.789.com.mx/docs/integracion-ia`.
- **Procesamiento:** asíncrono (polling o webhooks)

## 2. Autenticación

Todas las rutas bajo `/v1` requieren API Key.

```http
X-API-Key: YOUR_API_KEY
Accept: application/json
```

Headers recomendados:

| Header | Cuándo | Descripción |
|---|---|---|
| `X-API-Key` | Siempre | `ocr_test_...`, `ocr_staging_...` o `ocr_live_...` |
| `Accept` | Siempre | `application/json` |
| `Idempotency-Key` | POST /documents y POST /extractions | UUID v4 único por creación (16–191 caracteres) |
| `X-Request-ID` | Opcional | Correlación con tus logs (`A-Z a-z 0-9 . _ -`, máx. 64; si no cumple, la API genera `req_...`) |
| `Content-Type` | POST JSON | `application/json` |

### Scopes

| Scope | Permite |
|---|---|
| `documents:create` | POST /documents, POST .../cancel |
| `documents:read` | GET /documents, GET /documents/{id} |
| `results:read` | GET /documents/{id}/result |
| `extractions:create` | POST /extractions |
| `extractions:read` | GET /extractions, GET /extractions/{id}, GET .../result |
| `usage:read` | GET /usage |

Mínimo OCR: `documents:create`, `documents:read`, `results:read`  
OCR + extracción: añadir `extractions:create`, `extractions:read`  
Consultar consumo: añadir `usage:read`

### Idempotencia

En `POST /documents` y `POST /extractions`:

- Misma `Idempotency-Key` + mismo payload → `200` (no `202`) con el mismo recurso y `meta.idempotent_replay: true`, sin duplicar.
- Misma key + payload distinto → `409 idempotency_conflict`.
- Sin key o con menos de 16 caracteres → `422 validation_error`.

### Límite de peticiones

120 peticiones por minuto por API Key. Al superarlo: `429 rate_limit_exceeded` con header `Retry-After`.

### Visibilidad

Solo ves los recursos creados con tu API Key (otra key del mismo proyecto recibe `404`).

## 3. Flujo: documento OCR

```
POST /documents → GET /documents/{id} (polling) → GET /documents/{id}/result
```

### POST /documents

`Content-Type: multipart/form-data`

| Campo | Requerido | Descripción |
|---|---|---|
| `file` | Sí | PDF, JPEG, PNG, HEIC/HEIF, TXT o XML (la extensión debe coincidir con el contenido). Máx. 50 MB y 100 páginas en los perfiles actuales |
| `profile` | Sí | Ver [perfiles México](#perfiles-disponibles-méxico) — ej. `cfdi_invoice`, `ine_document`, `general_document` |
| `output_formats[]` | Sí | Uno o más de `markdown`, `json`, `text` |
| `languages[]` | No | `es`, `en` |
| `external_reference` | No | Tu referencia interna (máx. 191) |
| `priority` | No | `normal` (defecto) o `high` |

Respuesta `202`:

```json
{
  "data": {
    "id": "doc_01J...",
    "status": "queued",
    "profile": "cfdi_invoice",
    "output_formats": ["markdown"],
    "external_reference": null,
    "created_at": "2026-06-24T18:00:00+00:00"
  },
  "meta": { "request_id": "req_..." }
}
```

### GET /documents — listado

Filtros: `status`, `profile`, `external_reference`, `created_from`, `created_to`, `page`, `per_page` (defecto 25, máx. 100). Paginación en `meta.pagination` (`page`, `per_page`, `total`, `last_page`).

### GET /documents/{document_id} — estados

| Estado | Significado |
|---|---|
| `queued` | Pendiente (también vuelve aquí para reintentar tras un corte de red con el OCR) |
| `submitting_to_docling` | Enviando a OCR |
| `processing` | OCR en curso |
| `completed` | Listo |
| `partial_success` | Parcial (ver `warnings`) |
| `failed` | Error — ver `failure.code` |
| `cancelled` | Cancelado |
| `expired` | Pasó la retención (30 días desde la creación); sin resultado |
| `purged` | Archivos eliminados (7 días tras expirar) |

Campos de la respuesta: `id, status, profile, original_filename, detected_mime_type, size_bytes, page_count, external_reference, created_at, requested_outputs, available_outputs, failure ({code, message} o null), warnings, started_at, completed_at, expires_at`.

`failure.code` de documentos: `document_queue_timeout` (15 min en cola), `docling_submit_timeout` (10 min enviando), `docling_processing_timeout`, `docling_polling_stalled`, `docling_submit_failed`, `docling_network_error`, `docling_http_error`, `docling_client_error`, `docling_processing_failed`, `docling_no_outputs`, `docling_invalid_response`. Un documento atascado pasa solo a `failed`.

### GET /documents/{document_id}/result

Solo con `completed` o `partial_success`. Query: `format=markdown|json|text` (**obligatorio**, uno de los `output_formats` pedidos), `download=true|false` (defecto `false`).

- Inline: `data = {document_id, format, content, generated_at}`; máximo 512 KB (si no, `413 result_not_available`: usa `download=true`).
- Descarga: `data = {document_id, format, download_url, expires_at}`; la URL vale 10 minutos.
- Documento en proceso → `409 result_not_ready`. Documento `failed`/`cancelled`/`expired`/`purged` o formato no pedido → `404 result_not_available`.

### POST /documents/{document_id}/cancel

Solo mientras el documento está en `queued`. Scope `documents:create`. Responde `{id, status: "cancelled", cancelled_at}`; si ya avanzó, `409 document_cannot_be_cancelled`.

## Perfiles disponibles (México)

| `code` (API) | Nombre | Cola | Uso |
|---|---|---|---|
| `ine_document` | Credencial INE / IFE | `ocr-high` | Identificación oficial. Nombre, CURP, Clave de Elector, OCR/CIC. |
| `csf_document` | Constancia de Situación Fiscal | `ocr-high` | CP, Régimen, RFC y Nombre (Facturación 4.0 SAT). |
| `cfdi_invoice` | Factura Electrónica (CFDI) | `ocr-default` | PDF factura: UUID, RFCs, montos, uso CFDI. |
| `curp_document` | Formato CURP | `ocr-default` | Identidad personas físicas (SEGOB). |
| `proof_of_address` | Comprobante de Domicilio | `ocr-default` | CFE, Telmex, agua — validación de dirección. |
| `bank_statement` | Estado de Cuenta Bancario | `ocr-default` | CLABE (18 dígitos) e ingresos. |
| `passport` | Pasaporte Mexicano | `ocr-high` | Segunda identificación oficial. |
| `birth_certificate` | Acta de Nacimiento | `ocr-default` | Trámites civiles, formato con QR. |
| `contract` | Contrato Legal | `ocr-default` | Inmobiliarios, créditos, laborales. |
| `general_document` | Documento General | `ocr-default` | Fallback para documentos no clasificados. |

## 4. Flujo: extracción LLM

```
POST /extractions → GET /extractions/{id} (polling) → GET /extractions/{id}/result
```

### Modo secuencial (debug)

1. Documento `completed`
2. POST extracción
3. Polling extracción
4. GET result

### Modo paralelo (producción)

1. POST documento
2. POST extracción inmediato (puede quedar `waiting_for_document`)
3. Polling extracción hasta estado terminal (si el OCR falla, la extracción termina `failed` con `extraction_input_unavailable`)

### POST /extractions

| Campo | Requerido | Descripción |
|---|---|---|
| `document_id` | Sí | Documento creado con la misma API Key. Se aceptan `completed`, `partial_success` y documentos aún en proceso (modo paralelo) |
| `custom_instruction` | Sí | 1–8000 caracteres. Ver sección 5 |
| `response_schema` | Sí | JSON Schema (subconjunto). Ver sección 5 |
| `input_result_type` | No | `markdown`, `text` o `json`; si se omite, el primero que el documento generó en ese orden (si el OCR sigue en curso, el primero pedido en `output_formats`) |

```json
{
  "document_id": "doc_01J...",
  "custom_instruction": "Factura mexicana. Fechas en formato YYYY-MM-DD. Montos como número decimal sin símbolo de moneda.",
  "response_schema": {
    "type": "object",
    "additionalProperties": false,
    "required": ["supplier_name", "invoice_number", "invoice_date", "total"],
    "properties": {
      "supplier_name": { "type": ["string", "null"], "description": "Nombre o razón social del emisor." },
      "invoice_number": { "type": ["string", "null"] },
      "invoice_date": { "type": ["string", "null"], "description": "Formato YYYY-MM-DD." },
      "total": { "type": ["number", "null"], "description": "Total a pagar con impuestos." }
    }
  },
  "input_result_type": "markdown"
}
```

Respuesta `202`:

```json
{
  "data": {
    "id": "ext_01J...",
    "document_id": "doc_01J...",
    "status": "queued",
    "input_result_type": "markdown",
    "created_at": "2026-06-24T18:01:00+00:00"
  },
  "meta": { "request_id": "req_..." }
}
```

`status` es `waiting_for_document` si el OCR sigue en curso.

### GET /extractions — listado

Filtros: `status`, `document_id`, `created_from`, `created_to`, `page`, `per_page` (defecto 25, máx. 100). Paginación en `meta.pagination`.

### GET /extractions/{extraction_id} — estados

| Estado | Significado |
|---|---|
| `waiting_for_document` | Esperando OCR |
| `queued` | En cola LLM |
| `processing` | LLM + validación |
| `completed` | JSON listo |
| `validation_failed` | JSON no cumple schema — ver `failure.validation_errors` |
| `failed` | Error — ver `failure.code` |
| `cancelled` | Reservado (no hay endpoint para cancelar extracciones) |

Estados terminales: `completed`, `validation_failed`, `failed`, `cancelled`.

Campos de la respuesta: `id, document_id, status, input_result_type, created_at, failure ({code, message, validation_errors?} o null), started_at, completed_at`.

Ejemplo `validation_failed`:

```json
{
  "data": {
    "id": "ext_01J...",
    "status": "validation_failed",
    "failure": {
      "code": "schema_validation_failed",
      "message": "The extraction response could not be validated against the requested schema.",
      "validation_errors": ["$.total: value does not match expected type."]
    }
  }
}
```

Códigos de `failure.code`: `schema_validation_failed` (estado `validation_failed`), `extraction_provider_failed`, `extraction_provider_unavailable` (proyecto sin proveedor LLM), `extraction_input_unavailable` (formato OCR ausente, contenido > 256 KB u OCR fallido en modo paralelo), `document_not_extractable`, `extraction_queue_timeout`, `extraction_processing_timeout`, `extraction_unexpected_error`.

### GET /extractions/{extraction_id}/result

Solo si `status` es `completed` (si no, `409 result_not_ready`). El JSON extraído está en **`data.result`**:

```json
{
  "data": {
    "extraction_id": "ext_01J...",
    "document_id": "doc_01J...",
    "result": { "supplier_name": "ACME SA DE CV", "invoice_number": "A-1024", "invoice_date": "2026-06-24", "total": 11600.0 },
    "completed_at": "2026-06-24T18:01:09+00:00"
  }
}
```

### GET /usage — consumo del proyecto

Scope `usage:read`. Devuelve tokens, costo estimado (LLM) y volumen OCR (documentos/páginas) del proyecto. Acepta `from`/`to` (`YYYY-MM-DD`, opcionales; por defecto el mes en curso).

```bash
curl "https://ocr.789.com.mx/v1/usage?from=2026-06-01&to=2026-06-30" \
  -H "X-API-Key: $OCR_API_KEY" -H "Accept: application/json"
```

Respuesta:

```json
{
  "data": {
    "period": { "from": "2026-06-01T00:00:00+00:00", "to": "2026-06-30T23:59:59+00:00" },
    "currency": "USD",
    "extraction": { "extractions": 42, "runs": 45, "input_tokens": 120000, "output_tokens": 38000, "total_tokens": 158000, "estimated_cost": 0.59 },
    "ocr": { "documents": 50, "pages": 320, "bytes": 18874368 },
    "project_id": "prj_xxxxxxxx",
    "estimated": true
  },
  "meta": { "request_id": "..." }
}
```

- El consumo es por proyecto (todas sus keys y ambientes), no por API Key.
- `runs` son intentos contra el modelo; tokens y costo suman todos los intentos, incluidos reintentos y extracciones fallidas.
- `estimated_cost` es estimado: usa el precio acordado del proyecto o, si no hay, el del modelo en la plataforma; se fija al procesar cada intento. Sin precio o sin tokens cuenta como 0.
- `to` por defecto es el momento de la consulta; `to` anterior a `from` → `422`. Envía siempre `from` junto con `to`.

## 5. Prompts y JSON Schema

### Lo que la plataforma ya envía al modelo

Un prompt de sistema fijo: devolver solo JSON válido que cumpla el schema, sin markdown ni explicaciones, no inventar datos, usar `null` si un dato falta. Después: `custom_instruction`, `response_schema` y el contenido OCR. Si la respuesta no cumple, se reintenta automáticamente (por defecto 3 intentos). **No repitas esas reglas en `custom_instruction`.**

### Reglas obligatorias de `response_schema`

1. Raíz `"type": "object"`.
2. Cada nodo (propiedad e `items`) declara `type`.
3. Keywords soportadas únicamente: `type`, `properties`, `required`, `items`, `enum`, `additionalProperties`, `description` (texto).
4. Tipos: `string`, `number`, `integer`, `boolean`, `object`, `array`, `null`. Anulable: `["string", "null"]`.
5. **Prohibido** (responde `422`): formato abreviado `"total": "number|null"`, `format`, `pattern`, `minimum`, `maximum`, `minLength`, `maxLength`, `oneOf`, `anyOf`, `allOf`, `$ref`, `default`, `examples`, `const`.
6. `required` exige que la clave exista; el valor puede ser `null` si el tipo lo permite.
7. `enum` con campo anulable debe incluir `null`: `"enum": ["H", "M", null]`.
8. `integer` rechaza decimales; usa `number` para montos. `[]` es válido para `array`.

Patrón recomendado: todos los campos en `required`, tipos anulables, `"additionalProperties": false`, reglas de formato en `description`.

### Reglas para `custom_instruction`

- Tipo de documento ("Factura CFDI 4.0", "Credencial INE").
- Roles ("emisor es quien factura; receptor es el cliente").
- Formatos globales (fechas `YYYY-MM-DD`, montos como número decimal sin símbolo, RFC/CURP en mayúsculas).
- Desambiguación ("si hay varios totales, usa el total a pagar").
- Breve: lo específico de cada campo va en `description`.

### Límites

| Límite | Valor por defecto |
|---|---|
| `custom_instruction` | 1–8000 caracteres |
| `response_schema` | 64 KB, profundidad 8, 100 propiedades, 50 nodos `items` en todo el schema |
| Contenido OCR enviado | 256 KB |

### Errores de schema al crear (`422`, en `error.details.response_schema`)

| Mensaje | Solución |
|---|---|
| `Root schema type must be object.` | Envolver en `{ "type": "object", "properties": {...} }` |
| `Schema node must define type.` | Agregar `type` a cada nodo |
| `Schema contains unsupported keywords or types.` | Quitar keyword no soportada; mover la regla a `description` |
| `Property names must be non-empty strings.` | Dar nombre a cada campo |
| `Property {nombre} must be an object.` | `"campo": { "type": ... }` en vez de `"campo": "tipo"` |
| `Schema exceeds maximum allowed ...` | Simplificar o dividir |

### `failure.validation_errors` tras `validation_failed`

| Error | Solución |
|---|---|
| `$.campo: is required.` | Hacerlo anulable y explicar en `description` dónde está |
| `$.campo: value does not match expected type.` | Indicar formato (ej. número sin separador de miles) o aceptar otro tipo |
| `$.campo: value is not in enum.` | Ampliar `enum`, incluir `null` o explicar el mapeo |
| `$.campo: additional property not allowed.` | Renombrar campos confusos o permitir propiedades extra |
| `Invalid JSON response from provider.` | Otro `input_result_type` o menos campos |

Tras ajustar: nueva extracción con nueva `Idempotency-Key`. Plantillas por tipo de documento (CFDI, INE, CSF, comprobante de domicilio): `https://ocr.789.com.mx/docs/guia-prompts-schemas`.

## 6. Referencia de endpoints

| Método | Ruta | Scope |
|---|---|---|
| GET | `https://ocr.789.com.mx/health/live` | — |
| POST | `/documents` | documents:create |
| GET | `/documents` | documents:read |
| GET | `/documents/{id}` | documents:read |
| GET | `/documents/{id}/result` | results:read |
| POST | `/documents/{id}/cancel` | documents:create |
| POST | `/extractions` | extractions:create |
| GET | `/extractions` | extractions:read |
| GET | `/extractions/{id}` | extractions:read |
| GET | `/extractions/{id}/result` | extractions:read |
| GET | `/usage` | usage:read |

Convenciones:

- IDs públicos: `doc_...`, `ext_...`
- Fechas: ISO 8601 UTC
- Respuesta: `{ "data": ..., "meta": { "request_id": ... } }`; listados con `meta.pagination`

## 7. Errores HTTP

| HTTP | Código | Acción |
|---|---|---|
| 401 | `invalid_api_key` | Revisar X-API-Key |
| 403 | `api_key_not_active` | Key suspendida/revocada/expirada o proyecto/ambiente inactivo: solicitar key nueva |
| 403 | `insufficient_scope` | Ampliar scopes |
| 404 | `document_not_found` / `extraction_not_found` | Verificar ID y key |
| 404 | `result_not_available` | Documento sin resultado (`failed`, `cancelled`, `expired`, `purged`) o `format` no pedido |
| 404 | `extraction_result_not_available` | Reportar `request_id` |
| 409 | `document_not_extractable` | Documento `failed`, `cancelled`, `expired` o `purged`, o terminado sin el `input_result_type` pedido |
| 409 | `document_cannot_be_cancelled` | El documento ya salió de `queued` |
| 409 | `idempotency_conflict` | Nueva key o mismo payload |
| 409 | `result_not_ready` | Seguir polling |
| 413 | `file_too_large` | Reducir el archivo |
| 413 | `result_not_available` | Resultado > 512 KB inline: usar `download=true` |
| 415 | `unsupported_media_type` | Tipo no permitido o extensión que no coincide |
| 422 | `validation_error` | Revisar `error.details` (campos, `Idempotency-Key`, `response_schema`, páginas) |
| 429 | `rate_limit_exceeded` | Esperar `Retry-After` |

Formato error:

```json
{
  "error": {
    "code": "validation_error",
    "message": "...",
    "details": { "file": ["The file field is required."] }
  },
  "meta": { "request_id": "req_..." }
}
```

## 8. Buenas prácticas

### Polling

- Documento: intervalo 2–10 s, backoff exponencial, timeout 35 min.
- Extracción: intervalo 2–5 s, timeout 20 min (más el OCR en modo paralelo).
- La plataforma pasa a `failed` lo que se atasca; el timeout propio es solo red de seguridad.
- No polling tight loop.

### Seguridad

- API Key solo en backend / secret manager.
- Test vs live keys por ambiente.

### Iterar prompts

- Sube el documento una vez y reutiliza su `document_id` en varias extracciones.
- Lee el OCR (`GET /documents/{id}/result?format=markdown`) para confirmar que el dato existe antes de ajustar el prompt.

## 9. Ejemplos cURL

### Health

```bash
curl -s "https://ocr.789.com.mx/health/live"
```

### Subir documento

```bash
curl -X POST "https://ocr.789.com.mx/v1/documents" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "file=@factura.pdf" \
  -F "profile=cfdi_invoice" \
  -F "output_formats[]=markdown"
```

### Consultar documento

```bash
curl -s "https://ocr.789.com.mx/v1/documents/doc_01JXXXXXXXX" \
  -H "X-API-Key: YOUR_API_KEY"
```

### Resultado OCR

```bash
curl -s "https://ocr.789.com.mx/v1/documents/doc_01JXXXXXXXX/result?format=markdown" \
  -H "X-API-Key: YOUR_API_KEY"
```

### Crear extracción

```bash
curl -X POST "https://ocr.789.com.mx/v1/extractions" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "document_id": "doc_01JXXXXXXXX",
    "custom_instruction": "Factura mexicana. Montos como número decimal sin símbolo de moneda.",
    "response_schema": {
      "type": "object",
      "additionalProperties": false,
      "required": ["supplier_name", "invoice_number", "total"],
      "properties": {
        "supplier_name": { "type": ["string", "null"] },
        "invoice_number": { "type": ["string", "null"] },
        "total": { "type": ["number", "null"], "description": "Total a pagar con impuestos." }
      }
    },
    "input_result_type": "markdown"
  }'
```

## 10. Ejemplos Guzzle (PHP)

```php
use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://ocr.789.com.mx/v1/',
    'headers' => [
        'Accept' => 'application/json',
        'X-API-Key' => getenv('OCR_PLATFORM_API_KEY'),
    ],
    'http_errors' => false,
]);

// Upload
$response = $client->post('documents', [
    'headers' => ['Idempotency-Key' => (string) \Illuminate\Support\Str::uuid()],
    'multipart' => [
        ['name' => 'file', 'contents' => fopen('factura.pdf', 'r'), 'filename' => 'factura.pdf'],
        ['name' => 'profile', 'contents' => 'cfdi_invoice'],
        ['name' => 'output_formats[]', 'contents' => 'markdown'],
    ],
]);

$documentId = json_decode((string) $response->getBody(), true)['data']['id'];

// Poll document
do {
    sleep(3);
    $status = json_decode((string) $client->get("documents/{$documentId}")->getBody(), true);
} while (! in_array($status['data']['status'] ?? '', ['completed', 'partial_success', 'failed', 'cancelled', 'expired', 'purged'], true));

// Extract
$response = $client->post('extractions', [
    'headers' => [
        'Content-Type' => 'application/json',
        'Idempotency-Key' => (string) \Illuminate\Support\Str::uuid(),
    ],
    'json' => [
        'document_id' => $documentId,
        'custom_instruction' => 'Factura mexicana. Montos como número decimal sin símbolo de moneda.',
        'response_schema' => [
            'type' => 'object',
            'additionalProperties' => false,
            'required' => ['supplier_name', 'invoice_number', 'total'],
            'properties' => [
                'supplier_name' => ['type' => ['string', 'null']],
                'invoice_number' => ['type' => ['string', 'null']],
                'total' => ['type' => ['number', 'null'], 'description' => 'Total a pagar con impuestos.'],
            ],
        ],
        'input_result_type' => 'markdown',
    ],
]);

$extractionId = json_decode((string) $response->getBody(), true)['data']['id'];

// Poll extraction
do {
    sleep(3);
    $extraction = json_decode((string) $client->get("extractions/{$extractionId}")->getBody(), true)['data'];
} while (! in_array($extraction['status'] ?? '', ['completed', 'validation_failed', 'failed', 'cancelled'], true));

if ($extraction['status'] === 'completed') {
    $result = json_decode((string) $client->get("extractions/{$extractionId}/result")->getBody(), true)['data']['result'];
} else {
    // $extraction['failure']['code'], $extraction['failure']['validation_errors'] ?? []
}
```

## 11. Checklist implementación (para IA)

Al generar código de integración, verificar:

- [ ] Variable de entorno para API Key (nunca hardcode en repo)
- [ ] Headers X-API-Key, Accept, Idempotency-Key en POST
- [ ] multipart/form-data en upload
- [ ] Polling con backoff y timeout
- [ ] Manejo de error JSON + request_id en logs
- [ ] Scopes suficientes para el flujo
- [ ] Modo paralelo o secuencial según requisito
- [ ] Health check en monitorización
- [ ] `response_schema` cumple la sección 5 (raíz object, `type` en cada nodo, solo keywords soportadas)
- [ ] Resultado leído de `data.result`; `validation_failed` registra `failure.validation_errors`
- [ ] Si usas webhooks: verificar `X-Signature` (HMAC-SHA256 del body crudo) y responder 2xx < 10s

## 12. Webhooks

Alternativa al polling: configura una URL por ambiente en el panel de administración y recibe un `POST` firmado cuando un documento o extracción llega a estado final.

- **Eventos:** `document.completed|partial_success|failed`, `extraction.completed|failed|validation_failed`.
- **Headers:** `Content-Type: application/json`, `X-Webhook-Event: <evento>`, `X-Signature: sha256=<hmac>`.
- **Payload:** `{event, document_id, extraction_id?, status, timestamp, delivery_id}` — no incluye el resultado; consúltalo con `GET /extractions/{id}/result` o `GET /documents/{id}/result?format=...` al recibir el evento.
- **Firma:** HMAC-SHA256 del body crudo con el secreto mostrado (una sola vez) al configurar la URL:

```php
$esperada = 'sha256='.hash_hmac('sha256', $request->getContent(), $secret);
if (! hash_equals($esperada, (string) $request->header('X-Signature'))) {
    abort(401);
}
```

- **Entrega:** responde 2xx en < 10s; si no, hasta 5 intentos en total (envío + 4 reintentos: 30s, 2min, 10min, 1h). Deduplica por `delivery_id`. El orden no está garantizado: usa `timestamp`.
- **URL:** `https` obligatorio en producción; sin localhost/IPs privadas (IPv4 o IPv6); sin redirects.
- **Prueba:** `https://ocr.789.com.mx/webhook-test` registra las entregas en el log interno de la plataforma (no accesible para ti); pide al equipo de plataforma que confirme `signature_valid`.

## 13. MCP para asistentes de IA

Servidor MCP público en `https://ocr.789.com.mx/mcp` (HTTP, sin API Key, 60 peticiones/min por IP). No sube documentos ni ejecuta extracciones.

- Tool `validate-extraction-request`: valida `response_schema` (y opcionalmente `custom_instruction`) con las mismas reglas y mensajes que la API.
- Tool `get-schema-template`: plantilla válida por perfil (`cfdi_invoice`, `ine_document`, `csf_document`, `proof_of_address`).
- Prompt `design-extraction` y resource `readerdocs://docs/integracion-completa` (esta guía).

Claude Code: `claude mcp add --transport http readerdocs https://ocr.789.com.mx/mcp`. Configuración de Cursor, VS Code, Windsurf y Claude Desktop, y solución de problemas: `https://ocr.789.com.mx/docs/mcp`.

## 14. Fuera de alcance MVP

No implementar ni esperar en v1:

- Gestión de API keys vía API pública
- Multiarchivo por extracción
- Cancelación de extracciones

---

Documento generado para integradores internos 789.mx · OCR Platform API v1
