*API REST › Tutoriales*

# Contabilizar las facturas en tu sistema contable

> Lees las facturas validadas con una pasada por ventanas, luego sus líneas, pagos y notas de crédito, las contabilizas en tu libro mayor y llevas tu propio registro de lo que contabilizaste. La API publica totales en todas partes y el desglose de impuestos solo en el detalle de una factura, así que planifica primero las llamadas y las lagunas.

Este tutorial se apoya en la pasada por ventanas de [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data) y muestra solo lo específico de las facturas. El dinero que lees es siempre una cadena decimal con dos decimales (la llamada de factura externa del paso 8 acepta hasta seis decimales en la entrada); consulta [Importes y monedas](https://docs.filmo.io/es/further-reading/amounts-and-currencies).

## Qué te da la API y qué no

- Cada factura y nota de crédito lleva sus totales: [`subTotal`](https://docs.filmo.io/es/rest-api/objects/invoice#response-subTotal), [`netPrice`](https://docs.filmo.io/es/rest-api/objects/invoice#response-netPrice), [`taxesPrice`](https://docs.filmo.io/es/rest-api/objects/invoice#response-taxesPrice) y [`grossPrice`](https://docs.filmo.io/es/rest-api/objects/invoice#response-grossPrice).
- El desglose por tipo de impuesto está en un solo sitio: [`taxBreakdown`](https://docs.filmo.io/es/rest-api/objects/invoice#response-taxBreakdown), solo en la lectura de detalle de una factura, y es null cuando no se ha calculado.
- Las líneas de factura no tienen tipo de impuesto, y la API no publica la serie de una factura.
- Una nota de crédito solo tiene totales. La especificación dice: "The signed tax document, the tax breakdown, the lines and the reason for the correction are not published."
- El identificador fiscal del cliente es el actual de la ficha del contacto, no el que figuraba en la factura cuando se emitió.
- No hay PDF de una factura. Validar una factura es irreversible, y ni la respuesta de la validación ni ningún campo de la factura lleva un estado de la autoridad fiscal. No des por hecho a partir de la API si una factura validada se envió a una autoridad fiscal; pregunta a tu contacto de Filmo si lo necesitas.
- El `status` de una nota de crédito es una cadena libre, no una lista cerrada: tolera cualquier valor.

Trata Filmo como la fuente de importes y documentos, y conserva en tu propio registro, en el momento en que los lees, los datos legalmente relevantes que necesita tu contable. La tabla de campos de abajo indica, campo por campo, de dónde sale cada valor o que no está disponible. Hasta que Filmo publique más, contabiliza a partir de estos totales y del desglose del detalle; no deduzcas los tipos de impuesto a partir de los precios de las líneas.

## Requisitos previos

- Una clave de API con [`account:read`](https://docs.filmo.io/es/rest-api/guides/scopes#account:read), [`invoices:read`](https://docs.filmo.io/es/rest-api/guides/scopes#invoices:read) (facturas, líneas y notas de crédito), [`treasury:read`](https://docs.filmo.io/es/rest-api/guides/scopes#treasury:read) (pagos) y [`contacts:read`](https://docs.filmo.io/es/rest-api/guides/scopes#contacts:read) (el identificador fiscal del cliente). Añade [`invoices:write`](https://docs.filmo.io/es/rest-api/guides/scopes#invoices:write) solo si reflejas en Filmo facturas emitidas por el ERP. Crea la clave en los ajustes de API y MCP de la app (Ajustes → API y MCP); solo pueden hacerlo los administradores.
- Node 22 o posterior, o Python 3 con `requests`, y la clave en la variable de entorno `FILMO_API_KEY`.
- La pasada por ventanas de [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data). No sigas el cursor para averiguar qué ha cambiado.

## Paso 1. Configurar el cliente y leer la cuenta

Guarda el cliente una vez; todos los fragmentos posteriores lo importan. Aguarda un [`rate_limited`](https://docs.filmo.io/es/rest-api/errors/rate_limited) durante tantos segundos como indique `Retry-After` y devuelve la hora del servidor a partir de la cabecera `Date`.

*El cliente*

**cURL**

```bash
# Export FILMO_API_KEY first: a key with the scopes listed above.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"

```

**Node**

```javascript
// filmo.mjs: a small client. Node 22 or later, no dependencies.
export const API = (process.env.FILMO_BASE_URL ?? "https://app.filmo.io") + "/api/v1";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// GET with query parameters. Waits out a 429 as Retry-After says.
export async function get(path, query = {}, headers = {}) {
  const url = new URL(API + path);
  for (const [key, value] of Object.entries(query)) url.searchParams.set(key, String(value));
  for (;;) {
    const res = await fetch(url, {
      headers: { Authorization: ["Bearer", process.env.FILMO_API_KEY].join(" "), ...headers },
    });
    if (res.status === 429) {
      await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000);
      continue;
    }
    if (res.status === 304) return { status: 304, etag: res.headers.get("ETag") };
    if (!res.ok) {
      throw Object.assign(new Error(res.status + " " + (await res.text())), { status: res.status });
    }
    return {
      ...(await res.json()),
      status: res.status,
      etag: res.headers.get("ETag"),
      serverTime: new Date(res.headers.get("Date")), // the server's clock, not yours
    };
  }
}
```

**Python**

```python
# filmo.py: a small client. Python 3 and requests.
import os
import time
from email.utils import parsedate_to_datetime

import requests

API = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io") + "/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FILMO_API_KEY']}"


def get(path, headers=None, **query):
    """GET with query parameters. Waits out a 429 as Retry-After says."""
    while True:
        res = session.get(API + path, params=query, headers=headers)
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", "1")))
            continue
        if res.status_code == 304:
            return {"status": 304, "etag": res.headers.get("ETag")}
        res.raise_for_status()
        body = res.json()
        body["status"] = res.status_code
        body["etag"] = res.headers.get("ETag")
        body["serverTime"] = parsedate_to_datetime(res.headers["Date"])  # the server's clock
        return body
```

[`GET /api/v1/account`](https://docs.filmo.io/es/rest-api/endpoint-reference/account/get-the-account) te da [`currency`](https://docs.filmo.io/es/rest-api/objects/account#response-currency) y [`timezone`](https://docs.filmo.io/es/rest-api/objects/account#response-timezone). Una factura lleva su propio [`currency`](https://docs.filmo.io/es/rest-api/objects/invoice#response-currency), que puede ser distinto; contabiliza en la moneda de la factura.

**cURL**

```bash
# Export FILMO_API_KEY first: a key with the scopes listed above.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"

curl -sS "$FILMO_URL/api/v1/account" \
  -H "Authorization: Bearer $FILMO_API_KEY"
```

**Node**

```javascript
import { get } from "./filmo.mjs";

const { data: account } = await get("/account");
console.log(account.timezone, account.currency, account.rateLimits.global.perMinute);
```

**Python**

```python
from filmo import get

account = get("/account")["data"]
print(account["timezone"], account["currency"], account["rateLimits"]["global"]["perMinute"])
```

## Paso 2. Leer las facturas validadas por ventana

[`GET /api/v1/invoices`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/list-invoices) tiene [`updatedFrom`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/list-invoices#param-updatedFrom) (desde, inclusive) y [`updatedBefore`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/list-invoices#param-updatedBefore) (hasta, exclusive), así que una ventana semiabierta de `updatedAt` funciona exactamente como en la guía: una página de hasta 500, divide la ventana si `pagination.hasMore` es `true`, deduplica por `id`. La lista no tiene filtro por estado del documento, así que léelas todas y sepáralas por [`status`](https://docs.filmo.io/es/rest-api/objects/invoice#response-status): `draft` aún no tiene número (no contabilices nada), `validated` está numerada y lista, `voided` se trata en el paso 7. Cuando se valida un borrador, su registro cambia, así que vuelve en una ventana posterior. Validar, anular y registrar un pago mueven el `updatedAt` de la factura. La marca un segundo anterior que describe la guía pertenece al pedido que contiene la factura, no a la factura, pero Filmo sigue sin dar una cota garantizada para el retraso antes de que un cambio sea legible, así que mantén el margen de seguridad de la guía (dos minutos como mínimo) y deduplica por `id`.

*La lectura por ventanas*

**cURL**

```bash
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"

# One window [updatedFrom, updatedBefore), one page. If pagination.hasMore is true, split the window.
curl -sS -G "$FILMO_URL/api/v1/invoices" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "updatedFrom=2026-10-08T00:00:00.000Z" \
  --data-urlencode "updatedBefore=2026-10-09T00:00:00.000Z" \
  --data-urlencode "sort=updatedAt" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
// window.mjs: the windowed read of the syncing guide, one page per query.
import { get } from "./filmo.mjs";

const iso = (ms) => new Date(ms).toISOString();

export const START = Date.parse("2020-01-01T00:00:00Z"); // move it to the month of your first record

// B: the server's time minus the safety margin of the guide (2 minutes and 1 second). The time is the
// Date header of a response, never your own clock.
export async function upperBound() {
  const { serverTime } = await get("/account");
  return serverTime.getTime() - (2 * 60 * 1000 + 1000);
}

// Every record whose updatedAt (or createdAt) falls in [from, to), in epoch milliseconds.
// It never follows the cursor: a window that overflows one page is split in half.
export async function windowed(path, from, to, { field = "updated", query = {} } = {}) {
  const page = await get(path, {
    ...query,
    [field + "From"]: iso(from),
    [field + "Before"]: iso(to),
    sort: field + "At",
    limit: 500,
  });
  if (!page.pagination.hasMore) return page.data;
  if (to - from <= 1) throw new Error("More than 500 records share one millisecond: see the guide");
  const mid = Math.floor((from + to) / 2);
  const merged = new Map();
  for (const half of [
    await windowed(path, from, mid, { field, query }),
    await windowed(path, mid, to, { field, query }),
  ]) {
    for (const record of half) merged.set(record.id, record); // deduplicate by id
  }
  return [...merged.values()];
}

// Walks [start, end) in 30-day windows. Returns a Map of id to record.
export async function readRange(path, start, end, options) {
  const step = 30 * 24 * 3600 * 1000;
  const records = new Map();
  for (let from = start; from < end; from += step) {
    for (const record of await windowed(path, from, Math.min(from + step, end), options)) {
      records.set(record.id, record);
    }
  }
  return records;
}
```

**Python**

```python
# window.py: the windowed read of the syncing guide, one page per query.
from datetime import datetime, timezone

from filmo import get


START = int(datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)  # move it to the month of your first record


def upper_bound():
    """B: the server's time minus the safety margin of the guide (2 minutes and 1 second).
    The time is the Date header of a response, never your own clock."""
    return int(get("/account")["serverTime"].timestamp() * 1000) - (2 * 60 * 1000 + 1000)


def iso(ms):
    stamp = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
    return stamp.isoformat(timespec="milliseconds").replace("+00:00", "Z")


def windowed(path, start, end, field="updated", **query):
    """Every record whose updatedAt (or createdAt) falls in [start, end), in epoch ms.
    It never follows the cursor: a window that overflows one page is split in half."""
    page = get(
        path,
        **query,
        **{field + "From": iso(start), field + "Before": iso(end), "sort": field + "At", "limit": 500},
    )
    if not page["pagination"]["hasMore"]:
        return page["data"]
    if end - start <= 1:
        raise RuntimeError("More than 500 records share one millisecond: see the guide")
    mid = (start + end) // 2
    merged = {r["id"]: r for r in windowed(path, start, mid, field, **query)}
    merged.update({r["id"]: r for r in windowed(path, mid, end, field, **query)})
    return list(merged.values())


def read_range(path, start, end, field="updated", **query):
    """Walks [start, end) in 30-day windows. Returns a dict of id to record."""
    step = 30 * 24 * 3600 * 1000
    records = {}
    for begin in range(start, end, step):
        for record in windowed(path, begin, min(begin + step, end), field, **query):
            records[record["id"]] = record
    return records
```

*Leer las facturas*

**cURL**

```bash
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"

# One window [updatedFrom, updatedBefore), one page. If pagination.hasMore is true, split the window.
curl -sS -G "$FILMO_URL/api/v1/invoices" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "updatedFrom=2026-10-08T00:00:00.000Z" \
  --data-urlencode "updatedBefore=2026-10-09T00:00:00.000Z" \
  --data-urlencode "sort=updatedAt" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
import { readRange, upperBound } from "./window.mjs";

const B = await upperBound();
const A = Date.parse("2025-01-01T00:00:00Z"); // first load: before your first invoice; later: the watermark

const all = await readRange("/invoices", A, B);

// The list has no status filter: split by status here. A credit note is read from /credit-notes;
// a row with isRectification set is kept: match it against the credit notes by number.
const rows = [...all.values()];
const validated = rows.filter((i) => i.status === "validated");
const voided = rows.filter((i) => i.status === "voided");
console.log(validated.length + " validated, " + voided.length + " voided; drafts are ignored");
```

**Python**

```python
from datetime import datetime, timezone

from window import read_range, upper_bound

B = upper_bound()
A = int(datetime(2025, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)  # first load; later: the watermark

all_rows = read_range("/invoices", A, B)

# The list has no status filter: split by status here. A credit note is read from /credit-notes;
# a row with isRectification set is kept: match it against the credit notes by number.
rows = list(all_rows.values())
validated = [i for i in rows if i["status"] == "validated"]
voided = [i for i in rows if i["status"] == "voided"]
print(len(validated), "validated,", len(voided), "voided; drafts are ignored")
```

La lista ya devuelve lo que necesitas para una cabecera de asiento contable: número, fechas de emisión y de vencimiento, moneda, el contacto como id y nombre, los totales, el importe cobrado y el estado de pago. La marca de agua pasa a B solo después de leer todas las ventanas de la pasada. Las notas de crédito se leen de sus propios endpoints, en el paso 6. La lista de facturas aún puede devolver una fila con [`isRectification`](https://docs.filmo.io/es/rest-api/objects/invoice#response-isRectification) activado, por ejemplo una factura importada de otro sistema: trátala como un documento propio (su importe puede ser negativo) y cótejala con las notas de crédito por número, para no contabilizarla dos veces.

## Paso 3. Leer el desglose de impuestos desde el detalle

[`GET /api/v1/invoices/{invoiceId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/get-an-invoice) devuelve la misma factura más [`taxBreakdown`](https://docs.filmo.io/es/rest-api/objects/invoice#response-taxBreakdown): una fila por tipo de impuesto, con `taxType`, `label`, `rate` (un porcentaje), `baseAmount` y `amount`. El detalle también tiene la dirección de facturación, el tipo de cambio y el reparto de los descuentos. Cuesta una llamada por factura, así que léelo una vez cuando veas la factura validada por primera vez, y de nuevo solo cuando la factura reaparezca en una ventana con un `updatedAt` distinto. Envía el `ETag` que guardaste en `If-None-Match` para obtener un 304 cuando nada ha cambiado; un 304 cuenta igualmente como petición.

**cURL**

```bash
# The detail is the only place with the tax breakdown.
curl -sS "$FILMO_URL/api/v1/invoices/$INVOICE_ID" \
  -H "Authorization: Bearer $FILMO_API_KEY"
```

**Node**

```javascript
import { get } from "./filmo.mjs";

const cents = (amount) => Number(amount.replace(".", "")); // "1234.50" -> 123450

export async function readTaxes(invoiceId) {
  const { data: invoice, etag } = await get("/invoices/" + invoiceId);
  const breakdown = invoice.taxBreakdown; // null if Filmo has not calculated it
  if (!breakdown) return { invoice, etag, breakdown: null, matches: false };

  // The breakdown is the detail of the totals; check it before you post it.
  const tax = breakdown.reduce((sum, row) => sum + cents(row.amount), 0);
  const base = breakdown.reduce((sum, row) => sum + cents(row.baseAmount), 0);
  const matches = tax === cents(invoice.taxesPrice) && base === cents(invoice.netPrice);
  return { invoice, etag, breakdown, matches };
}
```

**Python**

```python
from filmo import get


def cents(amount):
    return int(amount.replace(".", ""))  # "1234.50" -> 123450


def read_taxes(invoice_id):
    body = get("/invoices/" + invoice_id)
    invoice, etag = body["data"], body["etag"]
    breakdown = invoice["taxBreakdown"]  # None if Filmo has not calculated it
    if not breakdown:
        return invoice, etag, None, False

    # The breakdown is the detail of the totals; check it before you post it.
    tax = sum(cents(row["amount"]) for row in breakdown)
    base = sum(cents(row["baseAmount"]) for row in breakdown)
    matches = tax == cents(invoice["taxesPrice"]) and base == cents(invoice["netPrice"])
    return invoice, etag, breakdown, matches
```

> **Cuando el desglose es null o no coincide:** [`taxBreakdown`](https://docs.filmo.io/es/rest-api/objects/invoice#response-taxBreakdown) es null cuando no se ha calculado. Si es null, o si sus sumas difieren de [`taxesPrice`](https://docs.filmo.io/es/rest-api/objects/invoice#response-taxesPrice) y [`netPrice`](https://docs.filmo.io/es/rest-api/objects/invoice#response-netPrice), contabiliza la factura por totales y márcala para revisión; no construyas tú el desglose a partir de las líneas, que no llevan tipo de impuesto.

## Paso 4. Leer las líneas

[`GET /api/v1/invoices/{invoiceId}/lines`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/list-the-lines-of-an-invoice) devuelve lo que se facturó en orden de documento, con [`itemName`](https://docs.filmo.io/es/rest-api/objects/document-line#response-itemName) congelado en el momento de la emisión (puede diferir del catálogo de hoy), [`quantity`](https://docs.filmo.io/es/rest-api/objects/document-line#response-quantity), [`unitPrice`](https://docs.filmo.io/es/rest-api/objects/document-line#response-unitPrice), [`linePrice`](https://docs.filmo.io/es/rest-api/objects/document-line#response-linePrice) y el periodo facturado. Las líneas de tipo `section` y `type_group` son agrupaciones de presentación sin recurso y sin precio. Las líneas no incluyen un tipo de impuesto, como dice la especificación: "los totales de la factura ya incluyen los impuestos calculados". Una factura validada no cambia, así que lee sus líneas una sola vez.

**cURL**

```bash
# What was invoiced, line by line, with the names frozen when the invoice was issued.
curl -sS -G "$FILMO_URL/api/v1/invoices/$INVOICE_ID/lines" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
import { get } from "./filmo.mjs";

export async function readInvoiceLines(invoiceId) {
  const lines = [];
  let cursor;
  do {
    const page = await get("/invoices/" + invoiceId + "/lines", { limit: 500, ...(cursor && { cursor }) });
    lines.push(...page.data);
    cursor = page.pagination.hasMore ? page.pagination.nextCursor : undefined;
  } while (cursor);
  return lines; // document order; a validated invoice does not change, so read it once
}
```

**Python**

```python
from filmo import get


def read_invoice_lines(invoice_id):
    lines, cursor = [], None
    while True:
        query = {"limit": 500}
        if cursor:
            query["cursor"] = cursor
        page = get("/invoices/" + invoice_id + "/lines", **query)
        lines += page["data"]
        if not page["pagination"]["hasMore"]:
            return lines  # document order; a validated invoice does not change, so read it once
        cursor = page["pagination"]["nextCursor"]
```

## Paso 5. Leer los pagos

[`GET /api/v1/invoices/{invoiceId}/payments`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/list-the-payments-of-an-invoice) toma el scope [`treasury:read`](https://docs.filmo.io/es/rest-api/guides/scopes#treasury:read), no el de facturas, y devuelve los pagos asignados a la factura. No tiene filtro `updated*`, así que léela en orden descendente de [`sort`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/list-the-payments-of-an-invoice#param-sort) `-updatedAt` y detente en el primer pago más antiguo que lo que tienes; un pago que cambia en mitad de una lectura pasa al principio y te da un duplicado, que deduplicas por `id`, nunca un hueco. Un pago tiene [`amount`](https://docs.filmo.io/es/rest-api/objects/payment#response-amount), [`date`](https://docs.filmo.io/es/rest-api/objects/payment#response-date), [`reference`](https://docs.filmo.io/es/rest-api/objects/payment#response-reference), [`treasuryAccountId`](https://docs.filmo.io/es/rest-api/objects/payment#response-treasuryAccountId), un [`type`](https://docs.filmo.io/es/rest-api/objects/payment#response-type) de `normal` o `deposit`, y [`linkedDocuments`](https://docs.filmo.io/es/rest-api/objects/payment#response-linkedDocuments), que indica cuánto fue a cada documento.

No necesitas leer los pagos de cada factura cada vez. La lista te da [`totalPaid`](https://docs.filmo.io/es/rest-api/objects/invoice#response-totalPaid) y [`paymentStatus`](https://docs.filmo.io/es/rest-api/objects/invoice#response-paymentStatus) de cada factura, así que lee los pagos de una factura solo cuando difieran de lo que guardaste. Un pago puede llegar semanas después de la factura. Registrarlo mueve el `updatedAt` de la factura, pero Filmo no da ningún límite garantizado de cuánto puede retrasarse esa marca, así que una vez al día, enumera las facturas que aún pueden pagarse con [`paymentStatuses`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/list-invoices#param-paymentStatuses) `unpaid,partially_paid,overdue` y compara. Esa lista puede incluir borradores: fíltralos por `status` por tu parte. Si la suma de los importes que tienes de una factura no coincide con su `totalPaid`, vuelve a leer sus pagos desde el principio.

**cURL**

```bash
# Newest change first. Stop reading when updatedAt is older than what you already hold.
curl -sS -G "$FILMO_URL/api/v1/invoices/$INVOICE_ID/payments" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "sort=-updatedAt" \
  --data-urlencode "limit=500"

# Once a day: the invoices that can still receive a payment.
curl -sS -G "$FILMO_URL/api/v1/invoices" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "paymentStatuses=unpaid,partially_paid,overdue" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
import { get } from "./filmo.mjs";

// The payments of one invoice changed since "since" (epoch ms), newest first, with an early stop.
export async function paymentsSince(invoiceId, since) {
  const found = new Map(); // deduplicate by id
  let cursor;
  for (;;) {
    const page = await get("/invoices/" + invoiceId + "/payments", {
      sort: "-updatedAt",
      limit: 500,
      ...(cursor && { cursor }),
    });
    for (const payment of page.data) {
      if (Date.parse(payment.updatedAt) < since) return found; // everything after is older
      found.set(payment.id, payment);
    }
    if (!page.pagination.hasMore) return found;
    cursor = page.pagination.nextCursor;
  }
}

// Which invoices to check: compare totalPaid and paymentStatus with what you stored.
// "since" is your last check of that invoice minus the safety margin; use 0 to read all of them.
export function needsPayments(invoice, stored) {
  return invoice.totalPaid !== stored?.totalPaid || invoice.paymentStatus !== stored?.paymentStatus;
}
```

**Python**

```python
from datetime import datetime

from filmo import get


def payments_since(invoice_id, since_ms):
    """The payments of one invoice changed since since_ms, newest first, with an early stop."""
    found, cursor = {}, None  # deduplicate by id
    while True:
        query = {"sort": "-updatedAt", "limit": 500}
        if cursor:
            query["cursor"] = cursor
        page = get("/invoices/" + invoice_id + "/payments", **query)
        for payment in page["data"]:
            changed = datetime.fromisoformat(payment["updatedAt"].replace("Z", "+00:00")).timestamp() * 1000
            if changed < since_ms:
                return found  # everything after is older
            found[payment["id"]] = payment
        if not page["pagination"]["hasMore"]:
            return found
        cursor = page["pagination"]["nextCursor"]


def needs_payments(invoice, stored):
    """Compare totalPaid and paymentStatus with what you stored; read the payments if they differ."""
    stored = stored or {}
    return invoice["totalPaid"] != stored.get("totalPaid") or invoice["paymentStatus"] != stored.get("paymentStatus")
```

## Paso 6. Leer las notas de crédito

Lee las notas de crédito desde sus propios endpoints. [`GET /api/v1/credit-notes`](https://docs.filmo.io/es/rest-api/endpoint-reference/credit-notes/list-credit-notes) acepta la misma ventana [`updatedFrom`](https://docs.filmo.io/es/rest-api/endpoint-reference/credit-notes/list-credit-notes#param-updatedFrom) y [`updatedBefore`](https://docs.filmo.io/es/rest-api/endpoint-reference/credit-notes/list-credit-notes#param-updatedBefore), así que el mismo ayudante las lee. [`GET /api/v1/credit-notes/{creditNoteId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/credit-notes/get-a-credit-note) devuelve la misma ficha que la lista, así que no hay una llamada de detalle que presupuestar. Una nota de crédito tiene [`creditNoteNumber`](https://docs.filmo.io/es/rest-api/objects/credit-note#response-creditNoteNumber), [`issueDate`](https://docs.filmo.io/es/rest-api/objects/credit-note#response-issueDate), [`status`](https://docs.filmo.io/es/rest-api/objects/credit-note#response-status), [`invoiceId`](https://docs.filmo.io/es/rest-api/objects/credit-note#response-invoiceId) (la factura que rectifica, o null) y los totales; no hay desglose, ni líneas, ni motivo. Su `status` es una cadena libre: guárdala tal como llega y no ramifiques sobre una lista cerrada de valores. Comprueba el signo de los importes de una nota de crédito en tu propia cuenta antes de decidir el signo de tu asiento.

**cURL**

```bash
# Credit notes have their own endpoints. The same windowed read, on updatedAt.
curl -sS -G "$FILMO_URL/api/v1/credit-notes" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "updatedFrom=2026-10-08T00:00:00.000Z" \
  --data-urlencode "updatedBefore=2026-10-09T00:00:00.000Z" \
  --data-urlencode "sort=updatedAt" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
import { readRange, upperBound } from "./window.mjs";

const B = await upperBound();
const A = Date.parse("2025-01-01T00:00:00Z"); // first load; later: the watermark
const notes = await readRange("/credit-notes", A, B);
for (const note of notes.values()) {
  if (note.status !== "validated") continue; // draft: wait; voided: cancelled in Filmo, so reflect that in your books
  // invoiceId is the invoice it rectifies, or null.
  console.log(note.creditNoteNumber, note.issueDate, note.invoiceId, note.netPrice, note.taxesPrice, note.grossPrice);
}
```

**Python**

```python
from datetime import datetime, timezone

from window import read_range, upper_bound

B = upper_bound()
A = int(datetime(2025, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)  # first load; later: the watermark
notes = read_range("/credit-notes", A, B)
for note in notes.values():
    if note["status"] != "validated":
        continue  # draft: wait; voided: cancelled in Filmo, so reflect that in your books
    # invoiceId is the invoice it rectifies, or None.
    print(note["creditNoteNumber"], note["issueDate"], note["invoiceId"], note["netPrice"], note["taxesPrice"], note["grossPrice"])
```

## Paso 7. Gestionar las facturas anuladas

[`POST /api/v1/invoices/{invoiceId}/void`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/void-an-invoice) solo funciona con una factura validada. La factura conserva su número, porque anular no libera la numeración, y pasa a `voided`; no emite una nota de crédito. Por tanto, una factura anulada es un cambio, no un borrado. La pasada por ventana la devuelve con su nuevo estado; el barrido semanal de la guía, que compara el estado y `updatedAt`, es la red de seguridad por si se escapa alguna.

| Ves | Qué hacer |
| --- | --- |
| Una factura `voided` que ya contabilizaste | Contabiliza la anulación en tu libro como te indique tu contable. Conserva el asiento original y su número; no lo borres. |
| Una factura `voided` que nunca viste validada | Regístrala como anulada y no contabilices nada, o contabilízala y revierte el asiento si tu contable necesita el número en los libros. |
| Dinero a devolver | Anular no reembolsa nada. Si hay que devolver dinero, es otro documento: una nota de crédito (paso 6). |
| Una factura que desaparece de tus lecturas | Confirma con `GET /api/v1/invoices/{invoiceId}` antes de cambiar tu lado; solo un 404 significa que ya no existe, como en el barrido de conciliación de la guía. |

## Paso 8. Reflejar en Filmo una factura emitida por el ERP (opcional)

Si tu ERP emite algunas facturas, [`PUT /api/v1/invoices/external/{originSystem}/{originExternalId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice) guarda en Filmo una copia de solo lectura de cada una para que el equipo de operaciones pueda verla. La copia nunca se envía a una autoridad fiscal y no se numera en una serie de Filmo. La especificación es explícita sobre lo que es esa copia: "Filmo no la emite: no se numera en una serie de Filmo, no se firma y nunca se envía a Verifactu. Se guarda validada, bloqueada y marcada como emitida externamente, y en Filmo no se puede editar, anular, cobrar, rectificar, facturar electrónicamente ni enviar por correo: todo cambio llega por esta misma llamada."

Esta es la única llamada en la que eliges la clave. El par [`originSystem`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice#param-originSystem) (un slug en minúsculas tuyo, como `acme_erp`) y [`originExternalId`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice#param-originExternalId) (el id del documento en tu ERP) identifica el espejo: repetir la llamada lo actualiza, y repetir el mismo contenido no cambia nada. Responde 201 cuando crea el espejo y 200 cuando lo actualiza o lo encuentra idéntico. Envía una `Idempotency-Key` como en cualquier escritura. El cliente se resuelve por `contactId`, luego por identificador fiscal y luego por nombre exacto; un cliente desconocido o ambiguo se rechaza, porque los contactos nunca se crean de forma implícita. Los totales declarados se comprueban contra las líneas con una tolerancia de un céntimo por línea, y una discrepancia también se rechaza. Ambos casos responden 400 [`validation_error`](https://docs.filmo.io/es/rest-api/errors/validation_error), con `errors[0].path` indicando el campo (`contactId`, `contactTaxId`, `contactName`, `subTotal`, `taxTotal`, `total` o `lines[n].taxRate`), y no se escribe nada. Los importes, cantidades y precios de esta llamada admiten hasta seis decimales, y la regla de dos decimales se aplica a lo que lees de vuelta. En el ejemplo, 2 × 150.00 + 1 × 80.00 = 380.00 de base, 79.80 de impuesto al 21% y 459.80 de total.

**cURL**

```bash
# originSystem is a lowercase slug you choose; originExternalId is the document id in your ERP.
curl -sS -X PUT "$FILMO_URL/api/v1/invoices/external/acme_erp/INV-2026-0001" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "invoiceNumber": "INV-2026-0001",
    "invoiceDate": "2026-10-08",
    "dueDate": "2026-11-07",
    "currency": "EUR",
    "contactTaxId": "B00000000",
    "lines": [
      { "description": "Camera package, 2 days", "quantity": "2", "unitPrice": "150.00", "taxRate": "21" },
      { "description": "Delivery", "quantity": "1", "unitPrice": "80.00", "taxRate": "21" }
    ],
    "subTotal": "380.00",
    "taxTotal": "79.80",
    "total": "459.80",
    "externalStatus": "issued"
  }'
```

**Node**

```javascript
import { randomUUID } from "node:crypto";
import { API } from "./filmo.mjs";

const originSystem = "acme_erp";
const originExternalId = "INV-2026-0001";

const res = await fetch(API + "/invoices/external/" + originSystem + "/" + encodeURIComponent(originExternalId), {
  method: "PUT",
  headers: {
    Authorization: ["Bearer", process.env.FILMO_API_KEY].join(" "),
    "Content-Type": "application/json",
    "Idempotency-Key": randomUUID(), // reuse the same value if you retry this exact call
  },
  body: JSON.stringify({
    invoiceNumber: "INV-2026-0001",
    invoiceDate: "2026-10-08",
    dueDate: "2026-11-07",
    currency: "EUR",
    contactTaxId: "B00000000",
    lines: [
      { description: "Camera package, 2 days", quantity: "2", unitPrice: "150.00", taxRate: "21" },
      { description: "Delivery", quantity: "1", unitPrice: "80.00", taxRate: "21" },
    ],
    subTotal: "380.00",
    taxTotal: "79.80",
    total: "459.80",
    externalStatus: "issued",
  }),
});
const { data } = await res.json();
// 201 created the mirror, 200 updated it or found it identical. Keep data.id: it is the Filmo id.
console.log(res.status, data.id, data.status);
```

**Python**

```python
import uuid
from urllib.parse import quote

from filmo import API, session

origin_system = "acme_erp"
origin_external_id = "INV-2026-0001"

res = session.put(
    API + "/invoices/external/" + origin_system + "/" + quote(origin_external_id, safe=""),
    headers={"Idempotency-Key": str(uuid.uuid4())},  # reuse the same value if you retry this exact call
    json={
        "invoiceNumber": "INV-2026-0001",
        "invoiceDate": "2026-10-08",
        "dueDate": "2026-11-07",
        "currency": "EUR",
        "contactTaxId": "B00000000",
        "lines": [
            {"description": "Camera package, 2 days", "quantity": "2", "unitPrice": "150.00", "taxRate": "21"},
            {"description": "Delivery", "quantity": "1", "unitPrice": "80.00", "taxRate": "21"},
        ],
        "subTotal": "380.00",
        "taxTotal": "79.80",
        "total": "459.80",
        "externalStatus": "issued",
    },
)
res.raise_for_status()
data = res.json()["data"]
# 201 created the mirror, 200 updated it or found it identical. Keep data["id"]: it is the Filmo id.
print(res.status_code, data["id"], data["status"])
```

> **Estos espejos pueden volver en tu propia lectura:** Una factura que reflejas se guarda como validada, así que puede aparecer en [`GET /api/v1/invoices`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/list-invoices) como cualquier otra. Conserva el `id` de Filmo que devuelve la llamada junto a tu id del ERP y omite esos ids cuando contabilices, o contabilizarás tu propia factura dos veces. Automatizaciones: esta llamada dispara los workflows de eventos de la cuenta (factura creada, actualizada o anulada) después de la respuesta, en la medida de lo posible, y no puedes suprimirlos por petición. Primero lista los workflows de eventos activos (consulta [Pruebas](https://docs.filmo.io/es/rest-api/guides/testing)) y filtra por origen el trigger de cualquier workflow que no deba reaccionar, para excluir `api` y `mcp`. Una factura anulada en tu ERP se envía con `externalStatus` `voided` y un motivo; Filmo se niega (409, con `errors[0].code` `invalid_state`) a convertir de nuevo en emitido un espejo anulado.

## Mapeo de campos

De dónde sale cada campo de tu sistema contable. "Not available" significa que la API no lo publica hoy; la columna de la derecha da la solución alternativa.

| Campo contable | Campo de Filmo | Lee con | Notas o alternativa |
| --- | --- | --- | --- |
| Número de factura | `invoiceNumber` | `GET /api/v1/invoices` | Null solo mientras la factura es un borrador. Una factura anulada conserva su número. |
| Fecha de emisión | `invoiceDate` | `GET /api/v1/invoices` | Un instante. Toma la fecha en la zona horaria del negocio (`timezone`) para el libro contable. |
| Fecha de vencimiento | `dueDate` | `GET /api/v1/invoices` | Null si no se establece. |
| Moneda y tipo de cambio | `currency`; `exchangeRate` | `GET /api/v1/invoices`; `GET /api/v1/invoices/{invoiceId}` | La tasa solo aparece en el detalle y es null cuando no se aplica. |
| Cliente | `contact`: un `id` y un `name` | `GET /api/v1/invoices` | El id es tu clave del cliente; el nombre es tal como lo muestra Filmo. |
| Identificador fiscal del cliente en el momento de la emisión | No disponible | - | La API solo devuelve el `nif` actual, leído con `GET /api/v1/contacts/{contactId}`. Lee el contacto la primera vez que veas la factura y guarda el identificador fiscal con el asiento que contabilices. |
| Dirección de facturación | `billingAddress` | `GET /api/v1/invoices/{invoiceId}` | Solo en el detalle; null si no se registró. |
| Subtotal, descuentos, base, impuesto, total | `subTotal`, `discountTotal`, `netPrice`, `taxesPrice`, `grossPrice` | `GET /api/v1/invoices` | Cadenas decimales con dos decimales. El detalle divide el descuento en `productDiscountTotal` y `globalDiscountTotal` y añade `insuranceTotal` y `depositTotal`. |
| Base e impuesto por tipo de impuesto | `taxBreakdown` | `GET /api/v1/invoices/{invoiceId}` | Una llamada de detalle por factura; null si no se ha calculado. No está en la lista. |
| Líneas | `itemName`, `quantity`, `unitPrice`, `linePrice`, `startDate`, `endDate`, `rentalDays` | `GET /api/v1/invoices/{invoiceId}/lines` | Los nombres se congelan al emitir. Una línea facturada a cero tiene `free` activado. |
| Tipo de impuesto de cada línea | No disponible | - | Una línea no tiene campo de impuesto. Usa el `taxBreakdown` de la factura para el desglose por tipo de impuesto; no lo repartas entre las líneas. |
| Serie de factura | No disponible | - | La API no publica la serie de una factura. Tómala de la app; no la extraigas del número a menos que tu contable confirme el formato del número. |
| Pagos | `amount`, `date`, `reference`, `treasuryAccountId`; en la factura `totalPaid`, `paymentStatus`, `paymentMethod` | `GET /api/v1/invoices/{invoiceId}/payments`; `GET /api/v1/invoices` | Los pagos necesitan `treasury:read`. Un pago puede repartirse entre documentos: lee `linkedDocuments`. |
| Notas de crédito | `creditNoteNumber`, `issueDate`, `invoiceId`, `contactId`, `netPrice`, `taxesPrice`, `grossPrice` | `GET /api/v1/credit-notes`; `GET /api/v1/credit-notes/{creditNoteId}` | Solo totales. Léelos en los endpoints de notas de crédito. |
| Desglose de impuestos, líneas y motivo de la nota de crédito | No disponible | - | Contabiliza la nota de crédito por totales y completa el desglose y el motivo desde la app. |
| PDF de la factura | No disponible | - | Ninguna operación lo devuelve. Genera tu propio documento a partir de los datos anteriores, o descárgalo desde la app. |

La tabla siguiente se genera a partir de la especificación. Enumera, campo por campo, lo que publican los esquemas de factura y de nota de crédito, y los datos contables que la API no ofrece en esta versión.

| Campo | Tipo | Disponible |
| --- | --- | --- |
| `InvoiceDto.id` | string | Sí |
| `InvoiceDto.invoiceNumber` | string \| null | Sí |
| `InvoiceDto.status` | string | Sí |
| `InvoiceDto.isRectification` | boolean | Sí |
| `InvoiceDto.invoiceDate` | string \| null | Sí |
| `InvoiceDto.dueDate` | string \| null | Sí |
| `InvoiceDto.currency` | string | Sí |
| `InvoiceDto.contact` | object \| null | Sí |
| `InvoiceDto.orderId` | string \| null | Sí |
| `InvoiceDto.orderNumber` | string \| null | Sí |
| `InvoiceDto.projectId` | string \| null | Sí |
| `InvoiceDto.subTotal` | string | Sí |
| `InvoiceDto.discountTotal` | string | Sí |
| `InvoiceDto.taxesPrice` | string | Sí |
| `InvoiceDto.netPrice` | string | Sí |
| `InvoiceDto.grossPrice` | string | Sí |
| `InvoiceDto.totalPaid` | string \| null | Sí |
| `InvoiceDto.paymentStatus` | string | Sí |
| `InvoiceDto.paymentMethod` | string \| null | Sí |
| `InvoiceDto.tags` | array | Sí |
| `InvoiceDto.createdAt` | string | Sí |
| `InvoiceDto.updatedAt` | string | Sí |
| `InvoiceDetailDto.id` | string | Sí |
| `InvoiceDetailDto.invoiceNumber` | string \| null | Sí |
| `InvoiceDetailDto.status` | string | Sí |
| `InvoiceDetailDto.isRectification` | boolean | Sí |
| `InvoiceDetailDto.invoiceDate` | string \| null | Sí |
| `InvoiceDetailDto.dueDate` | string \| null | Sí |
| `InvoiceDetailDto.currency` | string | Sí |
| `InvoiceDetailDto.contact` | object \| null | Sí |
| `InvoiceDetailDto.orderId` | string \| null | Sí |
| `InvoiceDetailDto.orderNumber` | string \| null | Sí |
| `InvoiceDetailDto.projectId` | string \| null | Sí |
| `InvoiceDetailDto.subTotal` | string | Sí |
| `InvoiceDetailDto.discountTotal` | string | Sí |
| `InvoiceDetailDto.taxesPrice` | string | Sí |
| `InvoiceDetailDto.netPrice` | string | Sí |
| `InvoiceDetailDto.grossPrice` | string | Sí |
| `InvoiceDetailDto.totalPaid` | string \| null | Sí |
| `InvoiceDetailDto.paymentStatus` | string | Sí |
| `InvoiceDetailDto.paymentMethod` | string \| null | Sí |
| `InvoiceDetailDto.tags` | array | Sí |
| `InvoiceDetailDto.createdAt` | string | Sí |
| `InvoiceDetailDto.updatedAt` | string | Sí |
| `InvoiceDetailDto.projectName` | string \| null | Sí |
| `InvoiceDetailDto.taxBreakdown` | array \| null | Sí |
| `InvoiceDetailDto.productDiscountTotal` | string | Sí |
| `InvoiceDetailDto.globalDiscountTotal` | string | Sí |
| `InvoiceDetailDto.insuranceTotal` | string | Sí |
| `InvoiceDetailDto.depositTotal` | string | Sí |
| `InvoiceDetailDto.exchangeRate` | number \| null | Sí |
| `InvoiceDetailDto.billingAddress` | object \| null | Sí |
| `InvoiceDetailDto.sourceQuoteIds` | array \| null | Sí |
| `InvoiceDetailDto.sourceProformaIds` | array \| null | Sí |
| `DocumentLineDto.id` | string | Sí |
| `DocumentLineDto.resourceType` | string | Sí |
| `DocumentLineDto.transactionType` | string | Sí |
| `DocumentLineDto.position` | integer | Sí |
| `DocumentLineDto.itemId` | string \| null | Sí |
| `DocumentLineDto.itemName` | string \| null | Sí |
| `DocumentLineDto.variantId` | string \| null | Sí |
| `DocumentLineDto.variantName` | string \| null | Sí |
| `DocumentLineDto.quantity` | number | Sí |
| `DocumentLineDto.orderId` | string \| null | Sí |
| `DocumentLineDto.orderNumber` | string \| null | Sí |
| `DocumentLineDto.startDate` | string \| null | Sí |
| `DocumentLineDto.endDate` | string \| null | Sí |
| `DocumentLineDto.rentalDays` | number \| null | Sí |
| `DocumentLineDto.unitPrice` | string \| null | Sí |
| `DocumentLineDto.linePrice` | string \| null | Sí |
| `DocumentLineDto.free` | boolean | Sí |
| `DocumentLineDto.notes` | string \| null | Sí |
| `CreditNoteDto.id` | string | Sí |
| `CreditNoteDto.creditNoteNumber` | string \| null | Sí |
| `CreditNoteDto.status` | string | Sí |
| `CreditNoteDto.issueDate` | string \| null | Sí |
| `CreditNoteDto.currency` | string | Sí |
| `CreditNoteDto.contactId` | string \| null | Sí |
| `CreditNoteDto.invoiceId` | string \| null | Sí |
| `CreditNoteDto.subTotal` | string | Sí |
| `CreditNoteDto.taxesPrice` | string | Sí |
| `CreditNoteDto.netPrice` | string | Sí |
| `CreditNoteDto.grossPrice` | string | Sí |
| `CreditNoteDto.createdAt` | string | Sí |
| `CreditNoteDto.updatedAt` | string | Sí |
| PDF de facturas y otros documentos | — | No disponible en v1 |
| Componer borradores de factura y editar presupuestos | — | No disponible en v1 |
| Desglose de impuestos, líneas y motivo de una nota de crédito | — | No disponible en v1 |
| El identificador fiscal del cliente en el momento de emitir una factura | — | No disponible en v1 |
| Tipo de impuesto por línea y serie de una factura | — | No disponible en v1 |

La lista de lo que la API no ofrece en esta versión está en [Capacidades y límites](https://docs.filmo.io/es/further-reading/capabilities-and-limits).

## Qué guardar por tu parte

| Guardar | Por qué |
| --- | --- |
| `id` de la factura de Filmo junto al asiento que contabilizaste | Para volver a encontrarlo en una anulación, un pago o una nota de crédito. Los identificadores son opacos. |
| El `updatedAt` y el `ETag` de cada factura | Para omitir una lectura de detalle cuando no ha cambiado nada, y para el barrido semanal. |
| `totalPaid` y `paymentStatus`, y los pagos que tienes | Para decidir qué facturas necesitan una lectura de pagos. |
| El identificador fiscal del cliente en el momento en que lees la factura | La API solo devuelve el actual. |
| La marca de agua B de la última pasada completa, para facturas y para notas de crédito | La siguiente pasada empieza ahí. Solo avanza tras una pasada completa. |
| Los ids de Filmo de las facturas que has replicado con la llamada externa | Para omitirlos cuando leas facturas. |

## Presupuesto de llamadas frente a 120 peticiones por minuto

Tu clave permite 120 peticiones por minuto en toda la API, y cada respuesta autenticada cuenta. La parte cara de este tutorial es el desglose de impuestos: existe solo en la lectura de detalle, que cuesta una llamada por factura. Para una lista leída en W ventanas de las cuales S desbordan una página, el coste es W + 2S llamadas. La carga inicial es:

*Carga inicial*

```text
calls = 1 + (W_inv + 2*S_inv) + (W_cn + 2*S_cn) + N + N + P
  N = validated invoices (one detail call and one lines call each)
  P = invoices with a collection (one payments call each)
minutes = calls / 120
```

Ejemplo práctico. 12,000 facturas validadas en 24 meses, el 70% de ellas con un cobro (P = 8,400), y 600 notas de crédito. Facturas: 24 ventanas mensuales, de las cuales 12 tienen más de 500 y desbordan: 24 + 2 × 12 = 48 llamadas. Notas de crédito: 24 ventanas, ninguna desborda: 24 llamadas. Total: 1 + 48 + 24 + 12,000 + 12,000 + 8,400 = 32,473 llamadas. A 120 por minuto son 270.6 minutos (4.5 horas); a 96 por minuto, dejando libre una quinta parte de la cuota, 338.3 minutos (5.6 horas). Solo las lecturas de detalle son 12,000 de esas llamadas. Si puedes contabilizar por totales y no necesitas el desglose por tipo de impuesto, te las ahorras y la carga es de 20,473 llamadas, 170.6 minutos (2.8 horas).

*Ritmo diario*

```text
calls per day = 3 * polls + D + L + Pd + R
  polls = passes per day (each: 1 server time + 1 invoice window + 1 credit-note window)
  D = invoices that appear in a window (one detail call each)
  L = newly validated invoices (one lines call each)
  Pd = invoices whose totalPaid or paymentStatus changed (one payments call each)
  R = open-invoice refresh, once a day: ceil(rows the filter returns / 500), drafts included
```

Ejemplo práctico. Una pasada cada hora (24 pasadas, así que 72 llamadas), 40 facturas nuevas al día y 10 antiguas que cambian, así que D = 50 y L = 40. Pagos que leer: 28 de las facturas nuevas que ya llevan un cobro (70% de 40) y 10 de las modificadas, así que Pd = 38. Con 300 facturas abiertas la actualización es 1 llamada. Total: 72 + 50 + 40 + 38 + 1 = 201 llamadas al día, menos de dos minutos de cuota (1.7). El barrido semanal cuesta las llamadas de lista de la carga más un GET por cada id que falte.

Ejecuta la carga inicial en una hora tranquila y deja que el cliente espere cuando reciba [`rate_limited`](https://docs.filmo.io/es/rest-api/errors/rate_limited).

## Gestión de errores

| Respuesta | Qué significa aquí | Qué hacer |
| --- | --- | --- |
| 401 `api_key_owner_inactive` o `api_key_owner_not_admin` | La persona que creó la clave se fue o dejó de ser administradora. | Deja de publicar, avisa a alguien y vuelve a emitir la clave desde una cuenta de administrador. |
| 403 `insufficient_scope` | Falta un permiso (scope); `requiredScope` lo nombra. Los pagos necesitan `treasury:read` y el identificador fiscal necesita `contacts:read`. | Vuelve a emitir la clave con los permisos (scopes) de los requisitos previos. |
| 404 `not_found` | En una factura, una nota de crédito o sus pagos: no existe o está fuera de tu cuenta. Los pagos de una factura desconocida son un 404, no una lista vacía. | Marca el registro como perdido y confírmalo en el siguiente barrido; no borres un asiento contabilizado. |
| 400 `validation_error` | Un parámetro tiene un formato incorrecto, o el cuerpo de la llamada externa no es válido. | Corrige la petición; `errors[]` lista cada campo. |
| 409 `conflict` en la llamada externa | Lee `errors[0].code`. `invalid_state`: el número de factura lo usa otra factura, la clave pertenece a una factura que no es un reflejo, o se reenvió como emitido un reflejo anulado. `in_progress`: una petición con la misma clave sigue en ejecución. `lock_timeout`: otra escritura retuvo la factura durante más de cinco segundos. | Para `invalid_state`, no reintentes: corrige los datos. Para `in_progress` y `lock_timeout`, espera y reintenta con el mismo `Idempotency-Key`. |
| 422 `idempotency_key_reused` | Reutilizaste una `Idempotency-Key` con un cuerpo distinto. | Usa una clave nueva para un documento modificado; reutiliza una clave solo para reintentar la llamada idéntica. |
| 400 `validation_error` en la llamada externa, por el cliente o los totales | El cliente es desconocido o ambiguo, o los totales declarados no coinciden con las líneas. `errors[]` nombra el campo. | Resuelve el cliente con `contactId`, corrige los totales y envía el documento corregido. Una petición rechazada no deja ningún registro, así que puedes reutilizar la `Idempotency-Key` o usar una nueva. |
| 429 `rate_limited` | Gastaste las 120 peticiones del minuto. | Espera `Retry-After` segundos (también `retryAfter` en el cuerpo del problema) y continúa con la misma ventana. |
| 500 `internal_error`, 502 o 504 | Un fallo transitorio. Las lecturas se pueden repetir sin riesgo; para la llamada externa, reintenta con el mismo `Idempotency-Key`. | Reintenta con espera exponencial y no muevas la marca de agua. |
| 503 `service_unavailable` | Ninguna operación de este tutorial lo documenta. Filmo controla el interruptor que hay detrás. | No lo reintentes como un fallo transitorio. Detén esa llamada y contacta con el soporte de Filmo con el `requestId`. |

## Siguientes pasos

- [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data): el recorrido por ventanas, la parada anticipada y el barrido de conciliación.
- [Idempotencia](https://docs.filmo.io/es/rest-api/guides/idempotency): cómo reintentar la llamada externa de forma segura.
- [Importes y monedas](https://docs.filmo.io/es/further-reading/amounts-and-currencies): cadenas decimales y redondeo.
- [Capacidades y límites](https://docs.filmo.io/es/further-reading/capabilities-and-limits): lo que la API no ofrece en esta versión.
- [Sincronización de inventario](https://docs.filmo.io/es/rest-api/tutorials/inventory-sync): el mismo recorrido para artículos, unidades y líneas de pedido.

---

URL canónica: https://docs.filmo.io/es/rest-api/tutorials/invoices-to-accounting  
Última actualización: 2026-10-09