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:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| external_id | string · máx. 120 | sí | Identificador único del ticket en el TPV. Re-empujar el mismo external_id reemplaza el pedido entero. |
| closed_at | string · ISO 8601 con zona | sí | Momento de cierre/cobro del ticket (p. ej. 2026-07-23T14:32:00+02:00). Determina el día de venta. |
| items | array · máx. 200 | sí | Líneas del ticket. Un array vacío ([]) anula el ticket. |
| channel | string · máx. 60 | no | Canal de venta («sala», «delivery»…). Informativo. |
| covers | entero ≥ 0 | no | Comensales del ticket. Informativo. |
| payments | array · máx. 50 | no | Cobros del ticket: objetos { method, amount }. Informativo. |
Y cada línea de items:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| name | string · máx. 200 | sí | Nombre de la línea tal y como aparece en el TPV. Fooder lo vincula a la carta por nombre. |
| quantity | número ≥ 0 | sí | Unidades netas cobradas (admite decimales). Descuentos y modificadores ya absorbidos en la línea. |
| returned_quantity | número ≥ 0 | no | Unidades servidas y devueltas después: no cuentan como ingreso. Una línea devuelta entera viaja con quantity 0 y returned_quantity N. |
| unit_price_net | número ≥ 0 | no | Precio 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"]
}
]
}| status | Significado |
|---|---|
| imported | Día procesado: sales_rows indica las filas de venta resultantes. |
| unchanged | El push no cambió nada en ese día. |
| empty | El día quedó sin líneas de venta. |
| error | El 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
| HTTP | error | Significado |
|---|---|---|
| 400 | invalid_json | El cuerpo de la petición no es JSON válido. |
| 401 | missing_or_invalid_authorization | Falta la cabecera Authorization o no contiene una clave fdr_. |
| 401 | invalid_api_key | La clave no existe o ha sido revocada. |
| 422 | invalid_payload | El payload no cumple el formato: issues enumera los primeros errores campo a campo. |
| 429 | rate_limited | Límite de peticiones superado: la cabecera Retry-After indica los segundos de espera. |
| 500 | storage_failed | Error 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.