Invoices to your accounting system
Book the invoices from Klantly in your accounting system automatically, with the PDF attached, and report bank payments back.
You create and send invoices in Klantly; your bookkeeping happens somewhere else. With the API you book every sent invoice in your accounting system, keep the PDF with the entry and record payments that arrive at the bank on the invoice in Klantly.
What you need
An API key with these scopes:
| Scope | What for |
|---|---|
invoices.read |
Fetching invoices and their PDF. |
webhooks.manage |
Optional: creating a webhook for the invoice events. |
invoices.write |
Optional: reporting bank payments back, or turning an accepted quote into an invoice. |
invoices.send |
Optional: fetching the payment link so the customer can pay online. |
The Invoices feature must be active for your company.
Step 1: know when to book
Only book an invoice once it has been sent. A draft (draft) can still change or disappear; a sent invoice is final.
Listen to these events with a webhook:
| Event | What you do |
|---|---|
invoice.sent |
Book the invoice as a sales invoice. Also arrives when the invoice is sent again (a reminder): book by id, not twice. |
invoice.paid |
The invoice has been paid in full. Mark the entry as settled. |
invoice.cancelled |
The invoice has been cancelled. Create a credit entry in your accounting system. |
Prefer to fetch yourself, for example every hour? Then request the invoices that changed since your previous run:
curl --globoff "https://app.klantly.com/api/v1/invoices?filter[updated_since]=2026-10-06T00:00:00Z&sort=updated_at&limit=100" \
-H "Authorization: Bearer $KLANTLY_API_KEY"Keep paging with next_cursor until it is null, see Pagination. Remember the time your run started, minus a minute of margin, and use it as filter[updated_since] next time. Skip drafts and check status for each invoice.
Step 2: book the invoice
Everything you need for the entry is in the invoice:
| Field | What it contains |
|---|---|
number |
The invoice number, for example FAC-2026-00042. Use it as the reference of the entry. |
invoice_date, due_date |
Invoice date and due date (YYYY-MM-DD). |
customer |
Name, company name, address, VAT number and chamber of commerce number as they appear on the invoice. |
items |
The lines, with line_total (excluding VAT), tax_rate and is_taxable. |
discount_amount |
A discount on the whole invoice, as an amount. |
subtotal, tax_amount, total |
The totals. This always holds: subtotal − discount_amount + tax_amount = total. |
is_term_invoice, term_percentage |
An instalment invoice: part of a quote. |
Amounts are strings with two decimals, for example "1305.79". Calculate with them as decimals, not as floating-point numbers, or you will get rounding differences.
Book the sum of line_total per VAT rate. If the invoice has a discount_amount, spread it proportionally over the rates or book it as a separate line. Then check that your entry adds up to total.
Note
A line with unit_price_incl was entered as a price including VAT. line_total_incl is then the amount the customer pays for that line, to the cent. For the entry you still use line_total and tax_rate.
Step 3: keep the PDF with the entry
Fetch the invoice as the customer received it:
curl "https://app.klantly.com/api/v1/invoices/7a1d9e42-5c3b-4f6a-9d8e-2c4b6a8d0e13/pdf" \
-H "Authorization: Bearer $KLANTLY_API_KEY" \
-o FAC-2026-00042.pdfThe response is the file itself. The first time, Klantly creates the PDF, which can take a few seconds; after that it comes from the cache. This request counts towards the limit for heavy actions, so fetch the PDF once and store it.
Step 4: report payments back
When a payment arrives at the bank, report it to Klantly. Your team then sees the invoice is paid and Klantly stops sending payment reminders:
curl -X POST "https://app.klantly.com/api/v1/invoices/7a1d9e42-5c3b-4f6a-9d8e-2c4b6a8d0e13/payments" \
-H "Authorization: Bearer $KLANTLY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: bank-20261006-000318" \
-d '{
"amount": "1943.00",
"payment_method": "bank_transfer",
"reference": "NL91ABNA0417164300 / FAC-2026-00042"
}'- Put the bank's transaction number in the
Idempotency-Key: a payment is then never recorded twice, even if you repeat the request. - A partial payment is fine. The invoice is then
partialuntil the full amount has arrived; after that it ispaidand you receiveinvoice.paid. - The amount can never exceed
amount_due. If it does, you get422onamount.
If you only record payments in your accounting system, skip this step.
Let the customer pay online
If your company has connected Mollie and has the Online payments feature, the customer pays a sent invoice on a payment page. Fetch that link to share it yourself, for example in a reminder from your accounting system or via WhatsApp:
curl "https://app.klantly.com/api/v1/invoices/7a1d9e42-5c3b-4f6a-9d8e-2c4b6a8d0e13/payment-link" \
-H "Authorization: Bearer $KLANTLY_API_KEY"The response contains url and amount_due. If the invoice has a payment schedule, the customer picks the next instalment on that page. A payment through that page is recorded on the invoice automatically: you receive invoice.paid once everything is in, and you do not need to report it back with step 4.
Warning
Anyone with the link can view and pay the invoice. Only share it with the customer. That is why it requires the invoices.send scope.
If the invoice is still a draft, already paid or cancelled, you get 409 with invalid_state_transition; if Mollie is not connected yet, 409 with conflict.
From quote to invoice
When the customer has accepted a quote, you turn it into a draft invoice in one go. Customer, lines, discount and texts are copied:
curl -X POST "https://app.klantly.com/api/v1/quotes/3b8e5f20-6a1c-4d9e-8b7a-5c4d3e2f1a09/invoice" \
-H "Authorization: Bearer $KLANTLY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quote-OF-26457-invoice" \
-d '{"payment_term_days": 14}'A quote has at most one invoice. If you ask again, you get 409 with the code conflict and the id of the existing invoice in invoice_id. You then send it with POST /invoices/{id}/send.
Putting it together
use GuzzleHttp\Client;
/**
* Books the invoices that changed since $since and returns the time to use for the next run.
* $book receives the invoice and a function that fetches the PDF: only call it when the invoice has not
* been booked yet. $markPaid and $credit update your accounting.
*/
function syncInvoices(Client $klantly, string $since, callable $book, callable $markPaid, callable $credit): string
{
$startedAt = gmdate('Y-m-d\TH:i:s\Z', time() - 60); // A minute of margin for clock differences.
$cursor = null;
do {
$page = json_decode((string) $klantly->get('invoices', [
'query' => array_filter([
'filter' => ['updated_since' => $since],
'sort' => 'updated_at',
'limit' => 100,
'cursor' => $cursor,
]),
])->getBody(), true);
foreach ($page['data'] as $invoice) {
match ($invoice['status']) {
'draft' => null, // Do not book yet.
'cancelled' => $credit($invoice),
default => $book($invoice, fn () => downloadPdf($klantly, $invoice)), // Deduplicate on $invoice['id'].
};
if ($invoice['status'] === 'paid') {
$markPaid($invoice);
}
}
$cursor = $page['meta']['next_cursor'];
} while ($cursor !== null);
return $startedAt;
}
function downloadPdf(Client $klantly, array $invoice): string
{
$path = sys_get_temp_dir() . '/' . $invoice['number'] . '.pdf';
$klantly->get("invoices/{$invoice['id']}/pdf", ['sink' => $path]);
return $path;
}import { writeFile } from 'node:fs/promises';
const BASE_URL = 'https://app.klantly.com/api/v1';
const headers = { Authorization: `Bearer ${process.env.KLANTLY_API_KEY}` };
async function klantly(path) {
const response = await fetch(`${BASE_URL}/${path}`, { headers });
const json = await response.json();
if (!response.ok) throw new Error(json.detail ?? json.title);
return json;
}
async function downloadPdf(invoice) {
const response = await fetch(`${BASE_URL}/invoices/${invoice.id}/pdf`, { headers });
if (!response.ok) throw new Error(`Could not fetch the PDF of ${invoice.number} (${response.status})`);
const path = `/tmp/${invoice.number}.pdf`;
await writeFile(path, Buffer.from(await response.arrayBuffer()));
return path;
}
// Books the invoices that changed since `since` and returns the time to use for the next run.
export async function syncInvoices(since, { book, markPaid, credit }) {
const startedAt = new Date(Date.now() - 60_000).toISOString(); // A minute of margin for clock differences.
let cursor = null;
do {
const params = new URLSearchParams({ 'filter[updated_since]': since, sort: 'updated_at', limit: '100' });
if (cursor) params.set('cursor', cursor);
const page = await klantly(`invoices?${params}`);
for (const invoice of page.data) {
if (invoice.status === 'draft') continue; // Do not book yet.
if (invoice.status === 'cancelled') {
await credit(invoice);
continue;
}
// book receives a function that fetches the PDF: only call it when the invoice has not been booked yet.
await book(invoice, () => downloadPdf(invoice)); // Deduplicate on invoice.id.
if (invoice.status === 'paid') await markPaid(invoice);
}
cursor = page.meta.next_cursor;
} while (cursor);
return startedAt;
}Handling errors
404withnot_found: the invoice does not exist (any more), for example a deleted draft.409withinvalid_state_transition: you record a payment on a cancelled invoice, or you convert a quote that has not been accepted.422withvalidation_failed: for example a payment higher thanamount_due.errorsshows what is wrong per field.429withrate_limited: wait the number of seconds inRetry-Afterand try again. See Rate limits.
Last updated on September 17, 2026