*REST API › Tutorials*

# Post invoices to your accounting system

> You read validated invoices with a windowed pass, then their lines, payments and credit notes, post them to your ledger, and keep your own record of what you posted. The API publishes totals everywhere and the tax split only in an invoice's detail, so plan the calls and the gaps first.

This tutorial builds on the windowed pass of [Syncing data](https://docs.filmo.io/rest-api/guides/syncing-data) and shows only what is specific to invoices. Money you read is always a decimal string with two decimals (the external invoice call of step 8 accepts up to six decimals on input); see [Amounts and currencies](https://docs.filmo.io/further-reading/amounts-and-currencies).

## What the API gives you and what it does not

- Every invoice and credit note carries its totals: [`subTotal`](https://docs.filmo.io/rest-api/objects/invoice#response-subTotal), [`netPrice`](https://docs.filmo.io/rest-api/objects/invoice#response-netPrice), [`taxesPrice`](https://docs.filmo.io/rest-api/objects/invoice#response-taxesPrice) and [`grossPrice`](https://docs.filmo.io/rest-api/objects/invoice#response-grossPrice).
- The split by tax type is in one place: [`taxBreakdown`](https://docs.filmo.io/rest-api/objects/invoice#response-taxBreakdown), only on the detail read of an invoice, and null when it has not been calculated.
- Invoice lines have no tax type, and the API does not publish the series of an invoice.
- A credit note has totals only. The spec says: "The signed tax document, the tax breakdown, the lines and the reason for the correction are not published."
- The customer's tax ID is the current one from the contact record, not the one on the invoice when it was issued.
- There is no PDF of an invoice. Validating an invoice is irreversible, and neither the response of the validation nor any invoice field carries a tax-authority status. Do not assume from the API whether a validated invoice was submitted to a tax authority; ask your Filmo contact if you need that.
- A credit note's `status` is a free string, not a closed list: tolerate any value.

Treat Filmo as the source of amounts and documents, and keep the legally relevant data your accountant needs in your own record at the moment you read it. The field table below says, field by field, where each value comes from or that it is not available. Until Filmo publishes more, post from these totals and from the detail's breakdown; do not infer tax types from line prices.

## Prerequisites

- An API key with [`account:read`](https://docs.filmo.io/rest-api/guides/scopes#account:read), [`invoices:read`](https://docs.filmo.io/rest-api/guides/scopes#invoices:read) (invoices, lines and credit notes), [`treasury:read`](https://docs.filmo.io/rest-api/guides/scopes#treasury:read) (payments) and [`contacts:read`](https://docs.filmo.io/rest-api/guides/scopes#contacts:read) (the customer's tax ID). Add [`invoices:write`](https://docs.filmo.io/rest-api/guides/scopes#invoices:write) only if you mirror ERP-issued invoices back into Filmo. Create the key in the API and MCP settings of the app (Ajustes → API y MCP); only administrators can.
- Node 22 or later, or Python 3 with `requests`, and the key in the `FILMO_API_KEY` environment variable.
- The windowed pass from [Syncing data](https://docs.filmo.io/rest-api/guides/syncing-data). Do not follow the cursor to find what changed.

## Step 1. Set up the client and read the account

Save the client once; every later snippet imports it. It waits out [`rate_limited`](https://docs.filmo.io/rest-api/errors/rate_limited) for as many seconds as `Retry-After` says and returns the server's time from the `Date` header.

*The client*

**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/rest-api/endpoint-reference/account/get-the-account) gives you [`currency`](https://docs.filmo.io/rest-api/objects/account#response-currency) and [`timezone`](https://docs.filmo.io/rest-api/objects/account#response-timezone). An invoice carries its own [`currency`](https://docs.filmo.io/rest-api/objects/invoice#response-currency), which can differ; post in the invoice's currency.

**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"])
```

## Step 2. Read validated invoices by window

[`GET /api/v1/invoices`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/list-invoices) has [`updatedFrom`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/list-invoices#param-updatedFrom) (on or after) and [`updatedBefore`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/list-invoices#param-updatedBefore) (before), so a half-open window of `updatedAt` works exactly as in the guide: one page of up to 500, split the window if `pagination.hasMore` is `true`, deduplicate by `id`. The list has no filter for the document status, so read all of them and split on [`status`](https://docs.filmo.io/rest-api/objects/invoice#response-status): `draft` has no number yet (post nothing), `validated` is numbered and ready, `voided` is handled in step 7. When a draft is validated its record changes, so it comes back in a later window. Validating, voiding and recording a payment all move the invoice's `updatedAt`. The one-second-earlier stamp that the guide describes belongs to the order that holds the invoice, not to the invoice, but Filmo still gives no guaranteed bound for the delay before a change is readable, so keep the safety margin of the guide (two minutes at least) and deduplicate by `id`.

*The windowed read*

**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
```

*Read the invoices*

**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")
```

The list already returns what you need for a posting header: number, issue and due dates, currency, the contact as an id and a name, the totals, the amount collected and the payment status. The watermark moves to B only after every window of the pass was read. Credit notes are read from their own endpoints, in step 6. The invoice list can still return a row with [`isRectification`](https://docs.filmo.io/rest-api/objects/invoice#response-isRectification) set, for example an invoice imported from another system: treat it as a document of its own (its amount may be negative) and match it against the credit notes by number, so you do not post it twice.

## Step 3. Read the tax breakdown from the detail

[`GET /api/v1/invoices/{invoiceId}`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/get-an-invoice) returns the same invoice plus [`taxBreakdown`](https://docs.filmo.io/rest-api/objects/invoice#response-taxBreakdown): one row per tax type, with `taxType`, `label`, `rate` (a percentage), `baseAmount` and `amount`. The detail also has the billing address, the exchange rate and the split of discounts. It costs one call per invoice, so read it once when you first see the invoice validated, and again only when the invoice reappears in a window with a different `updatedAt`. Send the `ETag` you stored in `If-None-Match` to get a 304 when nothing changed; a 304 still counts as a request.

**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
```

> **When the breakdown is null or does not match:** [`taxBreakdown`](https://docs.filmo.io/rest-api/objects/invoice#response-taxBreakdown) is null when it has not been calculated. If it is null, or its sums differ from [`taxesPrice`](https://docs.filmo.io/rest-api/objects/invoice#response-taxesPrice) and [`netPrice`](https://docs.filmo.io/rest-api/objects/invoice#response-netPrice), post the invoice by totals and flag it for review; do not build the split yourself from the lines, which carry no tax type.

## Step 4. Read the lines

[`GET /api/v1/invoices/{invoiceId}/lines`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/list-the-lines-of-an-invoice) returns what was invoiced in document order, with [`itemName`](https://docs.filmo.io/rest-api/objects/document-line#response-itemName) frozen at the moment of issue (it can differ from today's catalog), [`quantity`](https://docs.filmo.io/rest-api/objects/document-line#response-quantity), [`unitPrice`](https://docs.filmo.io/rest-api/objects/document-line#response-unitPrice), [`linePrice`](https://docs.filmo.io/rest-api/objects/document-line#response-linePrice) and the invoiced period. Lines of type `section` and `type_group` are presentation groupings with no resource and no price. The lines do not include a tax type, as the spec says: "the invoice totals already carry the calculated taxes". A validated invoice does not change, so read its lines once.

**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"]
```

## Step 5. Read the payments

[`GET /api/v1/invoices/{invoiceId}/payments`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/list-the-payments-of-an-invoice) takes the [`treasury:read`](https://docs.filmo.io/rest-api/guides/scopes#treasury:read) scope, not the invoices one, and returns the payments assigned to the invoice. It has no `updated*` filter, so read it in descending order of [`sort`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/list-the-payments-of-an-invoice#param-sort) `-updatedAt` and stop at the first payment older than what you hold; a payment that changes in the middle of a read moves to the front and gives you a duplicate, which you deduplicate by `id`, never a gap. A payment has [`amount`](https://docs.filmo.io/rest-api/objects/payment#response-amount), [`date`](https://docs.filmo.io/rest-api/objects/payment#response-date), [`reference`](https://docs.filmo.io/rest-api/objects/payment#response-reference), [`treasuryAccountId`](https://docs.filmo.io/rest-api/objects/payment#response-treasuryAccountId), a [`type`](https://docs.filmo.io/rest-api/objects/payment#response-type) of `normal` or `deposit`, and [`linkedDocuments`](https://docs.filmo.io/rest-api/objects/payment#response-linkedDocuments), which says how much of it went to each document.

You do not need to read the payments of every invoice every time. The list gives you [`totalPaid`](https://docs.filmo.io/rest-api/objects/invoice#response-totalPaid) and [`paymentStatus`](https://docs.filmo.io/rest-api/objects/invoice#response-paymentStatus) for each invoice, so read an invoice's payments only when those differ from what you stored. A payment can arrive weeks after the invoice. Recording one moves the invoice's `updatedAt`, but Filmo gives no guaranteed bound on how late that stamp can land, so once a day, list the invoices that can still be paid with [`paymentStatuses`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/list-invoices#param-paymentStatuses) `unpaid,partially_paid,overdue` and compare. That list can include drafts: filter them out by `status` on your side. If the sum of the amounts you hold for an invoice does not match its `totalPaid`, read its payments again from the beginning.

**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")
```

## Step 6. Read the credit notes

Read credit notes from their own endpoints. [`GET /api/v1/credit-notes`](https://docs.filmo.io/rest-api/endpoint-reference/credit-notes/list-credit-notes) accepts the same [`updatedFrom`](https://docs.filmo.io/rest-api/endpoint-reference/credit-notes/list-credit-notes#param-updatedFrom) and [`updatedBefore`](https://docs.filmo.io/rest-api/endpoint-reference/credit-notes/list-credit-notes#param-updatedBefore) window, so the same helper reads them. [`GET /api/v1/credit-notes/{creditNoteId}`](https://docs.filmo.io/rest-api/endpoint-reference/credit-notes/get-a-credit-note) returns the same card as the list, so there is no detail call to budget. A credit note has [`creditNoteNumber`](https://docs.filmo.io/rest-api/objects/credit-note#response-creditNoteNumber), [`issueDate`](https://docs.filmo.io/rest-api/objects/credit-note#response-issueDate), [`status`](https://docs.filmo.io/rest-api/objects/credit-note#response-status), [`invoiceId`](https://docs.filmo.io/rest-api/objects/credit-note#response-invoiceId) (the invoice it rectifies, or null) and the totals; there is no breakdown, no lines and no reason. Its `status` is a free string: store it as received and do not branch on a closed list of values. Check the sign of the amounts on a credit note in your own account before you decide the sign of your posting.

**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"])
```

## Step 7. Handle voided invoices

[`POST /api/v1/invoices/{invoiceId}/void`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/void-an-invoice) works only on a validated invoice. The invoice keeps its number, because voiding does not release the numbering, and it moves to `voided`; it does not issue a credit note. So a voided invoice is a change, not a deletion. The windowed pass returns it with its new status; the weekly sweep of the guide, which compares status and `updatedAt`, is the safety net if one is missed.

| You see | What to do |
| --- | --- |
| A `voided` invoice you already posted | Post the cancellation in your ledger as your accountant prescribes. Keep the original entry and its number; do not delete it. |
| A `voided` invoice you never saw validated | Record it as voided and post nothing, or post and reverse it if your accountant needs the number in the books. |
| Money to give back | Voiding refunds nothing. If money has to go back, that is another document: a credit note (step 6). |
| An invoice that disappears from your reads | Confirm with `GET /api/v1/invoices/{invoiceId}` before you change your side; only a 404 means it no longer exists, as in the reconciliation sweep of the guide. |

## Step 8. Mirror an ERP-issued invoice into Filmo (optional)

If your ERP issues some invoices, [`PUT /api/v1/invoices/external/{originSystem}/{originExternalId}`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice) keeps a read-only copy of each in Filmo so the operations team can see it. The copy is never submitted to a tax authority and is not numbered in a Filmo series. The spec is explicit about what that copy is: "Filmo does not issue it: it is not numbered against a Filmo series, not signed and never sent to Verifactu. It is stored validated, locked and marked as externally issued, and in Filmo it cannot be edited, voided, collected, rectified, e-invoiced or emailed: every change arrives through this same call."

This is the only call where you choose the key. The pair [`originSystem`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice#param-originSystem) (a lowercase slug of yours, such as `acme_erp`) and [`originExternalId`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice#param-originExternalId) (the document's id in your ERP) identifies the mirror: repeating the call updates it, and repeating the same content changes nothing. It answers 201 when it creates the mirror and 200 when it updates it or finds it identical. Send an `Idempotency-Key` as on any write. The customer is resolved by `contactId`, then by tax ID, then by exact name; an unknown or ambiguous customer is rejected, because contacts are never created implicitly. The declared totals are checked against the lines with a tolerance of one cent per line, and a mismatch is rejected too. Both answer 400 [`validation_error`](https://docs.filmo.io/rest-api/errors/validation_error), with `errors[0].path` naming the field (`contactId`, `contactTaxId`, `contactName`, `subTotal`, `taxTotal`, `total` or `lines[n].taxRate`), and nothing is written. The amounts, quantities and prices of this call accept up to six decimals, and the two-decimal rule applies to what you read back. In the example, 2 × 150.00 + 1 × 80.00 = 380.00 of base, 79.80 of tax at 21% and 459.80 of 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"])
```

> **These mirrors can come back in your own read:** An invoice you mirror is stored as validated, so it can appear in [`GET /api/v1/invoices`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/list-invoices) like any other. Keep the Filmo `id` the call returns next to your ERP id and skip those ids when you post, or you post your own invoice twice. Automations: this call fires the account's event workflows (invoice created, updated or voided) after the response, on a best-effort basis, and you cannot suppress them per request. List the active event workflows first (see [Testing](https://docs.filmo.io/rest-api/guides/testing)) and filter the trigger of any workflow that should not react by origin to exclude `api` and `mcp`. A voided invoice in your ERP is sent with `externalStatus` `voided` and a reason; Filmo refuses (409, with `errors[0].code` `invalid_state`) to turn a voided mirror back into an issued one.

## Field mapping

Where each field of your accounting system comes from. "Not available" means the API does not publish it today; the right column gives the workaround.

| Accounting field | Filmo field | Read with | Notes or workaround |
| --- | --- | --- | --- |
| Invoice number | `invoiceNumber` | `GET /api/v1/invoices` | Null only while the invoice is a draft. A voided invoice keeps its number. |
| Issue date | `invoiceDate` | `GET /api/v1/invoices` | An instant. Take the date in the business timezone (`timezone`) for the ledger. |
| Due date | `dueDate` | `GET /api/v1/invoices` | Null if not set. |
| Currency and exchange rate | `currency`; `exchangeRate` | `GET /api/v1/invoices`; `GET /api/v1/invoices/{invoiceId}` | The rate is only in the detail and is null when it does not apply. |
| Customer | `contact`: an `id` and a `name` | `GET /api/v1/invoices` | The id is your key to the customer; the name is as Filmo shows it. |
| Customer tax ID at the time of issue | Not available | - | The API returns only the current `nif`, read with `GET /api/v1/contacts/{contactId}`. Read the contact when you first see the invoice and store the tax ID with the entry you post. |
| Billing address | `billingAddress` | `GET /api/v1/invoices/{invoiceId}` | Detail only; null if not recorded. |
| Subtotal, discounts, base, tax, total | `subTotal`, `discountTotal`, `netPrice`, `taxesPrice`, `grossPrice` | `GET /api/v1/invoices` | Decimal strings with two decimals. The detail splits the discount into `productDiscountTotal` and `globalDiscountTotal` and adds `insuranceTotal` and `depositTotal`. |
| Base and tax by tax type | `taxBreakdown` | `GET /api/v1/invoices/{invoiceId}` | One detail call per invoice; null if not calculated. Not on the list. |
| Lines | `itemName`, `quantity`, `unitPrice`, `linePrice`, `startDate`, `endDate`, `rentalDays` | `GET /api/v1/invoices/{invoiceId}/lines` | Names are frozen at issue. A line billed at zero has `free` set. |
| Tax type of each line | Not available | - | A line has no tax field. Use the invoice's `taxBreakdown` for the split by tax type; do not allocate it to lines. |
| Invoice series | Not available | - | The API does not publish the series of an invoice. Take it from the app; do not parse it out of the number unless your accountant confirms the number format. |
| Payments | `amount`, `date`, `reference`, `treasuryAccountId`; on the invoice `totalPaid`, `paymentStatus`, `paymentMethod` | `GET /api/v1/invoices/{invoiceId}/payments`; `GET /api/v1/invoices` | The payments need `treasury:read`. A payment can be split across documents: read `linkedDocuments`. |
| Credit notes | `creditNoteNumber`, `issueDate`, `invoiceId`, `contactId`, `netPrice`, `taxesPrice`, `grossPrice` | `GET /api/v1/credit-notes`; `GET /api/v1/credit-notes/{creditNoteId}` | Totals only. Read them from the credit-note endpoints. |
| Credit note tax breakdown, lines and reason | Not available | - | Post the credit note by totals and complete the split and the reason from the app. |
| PDF of the invoice | Not available | - | No operation returns it. Generate your own document from the data above, or download it from the app. |

The table below is generated from the specification. It lists, field by field, what the invoice and credit-note schemas publish, and the accounting data the API does not offer in this version.

| Field | Type | Available |
| --- | --- | --- |
| `InvoiceDto.id` | string | Yes |
| `InvoiceDto.invoiceNumber` | string \| null | Yes |
| `InvoiceDto.status` | string | Yes |
| `InvoiceDto.isRectification` | boolean | Yes |
| `InvoiceDto.invoiceDate` | string \| null | Yes |
| `InvoiceDto.dueDate` | string \| null | Yes |
| `InvoiceDto.currency` | string | Yes |
| `InvoiceDto.contact` | object \| null | Yes |
| `InvoiceDto.orderId` | string \| null | Yes |
| `InvoiceDto.orderNumber` | string \| null | Yes |
| `InvoiceDto.projectId` | string \| null | Yes |
| `InvoiceDto.subTotal` | string | Yes |
| `InvoiceDto.discountTotal` | string | Yes |
| `InvoiceDto.taxesPrice` | string | Yes |
| `InvoiceDto.netPrice` | string | Yes |
| `InvoiceDto.grossPrice` | string | Yes |
| `InvoiceDto.totalPaid` | string \| null | Yes |
| `InvoiceDto.paymentStatus` | string | Yes |
| `InvoiceDto.paymentMethod` | string \| null | Yes |
| `InvoiceDto.tags` | array | Yes |
| `InvoiceDto.createdAt` | string | Yes |
| `InvoiceDto.updatedAt` | string | Yes |
| `InvoiceDetailDto.id` | string | Yes |
| `InvoiceDetailDto.invoiceNumber` | string \| null | Yes |
| `InvoiceDetailDto.status` | string | Yes |
| `InvoiceDetailDto.isRectification` | boolean | Yes |
| `InvoiceDetailDto.invoiceDate` | string \| null | Yes |
| `InvoiceDetailDto.dueDate` | string \| null | Yes |
| `InvoiceDetailDto.currency` | string | Yes |
| `InvoiceDetailDto.contact` | object \| null | Yes |
| `InvoiceDetailDto.orderId` | string \| null | Yes |
| `InvoiceDetailDto.orderNumber` | string \| null | Yes |
| `InvoiceDetailDto.projectId` | string \| null | Yes |
| `InvoiceDetailDto.subTotal` | string | Yes |
| `InvoiceDetailDto.discountTotal` | string | Yes |
| `InvoiceDetailDto.taxesPrice` | string | Yes |
| `InvoiceDetailDto.netPrice` | string | Yes |
| `InvoiceDetailDto.grossPrice` | string | Yes |
| `InvoiceDetailDto.totalPaid` | string \| null | Yes |
| `InvoiceDetailDto.paymentStatus` | string | Yes |
| `InvoiceDetailDto.paymentMethod` | string \| null | Yes |
| `InvoiceDetailDto.tags` | array | Yes |
| `InvoiceDetailDto.createdAt` | string | Yes |
| `InvoiceDetailDto.updatedAt` | string | Yes |
| `InvoiceDetailDto.projectName` | string \| null | Yes |
| `InvoiceDetailDto.taxBreakdown` | array \| null | Yes |
| `InvoiceDetailDto.productDiscountTotal` | string | Yes |
| `InvoiceDetailDto.globalDiscountTotal` | string | Yes |
| `InvoiceDetailDto.insuranceTotal` | string | Yes |
| `InvoiceDetailDto.depositTotal` | string | Yes |
| `InvoiceDetailDto.exchangeRate` | number \| null | Yes |
| `InvoiceDetailDto.billingAddress` | object \| null | Yes |
| `InvoiceDetailDto.sourceQuoteIds` | array \| null | Yes |
| `InvoiceDetailDto.sourceProformaIds` | array \| null | Yes |
| `DocumentLineDto.id` | string | Yes |
| `DocumentLineDto.resourceType` | string | Yes |
| `DocumentLineDto.transactionType` | string | Yes |
| `DocumentLineDto.position` | integer | Yes |
| `DocumentLineDto.itemId` | string \| null | Yes |
| `DocumentLineDto.itemName` | string \| null | Yes |
| `DocumentLineDto.variantId` | string \| null | Yes |
| `DocumentLineDto.variantName` | string \| null | Yes |
| `DocumentLineDto.quantity` | number | Yes |
| `DocumentLineDto.orderId` | string \| null | Yes |
| `DocumentLineDto.orderNumber` | string \| null | Yes |
| `DocumentLineDto.startDate` | string \| null | Yes |
| `DocumentLineDto.endDate` | string \| null | Yes |
| `DocumentLineDto.rentalDays` | number \| null | Yes |
| `DocumentLineDto.unitPrice` | string \| null | Yes |
| `DocumentLineDto.linePrice` | string \| null | Yes |
| `DocumentLineDto.free` | boolean | Yes |
| `DocumentLineDto.notes` | string \| null | Yes |
| `CreditNoteDto.id` | string | Yes |
| `CreditNoteDto.creditNoteNumber` | string \| null | Yes |
| `CreditNoteDto.status` | string | Yes |
| `CreditNoteDto.issueDate` | string \| null | Yes |
| `CreditNoteDto.currency` | string | Yes |
| `CreditNoteDto.contactId` | string \| null | Yes |
| `CreditNoteDto.invoiceId` | string \| null | Yes |
| `CreditNoteDto.subTotal` | string | Yes |
| `CreditNoteDto.taxesPrice` | string | Yes |
| `CreditNoteDto.netPrice` | string | Yes |
| `CreditNoteDto.grossPrice` | string | Yes |
| `CreditNoteDto.createdAt` | string | Yes |
| `CreditNoteDto.updatedAt` | string | Yes |
| PDF of invoices and other documents | — | Not available in v1 |
| Composing draft invoices and editing quotes | — | Not available in v1 |
| Tax breakdown, lines and reason of a credit note | — | Not available in v1 |
| The customer's tax ID at the time an invoice was issued | — | Not available in v1 |
| Tax type per line and the series of an invoice | — | Not available in v1 |

The list of what the API does not offer in this version is in [Capabilities and limits](https://docs.filmo.io/further-reading/capabilities-and-limits).

## What to store on your side

| Store | Why |
| --- | --- |
| Filmo invoice `id` next to the entry you posted | To find it again on a void, a payment or a credit note. Identifiers are opaque. |
| The `updatedAt` and the `ETag` of each invoice | To skip a detail read when nothing changed, and for the weekly sweep. |
| `totalPaid` and `paymentStatus`, and the payments you hold | To decide which invoices need a payments read. |
| The customer's tax ID at the moment you read the invoice | The API only returns the current one. |
| The watermark B of the last complete pass, for invoices and for credit notes | The next pass starts there. It moves only after a full pass. |
| The Filmo ids of the invoices you mirrored with the external call | To skip them when you read invoices. |

## Call budget against 120 requests per minute

Your key allows 120 requests per minute across the whole API, and every authenticated response counts. The expensive part of this tutorial is the tax breakdown: it exists only on the detail read, which costs one call per invoice. For a list read in W windows of which S overflow one page, the cost is W + 2S calls. The initial load is:

*Initial load*

```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
```

Worked example. 12,000 validated invoices over 24 months, 70% of them with a collection (P = 8,400), and 600 credit notes. Invoices: 24 monthly windows, of which 12 hold more than 500 and overflow: 24 + 2 × 12 = 48 calls. Credit notes: 24 windows, none overflowing: 24 calls. Total: 1 + 48 + 24 + 12,000 + 12,000 + 8,400 = 32,473 calls. At 120 per minute that is 270.6 minutes (4.5 hours); at 96 per minute, leaving a fifth of the quota free, 338.3 minutes (5.6 hours). The detail reads alone are 12,000 of those calls. If you can post by totals and do not need the split by tax type, you skip them and the load is 20,473 calls, 170.6 minutes (2.8 hours).

*Daily pace*

```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
```

Worked example. An hourly pass (24 passes, so 72 calls), 40 new invoices a day and 10 older ones that change, so D = 50 and L = 40. Payments to read: 28 for the new invoices that already carry a collection (70% of 40) and 10 for the changed ones, so Pd = 38. With 300 open invoices the refresh is 1 call. Total: 72 + 50 + 40 + 38 + 1 = 201 calls a day, under two minutes of quota (1.7). The weekly sweep costs the list calls of the load plus one GET per missing id.

Run the initial load at a quiet hour and let the client sleep when it receives [`rate_limited`](https://docs.filmo.io/rest-api/errors/rate_limited).

## Error handling

| Response | What it means here | What to do |
| --- | --- | --- |
| 401 `api_key_owner_inactive` or `api_key_owner_not_admin` | The person who created the key left or stopped being an administrator. | Stop posting, alert someone and reissue the key from an administrator account. |
| 403 `insufficient_scope` | A scope is missing; `requiredScope` names it. Payments need `treasury:read` and the tax ID needs `contacts:read`. | Reissue the key with the scopes in the prerequisites. |
| 404 `not_found` | On an invoice, a credit note or its payments: it does not exist or is outside your account. Payments of an unknown invoice are a 404, not an empty list. | Mark the record as missing and confirm it at the next sweep; do not delete a posted entry. |
| 400 `validation_error` | A parameter is malformed, or the body of the external call is invalid. | Fix the request; `errors[]` lists each field. |
| 409 `conflict` on the external call | Read `errors[0].code`. `invalid_state`: the invoice number is used by another invoice, the key belongs to an invoice that is not a mirror, or a voided mirror was sent back as issued. `in_progress`: a request with the same key is still running. `lock_timeout`: another write held the invoice for more than five seconds. | For `invalid_state`, do not retry: fix the data. For `in_progress` and `lock_timeout`, wait and retry with the same `Idempotency-Key`. |
| 422 `idempotency_key_reused` | You reused an `Idempotency-Key` with a different body. | Use a new key for a changed document; reuse a key only for a retry of the identical call. |
| 400 `validation_error` on the external call, for the customer or the totals | The customer is unknown or ambiguous, or the declared totals do not match the lines. `errors[]` names the field. | Resolve the customer with `contactId`, fix the totals and send the corrected document. A rejected request leaves no record, so you can reuse the `Idempotency-Key` or use a new one. |
| 429 `rate_limited` | You spent the 120 requests of the minute. | Wait for `Retry-After` seconds (also `retryAfter` in the problem body), then continue the same window. |
| 500 `internal_error`, 502 or 504 | A transient failure. Reads are safe to repeat; for the external call, retry with the same `Idempotency-Key`. | Retry with exponential backoff and do not move the watermark. |
| 503 `service_unavailable` | No operation of this tutorial documents it. Filmo controls the switch behind it. | Do not retry it as a transient failure. Stop that call and contact Filmo support with the `requestId`. |

## Where to go next

- [Syncing data](https://docs.filmo.io/rest-api/guides/syncing-data): the windowed pass, the early stop and the reconciliation sweep.
- [Idempotency](https://docs.filmo.io/rest-api/guides/idempotency): retrying the external call safely.
- [Amounts and currencies](https://docs.filmo.io/further-reading/amounts-and-currencies): decimal strings and rounding.
- [Capabilities and limits](https://docs.filmo.io/further-reading/capabilities-and-limits): what the API does not offer in this version.
- [Inventory sync](https://docs.filmo.io/rest-api/tutorials/inventory-sync): the same pass for items, units and order lines.

---

Canonical URL: https://docs.filmo.io/rest-api/tutorials/invoices-to-accounting  
Last updated: 2026-10-09