# Firmas de presupuesto

Lista, consulta y verifica las firmas electrónicas de un presupuesto con /external/v1/budgets/:budgetId/signatures y los permisos crm:budgets:signatures:*.

Con `/external/v1/budgets/:budgetId/signatures` tu aplicación consulta
las firmas electrónicas de un presupuesto. Solo es lectura: listado,
detalle y verificación de la integridad de firmas ya hechas. Crear o
revocar los enlaces de firma queda dentro de UTILIA OS, porque son
acciones con efecto legal directo.

**De la firma a su verificación**

Participantes: Firmante, UTILIA OS y Tu aplicación.

1. UTILIA OS → Firmante: Correo con su enlace de firma. Si tiene acceso al portal del cliente, también puede firmar desde allí.
2. Firmante → UTILIA OS: Aceptar y firmar. Con su nombre, su correo y su firma.
3. UTILIA OS (por su cuenta): Guarda la firma con la huella del documento (documentHash y hashVersion), la IP, el navegador y la fecha.
4. Tu aplicación → UTILIA OS: `GET /api/external/v1/budgets/:budgetId/signatures`. Con X-Api-Key y el permiso crm:budgets:signatures:read.
5. UTILIA OS → Tu aplicación (respuesta): Firmas del presupuesto (items). Cada una con su huella y la IP parcialmente oculta.
6. Tu aplicación → UTILIA OS: `GET /api/external/v1/budgets/:budgetId/signatures/:sigId/verify`. Con el permiso crm:budgets:signatures:verify.
7. UTILIA OS (por su cuenta): Recalcula la huella con la versión de la firma: con los datos actuales del presupuesto en las versiones 1 y 2, y con el PDF que firmó la persona en la 3. Después la compara con la guardada.
8. UTILIA OS → Tu aplicación (respuesta): status, valid, expectedHash y actualHash. status es VALID, MODIFIED o UNVERIFIABLE.

_Tu aplicación solo lee: la firma nace dentro de UTILIA OS, desde el enlace de firma o desde el portal del cliente._

## Autenticación y plan

Todas las peticiones llevan la clave de API de la aplicación:

```
X-Api-Key: <api_key_de_la_app>
```

El plan de tu organización tiene que incluir el aplicativo **CRM**; si
no, la respuesta es `403 MODULE_NOT_ENABLED`.

## Permisos de la API (scopes)

| Endpoint | Permiso obligatorio |
| - | - |
| `GET /external/v1/budgets/:budgetId/signatures` | `crm:budgets:signatures:read` |
| `GET /external/v1/budgets/:budgetId/signatures/:sigId` | `crm:budgets:signatures:read` |
| `GET /external/v1/budgets/:budgetId/signatures/:sigId/verify` | `crm:budgets:signatures:verify` |

Sin el permiso adecuado, los endpoints responden `403 INSUFFICIENT_SCOPE`
con el permiso que falta en
`requiredScopes`; lo concede un administrador de la organización. El permiso se exige desde la primera llamada, sin periodo
de gracia. El catálogo completo y las reglas de concesión están en
[Permisos de la API](https://os.utilia.ai/docs/developers/api/scopes).

> **Atención: crm:budgets:signatures:verify solo lo concede un Super Administrador**
>
> La verificación recalcula la huella del documento firmado y la compara
> con la guardada. En malas manos, permitiría comprobar si una
> manipulación del documento pasa desapercibida. Por eso es un permiso
> crítico: solo lo concede un Super Administrador, con un motivo de al
> menos 20 caracteres.

## Endpoints

**`GET /api/external/v1/budgets/:budgetId/signatures`**

Lista las firmas del presupuesto, con la IP parcialmente oculta. Permiso: crm:budgets:signatures:read.

Parámetro de ruta: `budgetId`.

**`GET /api/external/v1/budgets/:budgetId/signatures/:sigId`**

Detalle completo de una firma, con la IP parcialmente oculta. Permiso: crm:budgets:signatures:read.

Parámetros de ruta: `budgetId`, `sigId`.

**`GET /api/external/v1/budgets/:budgetId/signatures/:sigId/verify`**

Recalcula la huella con la versión de la firma y la compara con la guardada. Devuelve status VALID, MODIFIED o UNVERIFIABLE. Permiso: crm:budgets:signatures:verify.

Parámetros de ruta: `budgetId`, `sigId`.

## Parámetros principales

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `X-Api-Key` | `header` | Sí | Clave de API de la aplicación externa. |
| `budgetId` | `path` | Sí | Identificador del presupuesto. |
| `sigId` | `path` | Sí | Identificador de la firma. |

## Respuesta de la verificación

La huella se recalcula siempre con la versión con la que se firmó
(`hashVersion`), que la respuesta devuelve tal cual:

| `hashVersion` | Sobre qué se calcula la huella |
| - | - |
| `1` | Los datos básicos del presupuesto: código, total y partidas. Solo en firmas antiguas. |
| `2` | Todos los datos visibles del presupuesto: partidas, apartados, descuentos, impuestos, condiciones y notas. La usan las firmas desde el portal del cliente y las firmas por enlace que se hicieron sin el PDF preparado. |
| `3` | El PDF exacto que firmó la persona desde el enlace de firma, guardado sin posibilidad de cambios. Cambiar después el presupuesto no la invalida, porque lo firmado es ese PDF. |

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `status` | `string` | Sí | VALID: las dos huellas coinciden. MODIFIED: no coinciden, lo firmado ya no es lo que hay. UNVERIFIABLE: no se puede recalcular porque el PDF firmado (versión 3) ya no está disponible o no se ha podido leer; no indica ningún cambio. |
| `valid` | `boolean` | Sí | true solo con VALID. Es false tanto con MODIFIED como con UNVERIFIABLE: decide siempre por status. |
| `expectedHash` | `string` | Sí | Huella guardada al firmar. |
| `actualHash` | `string \| null` | Sí | Huella recalculada ahora. null con UNVERIFIABLE. |
| `hashVersion` | `number` | Sí | Versión con la que se firmó: 1, 2 o 3. |
| `reason` | `object \| null` | Sí | Con UNVERIFIABLE, el motivo: errorCode y message (en castellano, listo para mostrar). null en el resto de casos. |
| `signedAt, signerName, signerEmail` | `string` | Sí | Fecha de la firma y nombre y correo del firmante. |
| `ipAddressMasked, userAgent` | `string` | Sí | IP parcialmente oculta y navegador desde el que se firmó. |
| `signingTokenId` | `string \| null` | Sí | Enlace de firma con el que se firmó. null si se firmó desde el portal o si el enlace ya no existe. |

Cuándo una firma no se puede verificar:

| `reason.errorCode` | Qué significa | Qué hacer |
| - | - | - |
| `BUDGET_SIGNED_PDF_UNAVAILABLE` | El PDF firmado ya no existe: se borró el enlace de firma, su archivo o el propio documento del almacén. | La firma sigue registrada con su huella, pero esta llamada ya no puede comprobarla. |
| `BUDGET_SIGNED_PDF_UNREADABLE` | El PDF existe, pero no se ha podido leer en este momento. | Vuelve a intentarlo pasados unos minutos. |

### Respuesta (200), firma que coincide

```json
{
  "valid": true,
  "status": "VALID",
  "expectedHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "actualHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "hashVersion": 3,
  "reason": null,
  "signedAt": "2026-09-10T10:00:00.000Z",
  "signerName": "Marta Ruiz",
  "signerEmail": "marta@estudiomarea.example",
  "ipAddressMasked": "***.***.12.34",
  "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
  "signingTokenId": "cktok1234567890"
}
```

### Respuesta (200), firma que no se puede verificar

```json
{
  "valid": false,
  "status": "UNVERIFIABLE",
  "expectedHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "actualHash": null,
  "hashVersion": 3,
  "reason": {
    "errorCode": "BUDGET_SIGNED_PDF_UNAVAILABLE",
    "message": "No se puede verificar: el PDF firmado ya no está disponible."
  },
  "signedAt": "2026-09-10T10:00:00.000Z",
  "signerName": "Marta Ruiz",
  "signerEmail": "marta@estudiomarea.example",
  "ipAddressMasked": "***.***.12.34",
  "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
  "signingTokenId": "cktok1234567890"
}
```

## Ejemplos

### curl

```bash
# Listar firmas
curl -X GET 'https://os.utilia.ai/api/external/v1/budgets/budget-uuid/signatures' \
  -H 'X-Api-Key: utl_xxx'

# Detalle de una firma
curl -X GET 'https://os.utilia.ai/api/external/v1/budgets/budget-uuid/signatures/sig-uuid' \
  -H 'X-Api-Key: utl_xxx'

# Verificar integridad
curl -X GET 'https://os.utilia.ai/api/external/v1/budgets/budget-uuid/signatures/sig-uuid/verify' \
  -H 'X-Api-Key: utl_xxx'
```

### Python

```python
import requests

resp = requests.get(
    'https://os.utilia.ai/api/external/v1/budgets/budget-uuid/signatures/sig-uuid/verify',
    headers={'X-Api-Key': 'utl_xxx'},
    timeout=30,
)
data = resp.json()
if data['status'] == 'MODIFIED':
    print('El documento ha cambiado después de la firma')
elif data['status'] == 'UNVERIFIABLE':
    print(data['reason']['errorCode'], data['reason']['message'])
```

## Errores

- `401`: falta la clave de API o no es válida.
- `403 MODULE_NOT_ENABLED`: el plan de tu organización no incluye el CRM.
- `403 INSUFFICIENT_SCOPE`: la aplicación no tiene concedido el permiso necesario.
- `404`: el presupuesto no existe o es de otra organización (mensaje `Presupuesto <budgetId> no encontrado`, con el identificador que enviaste).
- `404`: la firma no existe o no es de ese presupuesto (mensaje «Firma no encontrada en este presupuesto»).

Los `404` llegan como respuesta estándar (`statusCode`, `message`,
`error`), sin `errorCode`: distíngelos por el código HTTP y, si hace
falta, por el texto del mensaje.

Que el PDF firmado no se pueda leer no es un error de la petición: la
verificación responde `200` con `status: "UNVERIFIABLE"` y el motivo en
`reason.errorCode` (`BUDGET_SIGNED_PDF_UNAVAILABLE` o
`BUDGET_SIGNED_PDF_UNREADABLE`).

## Seguimiento de peticiones

Envía `X-Request-Id` en cada petición para poder cruzarla con los
registros de UTILIA OS. Si no lo envías, la respuesta trae el que se
haya generado.

## Siguiente paso

Si la verificación devuelve `status: "MODIFIED"`, lo firmado ya no
coincide con lo que hay: con `hashVersion` 1 o 2, los datos del
presupuesto han cambiado después de la firma; con `3`, el PDF guardado ya
no es el que se firmó. En los dos casos, avisa al administrador de la
organización. Con `UNVERIFIABLE` no hay indicio de cambio: guarda el
motivo y, si es `BUDGET_SIGNED_PDF_UNREADABLE`, vuelve a intentarlo más
tarde.
Cómo funciona la firma de presupuestos dentro de UTILIA OS lo explica [Solicitar la firma a personas concretas](https://os.utilia.ai/docs/guides/budgets/request-signature).
