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 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.
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 '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.
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) trae la sucursal MÁS el catálogo corporativo.
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). 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.
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.
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 -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.
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.
Errores
#Todo error responde el formato canónico { code, message, details }. Los códigos de /v1:
| Código | HTTP | Qué significa |
|---|---|---|
| API_KEY_REQUIRED | 401 | No viajó el header Authorization: Bearer pfk_… |
| API_KEY_INVALID | 401 | El token no existe (o no puede usarse desde tu IP, si configuraste allowlist de IPs). |
| API_KEY_REVOKED | 401 | La integración fue revocada — o la organización está suspendida o dada de baja. |
| API_KEY_EXPIRED | 401 | El token tenía vencimiento y venció. |
| RESOURCE_NOT_ENABLED | 403 | El recurso existe pero no está habilitado en TU integración: márcalo en Integraciones. |
| INVALID_CURSOR | 400 | El cursor no es de este token, recurso y filtros (o está corrupto). |
| NOT_FOUND | 404 | No existe — o no existe para tu empresa: es el mismo 404 a propósito. |
| RATE_LIMITED | 429 | Lí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.
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, sin hablar con nadie.
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 CON la API viva: puedes seguir consultando /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
Crea tu cuenta de prueba
En /signup: 14 días, sin tarjeta. La cuenta es real — el sandbox no es una maqueta.
- 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
Emite tu token pfk_
En Integraciones: marca recursos y campos (nada viene pre-palomeado) y guarda el token — se enseña una sola vez.
- 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 'https://pa.services.staging.pitflo.com/v1/customers?limit=50' \
-H 'Authorization: Bearer pfk_TU_TOKEN'