Tutoriales
De la comprobación de disponibilidad al pedido reservado
Comprueba el stock desde tu servidor con una caché y lotes que respeten las 30 peticiones por minuto de las operaciones de disponibilidad, crea el pedido con una clave de idempotencia, luego resérvalo y gestiona insufficient_stock.
Lo que construyes
Enlace a esta sección: Lo que construyes- Una comprobación de disponibilidad en el servidor para un sitio de reservas o un portal de clientes propio, que aguanta el tráfico porque usa caché y procesa por lotes.
- Una alternativa de “pedir un presupuesto” siempre que se agote la cuota, para que el visitante nunca vea un error.
- Un paso de reserva que crea un pedido, lo reserva y le dice al cliente qué ocurre cuando falta stock.
Antes de empezar
Enlace a esta sección: Antes de empezar| Permiso (scope) | Se usa para |
|---|---|
account:read | La primera llamada: zona horaria, moneda y los límites de peticiones de la clave. |
availability:read | POST /api. |
items:read | GET /api, para asignar tu catálogo a los ids de artículo de Filmo una sola vez. Opcional si ya guardas los ids. |
contacts:read, contacts:write | Buscar o crear el contacto del cliente. |
orders:write | POST /api y POST /api. |
orders:read | GET /api, para leer un pedido tras un conflicto o una respuesta perdida. Opcional. |
- Necesitas el id de Filmo del contacto del cliente. Obtenlo con el buscar o crear de los pasos 3 y 4 en Convertir un formulario web en una oportunidad.
- Necesitas el id de Filmo de cada artículo (y variante) que vendes. Léelos una vez con
GET /apiy guárdalos; los identificadores son opacos./v1 /items - Configura el cliente con el paso 1 del primer tutorial (los mismos dos ficheros).
1. Conocer tus límites
Enlace a esta sección: 1. Conocer tus límites| Límite | Valor |
|---|---|
| Consultas de disponibilidad | POST /api, POST /api y POST /api comparten una cuota extra: como máximo 30 peticiones por minuto por clave. También cuentan para las 120 por minuto de todas las rutas, así que dejan el resto de las 120 para todo lo demás. |
| Artículos por consulta de disponibilidad | De 1 a 100 (items), todos sobre el mismo rango de fechas. |
| Líneas por pedido | De 1 a 100, con de 1 a 9999 unidades por línea. |
| Cualquier otra llamada | 120 peticiones por minuto por clave. |
Treinta por minuto es una consulta de disponibilidad cada dos segundos, para todo el sitio. Una página que llama a Filmo una vez por visitante, o una vez por artículo, la agota en un momento. El resto de este tutorial existe para evitarlo. Las cabeceras RateLimit-Remaining y RateLimit-Reset muestran lo que queda, y un 429 rate_limited lleva Retry-After (Límites de peticiones).
2. Guardar la clave en tu servidor
Enlace a esta sección: 2. Guardar la clave en tu servidorEl navegador pregunta a tu servidor, y tu servidor pregunta a Filmo. Hay una sola clave para el sitio, guardada en el entorno del servidor: una clave en el código de la página la podría leer cualquier visitante, y además daría a cada uno su propia forma de agotar tus 30 por minuto.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/account" \
-H "Authorization: Bearer $FILMO_API_KEY"import { filmo } from "./filmo-client.mjs";
const res = await filmo("GET", "/api/v1/account");
if (!res.ok) throw new Error("getAccount " + res.status + " " + res.body?.code);
const { timezone, currency, addons, trial, apiKey } = res.body.data;
console.log({ timezone, currency, addons, trial: trial.active, scopes: apiKey.scopes });from filmo_client import filmo
status, body, _ = filmo("GET", "/api/v1/account")
if status != 200:
raise RuntimeError(f"getAccount {status} {(body or {}).get('code')}")
account = body["data"]
print(account["timezone"], account["currency"], account["addons"],
account["trial"]["active"], account["apiKey"]["scopes"])3. Comprobar la disponibilidad con caché y lotes
Enlace a esta sección: 3. Comprobar la disponibilidad con caché y lotesPOST /api toma un rango de fechas y hasta 100 artículos, cada uno con la cantidad que piensas reservar (1 si la omites). Las fechas son instantes ISO 8601 con desplazamiento; constrúyelas a partir de la elección del visitante en el timezone de la cuenta. Devuelve una entrada por artículo, en el orden en que los enviaste: available, un status (ok, partial o none), el shortfall, los días laborables que faltan (conflictingPeriods), o un error con código not_found para un artículo que no existe.
- **Agrupa en lotes.** Pon todos los artículos de una página para las mismas fechas en una sola consulta, hasta 100. No llames una vez por artículo.
- **Guarda en caché durante poco tiempo.** Usa como clave de la caché las fechas, el artículo, la variante y la cantidad, con un TTL de aproximadamente un minuto. El TTL es tu equilibrio entre cuota y respuestas desactualizadas; el código de abajo usa 60 segundos. Pide solo las entradas que faltan.
- **No muestres nunca los detalles internos.** La respuesta lista los pedidos que retienen el stock (
commitments) con sus números. Son pedidos de otros clientes: elimínalos antes de que nada llegue al navegador.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/availability/check" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"startDate": "2026-11-02T09:00:00+01:00",
"endDate": "2026-11-04T18:00:00+01:00",
"items": [
{ "itemId": "ITEM_ID_1", "quantity": 2 },
{ "itemId": "ITEM_ID_2" }
]
}
JSONimport { filmo } from "./filmo-client.mjs";
const TTL_MS = 60_000;
const cache = new Map(); // "start|end|item|variant|quantity" -> { at, entry }
const keyOf = (start, end, i) => [start, end, i.itemId, i.variantId ?? "", i.quantity ?? 1].join("|");
// What the visitor may see: no order numbers, no stock figures of the business.
function toPublic(item, entry) {
if ("error" in entry) return { itemId: item.itemId, state: "unknown" };
if (entry.status === "ok") return { itemId: item.itemId, state: "available" };
return {
itemId: item.itemId,
state: entry.status === "partial" ? "partial" : "unavailable",
maxQuantity: Math.max(0, (item.quantity ?? 1) - entry.shortfall),
};
}
// items: [{ itemId, quantity, variantId? }] for ONE date range.
// Returns { mode: "live", results } or { mode: "quote" } when you should not promise anything.
export async function checkRange(start, end, items) {
const entries = new Array(items.length);
const missing = [];
items.forEach((item, index) => {
const hit = cache.get(keyOf(start, end, item));
if (hit && Date.now() - hit.at < TTL_MS) entries[index] = hit.entry;
else missing.push(index);
});
try {
for (let from = 0; from < missing.length; from += 100) {
const slice = missing.slice(from, from + 100); // at most 100 items per query
const res = await filmo("POST", "/api/v1/availability/check", {
retries: 0, // never make a visitor wait out a 429
body: { startDate: start, endDate: end, items: slice.map((index) => items[index]) },
});
if (!res.ok) return { mode: "quote", reason: res.status };
slice.forEach((index, n) => {
// One entry per requested item, in request order.
cache.set(keyOf(start, end, items[index]), { at: Date.now(), entry: res.body.data[n] });
entries[index] = res.body.data[n];
});
}
} catch {
return { mode: "quote", reason: "network" };
}
return { mode: "live", results: entries.map((entry, i) => toPublic(items[i], entry)) };
}import time
from filmo_client import filmo
TTL = 60 # seconds
cache = {} # "start|end|item|variant|quantity" -> (stored_at, entry)
def key_of(start, end, item):
return "|".join([start, end, item["itemId"], item.get("variantId", ""), str(item.get("quantity", 1))])
# What the visitor may see: no order numbers, no stock figures of the business.
def to_public(item, entry):
if "error" in entry:
return {"itemId": item["itemId"], "state": "unknown"}
if entry["status"] == "ok":
return {"itemId": item["itemId"], "state": "available"}
return {
"itemId": item["itemId"],
"state": "partial" if entry["status"] == "partial" else "unavailable",
"maxQuantity": max(0, item.get("quantity", 1) - entry["shortfall"]),
}
# items: [{"itemId", "quantity", "variantId"?}] for ONE date range.
# Returns {"mode": "live", "results": [...]} or {"mode": "quote"} when you should not promise anything.
def check_range(start, end, items):
entries = [None] * len(items)
missing = []
for index, item in enumerate(items):
hit = cache.get(key_of(start, end, item))
if hit and time.monotonic() - hit[0] < TTL:
entries[index] = hit[1]
else:
missing.append(index)
try:
for offset in range(0, len(missing), 100): # at most 100 items per query
chunk = missing[offset:offset + 100]
status, body, _ = filmo(
"POST", "/api/v1/availability/check",
body={"startDate": start, "endDate": end, "items": [items[i] for i in chunk]},
retries=0, # never make a visitor wait out a 429
)
if status != 200:
return {"mode": "quote", "reason": status}
for n, index in enumerate(chunk):
# One entry per requested item, in request order.
cache[key_of(start, end, items[index])] = (time.monotonic(), body["data"][n])
entries[index] = body["data"][n]
except Exception:
return {"mode": "quote", "reason": "network"}
return {"mode": "live", "results": [to_public(items[i], e) for i, e in enumerate(entries)]}{
"data": [
{
"itemId": "ITEM_ID_1",
"variantId": null,
"itemName": "Cinema camera body",
"quantity": 2,
"available": false,
"status": "partial",
"totalStock": 3,
"reserved": 2,
"minRemaining": 1,
"shortfall": 1,
"conflictingPeriods": ["2026-11-03"],
"commitments": [ { "orderId": "...", "orderNumber": "...", "quantity": 2, "startDate": "...", "endDate": "..." } ],
"checkedAt": "2026-10-09T09:12:00Z"
},
{ "itemId": "ITEM_ID_2", "variantId": null, "error": { "code": "not_found", "message": "..." } }
]
}Una respuesta en caché puede tener un minuto de antigüedad, así que la comprobación informa al visitante; no reserva nada. Dos visitantes pueden ver como disponible la misma última unidad. Lo que decide es la reserva del paso 6, y el paso 7 gestiona al que pierde.
4. Recurrir a un presupuesto ante un 429
Enlace a esta sección: 4. Recurrir a un presupuesto ante un 429El código anterior pide al cliente que no reintente (retries en 0) y devuelve mode: "quote" ante un 429, ante cualquier otro fallo y ante un error de red. Cuando eso ocurra, muestra la misma página con “Pedir un presupuesto” en lugar de una etiqueta de stock, y registra la petición como un lead con el flujo del formulario web. El visitante nunca ve un error y el lead no se pierde. Opcionalmente, lee RateLimit-Remaining de las cabeceras de la respuesta y cambia al modo presupuesto antes de que se agote la cuota.
5. Crear el pedido
Enlace a esta sección: 5. Crear el pedidoPOST /api crea un pedido en borrador con el material solicitado. Es el equivalente validado de una petición desde un formulario: cada identificador se comprueba contra la cuenta de la clave antes de escribir nada, y uno solo incorrecto rechaza toda la petición con 400 y no crea nada.
El pedido nace en cero
La operación de creación no acepta importes de ningún tipo: ni precios, descuentos, seguros ni fianzas, ni estado, unidades serializadas, accesorios o notas. Los totales del pedido son cero hasta que el equipo le pone precio en Filmo. Muestra al cliente tu propio precio o “el equipo confirmará el precio”; no leas un precio del pedido nuevo. El pedido tampoco admite una referencia propia (externalReference se devuelve en las lecturas, pero la API no puede escribirlo), así que guarda juntos tu id de reserva y el id del pedido de Filmo.
Envía la Idempotency-Key booking-<your booking id>-order. Si tu servidor agota el tiempo de espera, repite la llamada con la misma clave y el mismo cuerpo en 24 horas y recibirás la primera respuesta, con Idempotent-Replay: true, en lugar de un segundo pedido. Un cuerpo distinto con la misma clave se rechaza con 422.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/orders" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: booking-1042-order" \
--data @- <<JSON
{
"contactId": "$CONTACT_ID",
"startDate": "2026-11-02T09:00:00+01:00",
"endDate": "2026-11-04T18:00:00+01:00",
"lines": [
{ "itemId": "ITEM_ID_1", "quantity": 2 },
{ "itemId": "ITEM_ID_2", "quantity": 1 }
]
}
JSON// order.mjs
import { filmo } from "./filmo-client.mjs";
// booking: { id, orderId?, contactId, start, end, lines: [{ itemId, quantity, variantId? }] }
// booking.id is YOUR id of the booking; both keys derive from it.
export async function createBookingOrder(booking) {
if (booking.orderId) return { ok: true }; // created before: skip straight to the reserve
const res = await filmo("POST", "/api/v1/orders", {
idempotencyKey: "booking-" + booking.id + "-order",
body: { contactId: booking.contactId, startDate: booking.start, endDate: booking.end, lines: booking.lines },
});
if (!res.ok) {
return { ok: false, outcome: res.status === 400 ? "invalid" : "retry-later", problem: res.body };
}
booking.orderId = res.body.data.id; // save it now, before reserving
return { ok: true };
}# order.py
from filmo_client import filmo
# booking: {"id", "order_id"?, "contact_id", "start", "end", "lines": [{"itemId", "quantity", "variantId"?}]}
# booking["id"] is YOUR id of the booking; both keys derive from it.
def create_booking_order(booking):
if booking.get("order_id"):
return {"ok": True} # created before: skip straight to the reserve
status, body, _ = filmo(
"POST", "/api/v1/orders",
body={
"contactId": booking["contact_id"],
"startDate": booking["start"],
"endDate": booking["end"],
"lines": booking["lines"],
},
idempotency_key="booking-" + booking["id"] + "-order",
)
if status != 201:
return {"ok": False, "outcome": "invalid" if status == 400 else "retry-later", "problem": body}
booking["order_id"] = body["data"]["id"] # save it now, before reserving
return {"ok": True}6. Reservarlo
Enlace a esta sección: 6. ReservarloPOST /api no toma cuerpo. Mueve el pedido a reserved, comprueba el stock de cada línea, lo descuenta y crea las tareas de entrega y devolución, así que la agenda del equipo cambia con esta llamada; meta. indica cuántas tareas se crearon. Reservar un pedido que ya está reservado no es un error: no cambia nada y responde 200, así que repetirlo tras un tiempo de espera agotado es seguro. Responde 404 si el pedido no existe o está archivado. La reserva se puede deshacer: POST /api devuelve el pedido a borrador y libera el stock, y el pedido se puede reservar de nuevo; cancelarlo no se puede deshacer.
Decide si reservar siquiera. Un pedido que se crea a cero y se reserva de inmediato toma stock antes de que nadie le haya puesto precio. Si tu negocio revisa primero las reservas, detente tras el paso 5 y deja que el equipo reserve el borrador en Filmo.
Automatizaciones: crear y reservar disparan los workflows de evento de la cuenta (pedido creado, reservado, cambio de estado y las tareas que crea la reserva). Se ejecutan después de la respuesta, en la medida de lo posible, y no puedes suprimirlos por petición. Lista antes los workflows de evento activos (consulta Pruebas) y filtra por origen el trigger de cualquier workflow que no deba reaccionar, para excluir api y mcp.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/orders/$ORDER_ID/reserve" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Idempotency-Key: booking-1042-reserve"// order.mjs, continued
export async function reserveBookingOrder(booking) {
const res = await filmo("POST", "/api/v1/orders/" + booking.orderId + "/reserve", {
idempotencyKey: "booking-" + booking.id + "-reserve",
});
if (res.ok) return { outcome: "reserved", tasksCreated: res.body.meta.tasksCreated };
if (res.status === 409 && res.body?.code === "insufficient_stock") {
// Nothing was reserved; the order is still a draft.
const short = res.body.errors.map(({ path, itemId, requested, available, shortfall }) => ({
path, itemId, requested, available, shortfall,
}));
return { outcome: "short", short };
}
return { outcome: "retry-later", problem: res.body };
}
export async function book(booking) {
const created = await createBookingOrder(booking);
if (!created.ok) return created;
return { orderId: booking.orderId, ...(await reserveBookingOrder(booking)) };
}# order.py, continued
def reserve_booking_order(booking):
status, body, _ = filmo(
"POST", "/api/v1/orders/" + booking["order_id"] + "/reserve",
idempotency_key="booking-" + booking["id"] + "-reserve",
)
if status == 200:
return {"outcome": "reserved", "tasks_created": body["meta"]["tasksCreated"]}
if status == 409 and (body or {}).get("code") == "insufficient_stock":
# Nothing was reserved; the order is still a draft.
short = [
{k: e.get(k) for k in ("path", "itemId", "requested", "available", "shortfall")}
for e in body["errors"]
]
return {"outcome": "short", "short": short}
return {"outcome": "retry-later", "problem": body}
def book(booking):
created = create_booking_order(booking)
if not created["ok"]:
return created
return {"order_id": booking["order_id"], **reserve_booking_order(booking)}7. Gestionar el stock insuficiente
Enlace a esta sección: 7. Gestionar el stock insuficienteSi no se puede atender una línea, POST /api responde 409 insufficient_stock. No se reserva nada y el pedido sigue en borrador. errors[] tiene una entrada por cada artículo que se queda corto, con el itemId, las unidades requested, las unidades available para las fechas del pedido y el shortfall. Un path de spaces en lugar de items significa que falta un espacio. El mismo estado 409 también lleva conflict por otras causas, así que ramifica primero por code y luego por errors[0]. del conflicto: invalid_state (el pedido no es un borrador, un número de serie está ocupado), in_progress (una petición con la misma clave sigue en curso) o lock_timeout (otra escritura retuvo el pedido más de cinco segundos).
{
"type": "https://docs.filmo.io/errors/insufficient_stock",
"status": 409,
"code": "insufficient_stock",
"errors": [
{ "path": "items", "itemId": "ITEM_ID_1", "requested": 2, "available": 1, "shortfall": 1 }
],
"requestId": "req_..."
}Qué hacer después es una decisión de negocio. La API no puede editar las líneas de un pedido (PATCH /api cambia solo el cliente, el proyecto y las fechas), así que no puedes reducir cantidades en el mismo pedido. Tus opciones:
- Conserva el borrador, dile al cliente lo que hay disponible (
availablepor artículo) y deja que el equipo lo ajuste en Filmo. Así no se pierde nada. - Ofrece otras fechas o cantidades menores e inicia una nueva reserva con un nuevo id de reserva, lo que crea un pedido nuevo. El primer borrador se queda; archívalo con el equipo, o cancélalo con
POST /api, algo que no se puede deshacer a través de la API./v1 /orders /{orderId} /cancel - Pide al cliente que pida un presupuesto para las unidades que faltan.
Vuelve a comprobar la disponibilidad tras un conflicto o un resultado corto: la caché que usaste para la página ya es más antigua que el stock.
Qué guardar por tu parte
Enlace a esta sección: Qué guardar por tu parte| Qué | Por qué |
|---|---|
| Tu id de reserva | La raíz de ambas claves de idempotencia. |
| El id del pedido de Filmo, en cuanto la creación devuelve respuesta | Los reintentos se saltan la creación y van directos a la reserva, incluso pasadas las 24 horas de la clave. |
| El resultado: reservado, insuficiente, no válido o reintentar más tarde | Qué se le dijo al cliente y qué debe seguir el equipo. |
requestId de las llamadas fallidas | Lo que indicas a soporte. |
Gestionar errores
Enlace a esta sección: Gestionar errores| Respuesta | Qué hacer |
|---|---|
429 rate_limited en disponibilidad | No reintentes por el visitante. Recurre al presupuesto. RateLimit-* indica qué cuota se agotó. |
429 rate_limited al crear o reservar | Espera Retry-After (también retryAfter en el cuerpo del problema) y repite con la misma clave. |
400 validation_error | Lee errors[].: contactId o lines[0]. con not_found significa un id que no pertenece a la cuenta; endDate con must_be_after significa que las fechas están al revés. No se escribió nada. |
409 insufficient_stock | Paso 7. |
409 conflict | Lee errors[0].. in_progress y lock_timeout: espera y repite con la misma clave; no envíes una segunda petición en paralelo. invalid_state: no reintentes; lee el pedido con GET /api para ver su estado. |
422 idempotency_key_reused | Se envió el mismo id de reserva con contenido distinto. Usa un id de reserva nuevo para una reserva modificada. |
403 insufficient_scope, 403 forbidden | Configuración de la clave (requiredScope nombra el scope que falta) o el propietario de la clave carece del permiso; corrígelo en la app. Pon la reserva en cola y avisa. |
500 internal_error | Repite con la misma clave y esperas crecientes. Si la respuesta incluye Idempotent-Replay: true, se está reproduciendo el error guardado: deja de repetir y busca el pedido con GET /api antes de crear otro. |
503 service_unavailable | Ninguna operación de este tutorial lo documenta. Si llega, Filmo controla el interruptor que hay detrás: no lo reintentes como un fallo transitorio. Encola la reserva y avisa con el requestId. |
Siguientes pasos
Enlace a esta sección: Siguientes pasos- Límites de peticiones y Idempotencia.
- Convertir un formulario web en una oportunidad: busca o crea el cliente y captura las solicitudes de presupuesto.
- Errores: las reglas de reintento para cada estado.
- Pasar a producción.
- En la app: Reservar y liberar stock.
Páginas relacionadas
Enlace a esta sección: Páginas relacionadasGuías
Límites de peticiones
Cada clave de API tiene 120 peticiones por minuto, y las tres comprobaciones de disponibilidad comparten su propia cuota de 30 por minuto. Al pasar un límite, la API responde 429 con
Retry-After.Guías
Idempotencia y reintentos seguros
Envía una cabecera
Idempotency-Keycon cada escritura y reutiliza el mismo valor cuando reintentes esa operación: Filmo devuelve el resultado almacenado del primer intento en lugar de escribir dos veces.Tutoriales
Convertir un formulario web en una oportunidad
Tu servidor recibe el formulario, busca o crea el contacto por correo electrónico y abre una oportunidad en el embudo, con una clave derivada del envío para que ningún reintento cree un segundo lead.
Guías
Errores
Los errores son documentos de problema RFC 9457. Ramifica según el
codeestable, nunca según el texto, y usa la tabla de reintentos para decidir si volver a intentarlo.Guías
Lista de comprobación de salida a producción
Repasa estas ocho comprobaciones antes de que tu integración funcione con datos de producción. Cada una remite a la guía que la explica.
¿Te ha resultado útil esta página?