Saltar al contenido
FlotaDoc

API para integraciones

Gastos

Combustible, recarga y otros gastos, con su ticket.

En esta página

Lista los gastos o sincroniza los cambios

POST/movimientos/gastos/list

  • Permiso lectura

Gastos de la empresa. Hay dos modos, según el cuerpo.

1. Lista con totales (sin updatedSince ni cursor). Gastos activos del filtro, del más reciente al más antiguo (expenseDate y, a igualdad, expenseId, descendentes), con los totales de todo el filtro en totals. Como mucho 1000 gastos por respuesta: si el filtro tiene más, truncated: true y conviene acotarlo (month, vehicleId, types). Este modo no pagina.

2. Sincronización incremental (con updatedSince o cursor). Entrega todos los gastos cuyo último cambio (alta, corrección, ticket o anulación) cae en (updatedSince, syncedUntil], anulados incluidos (active: false), en orden de cambio y, a igualdad, por expenseId. Sin totals ni truncated.

  1. Primera página: { "updatedSince": "<instante>", "limit": 200, ...filtros }. La primera vez, "1970-01-01T00:00:00Z".
  2. Mientras nextCursor no sea null: { "cursor": "<nextCursor>", "limit": 200, ...los mismos filtros }. El cursor no guarda los filtros: repítelos en cada página. Si llegan cursor y updatedSince, manda cursor.
  3. Con nextCursor: null has terminado: guarda syncedUntil y úsalo como updatedSince de la sincronización siguiente (nunca la hora de tu sistema).
  • syncedUntil lo fija la primera página (la hora de la base de datos menos 60 s de margen de asentamiento) y viaja en el cursor: es el mismo en todas las páginas del recorrido. Un cambio tarda hasta 1 minuto en aparecer.
  • Sin duplicados ni huecos: un gasto que cambia mientras recorres no se repite en ese recorrido; llega en la sincronización siguiente con su versión nueva.
  • Si updatedSince va por delante de esa hora, la página sale vacía y syncedUntil es el propio updatedSince (nunca retrocede).
  • limit (de 1 a 200, por defecto 200) solo vale en este modo.
  • Los filtros se aplican al estado actual del gasto: uno corregido que deja de cumplirlos (otra fecha, otro vehículo u otro tipo) ya no sale y no se avisa. Para una copia completa, sincroniza sin filtros.
  • Lo que borra la purga por retención (ejercicio + 5 años) no se avisa.

Filtros (los dos modos; opcionales y combinables): vehicleId, userId (el conductor del gasto), tripId (los peajes y aparcamientos ligados a ese viaje), types y el periodo por expenseDate: month, o from y to (incluidos). month no se combina con from/to.

Con clave API actúas como el owner que la creó: ves los gastos de toda la empresa, de todos los conductores. Leer no exige complementos: los gastos de un complemento que la empresa ya no tiene siguen saliendo.

Errores propios

  • 422 invalid_input con error.fields, todos a la vez:
    • month: invalid (mal formado, o junto a from/to).
    • from, to: invalid (no es una fecha real YYYY-MM-DD); to: out_of_range (from > to).
    • types: invalid (no es una lista, está vacía o trae un tipo desconocido).
    • vehicleId, userId, tripId: invalid (no es un entero positivo).
    • updatedSince: invalid (sin hora, sin zona, fecha imposible o anterior a 1970).
    • cursor: invalid (no es un nextCursor emitido por el servidor).
    • limit: invalid (no es un entero > 0), out_of_range (> 200) o unexpected_field (sin updatedSince ni cursor).
    • Cualquier otra clave: unexpected_field.
  • 422 invalid_input sin fields: el cuerpo no es un objeto JSON.

Petición

Cuerpo obligatorio

Filtros (opcionales y combinables) y, para la sincronización, updatedSince, cursor y limit. Con updatedSince o cursor la respuesta es una página de la sincronización; sin ellos, la lista con totales.

  • vehicleIdinteger | string | null

    Solo los gastos de ese vehículo.

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 7

  • userIdinteger | string | null

    Solo los gastos de ese conductor.

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 7

  • tripIdinteger | string | null

    Solo los peajes y aparcamientos ligados a ese viaje.

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 7

  • typesstring[] | null

    Solo esos tipos (lista no vacía; los repetidos se ignoran).

    Al menos 1 elementoValores: fuel ev_charge toll parking maintenance itv insurance vehicle_tax renting

  • monthstring | null

    Mes YYYY-MM de expenseDate (del día 1 al último). No se combina con from/to.

    Patrón: ^\d{4}-(0[1-9]|1[0-2])$Ejemplo: 2026-09

  • fromstring (date) | null

    Desde esa expenseDate (incluida).

  • tostring (date) | null

    Hasta esa expenseDate (incluida). No puede ser anterior a from.

  • updatedSincestring | null

    Sincronización: entrega lo cambiado después de este instante (exclusivo). ISO 8601 con hora y zona (Z o ±hh:mm); segundos y hasta 6 decimales opcionales; desde 1970. Usa el syncedUntil de la sincronización anterior.

    Patrón: ^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,6})?)?(?:Z|[+-](?:0\d|1[0-4]):[0-5]\d)$Ejemplo: 2026-10-01T00:00:00Z

  • cursorstring | null

    Sincronización: el nextCursor de la página anterior, tal cual (opaco). Si viene, manda sobre updatedSince.

    Patrón: ^[A-Za-z0-9_-]{1,200}$

  • limitinteger | null

    Sincronización: gastos por página (número JSON). Solo con updatedSince o cursor; si no, unexpected_field.

    Mínimo 1Máximo 200Por defecto: 200

Ejemplo: Lista con totales de un mes

curl -sS -X POST "https://app.flotadoc.com/api/movimientos/gastos/list" \
  -H "Authorization: Bearer $FLOTADOC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "month": "2026-09",
    "vehicleId": 7,
    "types": [
      "fuel",
      "ev_charge"
    ]
  }'
Otro ejemplo: Primera página de una sincronización
{
  "updatedSince": "2026-10-01T00:00:00Z",
  "limit": 200
}
Otro ejemplo: Página siguiente de la sincronización
{
  "cursor": "eyJ2IjoxLCJ1IjoiMjAyNi0xMC0xM1QwOTo1OTowMC4wMDAwMDBaIiwidCI6IjIwMjYtMTAtMTNUMDg6MTU6MDIuMTIzNDU2WiIsImkiOjgyfQ",
  "limit": 200
}

Respuesta

200

Lista con totales o página de la sincronización, según el cuerpo.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

  • dataGastosListPage | GastosSyncPage

    GastosListPage sin updatedSince ni cursor; GastosSyncPage con alguno de los dos.

    Una de estas opciones:

    • GastosListPage

      Lista con totales (gastos activos, del más reciente al más antiguo).

      • expensesGastosExpense[]

        Un gasto.

        Hasta 1000 elementos

      • Totales de todo el filtro, no solo de los gastos devueltos.

      • truncatedboolean

        true si el filtro tiene más de 1000 gastos y solo se devuelven los 1000 más recientes.

    • GastosSyncPage

      Página de la sincronización incremental (anulados incluidos, en orden de cambio).

      • expensesGastosExpense[]

        Como mucho limit gastos. active: false = anulado.

        Hasta 200 elementos

      • nextCursorstring | null

        Cursor de la página siguiente (con los mismos filtros); null = no hay más.

        Patrón: ^[A-Za-z0-9_-]{1,200}$

      • syncedUntilstring

        Instante (UTC, microsegundos) hasta el que llega este recorrido. Al terminar (nextCursor: null), es el updatedSince de la sincronización siguiente.

        Patrón: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$Ejemplo: 2026-10-13T09:59:00.000000Z

Ejemplo de respuesta: Lista con totales
{
  "data": {
    "expenses": [
      {
        "expenseId": 81,
        "tenantId": 42,
        "vehicleId": 7,
        "vehiclePlate": "1234ABC",
        "userId": 11,
        "userName": "Bea Conductora",
        "type": "fuel",
        "addon": null,
        "expenseDate": "2026-09-10",
        "supplierName": "Repsol Getafe",
        "supplierTaxId": "A12345678",
        "baseAmount": 41.32,
        "vatRate": 21,
        "vatAmount": 8.68,
        "totalAmount": 50,
        "liters": 30.12,
        "kwh": null,
        "odometerKm": 120800,
        "notes": null,
        "attachment": {
          "filename": "ticket-repsol.jpg",
          "contentType": "image/jpeg",
          "sizeBytes": 184320
        },
        "ocrExtractionId": null,
        "tripId": null,
        "active": true,
        "integration": {
          "apiKeyId": 7,
          "name": "ERP Contable",
          "prefix": "fd_live_Ab12"
        },
        "createdAt": "2026-09-10T08:12:00.000Z",
        "updatedAt": "2026-09-10T08:12:00.000Z"
      }
    ],
    "totals": {
      "count": 1,
      "baseAmount": 41.32,
      "vatAmount": 8.68,
      "totalAmount": 50,
      "liters": 30.12,
      "kwh": 0,
      "missingBreakdown": 0,
      "toReview": 0,
      "byType": [
        {
          "type": "fuel",
          "totalAmount": 50
        }
      ]
    },
    "truncated": false
  }
}
Ejemplo de respuesta: Página de la sincronización
{
  "data": {
    "expenses": [
      {
        "expenseId": 82,
        "tenantId": 42,
        "vehicleId": 7,
        "vehiclePlate": "1234ABC",
        "userId": 11,
        "userName": "Bea Conductora",
        "type": "toll",
        "addon": "tolls_parking",
        "expenseDate": "2026-10-12",
        "supplierName": "Autopistas AP-6",
        "supplierTaxId": null,
        "baseAmount": null,
        "vatRate": null,
        "vatAmount": null,
        "totalAmount": 12.4,
        "liters": null,
        "kwh": null,
        "odometerKm": null,
        "notes": null,
        "attachment": null,
        "ocrExtractionId": null,
        "tripId": 120,
        "active": false,
        "integration": null,
        "createdAt": "2026-10-12T17:40:11.250Z",
        "updatedAt": "2026-10-13T08:15:02.123Z"
      }
    ],
    "nextCursor": "eyJ2IjoxLCJ1IjoiMjAyNi0xMC0xM1QwOTo1OTowMC4wMDAwMDBaIiwidCI6IjIwMjYtMTAtMTNUMDg6MTU6MDIuMTIzNDU2WiIsImkiOjgyfQ",
    "syncedUntil": "2026-10-13T09:59:00.000000Z"
  }
}

Además, las respuestas comunes: 401, 403, 404, 422, 429, 503.

Consulta un gasto

POST/movimientos/gastos/get

  • Permiso lectura

Un gasto activo de la empresa. Un gasto anulado responde 404, igual que uno inexistente o de otra empresa: los anulados solo salen en la sincronización de list (active: false) y en history.

Con clave API actúas como el owner que la creó: alcanzas los gastos de todos los conductores. Un gasto dado de alta por la API lleva en integration la clave con la que se registró.

Errores propios

  • 404 expense_not_found: no existe, está anulado o es de otra empresa (no se distingue).
  • 422 invalid_input: fields.expenseId = required o invalid; otra clave, unexpected_field.

Petición

Cuerpo obligatorio

  • expenseIdinteger | stringobligatorio

    Identificador. Entero positivo; también se admite como texto de dígitos ("81").

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 81

Ejemplo

curl -sS -X POST "https://app.flotadoc.com/api/movimientos/gastos/get" \
  -H "Authorization: Bearer $FLOTADOC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expenseId": 81
  }'

Respuesta

200

El gasto.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

Ejemplo de respuesta
{
  "data": {
    "expense": {
      "expenseId": 81,
      "tenantId": 42,
      "vehicleId": 7,
      "vehiclePlate": "1234ABC",
      "userId": 11,
      "userName": "Bea Conductora",
      "type": "fuel",
      "addon": null,
      "expenseDate": "2026-10-01",
      "supplierName": "Repsol Getafe",
      "supplierTaxId": "A12345678",
      "baseAmount": 41.32,
      "vatRate": 21,
      "vatAmount": 8.68,
      "totalAmount": 50,
      "liters": 30.12,
      "kwh": null,
      "odometerKm": 120800,
      "notes": null,
      "attachment": {
        "filename": "ticket-repsol.jpg",
        "contentType": "image/jpeg",
        "sizeBytes": 184320
      },
      "ocrExtractionId": null,
      "tripId": null,
      "active": true,
      "integration": {
        "apiKeyId": 7,
        "name": "ERP Contable",
        "prefix": "fd_live_Ab12"
      },
      "createdAt": "2026-10-01T09:30:12.345Z",
      "updatedAt": "2026-10-01T09:30:12.345Z"
    }
  }
}

Además, las respuestas comunes: 401, 403, 404, 422, 429, 503.

Historial de versiones de un gasto

POST/movimientos/gastos/history

  • Permiso lectura

Versiones selladas de un gasto cuando la empresa tiene activos los registros inalterables: alta (create), correcciones (correct, también al adjuntar o sustituir el ticket) y anulación (void), de la primera a la última. Cada versión lleva autor, hora, motivo, la instantánea fiscal del gasto después del cambio, la huella de su ticket y qué campos cambiaron respecto a la anterior.

  • Vale también para un gasto anulado (su historial termina en void).
  • Un gasto de antes de activar el ajuste, o de una empresa sin él, da versions: [].
  • Una versión cuyo contenido falta o no se entiende sale con redacted: true y sin datos: es una alteración, y POST /movimientos/integridad/verify da la cadena por rota.
  • Hasta 500 versiones por gasto.

Con clave API actúas como el owner que la creó: alcanzas los gastos de todos los conductores. El autor de lo registrado por la API es ese owner.

Errores propios

  • 404 expense_not_found: no existe o es de otra empresa.
  • 422 invalid_input: fields.expenseId = required o invalid; otra clave, unexpected_field.

Petición

Cuerpo obligatorio

  • expenseIdinteger | stringobligatorio

    Identificador. Entero positivo; también se admite como texto de dígitos ("81").

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 81

Ejemplo

curl -sS -X POST "https://app.flotadoc.com/api/movimientos/gastos/history" \
  -H "Authorization: Bearer $FLOTADOC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expenseId": 81
  }'

Respuesta

200

Las versiones del gasto (vacía sin registros inalterables).

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

  • dataobject
    • expenseIdinteger

      Mínimo 1

    • versionsGastosVersion[]

      De la primera a la última. Vacía si el gasto no tiene versiones selladas.

      Hasta 500 elementos

Ejemplo de respuesta
{
  "data": {
    "expenseId": 81,
    "versions": [
      {
        "seq": 12,
        "version": 1,
        "change": "create",
        "author": {
          "userId": 10,
          "name": "Ana Titular"
        },
        "recordedAt": "2026-10-01T09:30:12.345Z",
        "reason": null,
        "snapshot": {
          "vehicleId": 7,
          "userId": 11,
          "type": "fuel",
          "expenseDate": "2026-10-01",
          "supplierName": "Repsol Getafe",
          "supplierTaxId": "A12345678",
          "baseAmount": 41.32,
          "vatRate": 21,
          "vatAmount": 8.68,
          "totalAmount": 50,
          "liters": 30.12,
          "kwh": null,
          "odometerKm": 120800,
          "notes": null,
          "tripId": null,
          "ticketSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
        },
        "files": [
          {
            "kind": "ticket",
            "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
            "bytes": 184320
          }
        ],
        "changed": [],
        "entryHash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
        "redacted": false
      },
      {
        "seq": 15,
        "version": 2,
        "change": "correct",
        "author": {
          "userId": 10,
          "name": "Ana Titular"
        },
        "recordedAt": "2026-10-02T10:02:40.120Z",
        "reason": "El importe estaba mal tecleado",
        "snapshot": {
          "vehicleId": 7,
          "userId": 11,
          "type": "fuel",
          "expenseDate": "2026-10-01",
          "supplierName": "Repsol Getafe",
          "supplierTaxId": "A12345678",
          "baseAmount": 45.45,
          "vatRate": 21,
          "vatAmount": 9.55,
          "totalAmount": 55,
          "liters": 30.12,
          "kwh": null,
          "odometerKm": 120800,
          "notes": null,
          "tripId": null,
          "ticketSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
        },
        "files": [
          {
            "kind": "ticket",
            "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
            "bytes": 184320
          }
        ],
        "changed": [
          "baseAmount",
          "totalAmount",
          "vatAmount"
        ],
        "entryHash": "fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9",
        "redacted": false
      }
    ]
  }
}

Además, las respuestas comunes: 401, 403, 404, 422, 429, 503.

Tipos de gasto disponibles

POST/movimientos/gastos/types-list

  • Permiso lectura

Los nueve tipos de gasto, siempre en el mismo orden, con el complemento que exige cada uno (addon; null = plan base) y si la empresa lo tiene activo (available).

  • available es true en el plan base o si el complemento está activo (pagado o regalado; en la prueba, solo los regalados). Es la misma regla que aplica save, que sigue siendo quien decide: guardar un tipo no disponible responde 403 feature_not_included.
  • No dice si la cuenta admite escrituras: eso lo responde cada escritura (423 account_read_only).

El cuerpo va vacío o como {}.

Errores propios

  • 422 invalid_input: cualquier clave del cuerpo es unexpected_field; sin fields si el cuerpo no es un objeto JSON.

Petición

Cuerpo opcional

Sin campos.

object

Ejemplo

curl -sS -X POST "https://app.flotadoc.com/api/movimientos/gastos/types-list" \
  -H "Authorization: Bearer $FLOTADOC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

Respuesta

200

El catálogo de tipos con su disponibilidad en la empresa.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

Ejemplo de respuesta
{
  "data": {
    "types": [
      {
        "type": "fuel",
        "addon": null,
        "available": true
      },
      {
        "type": "ev_charge",
        "addon": null,
        "available": true
      },
      {
        "type": "toll",
        "addon": "tolls_parking",
        "available": true
      },
      {
        "type": "parking",
        "addon": "tolls_parking",
        "available": true
      },
      {
        "type": "maintenance",
        "addon": "maintenance_itv",
        "available": false
      },
      {
        "type": "itv",
        "addon": "maintenance_itv",
        "available": false
      },
      {
        "type": "insurance",
        "addon": "insurance_tax_renting",
        "available": false
      },
      {
        "type": "vehicle_tax",
        "addon": "insurance_tax_renting",
        "available": false
      },
      {
        "type": "renting",
        "addon": "insurance_tax_renting",
        "available": false
      }
    ]
  }
}

Además, las respuestas comunes: 401, 403, 404, 422, 429, 503.

URL de descarga del ticket

POST/movimientos/gastos/attachment-download-url

  • Permiso lectura

URL prefirmada de S3 para descargar el ticket (foto o PDF) de un gasto activo. Caduca a los 300 segundos (expiresIn).

  • Descárgalo con un GET a url, sin cabecera Authorization: la firma de la URL es la credencial. No la guardes ni la registres en logs.
  • S3 lo sirve con el contentType y el filename guardados, como Content-Disposition: attachment (descarga) o, con inline: true, inline (para mostrarlo).
  • Es una lectura: funciona aunque la cuenta esté en solo lectura o el mes esté cerrado, y no exige complementos.
  • El ticket de un gasto anulado no se puede descargar por aquí (404).

Errores propios

  • 404 expense_not_found: el gasto no existe, está anulado o es de otra empresa.
  • 404 attachment_not_found: el gasto no tiene ticket.
  • 422 invalid_input: fields.expenseId = required o invalid; fields.inline = invalid (no es booleano); otra clave, unexpected_field.
  • 502 storage_unavailable: S3 no pudo firmar la URL. Reintenta.

Petición

Cuerpo obligatorio

  • expenseIdinteger | stringobligatorio

    Identificador. Entero positivo; también se admite como texto de dígitos ("81").

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 81

  • inlineboolean | null

    true = Content-Disposition: inline (para mostrarlo); por defecto, attachment (descarga).

    Por defecto: false

Ejemplo

curl -sS -X POST "https://app.flotadoc.com/api/movimientos/gastos/attachment-download-url" \
  -H "Authorization: Bearer $FLOTADOC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expenseId": 81,
    "inline": false
  }'

Respuesta

200

URL de descarga del ticket.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

  • dataobject
    • urlstring (uri)

      URL prefirmada de S3 para un GET sin Authorization. Es una credencial temporal.

    • filenamestring

      Hasta 120 caracteres

    • contentTypestring

      Tipo del ticket. Sin SVG ni HTML.

      Valores: application/pdf image/jpeg image/png image/webp

    • sizeBytesinteger

      Mínimo 1Máximo 20971520

    • expiresIninteger

      Segundos de validez de la URL.

      Siempre 300

Ejemplo de respuesta
{
  "data": {
    "url": "https://pfens-flotadoc-documents-production.s3.eu-west-1.amazonaws.com/42/expenses/u10/9f1c2a1b0d4e5f60718293a4b5c6d7e8/ticket-repsol.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Date=20261013T091500Z&X-Amz-Signature=5d41402abc4b2a76b9719d911017c592",
    "filename": "ticket-repsol.jpg",
    "contentType": "image/jpeg",
    "sizeBytes": 184320,
    "expiresIn": 300
  }
}

502

El almacén de documentos no ha respondido (storage_unavailable). Reintenta con espera.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

Cuerpo con el formato de los errores.

Ejemplo de respuesta
{
  "error": {
    "code": "storage_unavailable",
    "message": "No se pudo acceder al almacen de documentos"
  }
}

Además, las respuestas comunes: 401, 403, 404, 422, 429, 503.

Registra o corrige un gasto

POST/movimientos/gastos/save

  • Permiso registros
  • Admite Idempotency-Key

Alta (sin expenseId) o corrección (con expenseId) de un gasto. Es un guardado completo: manda siempre todos los campos; un campo opcional ausente, null o "" se guarda vacío (también en una corrección: no mandar tripId suelta el viaje). La excepción es el ticket: solo cambia si llega attachmentKey con una clave distinta de la que ya tiene.

Con clave API actúas como el owner que la creó (misma empresa, complementos, cuenta, mes cerrado y registros inalterables) y registras en nombre de un conductor:

  • En el alta, driverUserId es obligatorio (el id de POST /auth/usuarios/list). El gasto es suyo (userId): sale en su lista y en la del owner. Tiene que poder registrarlo él: persona activa de la empresa con rol user u owner (una invitada que aún no ha entrado vale) y, si es user, en un vehículo que puede usar (variable, o fijo con él entre sus conductores).
  • El alta queda marcada con la clave (integration). Una corrección no cambia ni el conductor ni el origen; driverUserId, si viene, tiene que ser el del gasto.
  • El autor del sello (registros inalterables) y de la auditoría es el owner de la clave.
  • ocrExtractionId es de la app (el OCR no está en la API): la lectura tendría que haberla pedido el propio owner. Una integración manda los campos y lo omite.

Ticket con el gasto. Pide la URL con attachment-upload-url, sube el fichero con el PUT y manda aquí su key en attachmentKey antes de 24 h: el gasto y su ticket se guardan en la misma operación (o ninguno). El servidor comprueba en S3 que el fichero está y es una imagen o PDF de hasta 20 MB, y calcula su huella SHA-256. La subida tiene que haberla pedido el mismo owner (con cualquiera de sus claves) y no puede estar adjunta a otro gasto. Para adjuntarlo después: attachment-attach.

Reglas de negocio

  • fuel solo en vehículos de combustión o híbridos y con liters; ev_charge solo en eléctricos o híbridos y con kwh. liters y kwh están prohibidos en los demás tipos.
  • tripId solo en toll y parking: viaje activo de la empresa y del mismo vehículo. Una corrección que mantiene el mismo viaje y vehículo no lo vuelve a comprobar.
  • Si llegan baseAmount y vatAmount, base + cuota = total con ±0,02 € de margen.
  • Vehículo activo de la empresa; una corrección que no cambia de vehículo admite uno ya de baja.
  • Registrar o corregir un tipo de complemento exige tenerlo (en una corrección, también el del tipo que tenía): types-list dice cuáles están disponibles.
  • Mes cerrado: el mes de destino (vehículo y fecha) y, en una corrección, el mes donde está hoy el gasto tienen que estar abiertos.
  • Registros inalterables: el alta sella una versión create (sin motivo); la corrección, una correct que exige reason. Sin el ajuste, reason es opcional y no se guarda.

Orden de comprobación: puerta → cuenta (423) → cuerpo (422) → driverUserId obligatorio → attachmentKey → gasto a corregir (404) y subida del ticket (422) → complemento (403) → vehículo (404) y energía (422) → conductor (422) → viaje (404/422) → ticket en S3 (422/502) → lectura OCR (422) → escritura: mes cerrado (409), subida caducada (422), motivo (422).

Errores propios

  • 403 feature_not_included (con feature): el tipo, o en una corrección el que tenía, es de un complemento que la empresa no tiene.
  • 404 expense_not_found: corrección de un gasto inexistente, anulado o de otra empresa.
  • 404 vehicle_not_found: el vehículo no existe, es de otra empresa o está de baja.
  • 404 trip_not_found: el viaje de tripId no existe, está anulado o descartado, o es de otra empresa.
  • 409 period_closed (con periods: [{ vehicleId, month: "YYYY-MM" }]): algún mes afectado está cerrado; hay que pedir a la empresa que lo reabra. También 409 idempotency_* de la puerta.
  • 422 invalid_input con error.fields. Los de formato llegan todos a la vez: required, invalid, too_long, out_of_range, in_future (expenseDate posterior a hoy en Madrid), mismatch (totalAmount no cuadra con base + cuota), not_for_type (liters, kwh o tripId en un tipo que no los admite), unexpected_field. Llegan solos, porque se comprueban después: driverUserId = required (alta sin conductor), not_found (no es conductor activo de la empresa), vehicle_not_allowed (no puede usar ese vehículo) o immutable (corrección con otro conductor); attachmentKey = invalid (no es una clave emitida para este owner en esta empresa); type = not_for_vehicle (energía del vehículo); tripId = not_for_vehicle (el viaje es de otro vehículo); ocrExtractionId = invalid.
  • 422 reason_required: corrección sin reason con registros inalterables (no se guardó nada).
  • 422 attachment_not_uploaded: la subida de attachmentKey no está registrada, tiene más de 24 h, se descartó, ya es el ticket de otro gasto, o el fichero no está en S3 (no se hizo el PUT o no terminó). Vuelve a subirlo.
  • 422 attachment_invalid: el fichero de S3 no es una imagen JPEG/PNG/WebP o un PDF de 1 byte a 20 MB.
  • 423 account_read_only (con reason: account, suspended o pending_payment): la cuenta no admite registros (prueba, contrato o pago vencidos). Leer sigue funcionando.
  • 502 storage_unavailable: S3 no respondió al comprobar o leer el ticket.

Petición

Cabeceras

  • Idempotency-Keystring

    Clave de idempotencia de la escritura (opcional, recomendada). Vale 24 horas por clave API: la misma Idempotency-Key con la misma ruta y el mismo cuerpo exacto devuelve la respuesta guardada con Idempotent-Replayed: true sin volver a ejecutar la operación. Con otro cuerpo u otra ruta: 409 idempotency_conflict; si la primera aún está en curso: 409 idempotency_in_progress. Una respuesta 5xx no se guarda (se puede reintentar). Mal formada: 422 invalid_idempotency_key.

    Patrón: ^[A-Za-z0-9_-]{1,100}$

Cuerpo obligatorio

Guardado completo: un campo opcional ausente, null o "" se guarda vacío. Los importes y cantidades admiten número JSON o texto con punto decimal ("41.32").

  • expenseIdinteger | string | null

    Ausente o null = alta. Un id = corrección de ese gasto (activo).

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 7

  • driverUserIdinteger | string | null

    Conductor del gasto. Con clave API, obligatorio en el alta: persona activa de la empresa con rol user u owner que pueda usar el vehículo. En una corrección se puede omitir; si viene, tiene que ser el conductor del gasto.

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 7

  • vehicleIdinteger | stringobligatorio

    Vehículo activo de la empresa (en una corrección que no lo cambia, también uno de baja).

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 81

  • typestringobligatorio

    Tipo de gasto. fuel (combustible) y ev_charge (recarga eléctrica) son del plan base; toll (peaje) y parking (aparcamiento) exigen el complemento tolls_parking; maintenance (mantenimiento o taller) e itv, maintenance_itv; insurance (seguro), vehicle_tax (impuesto de circulación) y renting (cuota de renting), insurance_tax_renting.

    Valores: fuel ev_charge toll parking maintenance itv insurance vehicle_tax renting

  • expenseDatestring (date)obligatorio

    Fecha del ticket. Real, desde 2000-01-01 y no posterior a hoy en Europe/Madrid.

  • totalAmountnumber | stringobligatorio

    Total en euros, > 0, hasta 2 decimales. Si vienen base y cuota, base + cuota = total ± 0,02.

    Mayor que 0Máximo 999999.99Patrón: ^\d{1,9}(\.\d{1,2})?$Ejemplo: 50

  • baseAmountnumber | string | null

    Base imponible en euros, ≥ 0, hasta 2 decimales.

    Mínimo 0Máximo 999999.99Patrón: ^(\d{1,9}(\.\d{1,2})?)?$

  • vatRatenumber | string | null

    Tipo de IVA en %, de 0 a 100, hasta 2 decimales.

    Mínimo 0Máximo 100Patrón: ^(\d{1,9}(\.\d{1,2})?)?$

  • vatAmountnumber | string | null

    Cuota de IVA en euros, ≥ 0, hasta 2 decimales.

    Mínimo 0Máximo 999999.99Patrón: ^(\d{1,9}(\.\d{1,2})?)?$

  • supplierNamestring | null

    Proveedor. Se recortan los espacios y se colapsan los internos; el límite cuenta después.

    Hasta 200 caracteres

  • supplierTaxIdstring | null

    NIF del proveedor. Se quitan espacios, puntos y guiones y pasa a mayúsculas; después tiene que quedar de 2 a 20 caracteres A-Z0-9 ("B-12.345.678" → B12345678). No se comprueba la letra de control (hay proveedores de fuera).

  • litersnumber | string | null

    Litros, > 0, hasta 3 decimales. Obligatorio en fuel y prohibido en el resto (not_for_type).

    Mayor que 0Máximo 9999.999Patrón: ^(\d{1,9}(\.\d{1,3})?)?$

  • kwhnumber | string | null

    kWh, > 0, hasta 3 decimales. Obligatorio en ev_charge y prohibido en el resto (not_for_type).

    Mayor que 0Máximo 9999.999Patrón: ^(\d{1,9}(\.\d{1,3})?)?$

  • odometerKminteger | string | null

    Cuentakilómetros, entero de 0 a 9 999 999 (número JSON; "" o null = sin dato).

    Mínimo 0Máximo 9999999Hasta 0 caracteres

  • notesstring | null

    Notas. Se recortan los espacios de los extremos; el límite cuenta después.

    Hasta 500 caracteres

  • tripIdinteger | string | null

    Viaje en el que se pagó. Solo en toll y parking; viaje activo del mismo vehículo.

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 7

  • ocrExtractionIdinteger | string | null

    Lectura OCR con la que se rellenó (uso de la app; el OCR no está en la API). Tiene que ser una lectura válida pedida por el owner de la clave; si no, invalid. En una corrección, sin él se conserva la que tuviera.

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 7

  • attachmentKeystring | null

    La key de attachment-upload-url, tal cual: el ticket se guarda con el gasto. Ausente o null = el ticket no cambia (igual que la clave del ticket que ya tiene).

    De 1 a 512 caracteresPatrón: ^[1-9]\d*/expenses/u[1-9]\d*/[0-9a-f]{32}/[A-Za-z0-9][A-Za-z0-9._-]{0,119}$

  • reasonstring | null

    Motivo de la corrección (se recortan los espacios; vacío = sin motivo). Obligatorio en una corrección con registros inalterables. En un alta no se usa.

    Hasta 500 caracteres

Ejemplo: Alta de un repostaje con su ticket, en nombre de un conductor

curl -sS -X POST "https://app.flotadoc.com/api/movimientos/gastos/save" \
  -H "Authorization: Bearer $FLOTADOC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{
    "driverUserId": 11,
    "vehicleId": 7,
    "type": "fuel",
    "expenseDate": "2026-10-01",
    "totalAmount": 50,
    "baseAmount": 41.32,
    "vatRate": 21,
    "vatAmount": 8.68,
    "supplierName": "Repsol Getafe",
    "supplierTaxId": "A12345678",
    "liters": 30.12,
    "odometerKm": 120800,
    "notes": null,
    "attachmentKey": "42/expenses/u10/9f1c2a1b0d4e5f60718293a4b5c6d7e8/ticket-repsol.jpg"
  }'
Otro ejemplo: Corrección con motivo (registros inalterables)
{
  "expenseId": 81,
  "vehicleId": 7,
  "type": "fuel",
  "expenseDate": "2026-10-01",
  "totalAmount": 55,
  "baseAmount": 45.45,
  "vatRate": 21,
  "vatAmount": 9.55,
  "supplierName": "Repsol Getafe",
  "supplierTaxId": "A12345678",
  "liters": 30.12,
  "odometerKm": 120800,
  "reason": "El importe estaba mal tecleado"
}

Respuesta

200

El gasto guardado.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

  • Idempotent-Replayedstring

    true si es la respuesta guardada de una petición anterior con la misma Idempotency-Key.

    Valores: true

Ejemplo de respuesta
{
  "data": {
    "expense": {
      "expenseId": 81,
      "tenantId": 42,
      "vehicleId": 7,
      "vehiclePlate": "1234ABC",
      "userId": 11,
      "userName": "Bea Conductora",
      "type": "fuel",
      "addon": null,
      "expenseDate": "2026-10-01",
      "supplierName": "Repsol Getafe",
      "supplierTaxId": "A12345678",
      "baseAmount": 41.32,
      "vatRate": 21,
      "vatAmount": 8.68,
      "totalAmount": 50,
      "liters": 30.12,
      "kwh": null,
      "odometerKm": 120800,
      "notes": null,
      "attachment": {
        "filename": "ticket-repsol.jpg",
        "contentType": "image/jpeg",
        "sizeBytes": 184320
      },
      "ocrExtractionId": null,
      "tripId": null,
      "active": true,
      "integration": {
        "apiKeyId": 7,
        "name": "ERP Contable",
        "prefix": "fd_live_Ab12"
      },
      "createdAt": "2026-10-01T09:30:12.345Z",
      "updatedAt": "2026-10-01T09:30:12.345Z"
    }
  }
}

502

El almacén de documentos no ha respondido al comprobar el ticket (storage_unavailable). No se guardó nada.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

Cuerpo con el formato de los errores.

Ejemplo de respuesta
{
  "error": {
    "code": "storage_unavailable",
    "message": "No se pudo acceder al almacen de documentos"
  }
}

Además, las respuestas comunes: 401, 403, 404, 409, 422, 423, 429, 503.

Anula un gasto

POST/movimientos/gastos/delete

  • Permiso registros
  • Admite Idempotency-Key

Anula un gasto activo. Es un borrado lógico: el gasto y su ticket se conservan como justificante fiscal, pero sale de las listas, de los totales y del precio del combustible del mes. La sincronización de list lo entrega después con active: false.

  • No exige complemento: se puede anular un peaje aunque la empresa ya no tenga ese complemento.
  • Registros inalterables: anular sella una versión void y exige reason. Sin el ajuste, reason es opcional y no se guarda.
  • Mes cerrado: si el mes del gasto (su vehículo y su fecha) está cerrado, no se anula.

Con clave API actúas como el owner que la creó: puedes anular cualquier gasto de la empresa.

Errores propios

  • 404 expense_not_found: no existe, ya está anulado o es de otra empresa.
  • 409 period_closed (con periods: [{ vehicleId, month: "YYYY-MM" }]): el mes del gasto está cerrado. También 409 idempotency_* de la puerta.
  • 422 invalid_input: fields.expenseId = required o invalid; fields.reason = invalid o too_long; otra clave, unexpected_field.
  • 422 reason_required: sin reason con registros inalterables (no se anuló).
  • 423 account_read_only (con reason: account, suspended o pending_payment).

Petición

Cabeceras

  • Idempotency-Keystring

    Clave de idempotencia de la escritura (opcional, recomendada). Vale 24 horas por clave API: la misma Idempotency-Key con la misma ruta y el mismo cuerpo exacto devuelve la respuesta guardada con Idempotent-Replayed: true sin volver a ejecutar la operación. Con otro cuerpo u otra ruta: 409 idempotency_conflict; si la primera aún está en curso: 409 idempotency_in_progress. Una respuesta 5xx no se guarda (se puede reintentar). Mal formada: 422 invalid_idempotency_key.

    Patrón: ^[A-Za-z0-9_-]{1,100}$

Cuerpo obligatorio

  • expenseIdinteger | stringobligatorio

    Identificador. Entero positivo; también se admite como texto de dígitos ("81").

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 81

  • reasonstring | null

    Motivo de la anulación (se recortan los espacios). Obligatorio con registros inalterables.

    Hasta 500 caracteres

Ejemplo

curl -sS -X POST "https://app.flotadoc.com/api/movimientos/gastos/delete" \
  -H "Authorization: Bearer $FLOTADOC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{
    "expenseId": 81,
    "reason": "Ticket duplicado"
  }'

Respuesta

200

El gasto ha quedado anulado.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

  • Idempotent-Replayedstring

    true si es la respuesta guardada de una petición anterior con la misma Idempotency-Key.

    Valores: true

  • dataobject
    • deletedboolean

      Siempre true

    • expenseIdinteger

      Mínimo 1

Ejemplo de respuesta
{
  "data": {
    "deleted": true,
    "expenseId": 81
  }
}

Además, las respuestas comunes: 401, 403, 404, 409, 422, 423, 429, 503.

URL de subida del ticket

POST/movimientos/gastos/attachment-upload-url

  • Permiso registros

Primer paso para guardar el ticket (foto o PDF) de un gasto. Declaras nombre, tipo y tamaño exacto del fichero y el servidor compone la clave de S3 y firma un PUT directo a S3: los bytes nunca pasan por la API. No pide gasto: el ticket se sube antes de guardarlo.

Subida (PUT). En menos de 300 s (expiresIn):

PUT <url>
Content-Type: <contentType, el mismo que declaraste>
Content-Length: <sizeBytes, exactamente>

<bytes del fichero>
  • Content-Type y Content-Length van en la firma (X-Amz-SignedHeaders=content-length;content-type;host): otro tipo u otro tamaño hace que S3 responda 403 y no guarde nada.
  • Sin cabecera Authorization: la firma de la URL es la credencial. No la guardes ni la registres en logs. S3 responde 200 con el cuerpo vacío.
  • Por ejemplo: curl -X PUT "$URL" -H 'Content-Type: image/jpeg' --data-binary @ticket.jpg.

Después, antes de 24 h desde esta llamada, liga el ticket a un gasto con su key: save con attachmentKey (alta o corrección; el gasto y el ticket se guardan juntos) o attachment-attach (gasto ya existente). Una subida que nadie adjunta en 24 h se borra sola.

  • Cada llamada crea una subida nueva (key distinta, aunque el nombre se repita): nunca pisa un ticket ya adjuntado.
  • filename vuelve saneado: es el nombre con el que se guardará y descargará el ticket.
  • Con clave API, la subida es del owner que creó la clave (la key lleva u{su id}), aunque el gasto sea de un conductor; solo ese owner (con cualquiera de sus claves) puede adjuntarla.
  • Sin Idempotency-Key: la operación solo firma una URL, así que no guarda respuesta y la cabecera se ignora (como en las lecturas). Repetir la llamada da otra url válida y otra key, nunca una URL caducada; la subida que no uses se borra sola a las 24 h. Si se corta la conexión, vuelve a pedirla.

Errores propios

  • 413 payload_too_large (con maxBytes: 20971520): sizeBytes supera 20 MB y el resto del cuerpo es válido.
  • 422 invalid_input con error.fields: filename = required o invalid (no queda nada útil al sanearlo); contentType = required o invalid (no es application/pdf, image/jpeg, image/png ni image/webp); sizeBytes = required, invalid (no es un entero > 0) o too_large (junto a otros errores); otra clave, unexpected_field.
  • 423 account_read_only (con reason: account, suspended o pending_payment): se comprueba antes que el cuerpo.
  • 502 storage_unavailable: S3 no pudo firmar. Pide otra URL.

Petición

Cuerpo obligatorio

  • filenamestringobligatorio

    Nombre del fichero. Se sanea: último segmento de la ruta, solo A-Z a-z 0-9 . _ - (lo demás pasa a _), sin .., empezando por letra o dígito y recortado a 120 caracteres conservando la extensión.

    Al menos 1 carácterEjemplo: ticket-repsol.jpg

  • contentTypestringobligatorio

    Tipo del ticket. Sin SVG ni HTML.

    Valores: application/pdf image/jpeg image/png image/webp

  • sizeBytesintegerobligatorio

    Tamaño exacto del fichero en bytes (hasta 20 MB). Va en la firma del PUT.

    Mínimo 1Máximo 20971520

Ejemplo

curl -sS -X POST "https://app.flotadoc.com/api/movimientos/gastos/attachment-upload-url" \
  -H "Authorization: Bearer $FLOTADOC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "ticket-repsol.jpg",
    "contentType": "image/jpeg",
    "sizeBytes": 184320
  }'

Respuesta

200

URL prefirmada para el PUT y la key con la que ligar el ticket al gasto.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

  • dataobject
    • urlstring (uri)

      URL prefirmada de S3 para el PUT (sin Authorization). Es una credencial temporal.

    • keystring

      {empresa}/expenses/u{quien sube}/{id de subida}/{fichero}. Mándala en save (attachmentKey) o en attachment-attach (key) antes de 24 h.

      Hasta 512 caracteresPatrón: ^[1-9]\d*/expenses/u[1-9]\d*/[0-9a-f]{32}/[A-Za-z0-9][A-Za-z0-9._-]{0,119}$

    • filenamestring

      El nombre ya saneado.

      Hasta 120 caracteres

    • contentTypestring

      Tipo del ticket. Sin SVG ni HTML.

      Valores: application/pdf image/jpeg image/png image/webp

    • sizeBytesinteger

      Mínimo 1Máximo 20971520

    • expiresIninteger

      Segundos de validez de la URL.

      Siempre 300

Ejemplo de respuesta
{
  "data": {
    "url": "https://pfens-flotadoc-documents-production.s3.eu-west-1.amazonaws.com/42/expenses/u10/9f1c2a1b0d4e5f60718293a4b5c6d7e8/ticket-repsol.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost&X-Amz-Date=20261013T091500Z&X-Amz-Signature=5d41402abc4b2a76b9719d911017c592",
    "key": "42/expenses/u10/9f1c2a1b0d4e5f60718293a4b5c6d7e8/ticket-repsol.jpg",
    "filename": "ticket-repsol.jpg",
    "contentType": "image/jpeg",
    "sizeBytes": 184320,
    "expiresIn": 300
  }
}

413

El fichero declarado supera el tamaño máximo (payload_too_large, con maxBytes).

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

Cuerpo con el formato de los errores.

Ejemplo de respuesta
{
  "error": {
    "code": "payload_too_large",
    "message": "El ticket supera el tamano maximo",
    "maxBytes": 20971520
  }
}

502

S3 no pudo firmar la URL (storage_unavailable). Pide otra.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

Cuerpo con el formato de los errores.

Ejemplo de respuesta
{
  "error": {
    "code": "storage_unavailable",
    "message": "No se pudo acceder al almacen de documentos"
  }
}

Además, las respuestas comunes: 401, 403, 404, 422, 423, 429, 503.

Adjunta o sustituye el ticket de un gasto

POST/movimientos/gastos/attachment-attach

  • Permiso registros
  • Admite Idempotency-Key

Liga a un gasto ya existente el ticket subido con attachment-upload-url y el PUT. Si el gasto ya tenía ticket, lo sustituye. Para un alta con ticket es más simple mandar la key en save (attachmentKey).

  • La key tiene que ser una subida del mismo owner (con cualquiera de sus claves), de menos de 24 h y no adjunta a otro gasto. El servidor comprueba en S3 que el fichero está y es una imagen o PDF de hasta 20 MB, y calcula su huella SHA-256.
  • Repetir la llamada con la key que ya es el ticket del gasto responde 200 sin cambiar nada.
  • Adjuntar a un gasto de complemento exige tener el complemento.
  • Mes cerrado: el ticket de un mes cerrado queda congelado (tampoco se sustituye).
  • Registros inalterables: adjuntar es una corrección sellada (correct). El primer ticket de un gasto que no tenía no necesita reason (el servidor pone «Se añade el ticket»); sustituirlo exige reason. El ticket sustituido se conserva en la versión anterior. Sin el ajuste, reason es opcional y no se guarda, y el ticket sustituido se descarta.

Con clave API actúas como el owner que la creó: puedes adjuntar el ticket a un gasto de cualquier conductor de la empresa.

Errores propios

  • 403 feature_not_included (con feature): el gasto es de un complemento que la empresa no tiene.
  • 404 expense_not_found: el gasto no existe, está anulado o es de otra empresa.
  • 409 period_closed (con periods: [{ vehicleId, month: "YYYY-MM" }]): el mes del gasto está cerrado. También 409 idempotency_* de la puerta.
  • 422 invalid_input con error.fields: expenseId = required o invalid; key = required o invalid (también, sola, si no es una clave emitida para este owner en esta empresa); reason = invalid o too_long; otra clave, unexpected_field.
  • 422 attachment_not_uploaded: la subida no está registrada, tiene más de 24 h, se descartó, ya es el ticket de otro gasto, o el fichero no está en S3. Vuelve a subirlo.
  • 422 attachment_invalid: el fichero de S3 no es una imagen JPEG/PNG/WebP o un PDF de 1 byte a 20 MB.
  • 422 reason_required: sustituir el ticket sin reason con registros inalterables (no se cambió nada).
  • 423 account_read_only (con reason: account, suspended o pending_payment).
  • 502 storage_unavailable: S3 no respondió al comprobar o leer el fichero.

Petición

Cabeceras

  • Idempotency-Keystring

    Clave de idempotencia de la escritura (opcional, recomendada). Vale 24 horas por clave API: la misma Idempotency-Key con la misma ruta y el mismo cuerpo exacto devuelve la respuesta guardada con Idempotent-Replayed: true sin volver a ejecutar la operación. Con otro cuerpo u otra ruta: 409 idempotency_conflict; si la primera aún está en curso: 409 idempotency_in_progress. Una respuesta 5xx no se guarda (se puede reintentar). Mal formada: 422 invalid_idempotency_key.

    Patrón: ^[A-Za-z0-9_-]{1,100}$

Cuerpo obligatorio

  • expenseIdinteger | stringobligatorio

    Identificador. Entero positivo; también se admite como texto de dígitos ("81").

    Mínimo 1Máximo 2147483647Patrón: ^[1-9]\d{0,15}$Ejemplo: 81

  • keystringobligatorio

    La key de attachment-upload-url, tal cual.

    De 1 a 512 caracteresPatrón: ^[1-9]\d*/expenses/u[1-9]\d*/[0-9a-f]{32}/[A-Za-z0-9][A-Za-z0-9._-]{0,119}$

  • reasonstring | null

    Motivo (se recortan los espacios). Con registros inalterables, obligatorio para sustituir un ticket; para el primero, opcional (el servidor pone «Se añade el ticket»).

    Hasta 500 caracteres

Ejemplo

curl -sS -X POST "https://app.flotadoc.com/api/movimientos/gastos/attachment-attach" \
  -H "Authorization: Bearer $FLOTADOC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{
    "expenseId": 81,
    "key": "42/expenses/u10/9f1c2a1b0d4e5f60718293a4b5c6d7e8/ticket-repsol.jpg"
  }'

Respuesta

200

El gasto con su ticket.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

  • Idempotent-Replayedstring

    true si es la respuesta guardada de una petición anterior con la misma Idempotency-Key.

    Valores: true

Ejemplo de respuesta
{
  "data": {
    "expense": {
      "expenseId": 81,
      "tenantId": 42,
      "vehicleId": 7,
      "vehiclePlate": "1234ABC",
      "userId": 11,
      "userName": "Bea Conductora",
      "type": "fuel",
      "addon": null,
      "expenseDate": "2026-10-01",
      "supplierName": "Repsol Getafe",
      "supplierTaxId": "A12345678",
      "baseAmount": 41.32,
      "vatRate": 21,
      "vatAmount": 8.68,
      "totalAmount": 50,
      "liters": 30.12,
      "kwh": null,
      "odometerKm": 120800,
      "notes": null,
      "attachment": {
        "filename": "ticket-repsol.jpg",
        "contentType": "image/jpeg",
        "sizeBytes": 184320
      },
      "ocrExtractionId": null,
      "tripId": null,
      "active": true,
      "integration": {
        "apiKeyId": 7,
        "name": "ERP Contable",
        "prefix": "fd_live_Ab12"
      },
      "createdAt": "2026-10-01T09:30:12.345Z",
      "updatedAt": "2026-10-01T11:02:05.871Z"
    }
  }
}

502

El almacén de documentos no ha respondido (storage_unavailable). No se adjuntó nada.

Cabeceras

  • X-Request-Idstring (uuid)

    Id de la petición en el historial de la empresa (para soporte).

Cuerpo con el formato de los errores.

Ejemplo de respuesta
{
  "error": {
    "code": "storage_unavailable",
    "message": "No se pudo acceder al almacen de documentos"
  }
}

Además, las respuestas comunes: 401, 403, 404, 409, 422, 423, 429, 503.

Modelos

Objetos que devuelven o reciben las operaciones de esta área. Cada campo enlaza a su modelo.

GastosExpenseobject

Un gasto.

  • expenseIdinteger

    Mínimo 1

  • tenantIdinteger

    Empresa del gasto.

    Mínimo 1

  • vehicleIdinteger

    Mínimo 1

  • vehiclePlatestring | null

    Matrícula del vehículo (null si no se pudo leer).

  • userIdinteger

    Conductor del gasto (de quién es). Por la API, el driverUserId del alta.

    Mínimo 1

  • userNamestring | null

    Nombre del conductor (null si no se pudo leer).

  • typestring

    Tipo de gasto. fuel (combustible) y ev_charge (recarga eléctrica) son del plan base; toll (peaje) y parking (aparcamiento) exigen el complemento tolls_parking; maintenance (mantenimiento o taller) e itv, maintenance_itv; insurance (seguro), vehicle_tax (impuesto de circulación) y renting (cuota de renting), insurance_tax_renting.

    Valores: fuel ev_charge toll parking maintenance itv insurance vehicle_tax renting

  • addonstring | null

    Complemento que exige el tipo; null = plan base.

    Valores: tolls_parking maintenance_itv insurance_tax_renting

  • expenseDatestring (date)

    Fecha del ticket (día fiscal Europe/Madrid).

  • supplierNamestring | null

    Hasta 200 caracteres

  • supplierTaxIdstring | null

    NIF del proveedor, normalizado (sin espacios, puntos ni guiones, en mayúsculas).

    Patrón: ^[A-Z0-9]{2,20}$

  • baseAmountnumber | null

    Base imponible en euros.

    Mínimo 0Máximo 999999.99

  • vatRatenumber | null

    Tipo de IVA en %.

    Mínimo 0Máximo 100

  • vatAmountnumber | null

    Cuota de IVA en euros.

    Mínimo 0Máximo 999999.99

  • totalAmountnumber

    Total en euros.

    Mayor que 0Máximo 999999.99

  • litersnumber | null

    Litros (solo en fuel, donde siempre vienen).

    Mayor que 0Máximo 9999.999

  • kwhnumber | null

    kWh (solo en ev_charge, donde siempre vienen).

    Mayor que 0Máximo 9999.999

  • odometerKminteger | null

    Lectura del cuentakilómetros.

    Mínimo 0Máximo 9999999

  • notesstring | null

    Hasta 500 caracteres

  • attachmentGastosAttachment | null

    El ticket, o null si no tiene.

  • ocrExtractionIdinteger | null

    Lectura OCR con la que se rellenó en la app; null si no.

    Mínimo 1

  • tripIdinteger | null

    Viaje del peaje o aparcamiento; null si no está ligado a un viaje.

    Mínimo 1

  • activeboolean

    false si está anulado (solo en la sincronización de list).

  • integrationGastosIntegration | null

    Clave API con la que se dio de alta; null si lo registró una persona en la web o la app.

  • createdAtstring (date-time)

    Alta (UTC, con milisegundos).

  • updatedAtstring (date-time)

    Último cambio (UTC, con milisegundos).

GastosTotalsobject

Totales de todo el filtro, no solo de los gastos devueltos.

  • countinteger

    Gastos del filtro.

    Mínimo 0

  • baseAmountnumber

    Suma de las bases (solo de los gastos que la tienen).

    Mínimo 0

  • vatAmountnumber

    Suma de las cuotas de IVA (solo de los gastos que la tienen).

    Mínimo 0

  • totalAmountnumber

    Mínimo 0

  • litersnumber

    Mínimo 0

  • kwhnumber

    Mínimo 0

  • missingBreakdowninteger

    Gastos sin base o sin cuota.

    Mínimo 0

  • toReviewinteger

    Gastos por revisar (sin ticket, sin base o sin cuota).

    Mínimo 0

  • Total de cada tipo con algún gasto en el filtro, en el orden del catálogo de tipos.

GastosVersionobject

  • seqinteger

    Número de la entrada en la cadena de la empresa.

    Mínimo 1

  • versioninteger

    Número de versión dentro del gasto (1, 2, 3…).

    Mínimo 1

  • changestring

    create alta, correct corrección (o ticket), void anulación, technical versión técnica.

    Valores: create correct void technical

  • authorGastosVersionAuthor | null

    Quién hizo el cambio; null solo en una versión técnica.

  • recordedAtstring (date-time)

    Hora del servidor (UTC).

  • reasonstring | null

    Motivo de la corrección o anulación.

    Hasta 500 caracteres

  • snapshotGastosSnapshot | null

    Instantánea fiscal del gasto después del cambio; null si la versión está redacted.

  • Huellas de los ficheros de la versión (el ticket vigente). Vacía sin ticket.

  • changedstring[]

    Claves de snapshot que cambiaron respecto a la versión anterior (orden alfabético; [] en la primera). ticketSha256 = se sustituyó el ticket.

  • entryHashstring

    Huella SHA-256 de la entrada en la cadena.

    Patrón: ^[0-9a-f]{64}$

  • redactedboolean

    true si el contenido falta o no se entiende (alteración: la verificación da la cadena por rota).

GastosTypeAvailabilityobject

  • typestring

    Tipo de gasto. fuel (combustible) y ev_charge (recarga eléctrica) son del plan base; toll (peaje) y parking (aparcamiento) exigen el complemento tolls_parking; maintenance (mantenimiento o taller) e itv, maintenance_itv; insurance (seguro), vehicle_tax (impuesto de circulación) y renting (cuota de renting), insurance_tax_renting.

    Valores: fuel ev_charge toll parking maintenance itv insurance vehicle_tax renting

  • addonstring | null

    Complemento que exige; null = plan base.

    Valores: tolls_parking maintenance_itv insurance_tax_renting

  • availableboolean

    true si la empresa puede registrar este tipo (plan base o complemento activo).

GastosAttachmentobject

Ticket adjunto. La clave de S3 nunca sale en las lecturas.

  • filenamestring

    Nombre saneado del fichero (A-Z a-z 0-9 . _ -).

    Hasta 120 caracteresEjemplo: ticket-repsol.jpg

  • contentTypestring

    Tipo del ticket. Sin SVG ni HTML.

    Valores: application/pdf image/jpeg image/png image/webp

  • sizeBytesinteger

    Tamaño en bytes del fichero guardado.

    Mínimo 1Máximo 20971520

GastosIntegrationobject

Clave API con la que se dio de alta el gasto.

  • apiKeyIdinteger

    Id de la clave (también si ya está revocada).

    Mínimo 1

  • namestring | null

    Nombre de la clave. null si no se pudo leer.

  • prefixstring | null

    Prefijo visible de la clave (fd_live_ + 4 caracteres). null si no se pudo leer.

    Ejemplo: fd_live_Ab12

GastosTypeTotalobject

  • typestring

    Tipo de gasto. fuel (combustible) y ev_charge (recarga eléctrica) son del plan base; toll (peaje) y parking (aparcamiento) exigen el complemento tolls_parking; maintenance (mantenimiento o taller) e itv, maintenance_itv; insurance (seguro), vehicle_tax (impuesto de circulación) y renting (cuota de renting), insurance_tax_renting.

    Valores: fuel ev_charge toll parking maintenance itv insurance vehicle_tax renting

  • totalAmountnumber

GastosVersionAuthorobject

  • userIdinteger

    Mínimo 1

  • namestring | null

    null si es alguien que no se ve desde la empresa (equipo de FlotaDoc).

GastosSnapshotobject

Instantánea fiscal sellada del gasto. Los importes van como número JSON.

  • vehicleIdintegeropcional
  • userIdintegeropcional

    Conductor del gasto.

  • typestringopcional

    Tipo de gasto. fuel (combustible) y ev_charge (recarga eléctrica) son del plan base; toll (peaje) y parking (aparcamiento) exigen el complemento tolls_parking; maintenance (mantenimiento o taller) e itv, maintenance_itv; insurance (seguro), vehicle_tax (impuesto de circulación) y renting (cuota de renting), insurance_tax_renting.

    Valores: fuel ev_charge toll parking maintenance itv insurance vehicle_tax renting

  • expenseDatestring (date)opcional
  • supplierNamestring | nullopcional
  • supplierTaxIdstring | nullopcional
  • baseAmountnumber | nullopcional
  • vatRatenumber | nullopcional
  • vatAmountnumber | nullopcional
  • totalAmountnumberopcional
  • litersnumber | nullopcional
  • kwhnumber | nullopcional
  • odometerKminteger | nullopcional
  • notesstring | nullopcional
  • tripIdinteger | nullopcional
  • ticketSha256string | nullopcional

    SHA-256 del ticket vigente; null sin ticket.

    Patrón: ^[0-9a-f]{64}$

GastosVersionFileobject

  • kindstring

    Tipo de fichero. En un gasto, siempre ticket.

    Valores: ticket signature track report

  • sha256string

    Patrón: ^[0-9a-f]{64}$

  • bytesintegeropcional

    Tamaño en bytes (si se conoce).

    Mínimo 0

¿Quieres conectar tu sistema?

Cuéntanos qué quieres integrar y lo vemos en una llamada: qué datos necesitas, con qué permisos y cada cuánto.

Si tu empresa ya usa FlotaDoc, el titular puede pedir el acceso desde la web, en Mi cuenta › Integraciones.

Habla con nosotros