Torna alla guida

Fatturare con l'API: dal cliente alla fattura inviata

L'intero percorso di fatturazione in chiamate REST: cliente, modello, fattura, finalizzazione, invio, PDF, poi preventivi e fatture ricorrenti.

Sviluppatori12 min di lettura
Indice+

Questa guida rifà, in curl, ciò che la schermata di fatturazione fa con il mouse. Ogni chiamata usa la stessa chiave e lo stesso URL di base di Iniziare con l'API.

BASE=https://<deployment>.convex.site/api/v1
KEY=ba_...

1. Prerequisiti

Da GET /me, prendi l'id di una società con modules.invoicing attivo, e tienilo a portata di mano:

COMPANY=<companyId>

2. Creare il cliente

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Muster AG", "email": "billing@muster.example",
        "address": { "kind": "swiss", "country": "CH", "street": "Rue du Lac",
                     "streetNumber": "12", "postalCode": "1700", "city": "Fribourg" },
        "details": { "kind": "company", "contactPersons": [] } }' \
  $BASE/companies/$COMPANY/clients

La risposta 201 contiene il cliente con il suo id. name, address e details sono obbligatori: details.kind vale company (con contactPersons, una lista che può essere vuota, e facoltativamente ideNumber e vatNumber) oppure individual. Un cliente appartiene a una società: usare in una fattura il cliente di un'altra società risponde 403 ACCESS_DENIED. Nel piano Business i clienti sono illimitati; 403 CLIENT_LIMIT_REACHED compare solo se il tuo abbonamento non copre il modulo Fatturazione.

Importare clienti in lotto

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "rows": [
        { "name": "Muster AG", "email": "billing@muster.example",
          "address": { "kind": "swiss", "country": "CH", "city": "Fribourg" },
          "details": { "kind": "company", "contactPersons": [] } },
        { "name": "Anna Beispiel",
          "address": { "kind": "foreign", "country": "FR", "city": "Annemasse" },
          "details": { "kind": "individual" } }
      ] }' \
  $BASE/companies/$COMPANY/clients/batch

Il lotto accetta da 1 a 200 clienti ed è atomico: tutto viene scritto oppure niente, e un lotto che supererebbe la quota di clienti viene rifiutato per intero (403 CLIENT_LIMIT_REACHED). Una riga la cui e-mail esiste già nella società, o compare più in alto nel lotto, viene ignorata senza errore; una riga senza e-mail non è mai considerata un duplicato. La risposta 201 riporta i due contatori, inserted e skippedDuplicates. La lettura del file (CSV, foglio di calcolo) resta a tuo carico.

3. Scegliere un modello

curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/invoice-templates

La lista dei modelli non è paginata e non accetta alcun parametro. Quello con isDefault: true è il modello a cui una fattura ripiega quando non precisi nulla: templateId è facoltativo alla creazione. Nel JSON hasLogo è di sola lettura: il logo passa da una rotta dedicata, in byte grezzi (PNG o JPEG, 2 MB al massimo).

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: image/png" \
  --data-binary @logo.png $BASE/companies/$COMPANY/invoice-templates/<templateId>/logo

PUT /companies/{companyId}/logo fa lo stesso con il logo della società (ruolo di proprietario o admin richiesto); il logo di un modello lo sostituisce sui documenti di quel modello. DELETE su entrambe le rotte rimuove il logo.

4. Creare la fattura

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "clientId": "<clientId>", "lang": "fr", "currency": "CHF",
        "issueDate": "2026-03-01", "dueDate": "2026-03-31",
        "items": [{ "description": "Design", "quantity": 2, "unitPrice": 150, "vatRate": 8.1 }] }' \
  $BASE/companies/$COMPANY/invoices

clientId, lang, issueDate, dueDate e currency sono obbligatori; templateId, discount, shippingCosts, notes, paymentTerms, showQrBill e items sono facoltativi. Il numero viene dal contatore della società e la fattura parte come bozza (draft).

Nel piano Business le fatture sono illimitate e le valute diverse dal CHF sono consentite. 403 INVOICE_LIMIT_REACHED e 403 CURRENCY_PRO_ONLY compaiono solo se il tuo abbonamento non copre il modulo Fatturazione: si applicano allora i limiti del piano Starter.

Riutilizzare il catalogo prodotti

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Consulenza design", "unitPrice": 150, "currency": "CHF", "vatRate": 8.1,
        "unit": "h", "reference": "DES-01" }' \
  $BASE/companies/$COMPANY/products

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Pacchetto lancio", "items": [{ "productId": "<productId>", "quantity": 2 }] }' \
  $BASE/companies/$COMPANY/product-lists

Un prodotto richiede name, unitPrice, currency e vatRate; description, unit e reference sono facoltativi. Un elenco conserva i suoi prodotti nell'ordine dell'array (500 al massimo, ciascuno una sola volta, altrimenti 422 PRODUCT_LIST_DUPLICATE_PRODUCT). Entrambe le risorse si elencano con paginazione, si leggono, si modificano con PATCH (items sostituisce l'intero elenco) e si eliminano; un prodotto eliminato scompare dagli elenchi che lo contenevano.

L'API non espande un elenco in righe di fattura: invia sempre description, quantity, unitPrice e vatRate su ogni riga e aggiungi productId per mantenere il legame con il prodotto. Se il catalogo è disattivato sulla società, queste rotte rispondono 403 PRODUCT_CATALOG_DISABLED: per attivarlo vedi la guida al catalogo prodotti.

5. Finalizzare e inviare

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "event": "FINALIZE" }' $BASE/companies/$COMPANY/invoices/<invoiceId>/transition

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "kind": "invoice" }' $BASE/companies/$COMPANY/invoices/<invoiceId>/send

Il ciclo di vita è quello dell'app: draftready (FINALIZE)sentpaid (MARK_PAID, con un paidDate facoltativo). CANCEL annulla da draft, ready, sent o overdue; REOPEN riporta in bozza una fattura annullata. Ogni altra coppia risponde 409 INVALID_TRANSITION, con la coppia rifiutata in message.

L'invio per e-mail passa da POST …/send: kind: "invoice" solo da ready (altrimenti 409 BILL_EMAIL_INVALID_STATUS), kind: "reminder" solo da overdue (altrimenti 409 BILL_REMINDER_INVALID_STATUS). Il PDF è allegato e l'e-mail parte nella lingua della fattura; un cliente senza indirizzo e-mail risponde 422 CLIENT_EMAIL_MISSING. Esiste anche l'evento SEND di …/transition, ma si limita a contrassegnare come sent una fattura ready, senza e-mail.

Annullare un pagamento

curl -X POST -H "Authorization: Bearer $KEY" \
  $BASE/companies/$COMPANY/invoices/<invoiceId>/cancel-payment

Senza corpo, risposta 204: la data di pagamento viene cancellata e la fattura torna allo stato precedente (sent oppure overdue). Una fattura non pagata risponde 409 BILL_NOT_PAID. Un pagamento registrato dalla riconciliazione bancaria risponde 409 BILL_PAYMENT_RECONCILED: si annulla rimuovendo la riconciliazione nell'app.

6. Scaricare il PDF

curl -L -H "Authorization: Bearer $KEY" \
  $BASE/companies/$COMPANY/invoices/<invoiceId>/pdf -o invoice.pdf

curl -L -H "Authorization: Bearer $KEY" \
  $BASE/companies/$COMPANY/invoices/<invoiceId>/qr-bill-pdf -o qr-bill.pdf

Entrambe le rotte rispondono 302 verso un URL di breve durata (15 minuti): segui sempre il redirect con -L e non mettere mai il Location in cache. I file sono generati su richiesta: la prima chiamata dopo una modifica renderizza il documento. 503 PDF_UNAVAILABLE significa che il rendering non è riuscito: riprova. Una fattura importata da un altro software non viene mai renderizzata: …/pdf serve il suo file originale, che può essere un'immagine, quindi leggi il Content-Type.

7. Modificare una fattura emessa

Una volta uscita da draft, una fattura cambia solo con un motivo: ogni modifica di intestazione o riga deve portare un modificationReason non vuoto, registrato nel giornale della fattura. Senza, la risposta è 422 MODIFICATION_REASON_REQUIRED e nulla viene scritto.

curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "dueDate": "2026-04-30", "modificationReason": "Client asked for a longer term" }' \
  $BASE/companies/$COMPANY/invoices/<invoiceId>

Le righe hanno le proprie rotte (POST …/items, PATCH …/items/{itemId}, DELETE …/items/{itemId}), tutte restituiscono l'intera fattura con i totali ricalcolati. Poiché un DELETE non ha corpo, il motivo viaggia come parametro di query:

curl -X DELETE -H "Authorization: Bearer $KEY" \
  "$BASE/companies/$COMPANY/invoices/<invoiceId>/items/<itemId>?modificationReason=Line%20billed%20twice"

Una fattura pagata rifiuta qualsiasi modifica degli importi (409 BILL_PAID_LOCKED) e una fattura importata qualsiasi modifica di ciò che viene stampato (409 BILL_IMPORTED_LOCKED).

Salvare tutto in una sola volta

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "expectedVersion": 1772323200000,
        "header": { "clientId": "<clientId>", "lang": "fr", "currency": "CHF",
                    "issueDate": "2026-03-01", "dueDate": "2026-04-30" },
        "items": [
          { "id": "<itemId>", "description": "Design", "quantity": 3, "unitPrice": 150, "vatRate": 8.1 },
          { "description": "Assistenza", "quantity": 1, "unitPrice": 90, "vatRate": 8.1 }
        ],
        "modificationReason": "Scope extended" }' \
  $BASE/companies/$COMPANY/invoices/<invoiceId>

PUT salva l'intestazione e tutte le righe in un'unica transazione e restituisce l'intera fattura. expectedVersion è l'updatedAt della tua ultima lettura: se nel frattempo la fattura è cambiata, la risposta è 409 BILL_VERSION_CONFLICT e non viene scritto nulla. header riprende i campi obbligatori della creazione; senza discount o shippingCosts i valori salvati restano, invia 0 per azzerarli.

items è l'elenco completo, in ordine: una riga assente viene eliminata. Un id che non appartiene alla fattura, o ripetuto, risponde 422 BILL_ITEM_NOT_FOUND. Gli identificativi delle righe cambiano quando cambia il contenuto della fattura: rileggili nella risposta, come il nuovo updatedAt. Su una fattura uscita da draft, modificationReason è obbligatorio non appena cambia ciò che viene stampato.

8. Preventivi e fatture ricorrenti

I preventivi (/companies/{companyId}/quotes) hanno la forma delle fatture, con validUntil al posto di dueDate e una regola in più: un preventivo si modifica solo in draft o expired. Un preventivo inviato risponde 409 QUOTE_NOT_EDITABLE; riportalo in bozza con REVERT_TO_DRAFT oppure duplicalo. Ai preventivi non si applica alcuna quota, solo la conversione consuma una fattura della quota:

curl -X POST -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/quotes/<quoteId>/convert

La conversione crea una fattura in bozza e risponde 201 con invoiceId e invoiceNumber. Un preventivo si converte una volta (409 QUOTE_ALREADY_CONVERTED), e un preventivo rifiutato o scaduto non si converte (409 QUOTE_NOT_CONVERTIBLE).

Per inviare un preventivo al cliente, POST …/send non prende alcun corpo e risponde 204:

curl -X POST -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/quotes/<quoteId>/send

Il PDF parte all'indirizzo del cliente, nella lingua del preventivo, poi il preventivo passa a sent. L'invio è possibile solo da draft (409 QUOTE_EMAIL_INVALID_STATUS), prima della data di validità (422 VALID_UNTIL_IN_PAST) e con un indirizzo cliente (422 CLIENT_EMAIL_MISSING). 502 QUOTE_PDF_GENERATION_FAILED e 502 QUOTE_EMAIL_SEND_FAILED indicano che non è partito nulla: riprova.

Da sent o expired, POST …/transition accetta MARK_ACCEPTED (con un acceptedAt facoltativo) e MARK_DECLINED. Un preventivo inviato passa da solo a expired alla data di validità; un PATCH che sposta validUntil nel futuro lo riattiva.

PUT …/quotes/{quoteId} salva anche l'intestazione e le righe in una sola volta, con le stesse regole su expectedVersion (409 QUOTE_VERSION_CONFLICT) e su items (422 QUOTE_ITEM_NOT_FOUND). Due differenze: header può contenere solo i campi da modificare e non esiste modificationReason.

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "expectedVersion": 1772323200000,
        "header": { "validUntil": "2026-06-30" },
        "items": [{ "id": "<itemId>", "description": "Design", "quantity": 3, "unitPrice": 150, "vatRate": 8.1 }] }' \
  $BASE/companies/$COMPANY/quotes/<quoteId>

Una fattura ricorrente (/companies/{companyId}/recurring-invoices) non è un documento ma un'intestazione, delle righe e un orologio: startDate innesca la prossima esecuzione, endDate o maxOccurrences la fermano, e con autoSend ogni fattura generata è finalizzata e inviata al cliente. Non c'è una rotta per riga, PUT …/items sostituisce l'intero insieme:

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "items": [
        { "description": "Abonnement", "quantity": 1, "unitPrice": 180, "vatRate": 8.1 },
        { "description": "Support", "quantity": 2, "unitPrice": 90, "vatRate": 8.1 }
      ] }' \
  $BASE/companies/$COMPANY/recurring-invoices/<scheduleId>/items

9. Eliminare fatture e preventivi

DELETE …/invoices/{invoiceId} e DELETE …/quotes/{quoteId} eliminano un documento e rispondono 204. Per più documenti, invia da 1 a 100 identificativi:

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "ids": ["<invoiceId1>", "<invoiceId2>"] }' \
  $BASE/companies/$COMPANY/invoices/bulk-delete

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "ids": ["<quoteId1>", "<quoteId2>"] }' \
  $BASE/companies/$COMPANY/quotes/bulk-delete

La risposta riporta affected e skipped, che conta gli identificativi ripetuti. Eliminare la fattura nata da un preventivo rende di nuovo convertibile quel preventivo.

Per paghe e contabilità, continua con Paghe, spese e contabilità con l'API; per paginazione, date e codici di errore, leggi Convenzioni ed errori. Il dettaglio di ogni campo è nella Swagger UI del tuo deployment (…/api/docs).