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 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.
What the API gives you and what it does not
Link to this section: What the API gives you and what it does not- Every invoice and credit note carries its totals:
subTotal,netPrice,taxesPriceandgrossPrice. - The split by tax type is in one place:
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
statusis 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
Link to this section: Prerequisites- An API key with
account:read,invoices:read(invoices, lines and credit notes),treasury:read(payments) andcontacts:read(the customer's tax ID). Addinvoices:writeonly 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 theFILMO_API_KEYenvironment variable. - The windowed pass from Syncing data. Do not follow the cursor to find what changed.
Step 1. Set up the client and read the account
Link to this section: Step 1. Set up the client and read the accountSave the client once; every later snippet imports it. It waits out rate_limited for as many seconds as Retry-After says and returns the server's time from the Date header.
# Export FILMO_API_KEY first: a key with the scopes listed above.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"
// 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
};
}
}# 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 bodyGET /api gives you currency and timezone. An invoice carries its own currency, which can differ; post in the invoice's currency.
# 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"import { get } from "./filmo.mjs";
const { data: account } = await get("/account");
console.log(account.timezone, account.currency, account.rateLimits.global.perMinute);from filmo import get
account = get("/account")["data"]
print(account["timezone"], account["currency"], account["rateLimits"]["global"]["perMinute"])Step 2. Read validated invoices by window
Link to this section: Step 2. Read validated invoices by windowGET /api has updatedFrom (on or after) and 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. is true, deduplicate by id. The list has no filter for the document status, so read all of them and split on 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.
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"// 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;
}# 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 recordsFILMO_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"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");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 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
Link to this section: Step 3. Read the tax breakdown from the detailGET /api returns the same invoice plus 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.
# 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"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 };
}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, matchesWhen the breakdown is null or does not match
taxBreakdown is null when it has not been calculated. If it is null, or its sums differ from taxesPrice and 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
Link to this section: Step 4. Read the linesGET /api returns what was invoiced in document order, with itemName frozen at the moment of issue (it can differ from today's catalog), quantity, unitPrice, 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.
# 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"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
}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
Link to this section: Step 5. Read the paymentsGET /api takes the 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 -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, date, reference, treasuryAccountId, a type of normal or deposit, and 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 and 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 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.
# 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"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;
}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
Link to this section: Step 6. Read the credit notesRead credit notes from their own endpoints. GET /api accepts the same updatedFrom and updatedBefore window, so the same helper reads them. GET /api returns the same card as the list, so there is no detail call to budget. A credit note has creditNoteNumber, issueDate, status, 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.
# 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"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);
}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
Link to this section: Step 7. Handle voided invoicesPOST /api 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 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)
Link to this section: Step 8. Mirror an ERP-issued invoice into Filmo (optional)If your ERP issues some invoices, PUT /api 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 (a lowercase slug of yours, such as acme_erp) and 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, with errors[0]. naming the field (contactId, contactTaxId, contactName, subTotal, taxTotal, total or lines[n].), 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.
# 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"
}'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);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 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) 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]. invalid_state) to turn a voided mirror back into an issued one.
Field mapping
Link to this section: Field mappingWhere 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 | Null only while the invoice is a draft. A voided invoice keeps its number. |
| Issue date | invoiceDate | GET /api | An instant. Take the date in the business timezone (timezone) for the ledger. |
| Due date | dueDate | GET /api | Null if not set. |
| Currency and exchange rate | currency; exchangeRate | GET /api; GET /api | The rate is only in the detail and is null when it does not apply. |
| Customer | contact: an id and a name | GET /api | 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. Read the contact when you first see the invoice and store the tax ID with the entry you post. |
| Billing address | billingAddress | GET /api | Detail only; null if not recorded. |
| Subtotal, discounts, base, tax, total | subTotal, discountTotal, netPrice, taxesPrice, grossPrice | GET /api | 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 | One detail call per invoice; null if not calculated. Not on the list. |
| Lines | itemName, quantity, unitPrice, linePrice, startDate, endDate, rentalDays | GET /api | 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; GET /api | 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; GET /api | 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. | string | Yes |
InvoiceDto. | string | null | Yes |
InvoiceDto. | string | Yes |
InvoiceDto. | boolean | Yes |
InvoiceDto. | string | null | Yes |
InvoiceDto. | string | null | Yes |
InvoiceDto. | string | Yes |
InvoiceDto. | object | null | Yes |
InvoiceDto. | string | null | Yes |
InvoiceDto. | string | null | Yes |
InvoiceDto. | string | null | Yes |
InvoiceDto. | string | Yes |
InvoiceDto. | string | Yes |
InvoiceDto. | string | Yes |
InvoiceDto. | string | Yes |
InvoiceDto. | string | Yes |
InvoiceDto. | string | null | Yes |
InvoiceDto. | string | Yes |
InvoiceDto. | string | null | Yes |
InvoiceDto. | array | Yes |
InvoiceDto. | string | Yes |
InvoiceDto. | string | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | null | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | boolean | Yes |
InvoiceDetailDto. | string | null | Yes |
InvoiceDetailDto. | string | null | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | object | null | Yes |
InvoiceDetailDto. | string | null | Yes |
InvoiceDetailDto. | string | null | Yes |
InvoiceDetailDto. | string | null | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | null | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | null | Yes |
InvoiceDetailDto. | array | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | null | Yes |
InvoiceDetailDto. | array | null | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | string | Yes |
InvoiceDetailDto. | number | null | Yes |
InvoiceDetailDto. | object | null | Yes |
InvoiceDetailDto. | array | null | Yes |
InvoiceDetailDto. | array | null | Yes |
DocumentLineDto. | string | Yes |
DocumentLineDto. | string | Yes |
DocumentLineDto. | string | Yes |
DocumentLineDto. | integer | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | number | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | number | null | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | string | null | Yes |
DocumentLineDto. | boolean | Yes |
DocumentLineDto. | string | null | Yes |
CreditNoteDto. | string | Yes |
CreditNoteDto. | string | null | Yes |
CreditNoteDto. | string | Yes |
CreditNoteDto. | string | null | Yes |
CreditNoteDto. | string | Yes |
CreditNoteDto. | string | null | Yes |
CreditNoteDto. | string | null | Yes |
CreditNoteDto. | string | Yes |
CreditNoteDto. | string | Yes |
CreditNoteDto. | string | Yes |
CreditNoteDto. | string | Yes |
CreditNoteDto. | string | Yes |
CreditNoteDto. | 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.
What to store on your side
Link to this section: 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
Link to this section: Call budget against 120 requests per minuteYour 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:
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 / 120Worked 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).
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 includedWorked 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.
Error handling
Link to this section: 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].. 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
Link to this section: Where to go next- Syncing data: the windowed pass, the early stop and the reconciliation sweep.
- Idempotency: retrying the external call safely.
- Amounts and currencies: decimal strings and rounding.
- Capabilities and limits: what the API does not offer in this version.
- Inventory sync: the same pass for items, units and order lines.
Related pages
Link to this section: Related pagesGuides
Syncing data
Filmo v1 does not send webhooks, so you keep another system in step by polling: read what changed in half-open time windows, and run a separate sweep to find what was deleted. Do not follow the pagination cursor to decide what changed or what disappeared.
Further reading
Amounts and currencies
Every monetary amount is a decimal string with two decimals, such as
"1234., never a floating-point number, with one exception: the prices in the50" priceConfiganddefaultExpensesof a service and thecustomPriceof an accessory are JSON numbers. Each document carries its own currency, which can differ from the account's.Further reading
Capabilities and limits of v1
The API covers most day-to-day work on orders, contacts, items, invoices and the schedule, and leaves a short list of things to the app. Check it here before you design an integration around a feature.
Guides
Idempotency and safe retries
Send an
Idempotency-Keyheader with every write and reuse the same value when you retry that operation: Filmo returns the stored result of the first attempt instead of writing twice.Tutorials
Sync your inventory with another system
You read the catalog, the physical units and the lines of the orders that commit them, load everything once, then poll for changes and reconcile weekly. Filmo does not send webhooks, so polling is how you stay current.
Was this page helpful?