Klantly Developers

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
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
curl "https://app.klantly.com/api/v1/invoices/7a1d9e42-5c3b-4f6a-9d8e-2c4b6a8d0e13/pdf" \
  -H "Authorization: Bearer $KLANTLY_API_KEY" \
  -o FAC-2026-00042.pdf

The 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
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 partial until the full amount has arrived; after that it is paid and you receive invoice.paid.
  • The amount can never exceed amount_due. If it does, you get 422 on amount.

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

PHP
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;
}
Node.js
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

  • 404 with not_found: the invoice does not exist (any more), for example a deleted draft.
  • 409 with invalid_state_transition: you record a payment on a cancelled invoice, or you convert a quote that has not been accepted.
  • 422 with validation_failed: for example a payment higher than amount_due. errors shows what is wrong per field.
  • 429 with rate_limited: wait the number of seconds in Retry-After and try again. See Rate limits.

Last updated on September 17, 2026