# API de Chasis

La API de Chasis entrega informes de vehículos de Argentina a partir de la patente: multas de jurisdicciones de todo el país, deuda de patentes, VTV, GNC, recalls y el informe de dominio oficial de DNRPA (titulares, gravámenes, chasis y motor), con su PDF original. También responde al instante con los datos y la radicación de una patente según DNRPA (consulta básica).

Es una API REST sobre HTTPS, con JSON, autenticación por API key, webhooks firmados e idempotencia en las operaciones que consumen créditos.

| | |
|---|---|
| URL base | `https://www.chasis.com.ar/api/v1` |
| Autenticación | `Authorization: Bearer <API key>` |
| Formato | JSON UTF-8. Fechas ISO 8601 en UTC |
| Panel de la cuenta | `https://www.chasis.com.ar/cuenta` (API keys, webhooks, avisos) |
| Cliente TypeScript | `https://www.chasis.com.ar/sdk/chasis.ts` |
| Esta guía en Markdown | `https://www.chasis.com.ar/docs/api.md` |
| Soporte | get@chasis.com.ar (incluí el `X-Request-Id` de la respuesta) |

> **Integración asistida por IA.** Esta guía está escrita para que un desarrollador o un agente de IA pueda dejar una integración completa funcionando sin más contexto. Para un agente alcanza con la URL de la versión en Markdown y una API key; la sección [Integración paso a paso](#integracion-paso-a-paso) es el plan a seguir.

---

## Inicio rápido

**1. Creá una API key** en `https://www.chasis.com.ar/cuenta/ajustes` y guardala en el servidor como `CHASIS_API_KEY`.

**2. Consultá tus créditos:**

```bash
curl https://www.chasis.com.ar/api/v1/usage \
  -H "Authorization: Bearer $CHASIS_API_KEY"
```

**3. Pedí un informe:**

```bash
curl -X POST https://www.chasis.com.ar/api/v1/informes \
  -H "Authorization: Bearer $CHASIS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"patente":"AB123CD","tipo":"dominio"}'
```

**4. Consultá el resultado** con el `id` devuelto (o esperá el webhook):

```bash
curl https://www.chasis.com.ar/api/v1/informes/{id} \
  -H "Authorization: Bearer $CHASIS_API_KEY"
```

---

## Conceptos

### Tipos de informe y fases

Un informe se pide por patente argentina (formato `ABC123` o `AB123CD`) y tiene una o dos **fases**:

| `tipo` | Fases | Contenido | Tiempo típico | Créditos |
|---|---|---|---|---|
| `dominio` | `multas` + `dominio` | Informe de dominio oficial de DNRPA y su PDF original, más todo lo de `chasis` | Multas: 15 minutos a 1 hora. Dominio: casi siempre menos de 24 h hábiles | 1 dominio (las multas van incluidas) |
| `chasis` | `multas` | Datos del vehículo, multas de todo el país, deuda de patentes, VTV, GNC, recalls | 15 minutos a 1 hora | 1 chasis |

`completo` (el valor por defecto) es el nombre anterior de `dominio` y funciona exactamente igual. Se sigue aceptando y el informe se devuelve con el `tipo` con que se pidió.

DNRPA emite los informes de dominio en días hábiles, de 8 a 17 h. Si no lo envía dentro de las 96 horas, lo verificamos con DNRPA: el pedido sigue en curso hasta que llegue el informe o hasta que confirmemos que no va a llegar. En ese caso la fase se marca como fallida, se devuelve el crédito y se avisa por webhook y email.

### Consultas

Una **consulta** (`POST /consultas`) devuelve en el momento los datos de la patente según DNRPA: marca, modelo, año, tipo de vehículo, si tiene placa Mercosur y el registro seccional donde está radicado (nombre, dirección, localidad y provincia). No es un informe: no tiene fases, webhooks ni PDF, y no incluye titulares, gravámenes ni multas.

Cada consulta descuenta un crédito `consulta`. La respuesta queda guardada 15 días: si volvés a consultar la misma patente en ese plazo, se devuelve lo guardado y no se descuenta nada (`reutilizada: true`). Pasados los 15 días se consulta de nuevo, porque los datos pueden cambiar (por ejemplo, por una transferencia).

### Estados

Cada fase tiene su propio estado: `processing` → `completed` o `failed`. El estado general del informe se deriva de sus fases:

| `status` | Significado |
|---|---|
| `pending`, `processing` | Al menos una fase sigue en curso |
| `completed` | Terminaron todas las fases y al menos una salió bien |
| `failed` | Fallaron todas las fases |

Un informe `dominio` puede estar en `processing` con la fase de multas ya terminada: sus datos están disponibles mientras se espera el dominio.

### Créditos

Cada cuenta tiene créditos `chasis`, `dominio` y `consulta`, cada uno con su propio contador, en una de dos modalidades (`billing`):

- **`mensual`**: un cupo por mes según el plan, que se renueva el día 1 de cada mes (hora de Argentina).
- **`creditos`**: un saldo prepago que no vence; cada informe lo descuenta.

En las dos, cuando una fase falla su crédito se devuelve automáticamente. Una consulta que no se puede completar, o de una patente que no figura registrada, no descuenta. Sin créditos suficientes, la API responde `402 quota_exceeded`.

### Pedidos repetidos

No se puede pedir una patente mientras haya otro pedido de la misma patente con una fase en curso que se superponga (por ejemplo, un `dominio` mientras otro `dominio` de esa patente espera a DNRPA). La API responde `409 duplicate_in_progress` con el `id` del pedido existente en `details.informeId`. Cuando ese pedido termina se puede volver a pedir. Un pedido `chasis` sólo se bloquea mientras las multas de un `dominio` de esa patente siguen en curso; esperando a DNRPA ya no.

### Avisos

Cuando termina una fase, Chasis envía un **webhook** a la URL configurada para esa fase y un **email** a los destinatarios de aviso de la cuenta.

La fase de multas se entrega a los 15 minutos si ya respondieron las fuentes principales, y si no, a la hora con lo obtenido hasta ese momento. Algunas fuentes pueden actualizar sus datos más tarde: mientras eso es posible, `fases.multas.seguimiento` es `true` y el resumen se va actualizando.

- **Webhook:** `multas.completed` al entregar la fase; después, `multas.updated` cada vez que cambia algo del resumen (multas, deuda, VTV, GNC, recalls) y una última vez cuando termina el seguimiento. Con los webhooks tenés siempre el informe al día sin consultar la API.
- **Email:** uno al entregar la fase. Después, uno de actualización únicamente si las multas cambian al terminar el seguimiento.
- **Flotas:** los informes de la flota avisan por webhook como cualquier otro; por email llega uno solo por actualización de la flota, con todos los autos (ver [Flotas](#flotas)).

### Flotas

Una **flota** es el listado de autos de tu empresa que Chasis actualiza solo, cada cierto tiempo. Agregás las patentes, elegís la periodicidad y, el día que toca, a las 7:00 (hora de Argentina), se pide un informe `chasis` de cada auto: multas, deuda de patentes, VTV, GNC y recalls, sin informe de dominio.

- Cada informe de la flota es un informe común: descuenta **1 crédito `chasis`**, aparece en `GET /informes`, tiene sus PDFs y avisa por webhook (`multas.completed`, `multas.updated`) como cualquier otro. Se distingue por el campo `flota`.
- Por email no llega un aviso por auto: llega **uno solo por actualización**, cuando terminan todos los autos, con las multas de cada uno y las novedades respecto de la actualización anterior.
- Si en una actualización se acaban los créditos, los autos que faltan quedan sin actualizar (figuran en `omitidos` con motivo `sin_cupo`) y se actualizan en la próxima fecha.
- Si ya hay un pedido en curso de esa patente (por ejemplo, uno que hiciste por la API), no se pide otro: la actualización usa ese.
- Quitar un auto de la flota sólo deja de actualizarlo. Sus informes siguen siendo tuyos y el [historial de la patente](#historial-de-una-patente-get-vehiculos-patente) los muestra todos, igual que los de cualquier patente que hayas consultado.

| `periodicidad` | `dia` | Se actualiza |
|---|---|---|
| `semanal` | 1 a 7 (1 = lunes) | Ese día de cada semana |
| `quincenal` | 1 a 13 | Ese día y 15 días después, todos los meses (por ejemplo `5`: los días 5 y 20) |
| `mensual` | 1 a 28 | Ese día de cada mes |
| `bimestral` | 1 a 28 | Ese día, cada 2 meses desde que se configuró |
| `trimestral` | 1 a 28 | Ese día, cada 3 meses desde que se configuró |

La flota de una cuenta nueva arranca activa, `mensual`, con el día en que se usó por primera vez. Admite hasta 2.000 autos.

---

## Autenticación

Todas las solicitudes llevan la API key en el header `Authorization`:

```
Authorization: Bearer chs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

La key identifica la cuenta. Opcionalmente podés enviar `Chasis-Account: <account_id>`: si no coincide con la cuenta de la key, la solicitud se rechaza con `401`. Sirve como resguardo cuando un mismo sistema maneja varias cuentas.

### Permisos

| Permiso | Alcance | Uso recomendado |
|---|---|---|
| Completo | Todas las operaciones | Backend que pide informes |
| Sólo lectura | Sólo `GET`: créditos, cuenta, informes, PDFs, flota e historiales | Dashboards, reportes, herramientas internas |

Con una key de sólo lectura, cualquier otra operación responde `403 forbidden`.

### Administración de keys

- Las keys se crean y revocan en `https://www.chasis.com.ar/cuenta/ajustes`. Se muestran una sola vez.
- Podés tener hasta 5 activas a la vez, para rotarlas sin cortar el servicio o separar sistemas.
- Las API keys no pueden crear ni revocar otras keys, ni cambiar los webhooks o su secret: eso se hace sólo desde el panel, con el login de la cuenta. Así, una key filtrada no permite redirigir los informes a otro destino.
- Cada vez que se crea o revoca una key, cambian los webhooks o se rota el secret de firma, se envía un email al titular de la cuenta.

---

## Convenciones

### Errores

Los errores responden con un código HTTP y este cuerpo:

```json
{
  "error": {
    "code": "quota_exceeded",
    "message": "Cupo mensual de informes dominio agotado (100/100)",
    "requestId": "req_8Hc2kQ...",
    "details": { "tipo": "dominio", "used": 100, "quota": 100, "period": "2026-10" }
  }
}
```

Programá contra `code`, no contra `message` (el texto puede cambiar).

| HTTP | `code` | Causa y acción |
|---|---|---|
| 400 | `invalid_request` | Parámetros inválidos. `details` indica campo y motivo |
| 401 | `unauthorized` | Key ausente, inválida o revocada, o `Chasis-Account` no coincide |
| 402 | `quota_exceeded` | Sin créditos de ese tipo (cupo del mes agotado o saldo en cero) |
| 403 | `forbidden` | Key de sólo lectura en una operación de escritura, u operación que sólo se permite desde el panel |
| 403 | `client_suspended` | Cuenta suspendida. Contactar a soporte |
| 404 | `not_found` | El recurso no existe o pertenece a otra cuenta. En `POST /consultas`: la patente no figura registrada (no se descuenta) |
| 409 | `duplicate_in_progress` | Ya hay un pedido en curso de esa patente. `details.informeId` es el existente. En consultas: otra consulta de la misma patente sigue en curso; reintentar en unos segundos |
| 409 | `idempotency_conflict` | `Idempotency-Key` reutilizada con otro cuerpo |
| 409 | `not_ready` | El PDF todavía no está disponible |
| 413 | `payload_too_large` | Cuerpo mayor a 16 KB |
| 415 | `unsupported_media_type` | Falta `Content-Type: application/json` |
| 429 | `rate_limited`, `poll_too_fast`, `polling_window_exceeded` | Esperar los segundos de `Retry-After` |
| 503 | `upstream_unavailable` | Servicio de origen no disponible. No se consumió crédito; reintentar |

### Límites

| Límite | Valor |
|---|---|
| Solicitudes por API key | 120 por minuto |
| Informes nuevos por cuenta | 30 por minuto |
| Consultas | Dentro del límite por API key |
| Detalle de un mismo informe | 1 consulta cada 10 segundos |

Ante un `429`, respetá el header `Retry-After`.

### Idempotencia

`POST /informes` acepta el header `Idempotency-Key` (8 a 128 caracteres `[A-Za-z0-9_-]`, válido por 24 horas). Si repetís la solicitud con la misma key, recibís el mismo informe con `200` y el header `Idempotent-Replayed: true`, sin crear otro ni consumir créditos. La misma key con un cuerpo distinto responde `409 idempotency_conflict`.

Recomendación: generá la key **una vez por pedido del usuario** (por ejemplo, al abrir el formulario) y reutilizala en todos los reintentos de ese pedido.

### Paginación

Los listados devuelven `{ "object": "list", "data": [...], "hasMore": true, "nextCursor": "..." }`. Para la página siguiente, enviá `cursor=<nextCursor>`. Los cursores son opacos.

### Trazabilidad

Cada respuesta incluye `X-Request-Id`. Registralo en tus logs y incluilo en las consultas a soporte.

### Versionado

La versión forma parte de la URL (`/v1`). Dentro de una versión sólo se hacen cambios compatibles: campos nuevos en las respuestas, endpoints nuevos y códigos de error nuevos. Tu integración tiene que ignorar los campos que no conoce.

---

## Referencia

### Créditos — `GET /usage`

```bash
curl https://www.chasis.com.ar/api/v1/usage -H "Authorization: Bearer $CHASIS_API_KEY"
```

```json
{
  "object": "usage",
  "billing": "mensual",
  "period": "2026-10",
  "chasis":   { "quota": 500,  "used": 42,  "remaining": 458 },
  "dominio":  { "quota": 100,  "used": 7,   "remaining": 93 },
  "consulta": { "quota": 5000, "used": 812, "remaining": 4188 }
}
```

En una cuenta con `billing: "creditos"`, `quota` es `null`, `remaining` es el saldo disponible (no vence) y `used` lo consumido en el mes:

```json
{
  "object": "usage",
  "billing": "creditos",
  "period": "2026-10",
  "chasis":   { "quota": null, "used": 12,  "remaining": 38 },
  "dominio":  { "quota": null, "used": 3,   "remaining": 17 },
  "consulta": { "quota": null, "used": 150, "remaining": 850 }
}
```

### Cuenta — `GET /account`

```json
{
  "object": "account",
  "accountId": "Ab3dEf6hIj9kLm2nOp",
  "name": "ACME SA",
  "email": "compras@acme.com.ar",
  "status": "active",
  "billing": "mensual",
  "plan": { "id": "p1", "name": "Pyme 100" },
  "usage": {
    "period": "2026-10",
    "chasis":   { "quota": 500,  "used": 42,  "remaining": 458 },
    "dominio":  { "quota": 100,  "used": 7,   "remaining": 93 },
    "consulta": { "quota": 5000, "used": 812, "remaining": 4188 }
  },
  "notifications": { "multas": true, "dominio": true, "emails": ["compras@acme.com.ar"] },
  "webhooks": { "multas": "https://acme.com/chasis/webhook", "dominio": "https://acme.com/chasis/webhook", "secretConfigured": true }
}
```

### Consultar una patente — `POST /consultas`

```bash
curl -X POST https://www.chasis.com.ar/api/v1/consultas \
  -H "Authorization: Bearer $CHASIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"patente":"AB123CD"}'
```

| Campo | Requerido | Descripción |
|---|---|---|
| `patente` | Sí | `ABC123` o `AB123CD`. Se ignoran espacios, puntos y guiones; se normaliza a mayúsculas |

Respuesta: `200 OK` con el objeto [Consulta](#el-objeto-consulta), en general en menos de 10 segundos. `cobrada` indica si esta llamada descontó una consulta y `reutilizada`, si se devolvió lo guardado de los últimos 15 días.

Errores frecuentes: `400 invalid_request`, `402 quota_exceeded`, `404 not_found` (la patente no figura registrada; no se descuenta), `409 duplicate_in_progress`, `429 rate_limited`, `503 upstream_unavailable` (no se descuenta; reintentar).

Requiere una API key con permiso completo: las de sólo lectura no pueden consultar porque la consulta consume créditos.

### Listar consultas — `GET /consultas`

| Parámetro | Descripción |
|---|---|
| `patente` | Devuelve sólo la consulta de esa patente (vacío si nunca se consultó) |
| `limit` | 1 a 100 (por defecto 25) |
| `cursor` | `nextCursor` de la página anterior |

Devuelve las [Consultas](#el-objeto-consulta) de la cuenta, de la última pedida a la más vieja, con `ultimaVez` (la última vez que se pidió). No descuenta créditos.

### Crear un informe — `POST /informes`

```bash
curl -X POST https://www.chasis.com.ar/api/v1/informes \
  -H "Authorization: Bearer $CHASIS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4b7e1c2a-pedido-8812" \
  -d '{"patente":"AB123CD","tipo":"dominio","reference":"pedido-8812"}'
```

| Campo | Requerido | Descripción |
|---|---|---|
| `patente` | Sí | `ABC123` o `AB123CD`. Se ignoran espacios, puntos y guiones; se normaliza a mayúsculas |
| `tipo` | No | `dominio` o `chasis`. Por defecto `completo`, que equivale a `dominio` |
| `provincia` | No | Provincia de radicación, si se conoce (por ejemplo `"Córdoba"`) |
| `reference` | No | Identificador propio, hasta 128 caracteres `[A-Za-z0-9_.:-]`. Se devuelve en el informe y en los webhooks |

Respuesta: `202 Accepted` con el objeto [Informe](#el-objeto-informe) (sin `resumen`) y el header `Location`.

Errores frecuentes: `400 invalid_request`, `402 quota_exceeded`, `409 duplicate_in_progress`, `429 rate_limited`, `503 upstream_unavailable`.

### Listar informes — `GET /informes`

```bash
curl "https://www.chasis.com.ar/api/v1/informes?dias=30&limit=25" \
  -H "Authorization: Bearer $CHASIS_API_KEY"
```

| Parámetro | Descripción |
|---|---|
| `patente` | Filtra por patente |
| `dias` | Informes de los últimos N días |
| `tipo` | `dominio`, `chasis` o `completo` (ver arriba) |
| `status` | `pending`, `processing`, `completed` o `failed` |
| `limit` | 1 a 100 (por defecto 25) |
| `cursor` | `nextCursor` de la página anterior |

Devuelve una lista de [Informes](#el-objeto-informe) sin `resumen`, del más reciente al más antiguo. No tiene límite propio de consultas (aplica el general), así que es el endpoint indicado para refrescar estados.

### Obtener un informe — `GET /informes/{id}`

Devuelve el [Informe](#el-objeto-informe) completo, con `resumen`.

Máximo una consulta cada 10 segundos por informe (`429 poll_too_fast`). Si el informe todavía no tiene ninguna fase terminada, el detalle se puede consultar durante 75 minutos desde la primera vez (las multas se entregan dentro de la primera hora); después responde `429 polling_window_exceeded` hasta que termine una fase. El dominio puede tardar horas: para seguirlo, usá el listado o los webhooks.

### Descargar PDFs

| Endpoint | Contenido | Disponible cuando |
|---|---|---|
| `GET /informes/{id}/pdf` | Reporte de Chasis en formato técnico: multas por jurisdicción, deuda de patentes y, al final, vehículo, radicación, VTV, GNC, recalls e informe de dominio | Terminó la fase de multas |
| `GET /informes/{id}/pretty-pdf` | El mismo informe con el diseño de Chasis: imagen del auto, resumen y una sección por fuente. Pensado para mostrárselo a tu cliente final | Terminó la fase de multas. La primera descarga puede tardar hasta un minuto |
| `GET /informes/{id}/dominio.pdf` | PDF original emitido por DNRPA | `fases.dominio.pdfDisponible` es `true` |

Responden `application/pdf` con `Content-Disposition: attachment`. Si el documento todavía no está disponible, `409 not_ready`. Los PDFs de DNRPA se conservan sin vencimiento.

```bash
curl -o informe.pdf https://www.chasis.com.ar/api/v1/informes/{id}/pdf \
  -H "Authorization: Bearer $CHASIS_API_KEY"
```

### Configurar avisos — `PATCH /account`

Requiere una key con permiso completo.

```json
{ "notifications": { "multas": true, "dominio": false } }
```

Activa o desactiva los emails de aviso de cada fase. Los destinatarios los administra Chasis; pedí cambios a soporte. Devuelve el objeto Cuenta actualizado.

### Webhooks y secret de firma

Las URLs de webhook (una por fase) y el secret de firma se configuran **sólo desde el panel**, en `https://www.chasis.com.ar/cuenta/ajustes`. Por seguridad no se pueden modificar con una API key: si una key se filtra, no permite redirigir los informes. Cada cambio se notifica por email al titular de la cuenta.

- **URLs:** sólo `https`, con un nombre de host público, en el puerto 443. Podés usar la misma para las dos fases y distinguir por el tipo de evento.
- **Secret:** se muestra una sola vez al generarlo. Al rotarlo, el anterior sigue siendo válido durante **24 horas**: en ese período los webhooks llevan las dos firmas, para que lo actualices sin perder eventos.

`GET /account` devuelve las URLs configuradas en `webhooks`.

### Probar el webhook — `POST /account/webhook-test`

Envía un evento `webhook.test` y responde:

```json
{ "deliveryId": "whd_...", "status": "delivered", "lastStatusCode": 200, "lastError": null }
```

`status` es `delivered` si tu servidor respondió `2xx`, o `failed` en otro caso. `lastStatusCode` es el código HTTP que respondió tu servidor (`null` si no se pudo conectar).

### Historial de una patente — `GET /vehiculos/{patente}`

Todos los informes de una patente en tu cuenta, del más nuevo al más viejo: los de la flota y los que pediste por la API o el panel. Funciona aunque el auto nunca haya estado en la flota o ya se haya quitado. Cada informe viene completo, con su `resumen`, y con `cambios`: lo que cambió respecto del informe anterior de la misma patente.

```bash
curl https://www.chasis.com.ar/api/v1/vehiculos/AB123CD \
  -H "Authorization: Bearer $CHASIS_API_KEY"
```

```json
{
  "object": "vehiculo",
  "patente": "AB123CD",
  "vehiculo": { "marca": "FIAT", "modelo": "CRONOS DRIVE 1.3", "anio": "2023" },
  "flota": { "activo": true, "alias": "Camioneta 3", "reference": "int-3", "provincia": null, "agregadoEn": "2026-10-01T12:00:00.000Z", "quitadoEn": null, "ultimaActualizacion": "2026-11-01T10:20:31.000Z" },
  "ultimoInforme": { "id": "Xk2pQ9...", "status": "completed", "createdAt": "2026-11-01T10:20:31.000Z", "links": { } },
  "informes": {
    "object": "list",
    "data": [
      {
        "id": "Xk2pQ9...", "object": "informe", "tipo": "chasis", "status": "completed",
        "flota": { "ejecucionId": "fej_..." },
        "resumen": { },
        "cambios": {
          "hay": true,
          "multasNuevas": [ { "jurisdiccion": "CABA", "fecha": "12/10/2026", "monto": 98400, "descripcion": "Exceso de velocidad" } ],
          "multasQueNoFiguran": 1,
          "lineas": ["1 multa nueva por $98.400", "1 multa del informe anterior ya no figura"]
        }
      }
    ],
    "hasMore": false,
    "nextCursor": null
  }
}
```

| Parámetro | Descripción |
|---|---|
| `limit` | Informes por página, 1 a 100 (por defecto 25) |
| `cursor` | `nextCursor` de la página anterior |

- `flota` es `null` si la patente nunca estuvo en la flota.
- `cambios` es `null` cuando no hay un informe anterior con las multas terminadas para comparar, o cuando este todavía no las terminó. Sólo se compara lo que los dos informes saben: si una fuente no respondió en alguno, ese dato no cuenta como cambio.
- Si la cuenta no tiene informes de esa patente y nunca estuvo en la flota, responde `404 not_found`.

Cada informe incluye además `links.historial` con esta ruta.

### Flota — `GET /flota`

```json
{
  "object": "flota",
  "activa": true,
  "periodicidad": "mensual",
  "dia": 1,
  "proximaEjecucion": "2026-11-01",
  "vehiculos": 48,
  "maxVehiculos": 2000,
  "ultimaEjecucion": { "object": "ejecucion", "id": "fej_...", "estado": "terminada" },
  "links": { "vehiculos": "/api/v1/flota/vehiculos", "ejecuciones": "/api/v1/flota/ejecuciones" }
}
```

`proximaEjecucion` es el próximo día que se actualiza (`YYYY-MM-DD`, hora de Argentina), o `null` si la flota está pausada. `ultimaEjecucion` es la última actualización programada o manual (ver [El objeto Ejecución](#el-objeto-ejecucion)).

### Configurar la flota — `PATCH /flota`

Requiere una key con permiso completo.

```bash
curl -X PATCH https://www.chasis.com.ar/api/v1/flota \
  -H "Authorization: Bearer $CHASIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"periodicidad":"quincenal","dia":5}'
```

| Campo | Descripción |
|---|---|
| `periodicidad` | `semanal`, `quincenal`, `mensual`, `bimestral` o `trimestral` (ver [Flotas](#flotas)) |
| `dia` | Día en que se actualiza, según la periodicidad. Si cambiás la periodicidad sin mandar `dia`, se toma el de hoy |
| `activa` | `false` pausa las actualizaciones (los autos y su historial se conservan); `true` las reanuda |

Devuelve el objeto Flota. Un `dia` fuera de rango responde `400 invalid_request`.

### Autos de la flota — `GET /flota/vehiculos`

El listado completo de la flota. Cada auto trae lo esencial de su último informe; el informe completo está en su detalle o en `GET /informes/{id}`.

```json
{
  "object": "list",
  "data": [
    {
      "object": "vehiculo_flota",
      "patente": "AB123CD",
      "alias": "Camioneta 3",
      "reference": "int-3",
      "provincia": null,
      "activo": true,
      "agregadoEn": "2026-10-01T12:00:00.000Z",
      "quitadoEn": null,
      "ultimaActualizacion": "2026-11-01T10:20:31.000Z",
      "vehiculo": { "marca": "FIAT", "modelo": "CRONOS DRIVE 1.3", "anio": "2023" },
      "ultimoInforme": {
        "id": "Xk2pQ9...",
        "status": "completed",
        "createdAt": "2026-11-01T10:20:31.000Z",
        "completedAt": "2026-11-01T10:41:02.000Z",
        "multas": { "cantidad": 5, "montoTotal": 1689366 },
        "patentes": { "tieneDeuda": false, "montoTotal": 0 },
        "vtv": { "vigente": false, "fuente": "vtv_caba" },
        "links": { "self": "/api/v1/informes/Xk2pQ9..." }
      },
      "links": { "self": "/api/v1/flota/vehiculos/AB123CD", "historial": "/api/v1/vehiculos/AB123CD" }
    }
  ],
  "total": 48,
  "hasMore": false,
  "nextCursor": null
}
```

| Parámetro | Descripción |
|---|---|
| `limit` | 1 a 500 (por defecto 100) |
| `cursor` | `nextCursor` de la página anterior |
| `q` | Busca en la patente, el alias y `reference` |
| `incluirQuitados` | `true` incluye los autos que se quitaron de la flota (`activo: false`) |

`ultimoInforme` es `null` si el auto todavía no tiene informes. Sus bloques (`multas`, `patentes`, `vtv`) son `null` mientras las multas siguen en proceso.

### Agregar autos — `POST /flota/vehiculos`

Requiere una key con permiso completo. Hasta 500 autos por solicitud.

```bash
curl -X POST https://www.chasis.com.ar/api/v1/flota/vehiculos \
  -H "Authorization: Bearer $CHASIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"vehiculos":["AB123CD",{"patente":"AC456EF","alias":"Camioneta 3","reference":"int-3"}],"informeInicial":true}'
```

| Campo | Requerido | Descripción |
|---|---|---|
| `vehiculos` | Sí | Patentes, o objetos con `patente` y opcionalmente `alias` (hasta 80 caracteres), `reference` (tu identificador, como en `POST /informes`) y `provincia` |
| `informeInicial` | No | `true` pide ya un informe de los autos nuevos (1 crédito `chasis` cada uno). Por defecto `false`: se actualizan la próxima fecha |

```json
{
  "object": "flota_alta",
  "agregados": ["AC456EF"],
  "reactivados": [],
  "yaEstaban": ["AB123CD"],
  "ejecucion": { "object": "ejecucion", "id": "fej_...", "tipo": "alta", "estado": "en_curso" },
  "flota": { "object": "flota" }
}
```

- Responde `201` si agregó algún auto y `200` si ya estaban todos. Repetir la solicitud no duplica autos: es seguro reintentarla.
- Un auto que ya estaba conserva sus datos, salvo los campos que mandes. Uno que se había quitado vuelve a la flota (`reactivados`).
- Si la patente ya tenía informes en la cuenta, el auto arranca con el último y la próxima actualización se compara contra ese.
- `ejecucion` sólo aparece con `informeInicial: true` (ver [El objeto Ejecución](#el-objeto-ejecucion)).

### Un auto de la flota — `GET /flota/vehiculos/{patente}`

El auto con su historial completo: mismo formato y parámetros que [`GET /vehiculos/{patente}`](#historial-de-una-patente-get-vehiculos-patente). Funciona también con los autos que se quitaron de la flota; si la patente nunca estuvo en la flota, `404 not_found`.

### Editar un auto — `PATCH /flota/vehiculos/{patente}`

Requiere una key con permiso completo. Cambia `alias`, `reference` o `provincia` (`null` los borra). Devuelve el auto con su historial.

### Quitar un auto — `DELETE /flota/vehiculos/{patente}`

Requiere una key con permiso completo. El auto deja de actualizarse. Sus informes y su historial se conservan y se pueden seguir consultando por patente. Para volver a agregarlo, `POST /flota/vehiculos`.

```json
{ "object": "vehiculo_flota", "patente": "AB123CD", "activo": false, "quitadoEn": "2026-11-03T15:02:11.000Z", "links": { "historial": "/api/v1/vehiculos/AB123CD" } }
```

Si el auto no está en la flota, `404 not_found`.

### Actualizaciones de la flota — `GET /flota/ejecuciones`

Las actualizaciones (programadas, manuales y los informes iniciales de autos agregados), de la más reciente a la más antigua. `limit` de 1 a 100 (por defecto 25). El detalle de una: `GET /flota/ejecuciones/{id}`.

### Actualizar la flota ahora — `POST /flota/ejecuciones`

Requiere una key con permiso completo. Pide ya un informe de cada auto activo (1 crédito `chasis` cada uno), además de las fechas programadas. Responde `202` con el objeto [Ejecución](#el-objeto-ejecucion). Si ya hay una actualización en curso, `409 duplicate_in_progress` con su id en `details.ejecucionId`.

---

## Objetos

### El objeto Informe

```json
{
  "id": "Xk2pQ9...",
  "object": "informe",
  "accountId": "Ab3dEf6hIj9kLm2nOp",
  "tipo": "dominio",
  "patente": "AB123CD",
  "status": "processing",
  "fases": {
    "multas":  { "status": "completed",  "completedAt": "2026-10-14T18:09:12.000Z", "error": null, "seguimiento": true, "actualizadoEn": null },
    "dominio": { "status": "processing", "completedAt": null, "error": null, "pdfDisponible": false }
  },
  "source": "api",
  "flota": null,
  "reference": "pedido-8812",
  "vehiculo": { "marca": "VOLKSWAGEN", "modelo": "GOL TREND", "anio": "2018" },
  "createdAt": "2026-10-14T18:01:03.000Z",
  "updatedAt": "2026-10-14T18:09:12.000Z",
  "completedAt": null,
  "error": null,
  "links": {
    "self": "/api/v1/informes/Xk2pQ9...",
    "pdf": "/api/v1/informes/Xk2pQ9.../pdf",
    "prettyPdf": "/api/v1/informes/Xk2pQ9.../pretty-pdf",
    "dominioPdf": null,
    "historial": "/api/v1/vehiculos/AB123CD"
  },
  "resumen": { }
}
```

| Campo | Tipo | Descripción |
|---|---|---|
| `id` | string | Identificador del informe |
| `accountId` | string | Cuenta dueña del informe |
| `tipo` | string | `dominio`, `chasis` o `completo` (lo que se pidió) |
| `patente` | string | Patente normalizada |
| `status` | string | Estado general (ver [Estados](#estados)) |
| `fases` | object | Estado de cada fase pedida. `error` tiene `code` y `message` cuando la fase falló |
| `fases.dominio.pdfDisponible` | boolean | El PDF original de DNRPA está disponible |
| `fases.multas.seguimiento` | boolean | Las multas ya se entregaron, pero pueden actualizarse en las próximas horas; cada actualización llega como `multas.updated` |
| `fases.multas.actualizadoEn` | string \| null | Última actualización de las multas posterior a la entrega |
| `source` | string | `api`, `panel` o `chasis` (cargado por el equipo de Chasis) |
| `flota` | object \| null | `{ "ejecucionId": "fej_..." }` si lo pidió una actualización de la flota; `null` en el resto. Los informes de la flota tienen `source: "api"` |
| `reference` | string \| null | Tu identificador, si lo enviaste |
| `vehiculo` | object \| null | Marca, modelo y año, cuando ya se conocen |
| `links` | object | Rutas relativas a `https://www.chasis.com.ar`. `historial` lleva a todos los informes de la patente |
| `resumen` | object \| null | Sólo en el detalle y en los webhooks. Ver abajo |

### El objeto Consulta

```json
{
  "object": "consulta",
  "patente": "AB123CD",
  "dominio": "AB123CD",
  "marca": "FIAT",
  "modelo": "CRONOS DRIVE 1.3 GSE CVT BZ",
  "anio": 2023,
  "tipo": "automotor",
  "placaMercosur": true,
  "registro": {
    "codigo": 1234,
    "nombre": "CORDOBA N° 5",
    "direccion": "AV. COLON 1234",
    "localidad": "CORDOBA",
    "provincia": "Córdoba"
  },
  "consultadoEn": "2026-10-20T14:03:11.000Z",
  "vigenteHasta": "2026-11-04T14:03:11.000Z",
  "cobrada": true,
  "reutilizada": false
}
```

| Campo | Descripción |
|---|---|
| `marca`, `modelo`, `anio` | Según DNRPA. `modelo` incluye la versión |
| `tipo` | `automotor`, `moto` o `null` |
| `placaMercosur` | Si la patente es del formato Mercosur |
| `registro` | Registro seccional de radicación. Cualquiera de sus campos puede venir en `null` |
| `consultadoEn` | Cuándo se consultó DNRPA |
| `vigenteHasta` | Hasta cuándo una consulta repetida devuelve estos datos sin descontar |
| `cobrada`, `reutilizada` | Sólo en `POST /consultas` |

### El objeto Resumen

Contiene la información lista para mostrar. Es la estructura recomendada para cualquier interfaz.

```json
{
  "patente": "AB123CD",
  "vehiculo": { "marca": "VOLKSWAGEN", "modelo": "GOL TREND", "anio": "2018", "version": null },
  "multas": {
    "cantidad": 3,
    "montoTotal": 245000,
    "lista": [
      {
        "entity": { "name": "CABA", "province": "CABA" },
        "infracted_at": "2026-03-02T14:10:00Z",
        "unique_id": "Q12345678",
        "description": "Exceso de velocidad",
        "amount": 120000,
        "expires_at": "2026-11-01",
        "requires_controller": false,
        "resolved_at": null,
        "location": "AV. LUGONES Y ..."
      }
    ],
    "conMultas": [ { "id": "caba", "nombre": "CABA", "provincia": "CABA" } ]
  },
  "patentes": {
    "tieneDeuda": true,
    "montoTotal": 58000,
    "detalle": [ { "jurisdiccion": "CABA", "periodos": ["2026-01", "2026-02"], "monto": 58000 } ],
    "consultadas": ["CABA", "BUENOS AIRES"]
  },
  "vtv": { "vigente": true, "fuente": "vtv_provincia" },
  "gnc": { "tiene": false },
  "recalls": { "tiene": false, "cantidad": 0 },
  "dominio": {
    "marca": "VOLKSWAGEN",
    "modelo": "GOL TREND",
    "anio": "2018",
    "nroChasis": "9BWAB45U0JT000000",
    "nroMotor": "CFZ000000",
    "titularesCantidad": 1,
    "titulares": [ { "nombre": "PEREZ JUAN", "porcentaje": "100%", "documento": "********123" } ],
    "estadosActivos": [ { "key": "prenda", "label": "Prenda" } ],
    "fechaInscripcion": "2018-05-10",
    "radicacion": "CABA"
  }
}
```

| Bloque | Descripción |
|---|---|
| `multas` | Multas **abiertas** (sin pagar ni resolver) de todas las jurisdicciones consultadas, monto total en pesos y jurisdicciones con multas |
| `patentes` | Deuda de patentes por jurisdicción y jurisdicciones consultadas |
| `vtv`, `gnc`, `recalls` | Indicadores; `null` en un campo indica que no se pudo determinar |
| `dominio` | Datos del informe de DNRPA. Los documentos de los titulares van enmascarados (últimos 3 dígitos). `estadosActivos` lista gravámenes y restricciones vigentes (prenda, embargo, inhibición, denuncia de robo, etc.) |

Los bloques aparecen recién cuando terminó su fase: `multas`, `patentes`, `vtv`, `gnc` y `recalls` cuando termina la fase de multas, y `dominio` cuando llega el informe de DNRPA. Si después cambian las multas, el resumen se actualiza y se envía `multas.updated`. Un bloque en `null` significa que la fase no se pidió, todavía no terminó o la fuente no aplica: en una interfaz, mostralo como "en proceso" o "no disponible", nunca como ausencia de multas o de deuda.

### El objeto Ejecución

Una actualización de la flota.

```json
{
  "object": "ejecucion",
  "id": "fej_...",
  "tipo": "programada",
  "fecha": "2026-11-01",
  "estado": "terminada",
  "total": 48,
  "pedidos": 46,
  "pendientes": 0,
  "omitidos": [ { "patente": "AD789GH", "motivo": "sin_cupo", "detalle": "No quedaban créditos chasis" } ],
  "informes": [ { "patente": "AB123CD", "informeId": "Xk2pQ9...", "enCurso": false } ],
  "resumen": { "actualizados": 46, "conMultas": 12, "multas": 31, "montoMultas": 4210000, "conNovedades": 5, "sinCupo": 2, "errores": 0 },
  "createdAt": "2026-11-01T10:00:04.000Z",
  "terminadaEn": "2026-11-01T11:12:40.000Z"
}
```

| Campo | Descripción |
|---|---|
| `tipo` | `programada` (la del día que toca), `manual` (`POST /flota/ejecuciones`) o `alta` (`informeInicial` al agregar autos) |
| `estado` | `en_curso`: pidiendo los informes. `esperando`: todos pedidos, esperando que terminen las multas. `terminada`: se envió el email con el resumen |
| `pedidos` | Autos con su informe pedido. `informes` los lista; `enCurso: true` indica que ya había un informe en curso de esa patente y se usó ese |
| `pendientes` | Autos que todavía falta pedir |
| `omitidos` | Autos que no se actualizaron: `sin_cupo` (sin créditos `chasis`) o `error` |
| `resumen` | Sólo cuando está `terminada`: autos actualizados, cuántos con multas, total de multas y monto, cuántos con novedades respecto del informe anterior |

---

## Webhooks

Cuando termina una fase, Chasis envía un `POST` a la URL configurada para esa fase. Mientras las multas están en seguimiento (`fases.multas.seguimiento`), se envía `multas.updated` con el informe completo cada vez que cambia algo del resumen, y una última vez al terminar el seguimiento (con `seguimiento: false`). Cada `multas.updated` reemplaza al anterior: guardá siempre el último.

| Evento | Destino | Contenido de `data.informe` |
|---|---|---|
| `multas.completed` | `webhooks.multas` | Informe completo, con `resumen` |
| `multas.updated` | `webhooks.multas` | Informe completo, con el `resumen` actualizado de multas |
| `multas.failed` | `webhooks.multas` | Informe completo; el motivo en `fases.multas.error` |
| `dominio.completed` | `webhooks.dominio` | Informe completo, **incluido el dominio**; `links.dominioPdf` disponible |
| `dominio.failed` | `webhooks.dominio` | Informe completo; el motivo en `fases.dominio.error` |
| `webhook.test` | URL configurada | `data.message` |

```
POST https://acme.com/chasis/webhook
Content-Type: application/json
User-Agent: Chasis-Webhooks/1.0
Chasis-Event: dominio.completed
Chasis-Delivery: whd_...
Chasis-Account: Ab3dEf6hIj9kLm2nOp
Chasis-Signature: t=1760000000,v1=5f2c...

{ "id": "whd_...", "object": "event", "type": "dominio.completed", "createdAt": "...", "data": { "informe": { ... } } }
```

### Verificación de la firma

`Chasis-Signature` tiene la forma `t=<timestamp>,v1=<firma>`. La firma es `hex(HMAC-SHA256(secret, "<t>.<body>"))`, calculada sobre el **cuerpo crudo**, tal como llega, sin parsearlo ni volver a serializarlo.

1. Separá `t` y todos los valores `v1`. Durante las 24 horas posteriores a una rotación del secret, el header trae dos `v1`; la firma es válida si coincide alguno.
2. Rechazá el evento si `|ahora − t|` supera los 300 segundos.
3. Compará en tiempo constante.

```js
import crypto from "node:crypto";

export function verificarFirma(rawBody, header, secret) {
  let t;
  const firmas = [];
  for (const parte of (header ?? "").split(",")) {
    const [k, v] = parte.split("=");
    if (k === "t") t = Number(v);
    if (k === "v1" && /^[0-9a-f]{64}$/.test(v)) firmas.push(Buffer.from(v, "hex"));
  }
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const esperada = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return firmas.some((f) => crypto.timingSafeEqual(f, esperada));
}
```

El SDK incluye esta verificación (`verificarWebhook`).

### Entrega

- Respondé con un `2xx` en menos de 10 segundos y procesá el evento de forma asíncrona.
- Si la respuesta no es `2xx`, se reintenta a 1 min, 5 min, 30 min, 2 h, 6 h y 12 h.
- Un mismo evento puede llegar más de una vez. Deduplicá por `Chasis-Delivery`, o por `data.informe.id` más `type`.
- Las entregas no siguen redirecciones.

---

## Cliente TypeScript

`https://www.chasis.com.ar/sdk/chasis.ts` es un único archivo sin dependencias, con tipos para todos los objetos. Funciona en Node 18+, Bun, Deno y runtimes edge. Es para uso **en el servidor**.

```ts
import { Chasis, ChasisError, verificarWebhook } from "./chasis";

const chasis = new Chasis({ apiKey: process.env.CHASIS_API_KEY! });

await chasis.uso();                                        // créditos del mes
await chasis.cuenta();                                     // cuenta, plan, avisos, webhooks
await chasis.consultar("AB123CD");                         // datos y radicación según DNRPA, al instante
await chasis.listarConsultas({ limit: 50 });
await chasis.crearInforme({ patente: "AB123CD", tipo: "dominio" });  // DNRPA + multas
await chasis.crearInforme({ patente: "AB123CD", tipo: "chasis" });   // sólo multas
await chasis.crearInforme({ patente: "AB123CD", tipo: "dominio", idempotencyKey });
await chasis.listarInformes({ patente: "AB123CD", dias: 30 });
for await (const informe of chasis.todosLosInformes({ dias: 90 })) { /* ... */ }
await chasis.informe(id);                                  // detalle, con resumen
await chasis.pdfInforme(id);                               // ArrayBuffer
await chasis.pdfLindo(id);                                 // ArrayBuffer, con el diseño de Chasis
await chasis.pdfDominio(id);                               // ArrayBuffer
await chasis.esperar(id, { fase: "multas" });              // espera a que terminen las multas
await chasis.historial("AB123CD");                         // todos los informes de la patente, con los cambios
await chasis.configurarFlota({ periodicidad: "mensual", dia: 1 });
await chasis.agregarAFlota(["AB123CD", { patente: "AC456EF", alias: "Camioneta 3" }], { informeInicial: true });
await chasis.listarFlota();                                // autos con su último informe
for await (const auto of chasis.todaLaFlota()) { /* ... */ }
await chasis.vehiculoDeFlota("AB123CD");                   // auto + historial
await chasis.quitarDeFlota("AB123CD");                     // deja de actualizarse; el historial queda
await chasis.actualizarFlota();                            // actualiza ya toda la flota
await chasis.ejecucionesDeFlota();
await chasis.configurarAvisos({ multas: false });
await chasis.probarWebhook();                              // envía un webhook.test
const evento = await verificarWebhook(rawBody, signatureHeader, process.env.CHASIS_WEBHOOK_SECRET!);
```

- Los errores se lanzan como `ChasisError`, con `status`, `code`, `message`, `requestId`, `retryAfter` y `details`.
- Reintenta automáticamente sólo ante `429`, `503` y errores de red, respetando `Retry-After`.
- `crearInforme` envía siempre una `Idempotency-Key`: si no la pasás, genera una por llamada, de modo que sus propios reintentos nunca consumen créditos dos veces. Para cubrir los reintentos de tu aplicación, pasá la tuya.

---

## Integración paso a paso

Arquitectura recomendada: tu frontend nunca llama a Chasis. Llama a rutas de tu backend, que exigen un usuario autenticado de tu aplicación y son las únicas que conocen la API key. Los ejemplos usan Next.js (App Router); en otros frameworks la estructura es la misma.

### 1. Variables de entorno (servidor)

```
CHASIS_API_KEY=chs_live_...
CHASIS_WEBHOOK_SECRET=whsec_...
```

### 2. Cliente

Descargá `https://www.chasis.com.ar/sdk/chasis.ts` a `lib/chasis.ts` y creá una instancia única:

```ts
// lib/chasis-server.ts
import "server-only";
import { Chasis } from "./chasis";

export const chasis = new Chasis({ apiKey: process.env.CHASIS_API_KEY! });
```

Verificá la conexión con `await chasis.cuenta()`. Un `401` indica una key mal copiada o revocada.

### 3. Autenticación obligatoria en tus rutas

Todas las rutas que llaman a Chasis, salvo el receptor de webhooks, tienen que exigir una sesión válida de tu aplicación y rechazar la solicitud si no la hay. Sin esto, cualquiera podría consumir tus créditos y acceder a datos de titulares. Si tu aplicación no tiene autenticación, implementala antes de exponer estas rutas.

```ts
// lib/auth-guard.ts — adaptalo al sistema de sesión de tu aplicación.
import "server-only";

export async function requireUser(req: Request): Promise<{ id: string }> {
  const user = await getSessionUser(req); // tu función de sesión (NextAuth, Clerk, Supabase, Firebase...)
  if (!user) throw new Response("No autorizado", { status: 401 });
  return user;
}
```

```ts
// lib/chasis-route.ts — exige sesión y traduce los errores de Chasis.
import "server-only";
import { ChasisError } from "./chasis";
import { requireUser } from "./auth-guard";

export function ruta<C>(handler: (req: Request, ctx: C, user: { id: string }) => Promise<Response>) {
  return async (req: Request, ctx: C) => {
    try {
      const user = await requireUser(req);
      return await handler(req, ctx, user);
    } catch (e) {
      if (e instanceof Response) return e;
      if (e instanceof ChasisError) {
        return Response.json(
          { code: e.code, message: e.message, details: e.details ?? null },
          { status: e.status >= 400 ? e.status : 502 },
        );
      }
      console.error(e);
      return Response.json({ code: "internal_error", message: "Error interno" }, { status: 500 });
    }
  };
}
```

### 4. Rutas del backend

```ts
// app/api/chasis/creditos/route.ts
import { chasis } from "@/lib/chasis-server";
import { ruta } from "@/lib/chasis-route";

export const GET = ruta(async () => Response.json(await chasis.uso()));
```

```ts
// app/api/chasis/informes/route.ts
import { chasis } from "@/lib/chasis-server";
import { ruta } from "@/lib/chasis-route";

const TIPOS = ["dominio", "chasis"] as const;
const ESTADOS = ["pending", "processing", "completed", "failed"] as const;

export const GET = ruta(async (req) => {
  const sp = new URL(req.url).searchParams;
  const status = sp.get("status");
  return Response.json(
    await chasis.listarInformes({
      patente: sp.get("patente") || undefined,
      dias: sp.get("dias") ? Number(sp.get("dias")) : undefined,
      status: ESTADOS.find((s) => s === status),
      cursor: sp.get("cursor") || undefined,
      limit: 25,
    }),
  );
});

export const POST = ruta(async (req) => {
  const body = (await req.json().catch(() => ({}))) as { patente?: string; tipo?: string; idempotencyKey?: string };
  if (!body.patente) {
    return Response.json({ code: "invalid_request", message: "Falta la patente" }, { status: 400 });
  }
  const tipo = TIPOS.find((t) => t === body.tipo) ?? "dominio";
  // La genera el frontend una vez por pedido y la reenvía igual en cada reintento.
  const idempotencyKey = /^[A-Za-z0-9_-]{8,128}$/.test(body.idempotencyKey ?? "") ? body.idempotencyKey : undefined;
  const informe = await chasis.crearInforme({ patente: body.patente, tipo, idempotencyKey });
  return Response.json(informe, { status: 202 });
});
```

```ts
// app/api/chasis/informes/[id]/route.ts
import { chasis } from "@/lib/chasis-server";
import { ruta } from "@/lib/chasis-route";

export const GET = ruta<{ params: Promise<{ id: string }> }>(async (_req, { params }) => {
  return Response.json(await chasis.informe((await params).id));
});
```

```ts
// app/api/chasis/informes/[id]/pdf/route.ts — ?tipo=dominio para el PDF de DNRPA.
import { chasis } from "@/lib/chasis-server";
import { ruta } from "@/lib/chasis-route";

export const GET = ruta<{ params: Promise<{ id: string }> }>(async (req, { params }) => {
  const { id } = await params;
  const dominio = new URL(req.url).searchParams.get("tipo") === "dominio";
  const pdf = dominio ? await chasis.pdfDominio(id) : await chasis.pdfInforme(id);
  const nombre = `${dominio ? "dominio" : "informe"}-${id.replace(/[^A-Za-z0-9_-]/g, "")}.pdf`;
  return new Response(pdf, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `attachment; filename="${nombre}"`,
      "Cache-Control": "private, no-store",
    },
  });
});
```

Chasis separa los datos por **cuenta**, no por usuario de tu aplicación. Si no todos tus usuarios deben ver todos los informes, guardá en tu base qué usuario pidió cada `id` y verificalo en el detalle y en los PDFs.

En el frontend, generá la clave de idempotencia al abrir el formulario y reutilizala en los reintentos:

```tsx
const [idempotencyKey] = useState(() => crypto.randomUUID());

await fetch("/api/chasis/informes", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ patente, tipo, idempotencyKey }),
});
```

### 5. Interfaz

| Pantalla | Fuente | Contenido |
|---|---|---|
| Créditos | `GET /usage` | `remaining` para chasis y dominio (cupo del mes o saldo, según `billing`). Deshabilitá "Pedir" si no alcanza: `chasis` usa un crédito de chasis; `dominio` (o `completo`), uno de dominio |
| Pedir informe | `POST /informes` | Patente y tipo. Validá `^[A-Z]{3}\d{3}$` o `^[A-Z]{2}\d{3}[A-Z]{2}$` tras pasar a mayúsculas y quitar espacios. Ante `duplicate_in_progress`, ofrecé abrir `details.informeId` |
| Listado | `GET /informes` | Patente, vehículo, tipo, estado de cada fase y fecha. Filtros por patente y por días; paginación con `nextCursor` |
| Detalle | `GET /informes/{id}` | Bloques de `resumen`. Fases en curso como "En proceso"; el dominio, como "Esperando a DNRPA (días hábiles, 8 a 17 h)". PDFs habilitados según su disponibilidad |

Estados sugeridos: `processing` → "En proceso", `completed` → "Listo", `failed` → "No disponible", con `fases.<fase>.error.message`.

Para mantener los estados actualizados, procesá los webhooks (paso 6) o refrescá el listado cada 30 a 60 segundos mientras haya informes en curso.


### 6. Receptor de webhooks

Esta ruta no exige sesión: la protege la firma.

```ts
// app/api/chasis/webhook/route.ts
import { verificarWebhook } from "@/lib/chasis";

export async function POST(req: Request) {
  const raw = await req.text(); // cuerpo crudo
  const evento = await verificarWebhook(raw, req.headers.get("chasis-signature"), process.env.CHASIS_WEBHOOK_SECRET!);
  if (!evento) return new Response("Firma inválida", { status: 401 });

  // Idempotencia: registrá Chasis-Delivery y descartá los repetidos.
  const informe = evento.data.informe;
  switch (evento.type) {
    case "multas.completed":
    case "multas.updated":
      // Guardar informe.resumen (reemplaza al anterior) y notificar al usuario.
      break;
    case "dominio.completed":
      // Guardar el informe completo; links.dominioPdf ya está disponible.
      break;
    case "multas.failed":
    case "dominio.failed":
      // Marcar como no disponible con informe.fases[...].error.message.
      break;
  }
  return new Response("ok");
}
```

Configuración inicial, desde `https://www.chasis.com.ar/cuenta/ajustes`:

1. Cargá la URL del receptor (por ejemplo `https://tu-dominio.com/api/chasis/webhook`) para las fases de multas y de dominio.
2. Generá el secret de firma y guardalo en `CHASIS_WEBHOOK_SECRET`.
3. Enviá una prueba ("Enviar prueba" en el panel, o `await chasis.probarWebhook()`) y verificá que el resultado sea `delivered`.

### 7. Verificación

- [ ] `cuenta()` devuelve la cuenta.
- [ ] Sin sesión, todas las rutas `/api/chasis/*` salvo el webhook responden `401`.
- [ ] La API key no aparece en el bundle del frontend ni en los logs.
- [ ] La pantalla de créditos muestra los saldos de chasis y dominio.
- [ ] Un pedido `chasis` aparece en el listado en curso y, dentro de la primera hora, la fase de multas pasa a `completed` con su resumen y su PDF.
- [ ] Un pedido `dominio` entrega primero las multas y después el dominio (`dominio.completed`), con el PDF de DNRPA.
- [ ] Repetir un pedido en curso devuelve `duplicate_in_progress` con el informe existente.
- [ ] Patente inválida (`400`) y falta de créditos (`402`) muestran un mensaje claro.
- [ ] `probarWebhook()` devuelve `delivered`, y un webhook con la firma alterada se rechaza con `401`.
- [ ] Un doble click en "Pedir" no genera dos pedidos.

---

## Seguridad

- **La API key sólo en el servidor.** Nunca en código de navegador, apps móviles, repositorios ni logs. Ante una filtración, revocala desde el panel y creá otra.
- **Mínimo privilegio.** Usá keys de sólo lectura donde no haga falta pedir informes, y una key por sistema para poder revocarlas por separado.
- **Webhooks.** Verificá siempre la firma, rechazá eventos con más de 5 minutos de antigüedad y deduplicá.
