Skip to content

Custom Fields

MC0RE edited this page Aug 18, 2026 · 8 revisions

Custom Fields

Manage custom field definitions in Teamleader Focus.

Overview

The Custom Fields resource gives access to the custom field definitions in your Teamleader account β€” the schema-level objects that describe what extra fields exist on contacts, companies, deals, and other entities. It does not read or write the values of those fields; values are returned on their parent resource.

As of v1.2.0 the SDK supports creating custom field definitions programmatically. Creating requires the settings OAuth scope.

Access via Teamleader::customFields().

Deal fields are returned with context: sale. The API accepts deal as a filter value but sends sale back in the response body β€” a known defect on Teamleader's side. Since v2.2.0 the SDK normalises this to deal on the way out, so what you filter by and what you read back match. See The sale / deal mismatch.

configuration.options is a different shape on read and write. You send an array of strings on create(); you get back an array of {id, value} objects. See Response Structure.

Endpoint

customFieldDefinitions

Capabilities

Capability Supported
Pagination βœ… Supported
Filtering βœ… Supported (ids, context)
Sorting βœ… Supported (label, context)
Sideloading ❌ Not supported
Creation βœ… Supported (requires settings scope)
Update ❌ Not supported
Deletion ❌ Not supported

Note on pagination: The API defaults to page size 20. If you have more than 20 custom fields you must paginate explicitly, or use all() which pages for you. The list() method always sends a page block.


The sale / deal mismatch

Filtering by context: deal returns definitions whose context reads sale:

// What the API actually sends back
['id' => '...', 'label' => 'Lead Source', 'context' => 'sale']

Any code that stores or compares definitions by context silently matched nothing:

$dealFields = array_filter($fields['data'], fn ($f) => $f['context'] === 'deal');
// Always empty before v2.2.0

Since v2.2.0 the SDK rewrites sale to deal in list() and info() responses, so the above works. The mapping lives in $contextResponseAliases and can be removed once Teamleader corrects the API.

Normalisation is one-directional on purpose. sale is not accepted as an inbound filter value β€” the API rejects it, and quietly translating an invalid input into a valid one would hide the discrepancy in the other direction:

Teamleader::customFields()->forContext('sale');
// InvalidArgumentException: Invalid custom field context 'sale'. Valid contexts:
// contact, company, deal, project, milestone, product, invoice, subscription,
// ticket. The API returns 'sale' in responses but does not accept it as a
// filter; use 'deal' instead. The SDK normalises this automatically on the way back.

This also affects anything that groups definitions by context β€” including php artisan teamleader:export-uuids, which filed deal fields under sale before this fix.


Methods

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

Returns a paginated list of custom field definitions.

Options:

Key Type Default Description
page_size int 20 Records per page
page_number int 1 Page to retrieve
sort string β€” label or context
sort_order string asc asc or desc
use McoreServices\TeamleaderSDK\Facades\Teamleader;

// First page of 20
$fields = Teamleader::customFields()->list();

// All fields for a context
$contactFields = Teamleader::customFields()->list([
    'context' => 'contact',
]);

// Specific fields by UUID
$fields = Teamleader::customFields()->list([
    'ids' => ['uuid-1', 'uuid-2'],
]);

// Sorted
$fields = Teamleader::customFields()->list([], [
    'sort'       => 'label',
    'sort_order' => 'asc',
]);

Unknown filter keys and unknown sort fields throw before the request is sent.


all(array $filters = [], int $pageSize = 100)

Pages through every definition and returns them in one array. Added in v2.2.0.

Because the API returns no total count, this pages until a page comes back shorter than the requested size β€” so a complete final page costs one extra empty request. Capped at 50 pages.

$fields = Teamleader::customFields()->all();

$fields['data'];        // every definition
$fields['total_count']; // how many

Makes multiple API calls, so the return carries data and total_count but no headers.

// Every deal field, in one call
$dealFields = Teamleader::customFields()->all(['context' => 'deal']);

info(string $id)

Returns a single custom field definition by UUID. Contexts are normalised the same way as in list().

$field = Teamleader::customFields()->info('field-uuid');

$label   = $field['data']['label'];
$type    = $field['data']['type'];
$context = $field['data']['context']; // 'deal', never 'sale'

This endpoint accepts no includes. Passing any throws.


create(array $data)

Creates a new custom field definition. Requires the settings OAuth scope.

Required fields:

Field Type Description
label string Display label for the field
type string Field type β€” see Field Types
context string Entity the field belongs to β€” see Contexts

Optional fields:

Field Type Description
configuration array Type-specific configuration β€” see Configuration

The SDK validates label, type, context, and configuration before sending the request. An InvalidArgumentException is thrown for any invalid value.

// Simple single-line text field
$field = Teamleader::customFields()->create([
    'label'   => 'Purchase Order Number',
    'type'    => 'single_line',
    'context' => 'invoice',
]);

// Single-select dropdown with options
$field = Teamleader::customFields()->create([
    'label'         => 'Lead Source',
    'type'          => 'single_select',
    'context'       => 'deal',
    'configuration' => [
        'options' => ['Referral', 'Website', 'Cold Call', 'Event'],
    ],
]);

// Auto-increment with a starting value
$field = Teamleader::customFields()->create([
    'label'         => 'Customer Number',
    'type'          => 'auto_increment',
    'context'       => 'company',
    'configuration' => [
        'default_value' => 1000,
    ],
]);

// Searchable text field
$field = Teamleader::customFields()->create([
    'label'         => 'External ID',
    'type'          => 'single_line',
    'context'       => 'contact',
    'configuration' => [
        'searchable' => true,
    ],
]);

Note that context is deal on create, not sale β€” the mismatch is a response-side defect only.

Create response:

[
    'data' => [
        'type' => 'customFieldDefinition',
        'id'   => 'new-field-uuid',
    ],
]

Helper Methods

forContext(string $context, array $options = [])

Returns fields for a specific context. Validates the context against the API's enum before sending, and accepts pagination and sorting options.

$fields = Teamleader::customFields()->forContext('deal');
$fields = Teamleader::customFields()->forContext('deal', ['page_size' => 100]);

Context convenience methods

All accept an optional $options array.

Method Context passed
forContacts() contact
forCompanies() company
forDeals() deal
forSales() deal (alias for forDeals())
forProjects() project
forMilestones() milestone
forProducts() product
forInvoices() invoice
forSubscriptions() subscription
forTickets() ticket

forQuotations() and forCreditnotes() were removed in v2.2.0. They passed quotation and creditnote, neither of which is in Teamleader's context enum, so they returned empty results or a 422. There is no replacement β€” those contexts do not exist.

forSales() remains as a convenience for people who think in the API's response vocabulary. It sends deal and, after normalisation, reads back deal.

byIds(array $ids)

Shorthand for list(['ids' => $ids]).

$fields = Teamleader::customFields()->byIds(['uuid-1', 'uuid-2']);

byType(string $type)

Returns every definition of a given type. Filters client-side β€” customFieldDefinitions.list has no type filter, so this pages through all definitions via all() and filters in PHP.

$selects = Teamleader::customFields()->byType('single_select');

$selects['data'];        // matching definitions
$selects['total_count']; // how many matched

Makes multiple API calls. Before v2.2.0 this sent filter.type, which the API ignored β€” so it returned the entire catalogue regardless of the type requested.

Passing the type key to list() directly throws, with a message pointing here.


Introspection Helpers

// All valid contexts as an array
$contexts = Teamleader::customFields()->getAllSupportedContexts();

// All valid types as an array
$types = Teamleader::customFields()->getAllSupportedTypes();

// Check capabilities of a type
$hasOptions    = Teamleader::customFields()->typeHasOptions('single_select');     // true
$isSearchable  = Teamleader::customFields()->typeIsSearchable('single_line');     // true
$isReference   = Teamleader::customFields()->typeIsReference('company');          // true

Filters

Filter Type Description
ids array Filter by custom field UUIDs. A single string is wrapped for you
context string Entity context β€” validated against the enum below

That is the complete set. type is not a filter β€” see byType().

$fields = Teamleader::customFields()->list(['ids' => ['uuid-1', 'uuid-2']]);
$fields = Teamleader::customFields()->list(['context' => 'contact']);

Sorting

Field Description
label Field label
context Entity context
$fields = Teamleader::customFields()->list([], [
    'sort'       => 'label',
    'sort_order' => 'desc',
]);

Sorting was declared as supported before v2.2.0 but never implemented β€” list() built no sort parameter at all.


Contexts

Valid context values for filtering and creation. Verified against @teamleader/focus-api-specification.

Context Description
contact Contact fields
company Company fields
deal Deal fields β€” returned by the API as sale, normalised to deal
project Project fields
milestone Milestone fields
product Product fields
invoice Invoice fields
subscription Subscription fields
ticket Ticket fields

quotation and creditnote are not valid contexts, despite earlier versions of this page implying they might be.


Field Types

Type Description Supports options Supports searchable
single_line Single-line text ❌ βœ…
multi_line Multi-line text ❌ ❌
single_select Single-choice dropdown βœ… ❌
multi_select Multi-choice dropdown βœ… ❌
date Date picker ❌ ❌
money Monetary value ❌ ❌
auto_increment Auto-incrementing number ❌ (default_value only) βœ…
integer Whole number ❌ βœ…
number Decimal number ❌ βœ…
boolean True/false toggle ❌ ❌
email Email address ❌ βœ…
telephone Phone number ❌ βœ…
url URL / website ❌ ❌
company Reference to a company ❌ βœ…
contact Reference to a contact ❌ ❌
product Reference to a product ❌ ❌
user Reference to a user ❌ ❌

Configuration

The configuration key is optional on create() and its valid sub-keys depend on the field type.

options β€” single_select and multi_select only

An array of string option labels on write. Note the response returns objects, not strings β€” see below.

'configuration' => [
    'options' => ['Option A', 'Option B', 'Option C'],
],

default_value β€” auto_increment only

The starting integer for the auto-increment sequence.

'configuration' => [
    'default_value' => 1000,
],

searchable β€” specific types only

A boolean that makes the field searchable. Valid for: single_line, company, integer, number, auto_increment, email, telephone.

'configuration' => [
    'searchable' => true,
],

Passing a configuration key for a type that doesn't support it throws an InvalidArgumentException before the request is sent.


Response Structure

list() response

[
    'data' => [
        [
            'id'            => 'field-uuid',
            'label'         => 'Lead Source',
            'type'          => 'single_select',
            'context'       => 'deal',        // normalised from 'sale'
            'group'         => 'Sales',
            'required'      => false,
            'configuration' => [
                'options' => [
                    ['id' => 'option-uuid-1', 'value' => 'Referral'],
                    ['id' => 'option-uuid-2', 'value' => 'Website'],
                ],
                'extra_option_allowed' => true,
                'default_value'        => null,
            ],
        ],
    ],
    'headers' => ['X-RateLimit-Remaining' => ['199'], /* ... */],
]

configuration.options is asymmetric. You send ['Referral', 'Website'] on create() and read back [['id' => ..., 'value' => 'Referral'], ...]. If you're building a select input, map on value; if you're writing a field value back to an entity, you'll need the option id.

There is no meta block. Earlier versions of this page showed one with page and matches β€” customFieldDefinitions.list does not return pagination metadata. Use all(), or page until a short page.

extra_option_allowed and default_value are returned for select types; group is the field group label shown in the Teamleader UI.

info() response

Same object shape, with data as a single object rather than an array.


Usage Examples

Get all custom field definitions

$fields = Teamleader::customFields()->all();

Or manually, if you want control over the paging:

$all  = [];
$page = 1;

do {
    $response = Teamleader::customFields()->list([], [
        'page_size'   => 100,
        'page_number' => $page,
    ]);

    $all = array_merge($all, $response['data']);
    $page++;
} while (count($response['data']) === 100);

Build a UUID map for a context

$fields = Teamleader::customFields()->forDeals(['page_size' => 100]);

$map = array_column($fields['data'], 'id', 'label');
// ['Lead Source' => 'uuid-1', 'Budget' => 'uuid-2', ...]

Group definitions by context

This is the case the sale β†’ deal normalisation exists for:

$fields = Teamleader::customFields()->all();

$byContext = [];

foreach ($fields['data'] as $field) {
    $byContext[$field['context']][] = $field;
}

$byContext['deal'] ?? [];  // populated β€” before v2.2.0 these landed under 'sale'

Read custom field values from a company

Custom field values are returned on the parent resource, not fetched here. On info() they come back automatically:

$company = Teamleader::companies()->info('company-uuid');

foreach ($company['data']['custom_fields'] ?? [] as $field) {
    $definitionId = $field['definition']['id'];
    $value        = $field['value'];
}

On list() they must be requested:

$companies = Teamleader::companies()
    ->withCustomFields()
    ->list(['status' => 'active']);

custom_fields is a companies.list include only. Requesting it on companies.info throws β€” that endpoint returns custom fields without being asked. See Companies.

Cache field definitions

$fields = Cache::remember('tl_custom_fields', 3600, function () {
    return Teamleader::customFields()->all()['data'];
});

Error Handling

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

// Invalid context β€” thrown before the request
try {
    Teamleader::customFields()->forContext('quotation');
} catch (InvalidArgumentException $e) {
    // 'Invalid custom field context 'quotation'. Valid contexts: contact,
    //  company, deal, project, milestone, product, invoice, subscription, ticket.'
}

// 'sale' is a response value, not a filter value
try {
    Teamleader::customFields()->forContext('sale');
} catch (InvalidArgumentException $e) {
    // '... use 'deal' instead. The SDK normalises this automatically on the way back.'
}

// type is not a filter
try {
    Teamleader::customFields()->list(['type' => 'single_line']);
} catch (InvalidArgumentException $e) {
    // 'Unsupported filter key for customFieldDefinitions.list: type. Supported:
    //  ids, context. The API has no type filter; use byType(), which filters
    //  client-side.'
}

// create() validates before the request
try {
    $field = Teamleader::customFields()->create([
        'label'         => 'Lead Source',
        'type'          => 'single_select',
        'context'       => 'deal',
        'configuration' => ['options' => ['Referral', 'Website']],
    ]);
} catch (InvalidArgumentException $e) {
    // Invalid type, context, or configuration key
    Log::error('Invalid custom field data', ['message' => $e->getMessage()]);
} catch (TeamleaderException $e) {
    if ($e->getCode() === 403) {
        // Missing 'settings' OAuth scope
        Log::error('Missing settings scope for custom field creation');
    }
}

// info() on a missing field
try {
    $field = Teamleader::customFields()->info('field-uuid');
} catch (NotFoundException $e) {
    Log::warning('Custom field not found', ['id' => 'field-uuid']);
}

Related Resources

  • Companies β€” custom_fields is a list include; info returns them automatically
  • Contacts β€” same split as Companies
  • Deals β€” Deals support custom_fields sideloading on both list and info
  • Sideloading β€” Reading custom field values on entities
  • Filtering β€” Filter and pagination reference

Clone this wiki locally