API pública de Pitflo (/v1)

Introducción

#

Una API de SOLO LECTURA de tu propia empresa: órdenes, clientes, vehículos, citas, catálogo, inventario, cobros, comprobantes, gastos y remisiones. El token lo emites tú desde tu pantalla de Integraciones — nadie en Pitflo tiene que aprobarte nada.

Negado por omisión: al armar tu integración eliges qué recursos y qué campos expone, y nada nace expuesto. Los campos marcados como dato personal (pii) se agrupan bajo advertencia: al marcarlos estás sacando datos personales de tus clientes a un sistema externo, y tu empresa es responsable de lo que copie — incluidas las bajas (ver Paginación).

La API y los webhooks son del plan Empresarial de Pitflo Shop (Planes V1, 2026-09-10); en cualquier otro plan la pantalla de Integraciones lo dice con candado y el servidor responde 402 CAPABILITY_NOT_IN_PLAN con planMinimo: "empresarial". Si tu empresa baja de plan, tus llaves vivas siguen leyendo 30 días más —con aviso al gestor, y sin poder crear ni rotar llaves o webhooks en ese lapso— y al cumplirse el plazo se revocan junto con sus webhooks: desde entonces /v1 responde 402 con desactivadaEl y revocadaEl en details. Volver a Empresarial antes del día 30 las conserva tal cual.

La escritura por API no existe en v1 (decisión I6). Un cambio incompatible del contrato estrena /v2: lo que programes contra /v1 no se te rompe en silencio — el changelog es un compromiso con gate en nuestro CI, no una cortesía.

El contrato es 100% inglés: recursos, campos, eventos, códigos de error y headers se escriben igual leas esta página en español o en inglés. Esta prosa cambia de idioma; los literales que tecleas, jamás.

Descargar openapi.jsonTambién público en https://pa.services.staging.pitflo.com/v1/openapi.json — sin token.

Autenticación

#

Toda petición viaja con el header Authorization: Bearer pfk_… El token se emite en Integraciones y se enseña UNA sola vez; en nuestra base solo vive su hash.

El token es de SERVIDOR: jamás lo pongas en una app web o móvil — quien tenga el token lee todo lo que tu integración expone. Rotarlo entrega uno nuevo y mata el viejo al instante; revocar la integración mata también sus webhooks.

Una cookie de sesión de navegador sobre /v1 es 403 de plano: la API es máquina-a-máquina y no acepta sesiones. El token viaja SOLO por header — nunca por query string ni body.

curl
curl 'https://pa.services.staging.pitflo.com/v1/customers?limit=50' \
  -H 'Authorization: Bearer pfk_TU_TOKEN'

Recursos

#

Cada recurso se genera del mismo documento OpenAPI que sirve las consultas: la allowlist versionada es la única fuente. Abre uno para ver su descripción, la tabla de campos con tipo y marca pii, el ejemplo de petición en curl, JavaScript y Python, y una respuesta de ejemplo.

Paginación y updated_since

#

Las listas responden { data, cursor }. El cursor es opaco y firmado, atado a tu token, al recurso y a los filtros: pásalo tal cual en la siguiente página; cursor: null significa que no hay más. De otro token u otros filtros responde 400 INVALID_CURSOR.

updated_since filtra por fecha de actualización (updatedAt >= …; en service_payments, libro append-only, por createdAt). El filtro es gte a propósito: deduplica por id en tu lado. purchase_journal_entries es DERIVADO —se calcula de los hechos al leer y no tiene updatedAt—: no acepta updated_since (400 FILTER_NOT_SUPPORTED) y se acota con from= y to= sobre la fecha del hecho, inclusivos; esos dos filtros son solo suyos.

En customers, lastVisitAt —la última visita: la fecha de la orden viva más reciente del cliente, o null si no tiene— se recalcula al escribir órdenes SIN mover updatedAt: updated_since no la oye. Si la copias, reconcíliala con lecturas completas periódicas o derívala de services (customerId + createdAt).

journal_entries —las tres pólizas del mes CERRADO: diario, ingresos y egresos— también es DERIVADO y tampoco acepta updated_since. Exige workshop=, year= y month= (la póliza es de una sucursal y de un mes: sin ellos 400 WORKSHOP_REQUIRED / PERIOD_REQUIRED, y 400 PERIOD_INVALID si el mes no es un mes) y el mes tiene que estar cerrado en Finanzas: si no, 409 MONTH_NOT_CLOSED, el mismo que la pantalla. La póliza no viaja incompleta: si algún concepto con movimiento no tiene cuenta mapeada, responde 409 ACCOUNT_MAP_INCOMPLETE, el mismo que el Excel de la pantalla, con details.conceptos (las claves que faltan); mapéalas en Contabilidad › Cuentas y vuelve a pedirla. monthly_closes —el cierre de cada mes— tampoco acepta updated_since: su fila cambia al reabrir sin mover closedAt, su sello de página; óyelo por monthly_close.reopened. year= y month= son solo de esos dos recursos; en cualquier otro responden 400 FILTER_NOT_SUPPORTED.

visits —las visitas de la sucursal para el robot de la marca: una fila por orden terminada con factura o remisión, sin notas de crédito ni canceladas, con las 34 columnas, los nombres y el orden de la tabla que ese robot ya consulta— es DERIVADO y responde solo a la llave de EMPRESA. FechaVisita viaja en ISO 8601 (no como número de serie de Excel); IdentificadorSD e IdSucursal salen de las «Referencias externas» de cada sucursal (Empresa › Sucursales). Acepta workshop=, from= y to= (sobre FechaVisita, inclusivos y como instantes ISO 8601: una fecha sin hora es la medianoche UTC de ese día; para un día completo manda el día siguiente como to=) y updated_since —su sello es el más reciente entre la orden, su entrega, su factura, su remisión y sus líneas; un cambio en la ficha del cliente o del vehículo no lo mueve—, y pagina por marcador como los demás. Una visita cuya factura se cancela sin remisión viva deja de listarse y no tiene lápida: óyelo por invoice.cancelled / remission.cancelled o reconcilia con lecturas completas por ventana de FechaVisita.

order_files —los archivos de la orden: una fila por foto o video de la inspección y por adjunto de la orden— responde solo a la llave de EMPRESA y respeta sus sucursales. Cada fila trae la orden (serviceId, series y folio), la sucursal, el vehículo, el origen (source: inspection o attachment), la etapa (stageId y stage; null en el ingreso y en un adjunto) y la pregunta de la inspección tal como se contestó (null en un adjunto), quién lo subió y cuándo (uploadedAt), el nombre, el tipo y el tamaño. Los archivos no se copian: cada fila trae una liga de consulta que dura 5 minutos (urlExpiresAt); pídela de nuevo cada vez que vayas a enseñar el archivo. El orden es uploadedAt DESC, id DESC; GET /v1/order_files/{id} responde 404 NOT_FOUND si el archivo ya no existe o lo bloqueó el antivirus. En una foto, url es el original; en un video, url es la versión ligera (el original mientras la ligera no exista), posterUrl su portada y originalUrl el archivo tal como se subió; en cualquier otro archivo (mediaType file), url es el original. posterUrl va null si no es un video o aún no tiene portada, y originalUrl va null fuera de un video. Solo salen archivos vivos y revisados por el antivirus: uno borrado de la orden deja de listarse, sin lápida. Las firmas, las fotos de la entrega y el diagrama de daños no salen. No acepta updated_since (un archivo no se edita): se acota con from= y to= sobre la fecha de subida, inclusivos y como instantes ISO 8601 (una fecha sin hora es la medianoche UTC de ese día), y con service=, vehicle=, plate= y vin= (plate= se limpia antes de comparar: mayúsculas, sin espacios ni guiones, así que abc-123 encuentra lo mismo que ABC123). No hay webhook de archivo nuevo: consulta cuando lo necesites.

workshop={id} filtra la sucursal en recursos operativos; se ignora en el expediente de empresa (customers, vehicles); y en los catálogos con fila corporativa (products, suppliers, service_templates, expenses, expense_categories) trae la sucursal MÁS el catálogo corporativo.

Filtros de relación: service, customer, vehicle, invoice, appointment, supplier, purchase_order, purchase_order_line, receipt, expense, payment, product, category y subcategory (todos ={id}) acotan la lista a lo que cuelga de una orden, un cliente, un vehículo, un documento fiscal, una cita, un proveedor, una orden de compra, una línea de orden de compra, una recepción, un gasto, un cobro, un producto o una categoría/subcategoría (por ejemplo, service_items?service=… son los renglones de esa orden; service_items?category=… los renglones cuya categoría de VENTA sellada es ésa; products?subcategory=… los productos de esa subcategoría; purchase_order_requests?purchase_order_line=… el reparto de esa línea entre órdenes). Cada recurso declara cuáles entiende —vienen en su OpenAPI—; pedir uno que no declara responde 400 FILTER_NOT_SUPPORTED, nunca se ignora en silencio. Todos entran en la firma del cursor.

Los borrados lógicos no salen en las LECTURAS: una fila dada de baja deja de listarse. Por webhook sí hay lápidas en los recursos que ya las tienen — customer.deleted, vehicle.deleted y service.deleted entregan { id, deleted: true } para que quites esa fila de tu copia (service.restored la devuelve viva, con la fila completa). En los demás recursos la baja aún no tiene evento: si copias comprobantes o inventario, reconcilia con updated_since o con lecturas completas periódicas.

El orden es estable: updatedAt DESC, id DESC (createdAt en service_payments; occurredAt en purchase_journal_entries; closedAt en monthly_closes; en journal_entries, el número de póliza: 1 diario, 2 ingresos, 3 egresos). limit tope 100, default 50. El dinero viaja SIEMPRE en centavos, tal cual la base.

Webhooks y firma

#

Para no sondear: registra hasta 3 webhooks por integración (URL https, puerto 443) y Pitflo te avisa firmado cuando pasa algo. Solo puedes suscribir eventos cuyo recurso esté habilitado en tu integración. Los webhooks siguen la regla de plan de la API (ver Introducción): son del plan Empresarial y, al bajar de plan, se revocan junto con la llave al cumplirse los 30 días de solo lectura.

El payload es { id, event, createdAt, version: "v1", data }: data viene proyectado contra los campos marcados VIGENTES al entregar. El id es estable entre reintentos — es tu llave de idempotencia.

Entrega: POST con timeout de 10 s; cualquier 2xx cuenta como entregada (un 3xx NO, y el Location no se sigue). Reintentos a 1 m → 5 m → 30 m → 2 h → 8 h; al 6.º fallo la entrega muere; 10 muertas seguidas apagan el webhook con correo al gestor. Las entregas se conservan 30 días (solo metadatos: el payload se borra al entregarse o morir).

Eventos disponibles

—

Verificación de la firma Pitflo-Firma

Cada entrega lleva el header Pitflo-Firma: t=<epoch>,v1=<hex hmac_sha256(secret, "<t>.<cuerpo>")> — el secreto es el pfw_ que se te enseñó una vez al crear el webhook.

Verifícala en tres pasos: (1) rechaza si |ahora − t| > 5 minutos; (2) recomputa el HMAC sobre "<t>.<cuerpo crudo>" y compáralo EN TIEMPO CONSTANTE; (3) deduplica por id — el mismo id puede llegar hasta 6 veces.

Rotar el secreto abre una ventana de doble firma de 24 h para que cambies sin perder entregas: durante esa ventana el header trae DOS firmas completas separadas por UN espacio — t=<epoch>,v1=<hex> t=<epoch>,v1=<hex> — primero la del secreto vigente y después la del anterior. Parte el header por espacios y acepta si CUALQUIERA de las dos verifica; si solo lees la primera pareja t/v1 que encuentres, rechazarás todas las entregas durante la ventana. El ping de prueba que dispara tu ficha de API viaja firmado igual, así que prueba tu verificador con él.

La verificación completa, lista para pegar. Firma sobre el cuerpo CRUDO: si tu framework ya parseó el JSON y lo volviste a serializar, el HMAC no cuadra.

JavaScript
import crypto from 'node:crypto';

const TOLERANCIA_S = 300; // 5 minutos

/** `cuerpo` es el body CRUDO (Buffer o string), jamás el JSON re-serializado. */
export function verificarFirmaDePitflo(cuerpo, header, secreto) {
  // Durante la ventana de rotación el header trae DOS firmas completas
  // separadas por UN espacio (vigente primero, anterior después): basta con
  // que CUALQUIERA verifique contra el secreto que tú tengas cargado.
  return String(header ?? '')
    .split(/\s+/)
    .filter(Boolean)
    .some((firma) => verificarUnaFirma(cuerpo, firma, secreto));
}

function verificarUnaFirma(cuerpo, firma, secreto) {
  const partes = new Map(
    firma.split(',').map((p) => {
      const i = p.indexOf('=');
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    }),
  );
  const t = Number(partes.get('t'));
  const recibida = partes.get('v1');
  if (!Number.isFinite(t) || !recibida) return false;

  // 1) ventana de 5 minutos (corta el replay)
  const ahora = Math.floor(Date.now() / 1000);
  if (Math.abs(ahora - t) > TOLERANCIA_S) return false;

  // 2) HMAC sobre "<t>.<cuerpo crudo>"
  const esperada = crypto
    .createHmac('sha256', secreto)
    .update(`${t}.`)
    .update(cuerpo)
    .digest('hex');

  // 3) comparación en TIEMPO CONSTANTE (nunca ===)
  const a = Buffer.from(esperada, 'utf8');
  const b = Buffer.from(recibida, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: el cuerpo crudo se pide explícitamente.
// app.post('/pitflo', express.raw({ type: 'application/json' }), (req, res) => {
//   if (!verificarFirmaDePitflo(req.body, req.get('Pitflo-Firma'), process.env.PITFLO_WEBHOOK_SECRET)) {
//     return res.sendStatus(401);
//   }
//   const evento = JSON.parse(req.body.toString());
//   if (yaProcesado(evento.id)) return res.sendStatus(200); // dedupe por id
//   encolar(evento);
//   res.sendStatus(200); // contesta rápido: el timeout es de 10 s
// });

Probar la entrega y la firma

POST al ping de un webhook tuyo: Pitflo entrega un evento firmado a la URL registrada y responde qué contestó y en cuánto tiempo. El id del webhook se ve en la ficha de tu API.

curl
curl -X POST 'https://pa.services.staging.pitflo.com/v1/webhooks/WEBHOOK_ID/ping' \
  -H 'Authorization: Bearer pfk_TU_TOKEN'

Pruébalo desde tu ficha de API

Entra a Integraciones, abre la API y usa su pestaña Probar: ahí consultas tus recursos, entregas un evento de prueba a tus webhooks y tienes un buzón que recibe entregas reales aunque todavía no tengas servidor. Con tu sesión: aquí no hace falta pegar ninguna llave.

¿Dudas del contrato? Pregúntale a Escucha

Con tu sesión de Pitflo: te contesta con el contrato público de /v1 —recurso, filtro, campo y ejemplo— y registra para el equipo lo que te falte.

Límites y cupos

#

Los números salen del catálogo único de límites del producto (co-probado en CI contra las constantes reales) — aquí se citan, no se copian. El catálogo completo, con tamaños, techos de almacenamiento y retenciones, vive en Límites del producto.

Cupos del contrato de integraciones (regla 7): 10 integraciones activas por empresa, 3 webhooks por integración y limit máximo de 100 filas por página. Al exceder un límite la respuesta es 429 con Retry-After.

Límites del producto

Errores

#

Todo error responde el formato canónico { code, message, details }. Los códigos de /v1:

CódigoHTTPQué significa
API_KEY_REQUIRED401No viajó el header Authorization: Bearer pfk_…
API_KEY_INVALID401El token no existe (o no puede usarse desde tu IP, si configuraste allowlist de IPs).
API_KEY_REVOKED401La integración fue revocada — o la organización está suspendida o dada de baja.
API_KEY_EXPIRED401El token tenía vencimiento y venció.
CAPABILITY_NOT_IN_PLAN402El plan de la empresa no incluye API y webhooks (son del plan Empresarial), o ya pasaron los 30 días de solo lectura tras bajar de plan. details trae capacidad, planMinimo y, en el segundo caso, desactivadaEl y revocadaEl.
RESOURCE_NOT_ENABLED403El recurso existe pero no está habilitado en TU integración: márcalo en Integraciones.
INVALID_CURSOR400El cursor no es de este token, recurso y filtros (o está corrupto).
FILTER_NOT_SUPPORTED400Un filtro que ese recurso no declara: updated_since en purchase_journal_entries, journal_entries o monthly_closes; year= o month= fuera de esos dos últimos; un filtro de relación ajeno. details trae filtro. Nunca se ignora en silencio.
PERIOD_REQUIRED400journal_entries sin year= o month=: la póliza es de un mes. details trae year y month.
PERIOD_INVALID400year= fuera de 2000–2100 o month= fuera de 1–12.
WORKSHOP_REQUIRED400journal_entries sin workshop=: la póliza es de una sucursal. Una sucursal que no es de tu empresa responde 404.
NOT_FOUND404No existe — o no existe para tu empresa: es el mismo 404 a propósito.
MONTH_NOT_CLOSED409journal_entries de un mes sin cierre vigente en Finanzas: la póliza sale solo del mes cerrado, y al reabrirlo deja de salir hasta que se vuelva a cerrar. details trae year y month.
ACCOUNT_MAP_INCOMPLETE409journal_entries con algún concepto con movimiento sin cuenta mapeada: la póliza no viaja incompleta, igual que el Excel de la pantalla. details.conceptos trae las claves que faltan; mapéalas en Contabilidad › Cuentas y vuelve a pedirla.
WEBHOOK_DISABLED409El ping a un webhook apagado (se apaga solo tras diez entregas fallidas seguidas). Reactívalo en Integraciones antes de volver a probarlo.
RATE_LIMITED429Límite de peticiones (respeta Retry-After) o demasiados intentos fallidos de autenticación desde tu IP.

Changelog

#

Todo cambio de la superficie expuesta (recursos, campos o eventos) exige su entrada aquí — lo custodia un gate en nuestro CI. La versión del documento es la fecha de la entrada más reciente.

Catálogo vehicular público

#

Cuatro rutas de SOLO LECTURA, sin token y abiertas a cualquier origen (CORS), con las marcas, los modelos, los años y los motores del catálogo vehicular global de Pitflo: para que un formulario de terceros —una página de citas, una landing— llene sus listas desplegables desde el navegador del visitante, sin un servidor propio de por medio. Solo salen las filas vigentes: lo que la consola retira deja de listarse en el acto.

La respuesta es { data, total }: data trae id y name (y makeId en los modelos), ordenados por nombre; total dice cuántas filas casan antes del tope de 500. Los modelos se piden POR MARCA (makeId obligatorio): una marca desconocida o retirada responde 404 NOT_FOUND. q filtra por nombre sin distinguir mayúsculas ni acentos («citroen» encuentra «Citroën»).

Los años y los motores se piden POR MODELO (modelId obligatorio; un modelo desconocido o retirado responde 404 NOT_FOUND). vehicle-years devuelve los años en que el modelo existió según la matriz, del más viejo al más nuevo (id, year, modelId). vehicle-engines devuelve los motores vigentes del modelo —id, modelId, engineCode, yearFrom, yearTo, displacementMilli (milésimas de litro: 1600 es 1.6 L), cylinders, engineType, transmission, driveType y bodyType—, por código y año de inicio, y con year solo los que cubren ese año.

Límite: 60 peticiones por minuto por IP (RATE_LIMITED con Retry-After al excederlo). Las respuestas se pueden guardar una hora (Cache-Control: public, max-age=3600) y llevan ETag. No forma parte de /v1: no requiere token, no tiene allowlist de campos ni webhooks y no figura en el openapi.json.

RutaQué devuelve
GET /public/vehicle-makesLas marcas vigentes, por nombre. Acepta q.
GET /public/vehicle-models?makeId=…Los modelos vigentes de UNA marca, por nombre. makeId obligatorio; acepta q.
GET /public/vehicle-years?modelId=…Los años en que UN modelo existió según la matriz, del más viejo al más nuevo. modelId obligatorio.
GET /public/vehicle-engines?modelId=…&year=…Los motores vigentes de UN modelo, con cilindrada y cilindros. modelId obligatorio; con year, solo los que cubren ese año.
ParámetroQué hace
qTexto a buscar dentro del nombre (hasta 80 caracteres); no distingue acentos ni mayúsculas. Solo en vehicle-makes y vehicle-models.
makeIdEl id (UUID) de la marca, tal como lo devolvió /public/vehicle-makes. Solo en vehicle-models.
modelIdEl id (UUID) del modelo, tal como lo devolvió /public/vehicle-models. Obligatorio en vehicle-years y vehicle-engines.
yearUn año de cuatro cifras: acota los motores a los que cubren ese año. Solo en vehicle-engines, opcional.

Sin cabeceras: basta la URL. Para los desplegables dependientes, pide los modelos con el id de la marca elegida, y los años y los motores con el id del modelo.

curl
curl "https://pa.services.staging.pitflo.com/public/vehicle-makes"
curl "https://pa.services.staging.pitflo.com/public/vehicle-models?makeId=<makeId>"
curl "https://pa.services.staging.pitflo.com/public/vehicle-years?modelId=<modelId>"
curl "https://pa.services.staging.pitflo.com/public/vehicle-engines?modelId=<modelId>&year=2018"
JSON
{
  "data": [
    { "id": "6f1c9e2a-7b1d-4e0e-9f7a-2c3d4e5f6a7b", "name": "Nissan" }
  ],
  "total": 1
}

Sandbox

#

No hay un ambiente sandbox aparte: la prueba gratuita de 14 días ES el sandbox. Tu equipo de integraciones evalúa contra el producto real, con datos de ejemplo. Una salvedad desde Planes V1: la prueba simula el plan Pro y la API es del plan Empresarial, así que en una cuenta de prueba la API no nace encendida — pídenos activarla para tu evaluación desde soporte (la encendemos como excepción sobre tu cuenta, con su vigencia) o contrata Empresarial.

Los datos sembrados son FICTICIOS y descartables (se quitan con un clic desde la misma pantalla). Al vencer la prueba tu cuenta queda en solo lectura y, si la API estaba encendida, sigue contestando /v1 mientras decides.

La siembra llena el expediente y el catálogo: customers, vehicles, products y suppliers llegan poblados. La operación llega VACÍA a propósito (órdenes, citas, cobros, comprobantes, inventario en movimiento…): esos recursos responden data: [] hasta que los generes usando el producto — una orden de ejemplo contaminaría tus números reales desde el día uno.

  1. 1

    Crea tu cuenta de prueba

    En /signup: 14 días, sin tarjeta. La cuenta es real — el sandbox no es una maqueta.

  2. 2

    Siembra los datos de ejemplo

    Desde el tablero o Configuración: «Cargar datos de ejemplo». Expediente y catálogo ficticios, descartables con un clic.

  3. 3

    Emite tu token pfk_

    Con la API encendida en tu cuenta, en Integraciones: marca recursos y campos (nada viene pre-palomeado) y guarda el token — se enseña una sola vez.

  4. 4

    Tu primer GET

    Corre el curl de abajo con tu token: /v1/customers responde los clientes de ejemplo con SOLO los campos que marcaste.

curl
curl 'https://pa.services.staging.pitflo.com/v1/customers?limit=50' \
  -H 'Authorization: Bearer pfk_TU_TOKEN'