mcore-services / teamleader-sdk
Laravel SDK for Teamleader Focus API
Requires
- php: ^8.4
- guzzlehttp/guzzle: ^7.0 || ^8.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.13
- laravel/pulse: ^1.8
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^12.5.8
- predis/predis: ^3.6
Suggests
- laravel/pulse: Show Teamleader traffic, failures, rate-limit waits and connections that need reconnecting on the Pulse dashboard (^1.8)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 3.x-dev
- v3.4.2
- v3.4.1
- v3.4.0
- v3.3.3
- v3.3.2
- v3.3.1
- v3.3.0
- v3.2.0
- v3.1.0
- v3.0.0
- v3.0.0-rc.2
- v3.0.0-rc.1
- v3.0.0-beta.2
- v3.0.0-beta.1
- 2.x-dev
- v2.3.2
- v2.3.1
- v2.3.0
- v2.2.17
- v2.2.16
- v2.2.15
- v2.2.14
- v2.2.13
- v2.2.12
- v2.2.11
- v2.2.10
- v2.2.9
- v2.2.8
- v2.2.7
- v2.2.6
- v2.2.5
- v2.2.4
- v2.2.3
- v2.2.2
- v2.2.1
- v2.2.0
- v2.1.1
- v2.1.0
- v2.0.0
- v1.2.8
- v1.2.7
- v1.2.6
- v1.2.5
- v1.2.4
- v1.2.3
- v1.2.2
- v1.2.1
- v1.2.0
- v1.1.6
- v1.1.5
- v1.1.4
- v1.1.3
- v1.1.2
- v1.1.1
- v1.1.0
- dev-feature/pulse
This package is auto-updated.
Last update: 2026-10-22 00:14:12 UTC
README
A Laravel package for the Teamleader Focus API. Handles OAuth, encrypted token storage and renewal, rate limiting, and around 70 API resources behind a consistent interface β for one Teamleader account or many.
Quick Links:
- π¦ Packagist: packagist.org/packages/mcore-services/teamleader-sdk
- π» GitHub: github.com/MCore-Services-bv/teamleader-sdk
- π Documentation: hub.mcore-services.be/docs/teamleader-sdk β guides, and an API reference generated from the code
β¨ Key Features
π Authentication & Security
- Complete OAuth 2.0 Flow β
Teamleader::authorize()and one callback route; thestateis generated and checked for you - Encrypted Token Storage β tokens encrypted with
APP_KEYin the database and the cache - Scheduled Token Renewal β tokens are renewed every ten minutes before they expire, so an idle connection stays connected
- Right-Account Check β
expected_account_idrefuses a callback that connects the wrong Teamleader account - Concurrent Request Safety β a per-connection lock prevents token refresh race conditions
π’ Multiple Accounts
- Connections β one application, several Teamleader accounts, each with its own credentials, tokens and rate-limit window
- Stored in config or the database β add an account with
php artisan teamleader:connections:add, no deploy needed
π Performance & Reliability
- Proactive Rate Limiting β Redis-backed sliding window that waits for a free slot rather than letting you hit the 200 req/min limit
- Retry Logic β Automatic retry with backoff for transient failures
- Lazy Pagination β
lazy()andcursor()page through any list with flat memory use
π¦ Bulk & Command Line
- Bulk Export β any list to CSV or JSON Lines, safe against spreadsheet formula injection
- Bulk Writes β create, update, delete or call any method for many rows: validated first, resumable, with a dry run, in-process or on the queue
- Artisan CLI β explore, list, export, import and call the API from the terminal; nothing writes without
--write
π§° Developer Experience
- Resource-Based Architecture β Organised access to all API endpoints
- Fluent Sideloading β Reduce API calls by including related resources
- Fail-Fast Validation β Unsupported filters, sort fields and includes throw before the request is sent, rather than being silently ignored by the API
- Typed Exceptions by Default β a failed request throws an exception with an actionable message
- Events β for every request, response, failure, rate-limit wait and token refresh, plus optional request logging
- Laravel Pulse Cards β Teamleader traffic, failures, rate-limit waits and connections that need reconnecting on your Pulse dashboard, plus which routes and jobs spend their time on Teamleader
- Test Fake β
Teamleader::fake()records requests instead of sending them, with assertions per endpoint and connection - Resource Introspection β Query any resource's capabilities programmatically
- AI Agent Guidelines β ships Laravel Boost guidelines, so Claude Code, Cursor and Copilot use the SDK correctly
π― API Coverage
CRM β Companies, Contacts, Business Types, Tags, Addresses
Deals & Sales β Deals, Quotations, Orders, Pipelines, Phases, Sources, Lost Reasons
Invoicing β Invoices, Credit Notes, Payment Methods, Payment Terms, Tax Rates, Withholding Tax Rates, Commercial Discounts, Subscriptions
Expenses β Expenses, Incoming Invoices, Incoming Credit Notes, Receipts, Bookkeeping Submissions
Projects & Time Tracking β Projects (both the current and legacy systems), Project Tasks, Groups, Materials, Project Lines, External Parties, Time Tracking, Timers
Planning β Reservations, User Availability, Plannable Items
Calendar & Activities β Meetings, Calls, Call Outcomes, Calendar Events, Activity Types
Products & Services β Products, Product Categories, Units of Measure, Work Types, Price Lists
General β Users, Teams, Departments, Custom Fields, Currencies, Notes, Files, Document Templates, User Schedules, Closing Days, Days Off, Day Off Types, Email Tracking
System β Webhooks, Cloud Platforms, Accounts, Migration Utilities
π Requirements
- PHP: 8.4 or higher (tested on 8.4 β 8.5)
- Laravel: 12.x or 13.x
- Extensions:
ext-json,ext-mbstring - Database: MySQL 5.7+, PostgreSQL 10+, or SQLite 3.8+
- Redis: required for rate limiting, which is enabled by default
Laravel 10 and 11 were dropped in v2.0. Both are EOL with unpatched CVEs, and Composer's security advisories block installing them.
π Installation
1. Install via Composer
composer require mcore-services/teamleader-sdk
2. Publish the Configuration and Run the Migrations
php artisan vendor:publish --tag=teamleader-config php artisan migrate
3. Configure Environment Variables
TEAMLEADER_CLIENT_ID=your_client_id TEAMLEADER_CLIENT_SECRET=your_client_secret TEAMLEADER_REDIRECT_URI="${APP_URL}/teamleader/callback"
The redirect URI must match the one registered on your integration in the
Teamleader developer portal exactly β
http versus https included.
4. Set Up OAuth Routes
// routes/web.php use Illuminate\Http\Request; use McoreServices\TeamleaderSDK\Facades\Teamleader; Route::get('/teamleader/connect', function () { return Teamleader::authorize(); }); Route::get('/teamleader/callback', function (Request $request) { if (Teamleader::handleCallback($request->query('code'), $request->query('state'))) { return response('Connected to Teamleader. You can close this tab and run: php artisan teamleader:status'); } return response('Connecting to Teamleader failed. Check storage/logs/laravel.log.', 500); });
Once others can reach your application, put both routes behind your login
(->middleware('auth')): anyone who can open /teamleader/connect can
connect a Teamleader account to it.
5. Run the Scheduler
The package schedules teamleader:tokens:refresh every ten minutes. Run
php artisan schedule:work locally, and a cron entry for
php artisan schedule:run in production (Laravel Forge: Scheduler).
Upgrading from 2.x? See the upgrade guide.
π Authentication
use McoreServices\TeamleaderSDK\Facades\Teamleader; // 1. Redirect the user to Teamleader β a state is generated and remembered return Teamleader::authorize(); // 2. Handle the callback β the state is checked, tokens are encrypted and stored Teamleader::handleCallback($code, $state); // 3. Check authentication status if (Teamleader::isAuthenticated()) { // Ready to make API calls }
php artisan teamleader:status --all # every connection, its account and token expiry
When Teamleader refuses a refresh token, the connection is marked
needs_reauthorization and its requests throw
ConnectionNeedsReauthorizationException until it is connected again.
π Basic Usage
Every resource follows the same shape:
$resource->list(array $filters = [], array $options = []); $resource->info(string $id); $resource->create(array $data); $resource->update(string $id, array $data); $resource->delete(string $id);
$filters maps to the API's filter object. $options carries page_size,
page_number, sort, sort_order and include.
Teamleader dates every record at the moment it is created: a deal, note, e-mail or file cannot be backdated through the API. When you migrate data, keep original dates in the content or in a custom field.
Companies
use McoreServices\TeamleaderSDK\Facades\Teamleader; // List with filters, pagination and sideloading $companies = Teamleader::companies()->list( ['status' => 'active'], ['page_size' => 50, 'sort' => 'name', 'include' => 'custom_fields'] ); // Get a single company $company = Teamleader::companies()->info('company-uuid'); // Create $company = Teamleader::companies()->create([ 'name' => 'Acme Corp', 'vat_number' => 'BE0123456789', 'emails' => [['type' => 'primary', 'email' => 'info@acme.be']], 'marketing_mails_consent' => true, ]); // Upload a logo β base64 data URI, or null to remove $logo = 'data:image/png;base64,'.base64_encode(file_get_contents('/path/to/logo.png')); Teamleader::companies()->uploadLogo('company-uuid', $logo);
Contacts
$contacts = Teamleader::contacts()->list( ['marketing_mails_consent' => true], ['include' => 'custom_fields'] ); // Link to a price list β or pass null to remove it Teamleader::contacts()->update('contact-uuid', ['price_list_id' => 'price-list-uuid']); $avatar = 'data:image/jpeg;base64,'.base64_encode(file_get_contents('/path/to/avatar.jpg')); Teamleader::contacts()->uploadAvatar('contact-uuid', $avatar);
Deals
$deals = Teamleader::deals()->list( ['status' => ['open']], ['sort' => 'weighted_value', 'sort_order' => 'desc'] ); $deal = Teamleader::deals()->create([ 'title' => 'New Business Deal', 'lead' => ['customer' => ['type' => 'company', 'id' => 'company-uuid']], 'estimated_value' => ['amount' => 10000, 'currency' => 'EUR'], ]); Teamleader::deals()->win($deal['data']['id']);
Invoices
// create() drafts an invoice. listDrafts() lists existing drafts. $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' => [[ 'line_items' => [[ 'quantity' => 5, 'description' => 'Consulting', 'unit_price' => ['amount' => 150.0, 'tax' => 'excluding'], 'tax_rate_id' => 'tax-rate-uuid', ]], ]], ]); $outstanding = Teamleader::invoices()->list(['status' => ['outstanding']]);
A grouped line with no section title must omit the
sectionkey entirely β Teamleader rejects bothnulland''. See the Invoices reference.
Time Tracking
Teamleader::timeTracking()->create([ 'started_at' => now()->toIso8601String(), 'duration' => 3600, 'subject' => ['type' => 'ticket', 'id' => 'ticket-uuid'], 'work_type_id' => 'work-type-uuid', ]); $entries = Teamleader::timeTracking()->betweenDates( '2026-08-01T00:00:00+02:00', '2026-08-31T23:59:59+02:00' );
Files
// Requests the upload link and sends the file to it Teamleader::files()->uploadFile(storage_path('contract.pdf'), 'deal', 'deal-uuid', 'Contracts'); $files = Teamleader::files()->forDeal('deal-uuid'); $offers = Teamleader::files()->forDeal('deal-uuid', ['filters' => ['term' => 'offerte']]); $some = Teamleader::files()->byIds(['file-uuid-1', 'file-uuid-2']); // Downloads: the bytes, or straight onto a Laravel disk $pdf = Teamleader::invoices()->downloadContents('invoice-uuid'); Teamleader::invoices()->downloadTo('invoice-uuid', 's3', 'invoices/2026-0042.pdf');
The extension must be a type Teamleader knows (Files::MIME_TYPES: pdf, docx,
xlsx, jpg, png and so on); others are refused before anything is sent.
upload() and download() on their own only return a temporary link.
downloadContents() and downloadTo() work on invoices, credit notes,
quotations and files.
Custom Fields
$field = Teamleader::customFields()->create([ 'label' => 'VAT Number', 'type' => 'single_line', 'context' => 'company', ]); // Every definition, paging handled for you $all = Teamleader::customFields()->all(); // A select field takes the option label, not the option id $value = Teamleader::customFields()->selectValue('field-uuid', 'option-uuid-or-label');
π Pagination
lazy() pages through any list for you and returns a LazyCollection: pages
of 100 are fetched only as you consume them.
Teamleader::companies() ->lazy(['status' => 'active']) ->each(function (array $company) { // one record at a time }); Teamleader::deals()->lazy(['status' => 'open'])->take(10)->all(); // one request
The API returns no total count on most endpoints, so the end of a list is
a page shorter than the page size. cursor() gives you the pager itself β
total() where the endpoint reports one, and lastPage() to resume an
interrupted export.
β‘ Rate Limiting
Teamleader allows 200 requests per sliding minute. The SDK tracks usage in Redis, throttles progressively as the window fills, and waits for a free slot rather than firing a request that can only come back as a 429.
$stats = Teamleader::getRateLimitStats(); echo "Remaining: {$stats['remaining']} / {$stats['rate_limit']}";
The wait is capped by teamleader.rate_limiting.max_wait_ms (default 5000). Once
the cap is reached, a RateLimitExceededException is thrown and the decision
is yours. In a queue worker, that usually means releasing the job:
use McoreServices\TeamleaderSDK\Exceptions\RateLimitExceededException; try { Teamleader::deals()->list(); } catch (RateLimitExceededException $e) { $this->release($e->getRetryAfter()); }
Set max_wait_ms to 65000 to have the SDK wait out a full window itself.
That suits a CLI import, but not a web request.
π Sideloading
Related records can be requested in the same call, via $options['include'] or
the fluent methods:
$deals = Teamleader::deals()->list( ['status' => ['open']], ['include' => 'custom_fields'] ); $company = Teamleader::companies() ->withRelatedContacts() ->info('company-uuid'); // What does this resource actually accept? $capabilities = Teamleader::companies()->getCapabilities();
Includes are per endpoint.
listandinfofrequently accept different sets, and some endpoints accept none at all. An unsupported include throws rather than being silently ignored.
π’ Multiple Connections
// config/teamleader.php 'connections' => [ 'antwerp' => [ 'client_id' => env('TEAMLEADER_ANTWERP_CLIENT_ID'), 'client_secret' => env('TEAMLEADER_ANTWERP_CLIENT_SECRET'), ], ],
Teamleader::connection('antwerp')->companies()->list();
Or store a connection in the database, without a deploy:
php artisan teamleader:connections:add antwerp
Each connection has its own integration, tokens, refresh lock and rate-limit window; one callback route serves them all.
π¦ Bulk Operations
// Any list to a file Teamleader::bulk() ->export('contacts', ['tags' => ['customer']]) ->toCsv(storage_path('contacts.csv'), ['id', 'first_name', 'last_name', 'emails.0.email']); // Many writes: every row validated before the first is sent $result = Teamleader::bulk() ->create('companies', $rows) ->continueOnError() ->run(); $result->succeeded(); // [row key => API response] $result->failed(); // [row key => BulkFailure] // Or on the queue, in chunks Teamleader::bulk()->update('deals', $rows)->dispatch(chunk: 50);
dryRun() shows exactly what would be sent, and resumeFrom($result) skips
the rows that already succeeded. call('deals', 'win', $rows) runs any
method once per row, and call('files', 'uploadFile', $rows) uploads local
files.
dispatch() needs a real queue; Horizon only works the redis connection.
The 200 requests a minute are per Teamleader account, so one or two worker
processes are enough.
π» Command Line
php artisan teamleader:describe deals php artisan teamleader:list deals --filter='status[]=open' --sort=created_at:desc --fields=id,title php artisan teamleader:export contacts --filter='tags[]=customer' --output=storage/customers.csv php artisan teamleader:import companies companies.csv --dry-run php artisan teamleader:call users.me
Every command validates filters, sort fields and includes the same way your
code does, and takes --connection=. Nothing writes without --write,
and in production --force is required as well.
π§ͺ Testing
Teamleader::fake([ 'deals.create' => ['data' => ['id' => 'deal-1', 'type' => 'deal']], 'deals.info' => Teamleader::response()->status(404), ]); // β¦ your code β¦ Teamleader::assertSent('deals.create', fn (array $body) => $body['title'] === 'Big deal');
No account, token or network needed, and the resources still validate what they build. See Testing your integration.
π€ AI Agents
The package ships guidelines for Laravel Boost. With Boost installed, your coding agent picks them up:
composer require laravel/boost --dev
php artisan boost:install # or boost:update, after upgrading the SDK
They teach the agent to go through the facade, to look up filters and fields instead of guessing them, and the API quirks that cost time to find out.
Not using Boost? Point any agent at
llms-full.txt:
the complete documentation as one file.
π‘ Events
use Illuminate\Support\Facades\Event; use McoreServices\TeamleaderSDK\Events\TokenRefreshFailed; Event::listen(function (TokenRefreshFailed $event) { if ($event->reauthorizationRequired) { // tell someone to reconnect $event->connection } });
RequestSending, ResponseReceived, RequestFailed, RateLimitWaited,
TokenRefreshed, TokenRefreshFailed, ConnectionAuthorized and
BulkBatchFinished are fired only when something listens. Set
TEAMLEADER_LOG_REQUESTS=true to log every request to a channel of your
choice.
π Monitoring with Pulse
With Laravel Pulse installed, add the SDK's
recorders to config/pulse.php:
'recorders' => [ \McoreServices\TeamleaderSDK\Pulse\Recorders\TeamleaderRequests::class => [ 'enabled' => env('PULSE_TEAMLEADER_REQUESTS_ENABLED', true), 'sample_rate' => env('PULSE_TEAMLEADER_REQUESTS_SAMPLE_RATE', 1), 'threshold' => env('PULSE_TEAMLEADER_REQUESTS_THRESHOLD', 1000), 'ignore' => [], 'connections' => ['ignore' => [], 'groups' => []], ], \McoreServices\TeamleaderSDK\Pulse\Recorders\TeamleaderCallers::class => [ 'enabled' => env('PULSE_TEAMLEADER_CALLERS_ENABLED', true), 'sample_rate' => env('PULSE_TEAMLEADER_CALLERS_SAMPLE_RATE', 1), 'ignore' => [], ], // β¦ ],
and the cards to your dashboard (php artisan vendor:publish --tag=pulse-dashboard):
<livewire:teamleader.requests cols="8" /> <livewire:teamleader.connections cols="4" /> <livewire:teamleader.callers cols="full" />
Requests and failures per connection and endpoint, rate-limit waits, token renewals, the connections that need reconnecting, and the share of each route's, job's and command's time spent on Teamleader. Bodies are never recorded. See Monitoring with Pulse.
πͺ Webhooks
Teamleader::webhooks()->register('https://your-app.com/webhooks/teamleader', [ 'invoice.booked', 'invoice.peppolSubmissionSucceeded', 'invoice.peppolSubmissionFailed', 'deal.won', ]); // Or a whole category at once $types = Teamleader::webhooks()->getInvoiceEventTypes(); Teamleader::webhooks()->register('https://your-app.com/webhooks/teamleader', $types);
The payload carries the entity id at subject.id, not data.id:
Route::post('/webhooks/teamleader', function (Request $request) { ProcessTeamleaderEvent::dispatch( $request->input('type'), $request->input('subject.id') ); return response()->json(['status' => 'received']); });
π οΈ Error Handling
Since v3.0, a failed request throws a typed exception by default. Set
TEAMLEADER_THROW_EXCEPTIONS=false to get the 2.x behaviour back: an array
with error => true. All API exceptions extend TeamleaderException.
use McoreServices\TeamleaderSDK\Exceptions\AuthenticationException; use McoreServices\TeamleaderSDK\Exceptions\NotFoundException; use McoreServices\TeamleaderSDK\Exceptions\RateLimitExceededException; use McoreServices\TeamleaderSDK\Exceptions\TeamleaderException; use McoreServices\TeamleaderSDK\Exceptions\ValidationException; try { $company = Teamleader::companies()->info('company-uuid'); } catch (NotFoundException $e) { // 404 β the record does not exist } catch (ValidationException $e) { // 422 β the API rejected the payload } catch (RateLimitExceededException $e) { // 429 β always thrown, never swallowed $this->release($e->getRetryAfter()); } catch (AuthenticationException $e) { // Includes ConnectionNeedsReauthorizationException β connect again return redirect('/teamleader/connect'); } catch (TeamleaderException $e) { logger()->error('Teamleader API error', ['message' => $e->getMessage()]); }
Unsupported filter keys, sort fields, includes and subject types throw an
InvalidArgumentException before the request is sent:
Teamleader::timeTracking()->list(['updated_since' => '2026-08-01T00:00:00+02:00']); // InvalidArgumentException: Unsupported filter key for timeTracking.list: // updated_since. Supported: ids, user_id, started_after, ...
This is deliberate. The API answers 200 to filters it does not recognise and
returns the complete unfiltered set, so silence is the more dangerous outcome.
π₯οΈ Artisan Commands
php artisan teamleader:status --all # Every connection: account, status, token expiry php artisan teamleader:health # Health check php artisan teamleader:config:validate # Validate configuration php artisan teamleader:tokens:refresh # Renew tokens that expire soon (scheduled) php artisan teamleader:connections:add # Store a connection in the database php artisan teamleader:connections:list php artisan teamleader:connections:remove php artisan teamleader:connections:rename # Keeps credentials and tokens php artisan teamleader:connections:expect # Pin the account a connection must connect to php artisan teamleader:resources # Every resource and what it supports php artisan teamleader:export-uuids # Export reference UUIDs
Plus describe, list, info, export, import and call β see
Command Line.
β Specification Parity
Every resource is checked against Teamleader's machine-readable API
specification, @teamleader/focus-api-specification,
currently pinned at 1.229.0.
The Teamleader API answers 200 OK to a filter, sort field, include or body
field it does not recognise, and simply ignores it. A typo therefore looks like
success: a mistyped filter returns every record, and a mistyped field on an
update changes nothing. The SDK checks those names against the specification
and throws before the request is sent.
How that is kept true:
- Spec contract tests. Field lists, required fields and enums on each resource are asserted against a fixture generated from the specification.
- Spec audit.
composer spec:auditcompares every resource's filters, sort fields, includes, pagination and endpoints with the specification;php bin/spec-audit --checkfails CI on anything not recorded in the baseline. Two divergences are recorded:users.getWeekSchedule, which Teamleader deprecated and the SDK deliberately does not wrap, anddealPhases.duplicate, which Teamleader removed from the specification and the SDK keeps, deprecated, until v4.0. - Weekly watch. A scheduled workflow audits the SDK against the newest published specification and opens an issue when it finds a difference, so API changes surface before they surface as bug reports.
composer spec:audit -- --summary # counts per category composer spec:audit -- --new # anything not in the baseline composer spec:check # the CI gate
π€ Contributing
Contributions are welcome β see CONTRIBUTING.md.
Bug reports are especially useful when they include what you expected, what happened, and how you worked around it. Several fixes in v2.2.0 came directly from reports written that way.
π Security
If you discover a security issue, email help@mcore-services.be rather than using the issue tracker. See SECURITY.md.
π Changelog
See CHANGELOG.md.
π License
The MIT License (MIT). See LICENSE.md.
π Credits
- MCore Services β https://mcore-services.be
- Built with β€οΈ for the Laravel and Teamleader communities
π¬ Support
- Documentation: hub.mcore-services.be/docs/teamleader-sdk
- Email: help@mcore-services.be
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Teamleader API: developer.focus.teamleader.eu
πΊοΈ Roadmap
- Spec-derived filter parity across every resource, enforced by a test (v2.3.0)
- Payload tests for all resources (v2.3.0)
- Multiple connections, encrypted token storage and scheduled renewal (v3.0)
- Bulk operations: export, writes and queued bulk (v3.0)
- CLI for exploring and querying the API (v3.0)
- Events and request logging (v3.0)
- Test fake and document downloads (v3.1)
- Connection management: rename, expected account, account ids (v3.2)
- Laravel Boost guidelines for AI coding agents (v3.3)
- Laravel Pulse recorders and cards (v3.4)
- Webhook receiver: typed events per connection, duplicate protection,
webhooks:sync(v3.5) - Upsert helper for bulk writes, with id mapping (v3.5)
- Incremental sync and reconciliation (v3.6)
- Cached lookups for reference data (v3.6)
Made with β€οΈ by MCore Services