-
-
Notifications
You must be signed in to change notification settings - Fork 0
Custom Fields
Manage custom field definitions in Teamleader Focus.
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 acceptsdealas a filter value but sendssaleback in the response body β a known defect on Teamleader's side. Since v2.2.0 the SDK normalises this todealon the way out, so what you filter by and what you read back match. See The sale / deal mismatch.
configuration.optionsis a different shape on read and write. You send an array of strings oncreate(); you get back an array of{id, value}objects. See Response Structure.
customFieldDefinitions
| 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. Thelist()method always sends a page block.
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.0Since 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.
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.
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 manyMakes 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']);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.
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',
],
]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]);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()andforCreditnotes()were removed in v2.2.0. They passedquotationandcreditnote, 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 sendsdealand, after normalisation, reads backdeal.
Shorthand for list(['ids' => $ids]).
$fields = Teamleader::customFields()->byIds(['uuid-1', 'uuid-2']);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 matchedMakes 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.
// 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| 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']);| 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.
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.
| 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 | β | β |
The configuration key is optional on create() and its valid sub-keys depend on the field type.
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'],
],The starting integer for the auto-increment sequence.
'configuration' => [
'default_value' => 1000,
],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.
[
'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.optionsis asymmetric. You send['Referral', 'Website']oncreate()and read back[['id' => ..., 'value' => 'Referral'], ...]. If you're building a select input, map onvalue; if you're writing a field value back to an entity, you'll need the optionid.There is no
metablock. Earlier versions of this page showed one withpageandmatchesβcustomFieldDefinitions.listdoes not return pagination metadata. Useall(), or page until a short page.
extra_option_allowedanddefault_valueare returned for select types;groupis the field group label shown in the Teamleader UI.
Same object shape, with data as a single object rather than an array.
$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);$fields = Teamleader::customFields()->forDeals(['page_size' => 100]);
$map = array_column($fields['data'], 'id', 'label');
// ['Lead Source' => 'uuid-1', 'Budget' => 'uuid-2', ...]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'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_fieldsis acompanies.listinclude only. Requesting it oncompanies.infothrows β that endpoint returns custom fields without being asked. See Companies.
$fields = Cache::remember('tl_custom_fields', 3600, function () {
return Teamleader::customFields()->all()['data'];
});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']);
}-
Companies β
custom_fieldsis alistinclude;inforeturns them automatically - Contacts β same split as Companies
-
Deals β Deals support
custom_fieldssideloading on bothlistandinfo - Sideloading β Reading custom field values on entities
- Filtering β Filter and pagination reference
Last Updated: August 2026 β’ SDK Version: 2.2.2 β’ Made with β€οΈ by MCore Services
- Departments
- Users
- Teams
- Custom Fields
- Work Types
- Document Templates
- Currencies
- Notes
- Email Tracking
- Closing Days
- Day Off Types
- Days Off
- User Schedules
- Invoices
- Credit Notes
- Subscriptions
- Payment Methods
- Payment Terms
- Tax Rates
- Withholding Tax Rates
- Commercial Discounts
Next Gen Projects
Legacy Projects