Doc para integradores

API de ventas para TPVs (v1)

Empuja cada ticket a Fooder Controller según se cobra y el restaurante tiene sus ventas, su stock y su rentabilidad al día sin exportar nada. Un solo endpoint, idempotente, pensado para integrarse en una tarde.

Cómo funciona

Tu TPV empuja cada ticket a Fooder según se cobra (o en lotes pequeños) y el restaurante tiene sus ventas al día sin exportar nada. La API es idempotente: re-empujar un ticket con el mismo external_id reemplaza el pedido entero, así que puedes reintentar sin miedo — nada se duplica.

La vinculación con la carta es por nombre de plato y la gestiona el restaurante desde Fooder. El integrador solo necesita la clave.

Clave y autenticación

La gestión del restaurante crea la clave fdr_… en Configuración → Integraciones → Conectar TPV; se muestra una sola vez y se puede revocar al instante. La clave ya identifica al restaurante y al espacio: el payload no lleva ningún identificador de cuenta.

Recomendada: cabecera Authorization: Bearer fdr_tu_clave. Para TPVs que solo hablan HTTP Basic también vale: la clave como usuario o como contraseña (el otro campo puede ir vacío). Sin una clave válida la API responde 401.

Endpoint

POST https://foodercontroller.com/api/pos/v1/orders — cuerpo JSON (UTF-8) con hasta 500 tickets por petición y 200 líneas por ticket. En uso normal se empuja un ticket por petición según se cobra; el lote grande es para reenvíos o cargas históricas (que pueden tardar unos segundos).

Hay un límite de peticiones por minuto: al superarlo, la API responde 429 con la cabecera Retry-After.

El pedido

El cuerpo es un objeto con un array orders (hasta 500 tickets por petición). Cada pedido:

CampoTipoObligatorioDescripción
external_idstring · máx. 120Identificador único del ticket en el TPV. Re-empujar el mismo external_id reemplaza el pedido entero.
closed_atstring · ISO 8601 con zonaMomento de cierre/cobro del ticket (p. ej. 2026-07-23T14:32:00+02:00). Determina el día de venta.
itemsarray · máx. 200Líneas del ticket. Un array vacío ([]) anula el ticket.
channelstring · máx. 60noCanal de venta («sala», «delivery»…). Informativo.
coversentero ≥ 0noComensales del ticket. Informativo.
paymentsarray · máx. 50noCobros del ticket: objetos { method, amount }. Informativo.

Y cada línea de items:

CampoTipoObligatorioDescripción
namestring · máx. 200Nombre de la línea tal y como aparece en el TPV. Fooder lo vincula a la carta por nombre.
quantitynúmero ≥ 0Unidades netas cobradas (admite decimales). Descuentos y modificadores ya absorbidos en la línea.
returned_quantitynúmero ≥ 0noUnidades servidas y devueltas después: no cuentan como ingreso. Una línea devuelta entera viaja con quantity 0 y returned_quantity N.
unit_price_netnúmero ≥ 0noPrecio unitario SIN IVA realmente cobrado. Si no viaja, se usa la tarifa de la casa. Si tu TPV trabaja con IVA incluido, divide antes de enviar.

Regla única: toda línea necesita cantidad cobrada o devuelta (quantity + returned_quantity > 0). Las líneas a 0 € viajan tal cual, con unit_price_net: 0.

Ejemplos

Push de un ticket al cobrarlo:

curl -X POST https://foodercontroller.com/api/pos/v1/orders \
  -H "Authorization: Bearer fdr_tu_clave" \
  -H "Content-Type: application/json" \
  -d '{
    "orders": [
      {
        "external_id": "T-2026-000412",
        "closed_at": "2026-07-23T14:32:00+02:00",
        "channel": "sala",
        "covers": 2,
        "items": [
          { "name": "Arroz del señoret", "quantity": 2, "unit_price_net": 11.0 },
          { "name": "Ensalada de tomate", "quantity": 1, "unit_price_net": 6.5 }
        ],
        "payments": [{ "method": "card", "amount": 31.35 }]
      }
    ]
  }'

Devolución: de dos arroces servidos, uno vuelve a cocina y se cobra solo uno:

{
  "orders": [
    {
      "external_id": "T-2026-000413",
      "closed_at": "2026-07-23T15:05:00+02:00",
      "items": [
        { "name": "Arroz del señoret", "quantity": 1, "returned_quantity": 1, "unit_price_net": 11.0 }
      ]
    }
  ]
}

Anulación de un ticket ya enviado: re-push del mismo external_id con items vacío:

{
  "orders": [
    { "external_id": "T-2026-000412", "closed_at": "2026-07-23T14:32:00+02:00", "items": [] }
  ]
}

La respuesta

Un push correcto responde 200 con los pedidos recibidos y el resultado de cada día de venta afectado:

{
  "received": 1,
  "days": [
    {
      "date": "2026-07-23",
      "status": "imported",
      "sales_rows": 14,
      "unmatched_names": ["Café bombón"]
    }
  ]
}
statusSignificado
importedDía procesado: sales_rows indica las filas de venta resultantes.
unchangedEl push no cambió nada en ese día.
emptyEl día quedó sin líneas de venta.
errorEl día no pudo procesarse; el siguiente push que lo toque lo reintenta.

unmatched_names lista los nombres que el restaurante aún no ha vinculado a su carta; los vincula la gestión desde Fooder, sin reenviar nada.

Errores

HTTPerrorSignificado
400invalid_jsonEl cuerpo de la petición no es JSON válido.
401missing_or_invalid_authorizationFalta la cabecera Authorization o no contiene una clave fdr_.
401invalid_api_keyLa clave no existe o ha sido revocada.
422invalid_payloadEl payload no cumple el formato: issues enumera los primeros errores campo a campo.
429rate_limitedLímite de peticiones superado: la cabecera Retry-After indica los segundos de espera.
500storage_failedError interno. Reintenta el push: es idempotente.

Ante un error de red o un 5xx, reintenta el mismo push: la idempotencia por external_id garantiza que nada se duplique.

¿Integras un TPV o desarrollas para uno?

Escríbenos y te acompañamos en la integración — normalmente se resuelve en una tarde con el ticket de ejemplo de arriba.

Ir al contacto →
API de ventas para TPVs (v1) | Fooder Controller