Skip to content

Invoices

MC0RE edited this page Aug 18, 2026 · 4 revisions

Invoices

Manage invoices in Teamleader Focus.

Overview

The Invoices resource covers the full invoice lifecycle: drafting, booking, crediting, sending (email and Peppol), payments, and downloads.

Access via Teamleader::invoices().

create() drafts an invoice (posts to invoices.draft). The draft() helper lists draft invoices and is deprecated in favour of listDrafts() β€” don't confuse the two.

Untitled grouped_lines sections must omit the section key entirely. This is the single most common cause of a 400 grouped_lines must be valid on this resource. See Grouped lines and untitled sections.

Endpoint

invoices

Capabilities

Capability Supported
Pagination βœ… Supported
Filtering βœ… Supported
Sorting βœ… Supported (invoice_number, invoice_date)
Sideloading βœ… Supported (late_fees)
Creation βœ… Supported (draft)
Update βœ… Supported
Deletion βœ… Supported

Grouped lines and untitled sections

section is optional on a grouped line. When a group has no title, the key must be omitted entirely β€” Teamleader rejects both of these with HTTP 400:

['section' => ['title' => null], 'line_items' => [...]]   // 400
['section' => ['title' => ''],   'line_items' => [...]]   // 400
['line_items' => [...]]                                   // βœ… correct
['section' => ['title' => 'Services'], 'line_items' => [...]]  // βœ… correct

This bites hardest on read-modify-write. invoices.info returns section.title as null for untitled sections, so echoing back exactly what you just read is rejected. Drop the key instead:

$info = Teamleader::invoices()->info($invoiceId);

$groupedLines = [];

foreach ($info['data']['grouped_lines'] as $group) {
    $out = ['line_items' => $lineItems];

    if (! empty($group['section']['title'])) {
        $out['section'] = ['title' => $group['section']['title']];
    }

    $groupedLines[] = $out;
}

Teamleader::invoices()->update($invoiceId, ['grouped_lines' => $groupedLines]);

Untitled sections are common β€” Teamleader's own UI creates them whenever a user doesn't name a section.

Before v2.2.0 this was impossible through the SDK. validateGroupedLines() required every group to carry a section.title, so omitting the key threw client-side and including it as null was rejected by the API. There was no accepted shape, which made create(), update() and updateBooked() unreachable for any invoice with an untitled section.

Validation now applies these rules, on all four write paths (create(), update(), updateBooked(), creditPartially()):

Shape Result
section omitted βœ… Passes
section.title a non-empty string βœ… Passes
section.title null ❌ Throws β€” omit the key
section.title '' ❌ Throws β€” omit the key
section present without title ❌ Throws
section not an array ❌ Throws
line_items missing or not an array ❌ Throws

The SDK is stricter than the API on line items. It requires unit_price on every line item; the specification lists only quantity, description and tax_rate_id as required. An invoice line without a price is almost always a mistake, so this is deliberate β€” but worth knowing if you hit it.


Methods

list(array $filters = [], array $options = [])

The status filter is validated against draft, outstanding, matched (a lone string is coerced to an array).

use McoreServices\TeamleaderSDK\Facades\Teamleader;

$invoices = Teamleader::invoices()->list(['status' => ['outstanding']]);

$invoices = Teamleader::invoices()->list(
    ['department_id' => 'dept-uuid'],
    ['sort' => 'invoice_date', 'sort_order' => 'desc', 'page_size' => 50]
);

info(string $id, mixed $includes = null)

$invoice = Teamleader::invoices()->info('invoice-uuid');
$invoice = Teamleader::invoices()->info('invoice-uuid', 'late_fees');

create(array $data)

Drafts an invoice (invoices.draft). Required: invoicee (customer.type + customer.id), department_id, payment_term (type), grouped_lines.

Notable optional fields: currency, project_id, quotation_id, purchase_order_number, invoice_date, note, expected_payment_method, custom_fields, delivery_date.

quotation_id (v1.2.8): links the created invoice to a source quotation and its deal, and marks the deal as won.

$invoice = Teamleader::invoices()->create([
    'department_id' => 'dept-uuid',
    'invoicee'      => ['customer' => ['type' => 'company', 'id' => 'company-uuid']],
    'payment_term'  => ['type' => 'after_invoice_date', 'days' => 30],
    'grouped_lines' => [[
        'section'    => ['title' => 'Services'],
        'line_items' => [[
            'quantity' => 5, 'description' => 'Consulting',
            'unit_price' => ['amount' => 150.0, 'tax' => 'excluding'],
            'tax_rate_id' => 'tax-rate-uuid',
        ]],
    ]],
    'quotation_id'  => 'quotation-uuid',   // links to quotation + deal, marks deal won
]);

A draft without section titles β€” note the absent section key:

$invoice = Teamleader::invoices()->create([
    'department_id' => 'dept-uuid',
    'invoicee'      => ['customer' => ['type' => 'company', 'id' => 'company-uuid']],
    'payment_term'  => ['type' => 'cash'],
    'grouped_lines' => [[
        'line_items' => [[
            'quantity' => 1, 'description' => 'Callout fee',
            'unit_price' => ['amount' => 75.0, 'tax' => 'excluding'],
            'tax_rate_id' => 'tax-rate-uuid',
        ]],
    ]],
]);

update(mixed $id, array $data) / updateBooked(string $id, array $data)

update() edits a draft; updateBooked() edits a booked invoice (only when enabled in Teamleader settings). Both validate expected_payment_method when present (see Expected Payment Method) and grouped_lines when present.

Teamleader::invoices()->update('invoice-uuid', ['note' => 'Updated note']);

Teamleader::invoices()->updateBooked('invoice-uuid', [
    'expected_payment_method' => ['method' => 'sepa_direct_debit', 'reference' => 'MND-2026-000123'],
]);

book(string $id, string $on) / copy(string $id) / delete(mixed $id)

Teamleader::invoices()->book('invoice-uuid', '2025-04-01');
$draft = Teamleader::invoices()->copy('invoice-uuid');
Teamleader::invoices()->delete('invoice-uuid'); // draft or last booked only

credit(string $id, string $creditNoteDate) / creditPartially(string $id, string $creditNoteDate, array $groupedLines, ?array $discounts = null)

creditPartially() validates grouped_lines under the same rules as create() β€” including the untitled-section rule.

Teamleader::invoices()->credit('invoice-uuid', '2025-04-15');
Teamleader::invoices()->creditPartially('invoice-uuid', '2025-04-15', $groupedLines);

download(string $id, string $format = 'pdf')

Valid formats: pdf, ubl/e-fff, ubl/peppol_bis_3, ubl/xrechnung (the last added in v1.2.8). Returns a temporary download URL.

$download = Teamleader::invoices()->download('invoice-uuid', 'ubl/xrechnung');

send(string $id, array $content, array $recipients = [], ?array $attachments = null)

Sends an invoice by email. content requires subject and body (optional mail_template_id). recipients is optional (v1.2.8) β€” when omitted, the invoice is sent to the invoicee's email. Each recipient in to/cc/bcc must include an email.

Teamleader::invoices()->send('invoice-uuid', [
    'subject' => 'Your invoice',
    'body'    => 'Please find your invoice attached.',
]);

sendViaPeppol(string $id)

Teamleader::invoices()->sendViaPeppol('invoice-uuid');

registerPayment(string $id, array $payment, string $paidAt, ?string $paymentMethodId = null) / removePayments(string $id)

Teamleader::invoices()->registerPayment(
    'invoice-uuid',
    ['amount' => 250.00, 'currency' => 'EUR'],
    '2025-04-10',
    'payment-method-uuid'
);
Teamleader::invoices()->removePayments('invoice-uuid');

Helper Methods

Method Filter applied
listDrafts(array $filters = [], array $options = []) status: ['draft'] (replaces deprecated draft())
outstanding(...) status: ['outstanding']
matched(...) status: ['matched']
forCustomer(string $type, string $id, ...) customer
forProject(string $projectId, ...) project_id
forDeal(string $dealId, ...) deal_id
forDepartment(string $departmentId, ...) department_id
search(string $term, ...) term
updatedSince(string $datetime, ...) updated_since

Introspection

$statuses = Teamleader::invoices()->getValidPeppolStatuses(); // possible peppol_status values

Expected Payment Method

expected_payment_method is validated on create(), update() and updateBooked().

Valid method values: direct_debit, credit_card, cash, cheque, bankers_draft, bank_transfer, payment_card, sepa_direct_debit.

When method is sepa_direct_debit, reference is required.

// Valid
['method' => 'sepa_direct_debit', 'reference' => 'MND-2026-000123']
['method' => 'credit_card']

// Throws InvalidArgumentException β€” sepa_direct_debit without reference
['method' => 'sepa_direct_debit']

Filters

Verified against @teamleader/focus-api-specification.

Filter Type Description
ids array Invoice UUIDs
term string Invoice number, PO number, payment reference, invoicee
invoice_number string Full invoice number (fiscal year / number)
department_id string Department (company entity)
deal_id string Deal UUID
project_id string Project UUID
subscription_id string Subscription UUID
status array draft, outstanding, matched (validated)
updated_since string ISO 8601 datetime
purchase_order_number string PO number
payment_reference string Structured payment reference
invoice_date_after string Date (inclusive, YYYY-MM-DD)
invoice_date_before string Date (inclusive, YYYY-MM-DD)
customer object Customer {type, id}

Sorting

Field Description
invoice_number Invoice number
invoice_date Invoice date

Default order is desc.

Sideloading

Include Description
late_fees Adds totals.due_incasso_inclusive, totals.fixed_late_fee and totals.interest

Sent as includes (plural) in the request body.


Response Notes

  • list() / info() responses include peppol_status (nullable) β€” see getValidPeppolStatuses() for the possible values.
  • grouped_lines[].section.title is nullable on read. See Grouped lines and untitled sections before writing it back.
  • There is no meta block. The API returns pagination metadata only when a resource sends includes=pagination, and Invoices does not β€” so there is no total count, and the end of a list is a page shorter than the requested page size.

Usage Examples

Add a line to an existing draft

The round trip that used to be impossible:

$info = Teamleader::invoices()->info('invoice-uuid');

$groupedLines = [];

foreach ($info['data']['grouped_lines'] as $group) {
    $lineItems = [];

    foreach ($group['line_items'] as $line) {
        $lineItems[] = [
            'quantity'    => $line['quantity'],
            'description' => $line['description'],
            'unit_price'  => ['amount' => $line['unit_price']['amount'], 'tax' => 'excluding'],
            'tax_rate_id' => $line['tax']['id'],
        ];
    }

    // New line on the first group
    if ($groupedLines === []) {
        $lineItems[] = [
            'quantity'    => 1,
            'description' => 'Additional callout',
            'unit_price'  => ['amount' => 75.0, 'tax' => 'excluding'],
            'tax_rate_id' => 'tax-rate-uuid',
        ];
    }

    $out = ['line_items' => $lineItems];

    // Only include section when it has a real title
    if (! empty($group['section']['title'])) {
        $out['section'] = ['title' => $group['section']['title']];
    }

    $groupedLines[] = $out;
}

Teamleader::invoices()->update('invoice-uuid', ['grouped_lines' => $groupedLines]);

Book and send an invoice

Teamleader::invoices()->book('invoice-uuid', now()->toDateString());

Teamleader::invoices()->send('invoice-uuid', [
    'subject' => 'Invoice from MCore Services',
    'body'    => 'Please find your invoice attached.',
]);

Page through outstanding invoices

$all  = [];
$page = 1;

do {
    $response = Teamleader::invoices()->list(
        ['status' => ['outstanding']],
        ['page_size' => 100, 'page_number' => $page]
    );
    $all = array_merge($all, $response['data']);
    $page++;
} while (count($response['data']) === 100);

Error Handling

use InvalidArgumentException;
use McoreServices\TeamleaderSDK\Exceptions\TeamleaderException;

// Untitled section sent as null β€” omit the key instead
try {
    Teamleader::invoices()->update('invoice-uuid', [
        'grouped_lines' => [
            ['section' => ['title' => null], 'line_items' => [$lineItem]],
        ],
    ]);
} catch (InvalidArgumentException $e) {
    // 'section.title must be a non-empty string. Omit the section key entirely
    //  for untitled groups β€” the API rejects both null and "".'
}

// section present but malformed
try {
    Teamleader::invoices()->update('invoice-uuid', [
        'grouped_lines' => [['section' => [], 'line_items' => [$lineItem]]],
    ]);
} catch (InvalidArgumentException $e) {
    // 'When present, section must contain a title'
}

// Missing line items
try {
    Teamleader::invoices()->update('invoice-uuid', [
        'grouped_lines' => [['section' => ['title' => 'Services']]],
    ]);
} catch (InvalidArgumentException $e) {
    // 'Each grouped line must have line_items array'
}

// sepa_direct_debit without a reference
try {
    Teamleader::invoices()->update('invoice-uuid', [
        'expected_payment_method' => ['method' => 'sepa_direct_debit'],
    ]);
} catch (InvalidArgumentException $e) {
    // reference is required for sepa_direct_debit
}

// Server-side rejection, if a bad payload gets past client-side validation
try {
    Teamleader::invoices()->update('invoice-uuid', ['grouped_lines' => $groupedLines]);
} catch (TeamleaderException $e) {
    // {"errors":[{"code":0,"title":"grouped_lines must be valid","status":400, ...}]}
}

Related Resources

  • Credit-Notes β€” Created via credit() / creditPartially(); read-only otherwise
  • Subscriptions β€” Can generate invoices; grouped_lines there is not section-validated
  • Quotations β€” quotation_id source on draft
  • Deals β€” Linked deal
  • Payment Terms β€” payment_term.type reference
  • Tax Rates β€” tax_rate_id on line items
  • Files β€” Use files()->forInvoice() to list attachments
  • Filtering β€” Filter and pagination reference

Clone this wiki locally