# Teamleader SDK for Laravel: complete documentation Published at https://hub.mcore-services.be/docs/teamleader-sdk on 2026-10-20. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk # Teamleader Focus SDK for Laravel A Laravel package for the [Teamleader Focus API](https://developer.focus.teamleader.eu/). It handles the OAuth flow and token refresh, keeps you under the rate limit, and puts around 70 API resources behind one consistent interface. ```php use McoreServices\TeamleaderSDK\Facades\Teamleader; $companies = Teamleader::companies()->list( ['status' => 'active', 'tags' => ['customer']], ['sort' => 'name', 'page_size' => 50] ); ``` ## Why this SDK **It fails loudly where the API fails silently.** The Teamleader API answers `200 OK` to a filter, sort field, include or body field it does not recognise, and ignores it. A typo looks like success: a mistyped filter returns every record, and a mistyped field on an update changes nothing. The SDK checks those names against Teamleader's own machine-readable specification and throws before the request is sent. See [Validation](https://hub.mcore-services.be/docs/teamleader-sdk/guides/validation). **It is checked against the specification in CI.** Every resource is audited against `@teamleader/focus-api-specification`, and a weekly job reports new API versions before they turn into bug reports. See [Specification parity](https://hub.mcore-services.be/docs/teamleader-sdk/project/specification-parity). **It is built for queue workers.** Token refresh is guarded by a distributed lock, the rate limiter waits for a free slot instead of spending requests on 429s, and a rate-limit error hands control back to your job so it can `release()` rather than block the worker. **It runs many accounts as easily as one.** Each Teamleader account is a [connection](https://hub.mcore-services.be/docs/teamleader-sdk/guides/multiple-connections) with its own tokens, encrypted with `APP_KEY` and renewed on a schedule, and a callback that connects the wrong account is refused. See [Token storage and security](https://hub.mcore-services.be/docs/teamleader-sdk/guides/token-storage-and-security). **It moves data in bulk.** Export any list to CSV or JSON Lines, and create, update or delete thousands of records. Every row is validated before the first request, a failed run can be resumed, and the work can run on the queue. See [Bulk operations](https://hub.mcore-services.be/docs/teamleader-sdk/guides/bulk-operations) and the [command line](https://hub.mcore-services.be/docs/teamleader-sdk/guides/cli). ## Where to start | | | |---|---| | New to the SDK | [Installation](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/installation), then [Quick start](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/quick-start) | | Looking up a resource | [API reference](https://hub.mcore-services.be/docs/teamleader-sdk/reference) — one page per resource, generated from the code | | Upgrading from 2.x | [Upgrading](https://hub.mcore-services.be/docs/teamleader-sdk/project/upgrading) — four steps for most applications | | Something went wrong | [Error handling](https://hub.mcore-services.be/docs/teamleader-sdk/guides/error-handling) | | Working with an AI agent | [AI agents](https://hub.mcore-services.be/docs/teamleader-sdk/guides/ai-agents) — Laravel Boost guidelines and `llms-full.txt` | ## Requirements - PHP 8.4 or higher (tested on 8.4 – 8.5) - Laravel 12.x or 13.x - A database for the token table (MySQL, PostgreSQL or SQLite) - Redis, for rate limiting (enabled by default) ## Links - [Documentation site](https://hub.mcore-services.be/docs/teamleader-sdk) - [Packagist](https://packagist.org/packages/mcore-services/teamleader-sdk) - [GitHub](https://github.com/MCore-Services-bv/teamleader-sdk) - [Changelog](https://github.com/MCore-Services-bv/teamleader-sdk/blob/main/CHANGELOG.md) - [Teamleader developer portal](https://developer.focus.teamleader.eu/) --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/installation # Installation ## Requirements | Requirement | Version | |---|---| | PHP | 8.4 or higher (tested on 8.4 – 8.5) | | Laravel | 12.x or 13.x | | Database | MySQL 5.7+, PostgreSQL 10+ or SQLite 3.8+ | | Cache | Any Laravel store; Redis for production | | Redis | Required for rate limiting, which is enabled by default | Laravel 10 and 11 were dropped in v2.0: both are end of life, and Composer's security advisories block installing them. ## Install the package ```bash composer require mcore-services/teamleader-sdk ``` The service provider and the `Teamleader` facade are registered through package discovery. Publish the configuration file: ```bash php artisan vendor:publish --tag=teamleader-config ``` This creates `config/teamleader.php`. Run the migrations: ```bash php artisan migrate ``` This creates two tables: `teamleader_tokens`, which holds each connection's tokens (encrypted with `APP_KEY`), and `teamleader_connections`, for [connections stored in the database](https://hub.mcore-services.be/docs/teamleader-sdk/guides/multiple-connections). To change the migrations before running them, publish them with `php artisan vendor:publish --tag=teamleader-migrations`. ## Run the scheduler The package schedules `teamleader:tokens:refresh` every ten minutes. It renews tokens before they run out, also on connections nobody has used in a while. This needs the Laravel scheduler: - **Locally:** `php artisan schedule:work` - **In production:** a cron entry for `php artisan schedule:run` every minute (Laravel Forge sets one up under *Scheduler*) Without it, tokens are still refreshed when a request finds them close to expiry, and `teamleader:health` warns that the scheduler is not running. See [Authentication](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/authentication#token-refresh). ## Create a Teamleader integration The SDK authenticates with OAuth 2.0, so you need an integration in the Teamleader developer portal for a client ID and secret. 1. Sign in at [developer.focus.teamleader.eu](https://developer.focus.teamleader.eu/) and open **Integrations**. 2. Choose **Create integration**, give it a name and continue. 3. Set the **Redirect URI** to your callback route, for example `https://your-app.com/teamleader/callback`. It must match `TEAMLEADER_REDIRECT_URI` exactly. 4. Copy the **Client ID** and **Client Secret**. 5. Fill in the remaining required fields and save. You only need to request publication if the integration is meant for other Teamleader accounts. > [!NOTE] > Teamleader accepts `http://` redirect URIs for local development only. ## Add your credentials ```env TEAMLEADER_CLIENT_ID=your_client_id TEAMLEADER_CLIENT_SECRET=your_client_secret TEAMLEADER_REDIRECT_URI="${APP_URL}/teamleader/callback" ``` These three are required. Every other setting has a default; see [Configuration](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/configuration). ## Next - [Authentication](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/authentication) — the two OAuth routes - [Quick start](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/quick-start) — your first requests --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/configuration # Configuration Settings live in `config/teamleader.php` and are set from `.env`. Only the three OAuth credentials are required. ## Settings the SDK reads | Variable | Default | | |---|---|---| | `TEAMLEADER_CLIENT_ID` | — | Required | | `TEAMLEADER_CLIENT_SECRET` | — | Required | | `TEAMLEADER_REDIRECT_URI` | — | Required; must match your integration | | `TEAMLEADER_API_VERSION` | `2023-09-26` | Sent as the API version header | | `TEAMLEADER_API_TIMEOUT` | `30` | Request timeout, seconds | | `TEAMLEADER_API_CONNECT_TIMEOUT` | `10` | Connect timeout, seconds | | `TEAMLEADER_API_READ_TIMEOUT` | `25` | Read timeout, seconds | | `TEAMLEADER_API_RETRY_ATTEMPTS` | `3` | Attempts for server and connection errors | | `TEAMLEADER_API_RETRY_DELAY` | `1000` | First retry delay, milliseconds; doubles per attempt, capped at 30 s | | `TEAMLEADER_BASE_URL` | `https://api.focus.teamleader.eu` | API host — change only for a proxy or a sandbox | | `TEAMLEADER_AUTH_URL` | `https://focus.teamleader.eu` | OAuth host (authorize, token exchange and refresh) | | `TEAMLEADER_THROW_EXCEPTIONS` | `true` | Throw typed exceptions on failure; `false` returns error arrays | | `TEAMLEADER_RATE_LIMITING_ENABLED` | `true` | Track and throttle requests in Redis | | `TEAMLEADER_RATE_LIMIT_REDIS_CONNECTION` | `default` | Redis connection used by the limiter | | `TEAMLEADER_RATE_LIMIT_MAX_WAIT_MS` | `5000` | Longest wait for a free slot before throwing | | `TEAMLEADER_VALIDATE_ON_BOOT` | `false` | Validate the configuration when the app boots | | `TEAMLEADER_LOG_CHANNEL` | — | Log channel for SDK output; unset uses your default channel | | `TEAMLEADER_LOG_REQUESTS` | `false` | Log every request body at debug level | | `TEAMLEADER_LOG_RESPONSES` | `false` | Log every response body at debug level | See [Events and Logging](https://hub.mcore-services.be/docs/teamleader-sdk/guides/events-and-logging). Tokens and secrets are redacted from all SDK log output. The SDK does not cache API responses, and include validation cannot be turned off: both are by design. Tokens are cached in your application's default cache store. ## Settings worth changing ### `TEAMLEADER_THROW_EXCEPTIONS` `true` by default since v3.0: a failed request throws a typed exception, after the SDK has retried server and connection errors. `false` returns failures as an array with `error => true`, as 2.x did by default; it is kept for one major version to ease upgrading, and turns the retries off. Some failures always throw, whatever this setting says. See [Error handling](https://hub.mcore-services.be/docs/teamleader-sdk/guides/error-handling). ### `TEAMLEADER_RATE_LIMIT_MAX_WAIT_MS` How long the SDK waits for a free slot in the rate-limit window before throwing `RateLimitExceededException`. The 5-second default suits web requests and queue workers. For a long-running CLI import, `65000` lets the SDK sit out a full window itself. See [Rate limiting](https://hub.mcore-services.be/docs/teamleader-sdk/guides/rate-limiting). ## Storing reference UUIDs Departments, deal phases, work types and custom field definitions have UUIDs that differ per Teamleader account. Keep them in `config/teamleader.php` instead of scattering them through your code: ```bash php artisan teamleader:export-uuids php artisan teamleader:export-uuids --resource=departments ``` The command prints configuration you can paste into the file. Then: ```php Teamleader::deals()->create([ 'title' => 'Enterprise deal', 'lead' => ['customer' => ['type' => 'company', 'id' => $companyId]], 'phase_id' => config('teamleader.deal_phases.qualified'), 'department_id' => config('teamleader.departments.sales'), ]); ``` ## Checking the configuration ```bash php artisan teamleader:config:validate # what is missing or wrong php artisan teamleader:config:validate --fix # with how to fix it ``` See [Artisan commands](https://hub.mcore-services.be/docs/teamleader-sdk/guides/artisan-commands). --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/authentication # Authentication The SDK uses Teamleader's OAuth 2.0 authorization-code flow. You add two routes; the SDK exchanges the code, stores the tokens and refreshes them from then on. ## The two routes ```php // routes/web.php use Illuminate\Http\Request; use McoreServices\TeamleaderSDK\Facades\Teamleader; // 1. Send the user to Teamleader to authorise your integration Route::get('/teamleader/connect', function () { return Teamleader::authorize(); }); // 2. Teamleader sends the user back here with a code 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); }); ``` These work in a fresh Laravel application, without login or pages of your own. Open `/teamleader/connect` in the browser, approve the integration in Teamleader, and check the result with `php artisan teamleader:status`. > [!WARNING] > **Anyone who can open `/teamleader/connect` can connect a Teamleader account > to your application.** Once the application is reachable by others, put both > routes behind your login (`->middleware('auth')`) and redirect to a page of > your own. Setting `expected_account_id` (see below) also stops the wrong > account from being connected. The callback path must match `TEAMLEADER_REDIRECT_URI` and the redirect URI of your integration exactly. Both routes need the `web` middleware group, for the session. Routes in `routes/web.php` have it already. ### The `state` check `authorize()` generates a random `state`, remembers it in the session, and sends it to Teamleader. `handleCallback()` only accepts a callback whose `state` this session issued, and each state works once. A mismatch throws `OAuthStateException` and nothing is stored: that is what stops another site from completing the flow with its own authorization code and connecting your application to the wrong Teamleader account. `authorize('your-state')` and `getAuthorizationUrl('your-state')` work as in 2.x: the SDK does not store or check a state you pass in. `authorize()` returns a redirect response. When you need the URL itself, for a button for example, use `Teamleader::getAuthorizationUrl()`. It generates and remembers a state the same way. ### Several accounts, one callback With [multiple connections](https://hub.mcore-services.be/docs/teamleader-sdk/guides/multiple-connections), start the flow on the connection and keep the one callback route: ```php Route::get('/teamleader/{connection}/connect', fn (string $connection) => Teamleader::connection($connection)->authorize() ); Route::get('/teamleader/callback', function (Request $request) { $connected = Teamleader::handleCallback($request->query('code'), $request->query('state')); if ($connected) { return response("Connected '{$connected->connectionName()}'. Run: php artisan teamleader:status --all"); } return response('Connecting to Teamleader failed. Check storage/logs/laravel.log.', 500); }); ``` The state remembers which connection started the flow, so the callback stores the tokens on that connection. Register the same redirect URI in every integration. ### Making sure the right account is connected `handleCallback()` identifies the account that was connected (`users.me`) and stores its id and name with the tokens. Set `expected_account_id` on a connection (`TEAMLEADER_EXPECTED_ACCOUNT_ID` for the default one), and a callback that connects any other account throws `AccountMismatchException` without storing anything. With several environments, the easy mistake is being logged into the wrong Teamleader account in the browser while connecting; this makes that mistake impossible to save. The first time, connect without it, then read the id from `php artisan teamleader:status --all` and add it. For a connection stored with `teamleader:connections:add`, one command does both: `php artisan teamleader:connections:expect {name} --current`. ### What `handleCallback()` returns The SDK instance of the connection that was connected, which is truthy, so the `if` above works. It returns `false` when the code exchange failed and `TEAMLEADER_THROW_EXCEPTIONS` is off. A `ConnectionAuthorized` event is fired on success. ## Checking the connection ```php if (Teamleader::isAuthenticated()) { // Tokens are stored and valid, or can be refreshed } ``` From the command line: ```bash php artisan teamleader:status ``` ## Token refresh Access tokens are refreshed before they run out, two ways: - **On a schedule.** The package schedules `teamleader:tokens:refresh` every ten minutes, which renews every connection whose token expires within 30 minutes. A connection nobody uses stays connected, and a revoked refresh token is found before a real request needs it. **This needs the Laravel scheduler to run** — `php artisan schedule:work` locally, a cron entry for `schedule:run` in production (Forge sets one up under *Scheduler*). - **On demand**, when a request finds less than 15 minutes left. Both use the same per-connection lock, so they never refresh a connection twice. That matters, because each refresh returns a new refresh token. ```bash php artisan teamleader:tokens:refresh # every connection that is due php artisan teamleader:tokens:refresh --connection=ghent # one php artisan teamleader:tokens:refresh --force # even when not due ``` `TEAMLEADER_TOKENS_AUTO_REFRESH=false` turns the schedule off; `TEAMLEADER_TOKENS_REFRESH_BEFORE` (seconds, default 1800) sets how early. ### When Teamleader refuses the refresh token A revoked or long-unused refresh token cannot be renewed. The connection is marked **`needs_reauthorization`** and a `TokenRefreshFailed` event with `reauthorizationRequired` is fired. The tokens are kept for inspection, not deleted. From then on, every request on that connection throws `ConnectionNeedsReauthorizationException` naming it, whatever `TEAMLEADER_THROW_EXCEPTIONS` says. Send a user through `authorize()` for that connection to fix it. To be alerted, listen for the event. See [Events and Logging](https://hub.mcore-services.be/docs/teamleader-sdk/guides/events-and-logging#alert-when-an-account-needs-reconnecting). With Laravel Pulse, the connections card lists every connection in this state. See [Monitoring with Pulse](https://hub.mcore-services.be/docs/teamleader-sdk/guides/monitoring). ### Status ```bash php artisan teamleader:status --all ``` ``` Connection Account Status Expires in Last refresh antwerp Klant Antwerpen connected 41 min 3 minutes ago default MCore Services connected 52 min 3 minutes ago ghent Klant Gent needs_reauthorization — 2 days ago ``` It exits with 1 when any connection needs to be connected again, so it can run in a deploy check. `teamleader:health` reports the same, and warns when a token expired without being renewed. That usually means the scheduler is not running. How tokens are stored, and how to harden that for production, is covered in [Token storage and security](https://hub.mcore-services.be/docs/teamleader-sdk/guides/token-storage-and-security). ## Disconnecting ```php Teamleader::logout(); ``` This clears the tokens stored by the SDK. To revoke access on Teamleader's side as well, remove the integration's access from the Teamleader account. ## Using a token you already have For scripts and tests, a token can be set directly. It bypasses the stored tokens and is not refreshed: ```php Teamleader::setAccessToken($token); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/quick-start # Quick Start Once [authentication](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/authentication) is set up, every resource is a method on the `Teamleader` facade. ```php use McoreServices\TeamleaderSDK\Facades\Teamleader; // List $companies = Teamleader::companies()->list(['status' => 'active']); // Get one $contact = Teamleader::contacts()->info('contact-uuid'); // Create $deal = Teamleader::deals()->create([ 'title' => 'New opportunity', 'lead' => ['customer' => ['type' => 'company', 'id' => 'company-uuid']], ]); // Update Teamleader::companies()->update('company-uuid', ['name' => 'Acme Corp Ltd']); // Delete Teamleader::companies()->delete('company-uuid'); ``` Responses are the decoded JSON body. A list comes back as `['data' => [...]]`, a single record as `['data' => [...]]` with its fields, and a create as `['data' => ['id' => ..., 'type' => ...]]`. ## The shape of a `list()` call Filters go in the first argument, everything else in the second: ```php $deals = Teamleader::deals()->list( ['phase_id' => $phaseId, 'responsible_user_id' => $userId], // filters ['sort' => 'created_at', 'sort_order' => 'desc', // options 'page_size' => 50, 'page_number' => 1] ); ``` Each resource accepts its own filters and sort fields, listed on its page in the [API reference](https://hub.mcore-services.be/docs/teamleader-sdk/reference). Anything else throws before the request is sent — see [Validation](https://hub.mcore-services.be/docs/teamleader-sdk/guides/validation). ## Dependency injection The facade resolves `McoreServices\TeamleaderSDK\TeamleaderSDK`, which you can inject instead: ```php use McoreServices\TeamleaderSDK\TeamleaderSDK; class CompanySync { public function __construct(private TeamleaderSDK $teamleader) {} public function active(): array { return $this->teamleader->companies()->list(['status' => 'active']); } } ``` ## Failures throw A failed request throws a typed exception — `NotFoundException`, `ValidationException` and so on, all extending `TeamleaderException`: ```php use McoreServices\TeamleaderSDK\Exceptions\NotFoundException; try { $company = Teamleader::companies()->info($id)['data']; } catch (NotFoundException) { $company = null; } ``` See [Error handling](https://hub.mcore-services.be/docs/teamleader-sdk/guides/error-handling) for what is thrown when. ## What a resource can do Every resource page in the [API reference](https://hub.mcore-services.be/docs/teamleader-sdk/reference) lists its endpoints, filters, sort fields, includes, accepted values and methods. The same information is available at runtime: ```php Teamleader::companies()->getCapabilities(); // supports_*, available_includes, endpoint Teamleader::companies()->getDocumentation(); // description, filters, sort fields, examples ``` ## Next - [Filtering and sorting](https://hub.mcore-services.be/docs/teamleader-sdk/guides/filtering-and-sorting) - [Pagination](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination) - [Sideloading](https://hub.mcore-services.be/docs/teamleader-sdk/guides/sideloading) --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/filtering-and-sorting # Filtering and Sorting Every `list()` call takes two arrays: **filters** first, **options** second. ```php Teamleader::companies()->list( ['status' => 'active', 'updated_since' => '2026-01-01T00:00:00+00:00'], // filters ['sort' => 'name', 'page_size' => 50] // options ); ``` ## Filters The SDK wraps the first array in the `filter` object the API expects. Each endpoint accepts its own set; the page for each resource in the [API reference](https://hub.mcore-services.be/docs/teamleader-sdk/reference) lists them with a description. ```php // Scalar filters Teamleader::companies()->list(['status' => 'active']); // List filters Teamleader::deals()->list(['ids' => ['uuid-1', 'uuid-2']]); // Object filters Teamleader::companies()->list([ 'email' => ['type' => 'primary', 'email' => 'info@example.com'], ]); // Search Teamleader::contacts()->list(['term' => 'janssens']); ``` Several filters that the API types as a list accept a single value too, and the SDK wraps it: `'ids' => 'uuid-1'` is sent as `['uuid-1']`. The reference notes where that applies. ### Unknown filters throw ```php Teamleader::companies()->list(['name' => 'Acme']); // InvalidArgumentException: Invalid filter key 'name' for companies.list. // Supported filters: ids, email, vat_number, ... companies.list has no name // filter — use 'term', which searches name, VAT, emails and telephones. ``` The API would have answered `200 OK` and returned every company. See [Validation](https://hub.mcore-services.be/docs/teamleader-sdk/guides/validation). ### Empty values are dropped A filter whose value is `null`, `''` or `[]` is not sent, so optional filters need no cleaning up first: ```php Teamleader::companies()->list([ 'status' => $status, // skipped when null 'updated_since' => $since, // skipped when null 'tags' => $tags ?? [], // skipped when empty ]); ``` The exception is a filter where `null` itself means something. On contacts, `company_id => null` finds contacts linked to no company. Use `Teamleader::contacts()->withoutCompany()` to make that explicit. ### Helpers Most resources have named helpers for their common filters, such as `search()`, `byIds()`, `updatedSince()` and `forUser()`. They build the same filter array and are listed under **Methods** on each reference page. ## Sorting Sorting goes in the options. Only resources whose reference page has a **Sorting** section accept it; the rest throw on any `sort` option. ```php // One field, ascending Teamleader::companies()->list([], ['sort' => 'name']); // One field, with a direction Teamleader::companies()->list([], ['sort' => 'name', 'sort_order' => 'desc']); // Several fields as a field => direction map Teamleader::companies()->list([], ['sort' => ['name' => 'asc', 'added_at' => 'desc']]); // Several fields as sort objects — the API's own shape Teamleader::companies()->list([], ['sort' => [ ['field' => 'name', 'order' => 'asc'], ['field' => 'added_at', 'order' => 'desc'], ]]); ``` A field the endpoint does not accept throws. So does an order other than `asc` or `desc`: ```php Teamleader::companies()->list([], ['sort' => 'created_at']); // InvalidArgumentException: Invalid sort field: created_at. // companies.list accepts: name, added_at, updated_at. ``` The API would have ignored it and returned its default order. Some endpoints accept only one direction. `files.list`, for example, sorts on `updated_at` descending only. The reference page says so where it applies. ## Options | Option | Purpose | |---|---| | `page_size`, `page_number` | [Pagination](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination) | | `sort`, `sort_order` | Sorting, above | | `include` (or `includes`) | [Sideloading](https://hub.mcore-services.be/docs/teamleader-sdk/guides/sideloading) | --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination # Pagination Paginated resources take `page_size` and `page_number` in the options. The defaults are 20 and 1. ```php $page = Teamleader::companies()->list([], ['page_size' => 100, 'page_number' => 2]); ``` ## Totals are the exception, not the rule Teamleader returns a `meta` block with totals only when the request asks for it with `includes=pagination`, and only some endpoints support that. Where they do, the SDK sends it for you, and the resource's reference page says *"the response includes `meta` with totals"*: ```php $response = Teamleader::projects()->list([], ['page_size' => 50]); $response['meta']['matches']; // total number of records $response['meta']['page']['number']; // current page $response['meta']['page']['size']; // page size ``` Everywhere else there is **no total count**. The only signal that the list has ended is a page shorter than the page size you asked for, so a final page that happens to be full costs one extra, empty request. ## Fetching everything `lazy()` pages through a list for you. It returns a Laravel [`LazyCollection`](https://laravel.com/docs/collections#lazy-collections): nothing is requested until you iterate it, and only as many pages as you consume are fetched. ```php Teamleader::companies() ->lazy(['status' => 'active']) ->each(function (array $company) { // one record at a time; pages of 100 are fetched as needed }); Teamleader::deals()->lazy(['status' => 'open'])->take(10)->all(); // one request ``` - Filters and options are the same as for `list()`, and are validated the same way. Because nothing is sent until iteration, an invalid filter throws on the first iteration, not when you call `lazy()`. - `page_size` defaults to **100** (instead of `list()`'s 20), so a full export costs a fifth of the requests. `page_number` is the page to start from. - Fluent includes apply to every page: `Teamleader::deals()->withCustomFields()->lazy()`. - A failed page throws, even with `TEAMLEADER_THROW_EXCEPTIONS=false`. Treating an error as an empty page would end the iteration early and silently lose every record after it. - On a resource that is not paginated, `lazy()` throws a `BadMethodCallException`. Those endpoints return everything in one `list()` response. > [!NOTE] > `lazy()` and `cursor()` are available from v3.0. ### Totals, progress and resuming `cursor()` returns the pager itself, when you need its state: ```php $cursor = Teamleader::calls()->cursor([], ['page_size' => 50]); $cursor->total(); // meta.matches where the endpoint reports it, otherwise null foreach ($cursor as $call) { // ... } $cursor->pagesFetched(); $cursor->lastPage(); // the last page completed ``` To resume an interrupted export, start a cursor after the last completed page: `$cursor->fromPage($lastPage + 1)`, or pass `page_number` to `lazy()`. Where the endpoint reports a total, the cursor stops as soon as it has read that many records, which saves the empty request at the end. ### Safety The cursor stops with an `UnexpectedValueException` if the endpoint returns more records than the page size, or the same first record on two consecutive pages. Either means the endpoint is not honouring the page parameters, and paging it would never end. Some resources also have an `all()` helper that fetches every page into one array, such as `customFields()->all()`. `lazy()` works everywhere and keeps memory flat. ## Large exports A full export of a big account can run into the rate limit: at 200 requests a minute and 100 records a page, that is about 20,000 records a minute at best. In a queue job, catch `RateLimitExceededException` and release the job with the page you reached, rather than looping until the worker times out. See [Rate limiting](https://hub.mcore-services.be/docs/teamleader-sdk/guides/rate-limiting). --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/bulk-operations # Bulk Operations Moving many records in or out of Teamleader: paging, the rate limit and failures are handled for you. > [!NOTE] > Available from v3.0. ## Export ```php use McoreServices\TeamleaderSDK\Facades\Teamleader; Teamleader::bulk() ->export('contacts', ['tags' => ['customer']]) ->toCsv(storage_path('contacts.csv'), ['id', 'first_name', 'last_name', 'emails.0.email']); ``` `export()` takes a resource key, which is the name you use in `Teamleader::{key}()`, and the same filters and options as that resource's `list()`. They are validated the same way. Paginated resources are read 100 records a page, others in a single `list()` call. Each returns the number of records. | Method | Writes | |---|---| | `toCsv($path, $columns = null)` | One row per record. Columns are dot paths: `address.city`, `emails.0.email` | | `toJsonLines($path)` | One complete JSON object per line — for files code reads back | | `each(fn (array $record, int $i) => ...)` | Nothing; hands each record to your callback | For another connection: `Teamleader::connection('antwerp')->bulk()->export(...)`. ### CSV details - **Columns.** Name them. Without them, the first record's fields are used, flattened, so a field missing from the first record is missing from the file. - **Values.** Arrays become JSON, booleans `true` / `false`, null an empty cell. - **Formulas.** Text starting with `=`, `+`, `-` or `@` gets a leading `'`, so a spreadsheet does not execute a value from Teamleader as a formula. Negative numbers are left alone. For a file only code reads, pass `escapeFormulas: false`. - **Delimiter.** `toCsv($path, $columns, ';')` for Excel with a Dutch or Belgian locale. ### Safe to fail The file is written under a temporary name and moved into place when complete. A failed page throws, even with `TEAMLEADER_THROW_EXCEPTIONS=false`, and leaves no partial file behind. ### Progress ```php Teamleader::bulk()->export('deals') ->onProgress(fn (int $done, ?int $total) => $this->output->write("\r{$done}/".($total ?? '?'))) ->toJsonLines(storage_path('deals.jsonl')); ``` `$total` is known on the endpoints that report one; elsewhere it is null. ### How long it takes At 100 records a request and 200 requests a minute, at most about 20,000 records a minute. In a web request, export in a queued job instead. ## Writing many records ```php $result = Teamleader::bulk() ->create('companies', $rows) ->continueOnError() ->run(); $result->succeeded(); // [row key => API response] $result->failed(); // [row key => BulkFailure] $result->skipped(); // [row key => reason] ``` Every row ends up in exactly one of the three, under the key it had in your input. A string key, such as a line number or your own id, maps results straight back. | Operation | Each row | |---|---| | `create('companies', $rows)` | The array passed to `companies()->create()` | | `update('companies', $rows)` | An array with `id`; the rest goes to `update($id, ...)` | | `delete('companies', $ids)` | An id, or an array with `id` | | `call('deals', 'win', $rows)` | The arguments for that method — a list is spread, anything else is the one argument | Each row goes through the resource's own method, with exactly the validation a single call gets. ### Every row is validated before any is sent By default, `run()` first passes every row through the resource's validation without sending anything. If any row is invalid, it throws a `BulkValidationException` listing **all** invalid rows, and nothing is sent: ``` 2 of 10000 rows are invalid for companies; nothing was sent. row 7412: Unsupported field for companies.add: emial. Accepted: ... row 9001: ... ``` `$e->failures` holds every one. So an import with a typo in row 7,412 fails in a second, before creating 7,411 records you then have to find and delete. `->validateFirst(false)` validates each row only as it is sent. ### When the API refuses a row By default the operation **stops** at the first row Teamleader refuses; the rows after it are reported as skipped. `->continueOnError()` sends every row and collects the failures. Two failures always stop, because no later row can do better: the rate limit running out, and a connection that [needs to be connected again](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/authentication#when-teamleader-refuses-the-refresh-token). Bulk operations throw internally whatever `TEAMLEADER_THROW_EXCEPTIONS` says, so a refusal is never counted as a success, and restore your setting afterwards. ### Dry run ```php $preview = Teamleader::bulk()->create('companies', $rows)->dryRun(); $preview->failed(); // the invalid rows $preview->requests(); // [row key => the exact request bodies that would be sent] ``` Nothing is sent. Validation is local, so a dry run is the real thing minus the network. ### Duplicates and resuming Teamleader has no idempotency keys: a create that is sent twice creates two records. Two tools: ```php // Only the first row per VAT number is sent; later ones are skipped ->uniqueBy(fn (array $row) => $row['vat_number'] ?? null) // After a stop or a crash, skip the rows that already succeeded $done = $first->succeededIndices(); // store this (json_encode) between runs Teamleader::bulk()->create('companies', $rows)->resumeFrom($done)->run(); ``` Resuming relies on the row keys staying the same between runs, so use stable keys or the same input file. For imports that may meet records that already exist in Teamleader, look them up first with `lazy()` and a filter, and split your rows into creates and updates. ### Progress ```php ->onProgress(fn (BulkProgress $p) => $bar->setProgress($p->processed)) ``` `processed`, `total`, `succeeded`, `failed`, `skipped`, `percentage()`. ### Rate limit and duration Rows are sent one at a time through the rate limiter. During `run()` the SDK waits out a full rate-limit window rather than giving up (65 seconds instead of `TEAMLEADER_RATE_LIMIT_MAX_WAIT_MS`), which suits a command or a job. At 200 requests a minute, **10,000 rows take about 50 minutes.** `->waitForRateLimit(false)` keeps your normal limit. Use it in a web request, for example, where waiting a minute is not an option. ## Queued bulk For anything that would run longer than a request, or that should survive a deploy, send the rows from queue workers: ```php $batch = Teamleader::bulk() ->create('companies', $rows) ->continueOnError() ->dispatch(chunk: 50); $batch->id; // keep it: Bus::findBatch($batch->id) for progress, Horizon shows it too ``` - **Validation, `uniqueBy()` and `resumeFrom()` happen in `dispatch()`**, in your request: an invalid row throws `BulkValidationException` and nothing is queued. - Each chunk is one job, on the connection you dispatched from (`Teamleader::connection('antwerp')->bulk()->...`). - **Rate limits.** A job that meets the rate limit releases itself for as long as Teamleader asks, instead of blocking the worker, and resumes after the rows it already sent, so a retried chunk never sends a row twice. Jobs retry for up to a day. - **Stopping.** Without `continueOnError()`, the first refusal cancels the batch: chunks that had not started yet report their rows as skipped. - Chunks may run in parallel on several workers. They share the connection's rate-limit window, so this does not make the import faster than 200 rows a minute. It only frees your workers while they wait. ### The result ```php use McoreServices\TeamleaderSDK\Events\BulkBatchFinished; Event::listen(function (BulkBatchFinished $event) { $event->result->counts(); // ['succeeded' => 9998, 'failed' => 2, 'skipped' => 0] $event->result->failed(); // [row key => BulkFailure] $event->cancelled; }); // Or at any time, also while it runs: Teamleader::bulk()->result($batch->id); ``` Succeeded rows carry only the record's id: `['data' => ['id' => '...']]`, whatever else the API answered. When a later step needs more than the id, keep your own mapping table — your row key to the new id, stored from the `BulkBatchFinished` listener — and read the rest with `info()`. Results are kept in the cache for a week. ### Requirements - A real queue, such as `database`, `redis` or `sqs`. `dispatch()` refuses the `sync` driver, which would run everything inside the request. - Laravel's batches table, once per application: ```bash php artisan make:queue-batches-table php artisan migrate ``` - A cache store shared by the workers, such as Redis or the database. The chunks record their progress there. ### With Horizon Horizon only works queues on a `redis` connection, so dispatch to one: ```php Teamleader::bulk()->create('contacts', $rows)->dispatch(chunk: 50, queueConnection: 'redis', queue: 'teamleader'); ``` The 200 requests a minute are per Teamleader account, not per worker. A dedicated supervisor with one or two processes is enough; more processes only wait for the same window. ```php // config/horizon.php, under 'environments' 'teamleader' => [ 'connection' => 'redis', 'queue' => ['teamleader'], 'maxProcesses' => 2, ], ``` The jobs set their own retry window (a day), which takes precedence over the supervisor's `tries`. ## Migrating from another system Teamleader dates every record at the moment it is created. A deal, note, e-mail or file cannot be backdated through the API, so put the original date in the content or in a custom field when it matters. Some other behaviour to plan for: - **Select custom fields** take the option label, not the option id. See [Custom Fields](https://hub.mcore-services.be/docs/teamleader-sdk/guides/custom-fields). - **Pipelines** come with four fixed phases that cannot be deleted. Map onto them instead of deleting and recreating. - **Notes** have no type and are authored by the connected user. - **E-mail tracking** files every item as a received e-mail, and items cannot be updated or deleted afterwards. Try one record before a bulk run. - **Files**: `call('files', 'uploadFile', [[$path, 'deal', $dealId, 'Folder'], …])` uploads local files, one row per file. Check every step with `dryRun()` first. It shows the exact request bodies, and it is where a wrong value format shows up before any data is touched. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/sideloading # Sideloading A few Teamleader endpoints accept `includes`, which adds optional data to the response: custom field values on a list, related contacts on a company, supplier details on a product. **Far fewer than you might expect.** Most related records, such as the responsible user, addresses, tags and the price list, are returned as a `{type, id}` reference by default and cannot be sideloaded at all. The complete list is the [**Includes** table in the API reference](https://hub.mcore-services.be/docs/teamleader-sdk/reference#includes). No other resource accepts includes. ## Requesting includes ### Fluent `with()` ```php use McoreServices\TeamleaderSDK\Facades\Teamleader; $companies = Teamleader::companies() ->with('custom_fields') ->list(['status' => 'active']); $company = Teamleader::companies() ->with(['related_companies', 'related_contacts']) ->info('company-uuid'); ``` `with()` takes a single value, a comma-separated string or an array. The includes apply to the next request only, including one that throws, so they never leak into a later call. Many resources also have named helpers, such as `withCustomFields()` or `withRelatedContacts()`. They are listed under **Methods** on each reference page. ### Options array ```php $contacts = Teamleader::contacts()->list([], ['include' => 'custom_fields']); $company = Teamleader::companies()->info('company-uuid', 'related_companies,related_contacts'); ``` On `list()`, pass `include` (or `includes`) in the options. On `info()`, it is the second argument. ## `list` and `info` differ Includes are declared per endpoint, and the two often take different sets: | | `companies.list` | `companies.info` | |---|---|---| | `custom_fields` | include | returned by default; asking for it throws | | `related_contacts` | not accepted | include | Each resource's reference page shows both sets. ## Unknown includes throw ```php Teamleader::contacts()->with('responsible_user')->list(); // InvalidArgumentException: Invalid include for contacts.list: responsible_user. // Accepts: custom_fields. ``` The API ignores include values it does not know and answers `200`. Because the field often arrives by default anyway, a wrong include looks like it works — which is how six phantom includes ended up documented for companies before v2.2.4. ## Custom fields On companies, contacts, deals, orders and projects, custom field values come with `list()` only when requested: ```php $companies = Teamleader::companies()->withCustomFields()->list([], ['page_size' => 100]); foreach ($companies['data'] as $company) { foreach ($company['custom_fields'] ?? [] as $field) { $definitionId = $field['definition']['id']; $value = $field['value']; } } ``` On `companies.info` and `contacts.info` they are always returned. The shape of `value` depends on the field type; see [Custom fields](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/custom-fields). ## Avoid a request per record Prefer one sideloaded `list()` to an `info()` per row. For related records that cannot be sideloaded, collect the ids and fetch them with one `ids` filter: ```php $userIds = array_values(array_unique(array_column(array_column($companies['data'], 'responsible_user'), 'id'))); $users = Teamleader::users()->list(['ids' => $userIds]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/custom-fields # Custom Fields Custom fields are defined once per context (contact, company, deal, project and so on) with `customFields()`, and filled in on the record itself through `custom_fields`: ```php Teamleader::deals()->create([ 'title' => 'Woonkrediet Janssens', 'lead' => ['customer' => ['type' => 'contact', 'id' => $contactId]], 'custom_fields' => [ ['id' => $amountFieldId, 'value' => 250000], ['id' => $typeFieldId, 'value' => 'Woonkrediet'], ], ]); ``` ## Select fields take the label A `single_select` or `multi_select` field takes the option's **label** as its value: a string for single select, a list of strings for multi select. Teamleader refuses the option id, with *"The custom field with id … has an invalid single selection value"*. ```php ['id' => $typeFieldId, 'value' => 'Woonkrediet'] // single select ['id' => $productsFieldId, 'value' => ['Hypothecair', 'Persoonlijke lening']] // multi select ``` The labels are the ones in the definition, exactly as written, so a renamed option needs the new label in your mapping too. ### Resolving labels and ids `selectValue()` takes a label or an option id and returns the label to send. A value that is not an option throws before any request is sent, and the message lists the options that exist: ```php $value = Teamleader::customFields()->selectValue($typeFieldId, 'opt-uuid'); // 'Woonkrediet' $value = Teamleader::customFields()->selectValue($productsFieldId, ['Hypothecair', 'opt-uuid']); ``` `options()` returns the whole list as `[label => option id]`: ```php Teamleader::customFields()->options($typeFieldId); // ['Woonkrediet' => '0b8f…', 'Hypothecair' => '5c1d…', 'Persoonlijke lening' => '9e2a…'] ``` Both read the definition once per field and keep it for the rest of the `customFields()` instance, so keep one instance while mapping many rows: ```php $fields = Teamleader::customFields(); $rows = collect($leads)->map(fn ($lead) => [ 'title' => $lead->title, 'lead' => ['customer' => ['type' => 'contact', 'id' => $lead->contact_id]], 'custom_fields' => [ ['id' => $typeFieldId, 'value' => $fields->selectValue($typeFieldId, $lead->credit_type)], ], ])->all(); Teamleader::bulk()->create('deals', $rows)->dryRun(); ``` Resolve the values while building the rows, as above. The bulk validation that runs before the first request works offline, so it cannot look up a field definition itself. ## Finding the field ids ```bash php artisan teamleader:list customFields --all --fields=id,context,label,type php artisan teamleader:info customFields ``` `teamleader:info` shows the options of a select field under `configuration.options`. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/validation # Validation The Teamleader API answers `200 OK` to a filter, sort field, include, option or body field it does not recognise, and ignores it. Nothing in the response says so. A typo looks like success: - a mistyped **filter** returns every record, unfiltered - a mistyped **sort field** returns the default order - a mistyped **include** returns the response without the extra data, or looks like it worked when the field is returned by default anyway - a mistyped **body field** on an update changes nothing, and reports success The SDK checks those names, and the values of enumerated fields, before the request is sent, and throws an `InvalidArgumentException` naming what the endpoint accepts. ```php Teamleader::companies()->update('company-uuid', ['vat' => 'BE0123456789']); // InvalidArgumentException: companies.update does not accept: vat. The API would // ignore it and report success. Accepted fields: addresses, bic, ..., vat_number, website. Teamleader::companies()->update('company-uuid', ['preferred_currency' => 'EURO']); // InvalidArgumentException: Invalid preferred_currency for companies.update: 'EURO'. // Must be one of: BAM, CAD, CHF, ..., EUR, ... Teamleader::timeTracking()->list(['updated_since' => '2026-08-01T00:00:00+02:00']); // InvalidArgumentException: Invalid filter key 'updated_since' for timeTracking.list. // Supported filters: ids, user_id, started_after, started_before, ... ``` ## What is checked | | Checked against | |---|---| | Filter keys | The endpoint's declared filters | | Sort fields and order | The endpoint's declared sort fields; `asc` / `desc` | | Includes | The includes of that exact endpoint — `list` and `info` often differ | | Body fields on create and update | The fields the endpoint accepts | | Enumerated values | The values the specification lists — statuses, currencies, types | | Required fields | Fields the endpoint requires, including "one of" rules | All of it comes from `@teamleader/focus-api-specification`, and the lists are public constants on each resource class, such as `Companies::WRITE_FIELDS` and `ProjectTasks::STATUSES`. Each [reference page](https://hub.mcore-services.be/docs/teamleader-sdk/reference) shows them under **Accepted values**, so you can build a form or a mapping against the same list the SDK checks. ```php use McoreServices\TeamleaderSDK\Resources\Projects\ProjectTasks; $options = ProjectTasks::STATUSES; // ['to_do', 'in_progress', 'on_hold', 'done'] ``` ## What is not checked Client-side validation covers the *shape* of a request. It does not know your data: an unknown UUID, a closed deal or a missing permission is still answered by the API, and becomes a `NotFoundException`, `ValidationException` or `AuthorizationException`. See [Error handling](https://hub.mcore-services.be/docs/teamleader-sdk/guides/error-handling). Where the SDK and the specification disagree with the live API, the specification wins until the API is shown to behave otherwise; see [Specification parity](https://hub.mcore-services.be/docs/teamleader-sdk/project/specification-parity) for how differences are tracked. ## Catching it `InvalidArgumentException` means the code is wrong, not the data or the network. Let it fail your tests rather than catching it in production: ```php it('syncs active companies', function () { Teamleader::companies()->list(['status' => 'active']); // throws on a typo }); ``` ## Upgrading from before v2.3 Between v2.2.4 and v2.3.0 these checks were added resource by resource. Code that passed an unsupported name now throws where it used to be silently ignored. In every such case the call was not doing what it appeared to, so fix the name rather than catching the exception. See [Upgrading](https://hub.mcore-services.be/docs/teamleader-sdk/project/upgrading). --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/error-handling # Error Handling ## Exceptions or error arrays Whether a failed request throws is controlled by one setting: ```env TEAMLEADER_THROW_EXCEPTIONS=true ``` | Setting | A failed request… | |---|---| | `true` (default since v3.0) | throws a typed exception — after retrying server and connection errors | | `false` | is logged and returned as an array with `error => true`, without retries | `false` was the default in 2.x. It is kept in 3.x to ease upgrading and will be removed in a later major version. These throw whatever the setting: - **`RateLimitExceededException`**. Swallowing a 429 would hand back an empty result with no sign that anything went wrong. - **`InvalidArgumentException`** from client-side [validation](https://hub.mcore-services.be/docs/teamleader-sdk/guides/validation). Nothing was sent, so there is no response to return. - **`ConnectionNeedsReauthorizationException`** — the connection lost its refresh token; see [Authentication](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/authentication#when-teamleader-refuses-the-refresh-token). - **`OAuthStateException`** and **`AccountMismatchException`** from `handleCallback()`. - A failed page during `lazy()` / `cursor()` — see [Pagination](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination). The setting can also be changed at runtime: ```php Teamleader::throwExceptions(); // on Teamleader::throwExceptions(false); // off ``` ### With `throw_exceptions` off ```php $result = Teamleader::companies()->info('company-uuid'); if ($result['error'] ?? false) { $result['status_code']; // HTTP status, 0 for a connection failure $result['message']; // the first error message $result['errors']; // every error message from Teamleader } ``` ## Exception reference Every exception extends `McoreServices\TeamleaderSDK\Exceptions\TeamleaderException`. | Status | Exception | Retried by the SDK | |---|---|---| | 400, other 4xx | `TeamleaderException` | No | | 401 | `AuthenticationException` | No | | 403 | `AuthorizationException` | No | | 404 | `NotFoundException` | No | | 422 | `ValidationException` | No | | 429 | `RateLimitExceededException` | No — see below | | 500, 502, 503, 504 | `ServerException` | Yes | | no response | `ConnectionException` | Yes | | 401, connection flagged | `ConnectionNeedsReauthorizationException` (extends `AuthenticationException`) | No — nothing is sent | | — | `ConfigurationException` | No | | — | `OAuthStateException`, `AccountMismatchException` | No — from `handleCallback()` | Server and connection errors are attempted three times in total (`TEAMLEADER_API_RETRY_ATTEMPTS`) before the exception reaches you. The wait between attempts doubles each time (1 second, then 2, then 4), up to 30. Retries happen only with `throw_exceptions` on. ### Methods ```php $e->getMessage(); // the primary error message $e->getStatusCode(); // HTTP status, or null $e->getAllErrors(); // every error message Teamleader returned $e->getContext(); // endpoint, request context, response data ``` `RateLimitExceededException` adds `getRetryAfter()` (seconds) and `getResetTime()` (Unix timestamp, when known). ## A complete handler ```php use McoreServices\TeamleaderSDK\Exceptions\{ AuthenticationException, ConnectionException, NotFoundException, RateLimitExceededException, ServerException, TeamleaderException, ValidationException }; try { $deal = Teamleader::deals()->create($data); } catch (ValidationException $e) { // 422 — Teamleader rejected the values. Fix the input; retrying won't help. return back()->withErrors($e->getAllErrors()); } catch (NotFoundException $e) { // 404 — a referenced record does not exist return null; } catch (RateLimitExceededException $e) { // 429 — hand the job back to the queue rather than sleeping $this->release($e->getRetryAfter()); } catch (AuthenticationException $e) { // 401 — the token could not be refreshed; the user must connect again return redirect('/teamleader/connect'); } catch (ServerException|ConnectionException $e) { // Retries exhausted — try again later $this->release(300); } catch (TeamleaderException $e) { logger()->error('Teamleader error', ['status' => $e->getStatusCode(), 'errors' => $e->getAllErrors()]); throw $e; } ``` `InvalidArgumentException` is left out on purpose: it points at a bug in the calling code, and should fail loudly. ## Logging Errors are logged whatever `throw_exceptions` is set to, through your application's default logger, at a level matching the status: `info` for a 404, `warning` for other 4xx and 429, `error` for 401 and connection failures, and `critical` for 5xx. Tokens and credentials are redacted from the log context. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/rate-limiting # Rate Limiting Teamleader allows **200 requests per minute**, measured over a sliding window. The SDK tracks your usage in Redis and slows down before the window fills, so requests are spent on data rather than on 429 responses. ## How it works 1. Every request is recorded in a Redis sorted set covering the last 60 seconds. 2. From 70% usage the SDK adds a delay before each request: 200 ms from 70%, 500 ms from 80%, 1 second from 90% and 2 seconds from 95%. 3. When the window is full, the SDK waits for a slot, for up to `max_wait_ms` (5 seconds by default). 4. If no slot frees up in time, it throws `RateLimitExceededException` instead of sending a request that can only fail. Because the count lives in Redis, every PHP process and queue worker shares one window. ## Handling the limit in queue jobs ```php use McoreServices\TeamleaderSDK\Exceptions\RateLimitExceededException; public function handle(): void { try { $deals = Teamleader::deals()->list(['status' => ['open']]); } catch (RateLimitExceededException $e) { $this->release($e->getRetryAfter()); return; } // ... } ``` `RateLimitExceededException` is never retried by the SDK and always thrown, whatever `TEAMLEADER_THROW_EXCEPTIONS` is set to. Releasing the job frees the worker instead of holding it for up to a minute. ## Long-running scripts For a CLI import, where blocking is fine, let the SDK sit out a full window itself: ```env TEAMLEADER_RATE_LIMIT_MAX_WAIT_MS=65000 ``` Keep the default for web requests: a page that hangs for a minute is worse than an error. ## Settings | Variable | Default | | |---|---|---| | `TEAMLEADER_RATE_LIMITING_ENABLED` | `true` | Turn tracking and throttling off entirely | | `TEAMLEADER_RATE_LIMIT_REDIS_CONNECTION` | `default` | Redis connection from `config/database.php` | | `TEAMLEADER_RATE_LIMIT_MAX_WAIT_MS` | `5000` | Longest wait for a free slot before throwing | The limit (200 per 60 seconds) and the throttle steps are fixed to Teamleader's published limit. With rate limiting disabled, Redis is not needed, but nothing stops a busy application from running into 429s. ## Watching usage ```php $stats = Teamleader::getRateLimitStats(); $stats['remaining']; // requests left in the current window $stats['usage_percentage']; // 0–100 $stats['seconds_until_reset']; // until the oldest request leaves the window $stats['throttled_requests']; // requests delayed in this process ``` `php artisan teamleader:status` shows the same figures. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/events-and-logging # Events and Logging The SDK fires Laravel events for every request it sends, every answer it gets, every failure, every rate-limit wait and every token refresh. Listen to them to log, measure or alert, without wrapping the SDK. > [!NOTE] > Available from v3.0. ## The events All are in `McoreServices\TeamleaderSDK\Events`. Every event has a `connection` property, which is `default` for a single account. See [Multiple Connections](https://hub.mcore-services.be/docs/teamleader-sdk/guides/multiple-connections). None carries a token value, and request and response bodies have tokens, secrets and other sensitive keys redacted before the event is created. | Event | Fired when | Properties | |---|---|---| | `RequestSending` | A request is about to be sent — once per attempt, so a retry fires it again | `method`, `endpoint`, `body` | | `ResponseReceived` | The API answered, with any status | `method`, `endpoint`, `statusCode`, `durationMs`, `body` (built on first read); `successful()` | | `RequestFailed` | An error status, no answer at all, no access token, or the rate-limit wait ran out | `method`, `endpoint`, `statusCode` (null when the API was not reached), `message`, `exception`, `durationMs` (how long it waited, when it got no answer; since v3.4) | | `RateLimitWaited` | The SDK held a request back to stay inside the rate limit | `waitedMs`, `usagePercentage`, `endpoint`, `gaveUp` | | `TokenRefreshed` | The access token was refreshed and stored | `expiresIn` | | `TokenRefreshFailed` | A refresh failed | `reason`, `statusCode`, `reauthorizationRequired` | `RequestFailed` fires whether `TEAMLEADER_THROW_EXCEPTIONS` is on or off, so a listener sees every failure either way. An error status fires `ResponseReceived` and then `RequestFailed`. A request the SDK refuses to build, because of an unknown filter or a missing required field, throws before anything is sent and fires nothing. ## Listening ```php use Illuminate\Support\Facades\Event; use McoreServices\TeamleaderSDK\Events\ResponseReceived; Event::listen(function (ResponseReceived $event) { if ($event->durationMs > 2000) { logger()->warning("Slow Teamleader call: {$event->endpoint}", [ 'ms' => $event->durationMs, ]); } }); ``` ### Alert when an account needs reconnecting `TokenRefreshFailed` with `reauthorizationRequired` is the one moment a person has to act: Teamleader refused the refresh token, the stored tokens were cleared, and nothing works until someone connects the account again. ```php use McoreServices\TeamleaderSDK\Events\TokenRefreshFailed; Event::listen(function (TokenRefreshFailed $event) { if ($event->reauthorizationRequired) { Notification::route('mail', 'ops@example.com') ->notify(new TeamleaderNeedsReconnecting($event->reason)); } }); ``` ### Cost An event nobody listens to is not built: the SDK checks for a listener first, so the request body is not copied or sanitised. With no listeners, the events cost one lookup per request. The body of `ResponseReceived` is sanitised the first time a listener reads `$event->body`, not when the event is fired (since v3.4). Redacting a 100-row listing takes tens of milliseconds, so a listener that only counts or times requests, like the [Pulse recorder](https://hub.mcore-services.be/docs/teamleader-sdk/guides/monitoring), costs next to nothing. A queued listener receives the body already built. ### Monitoring To see these events on a dashboard instead of writing listeners, use the SDK's Laravel Pulse recorders and cards. See [Monitoring with Pulse](https://hub.mcore-services.be/docs/teamleader-sdk/guides/monitoring). ## Logging ```dotenv TEAMLEADER_LOG_CHANNEL=teamleader TEAMLEADER_LOG_REQUESTS=false TEAMLEADER_LOG_RESPONSES=false ``` **`TEAMLEADER_LOG_CHANNEL`** sends all SDK log output to one channel from `config/logging.php`. Unset, the SDK uses your default channel. A separate channel makes it easy to keep SDK output out of your main log, or to turn its level down: ```php // config/logging.php 'teamleader' => [ 'driver' => 'daily', 'path' => storage_path('logs/teamleader.log'), 'level' => env('TEAMLEADER_LOG_LEVEL', 'info'), 'days' => 14, ], ``` **`TEAMLEADER_LOG_REQUESTS`** and **`TEAMLEADER_LOG_RESPONSES`** write every request and response body to that channel at `debug` level. They are off by default: bodies hold your customers' names, addresses and email addresses, and logging them is a decision to make deliberately. Tokens are always redacted. Both are implemented as listeners on `RequestSending` and `ResponseReceived`, so the log shows exactly what your own listeners receive. ## Recent calls `TeamleaderSDK::getApiCalls()` returns the last 100 calls in the current process, with the method, endpoint, status, response size and duration. It is meant for a quick look in Tinker. For anything you keep, listen to `ResponseReceived`. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/monitoring # Monitoring with Pulse The SDK ships two [Laravel Pulse](https://laravel.com/docs/pulse) recorders and three dashboard cards. Together they show what the SDK does in production, per connection: * **Teamleader Requests**: every endpoint you call, with how often, how fast, how many failed and why. * **Teamleader Connections**: one row per connection, with traffic, error rate, rate-limit waits and token renewals. Connections that need someone to connect them again are listed at the top. * **Teamleader Time**: per route, queued job and Artisan command, how much time went to Teamleader, its share of the whole, and how many calls were made. This card explains a page that takes nine seconds because it makes 14 Teamleader calls. > [!NOTE] > Available from v3.4. Needs `laravel/pulse` 1.8 or later. Pulse is optional: > without it, nothing under `McoreServices\TeamleaderSDK\Pulse` is loaded. ## Why not Pulse's own cards Pulse's *Slow Outgoing Requests* card only sees requests sent with Laravel's `Http` client. The SDK sends through Guzzle directly, so Teamleader traffic does not show up there at all. Pulse's *Slow Requests* card shows that a page is slow, but not that Teamleader is why. ## Install If Pulse is not installed yet: ```bash composer require laravel/pulse php artisan vendor:publish --provider="Laravel\Pulse\PulseServiceProvider" php artisan migrate ``` ### 1. Add the recorders In `config/pulse.php`, add the SDK's recorders to `recorders`: ```php 'recorders' => [ // … Pulse's own 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' => [ // '/^webhooks\./', ], 'connections' => [ 'ignore' => [ // '/^sandbox$/', ], 'groups' => [ // '/^site-.+$/' => 'site-*', ], ], ], \McoreServices\TeamleaderSDK\Pulse\Recorders\TeamleaderCallers::class => [ 'enabled' => env('PULSE_TEAMLEADER_CALLERS_ENABLED', true), 'sample_rate' => env('PULSE_TEAMLEADER_CALLERS_SAMPLE_RATE', 1), 'ignore' => [ // '#^/pulse#', ], ], ], ``` ### 2. Add the cards to the dashboard Publish Pulse's dashboard if you have not already: ```bash php artisan vendor:publish --tag=pulse-dashboard ``` Then add the cards to `resources/views/vendor/pulse/dashboard.blade.php`: ```blade {{-- … Pulse's own cards … --}} ``` `cols` and `rows` work as for Pulse's own cards. The connections card also takes `limit` (default 10), the number of connections it lists, busiest first: ``. Pulse records from every process that has the recorders configured: web requests, queue workers and scheduled commands. Nothing else needs to run beyond what Pulse itself needs. ## The cards ### Teamleader Requests One row per connection and endpoint (`brand-a` · `invoices.draft`), with: | Column | | |---|---| | Count | Answers from the API | | Failed | Requests that did not succeed, with the reasons below the endpoint | | Slow | Answers slower than `threshold` | | Average, Slowest | Duration of the API call, in ms | Failure reasons: | Reason | Meaning | |---|---| | `HTTP 422`, `HTTP 404`, … | The API answered with that status | | needs reconnecting | Teamleader refused the refresh token; nothing works until someone connects the account again | | not connected | No tokens are stored for the connection, so nothing was sent | | rate-limit wait ran out | The SDK waited for room in the rate limit longer than `rate_limiting.max_wait_ms`, and gave up | | no answer | A timeout or connection error: the API was never reached | Sort by count, failures, slow, average or slowest. With more than one connection, a **Connection** filter appears. Like Pulse's own cards, a card shows at most 100 rows. ### Teamleader Connections One row per connection with its requests, failures and error rate (failures out of all attempts, including those that never reached the API), rate-limit waits (how many, and the time spent waiting), average duration and token renewals. Above the table, every connection whose last known state is not healthy: | State | Set when | Cleared when | |---|---|---| | **Needs reconnecting** | A refresh was refused (`TokenRefreshFailed` with `reauthorizationRequired`), or a request was refused because of it | The account is connected again (`ConnectionAuthorized`), a token is renewed, or the API answers a request on the connection | | **Not connected** | A request was made on a connection that has no tokens | The same | Each process writes a connection's state at most once a minute, so after reconnecting, the card can take up to a minute (plus Pulse's own polling) to catch up. A connection that was removed while flagged stays listed until Pulse trims its data (seven days by default) or `php artisan pulse:clear --type=teamleader_connection_state`. These are listed by their real connection name, also when connections are grouped (see below), and whatever the `limit`: a connection that needs someone to act is never hidden. ### Teamleader Time One row per route, queued job and Artisan command that called Teamleader: | Column | | |---|---| | Count | Runs of the route, job or command | | Calls | Average number of Teamleader calls per run (hover for the most in one run) | | Average | Average time spent on Teamleader per run: API calls (including ones that timed out) plus rate-limit waits. The pause between retry attempts is not included. | | Share | That time as a share of the run's total duration | | Slowest | The most time spent on Teamleader in one run | A route with a high share and many calls is a candidate for [sideloading](https://hub.mcore-services.be/docs/teamleader-sdk/guides/sideloading), [`lazy()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination), a cache, or a [queued bulk operation](https://hub.mcore-services.be/docs/teamleader-sdk/guides/bulk-operations). How runs are counted: * A queued job is its own caller. A job on the `sync` queue is part of whatever dispatched it. * A command started with `Artisan::call()`, from a web request, a job or another command, is a caller of its own, and its time also counts toward whatever started it. A command started with `$this->call()` from another command is part of that command: Laravel fires no command events for it. * Worker commands (`queue:work`, `horizon`, `octane:start`, `schedule:work`, …) are never callers themselves. The jobs and requests they run are. ## Configuration ### `TeamleaderRequests` | Key | Default | | |---|---|---| | `sample_rate` | `1` | The share of successful requests recorded, between 0 and 1. Failures, rate-limit waits and token events are always recorded. Counts on the cards are scaled back up and shown as `~1,200`. | | `threshold` | `1000` | When an answer counts as slow, in ms. Or per endpoint: `['/^files\./' => 5000, 'default' => 1000]` | | `ignore` | `[]` | Patterns matched against the endpoint. Matching requests are not recorded. | | `connections.ignore` | `[]` | Patterns matched against the connection name. Nothing from a matching connection is recorded. | | `connections.groups` | `[]` | Patterns mapped to one name, to record several connections as one row. | ### `TeamleaderCallers` | Key | Default | | |---|---|---| | `sample_rate` | `1` | The share of runs recorded | | `ignore` | `[]` | Patterns matched against the route path (`/admin/quotations`), job class or command name | ### Many connections An application with a connection per customer or per site can have hundreds of connections, often named after an id. Group them, so the cards show one row for all of them: ```php 'connections' => [ 'groups' => [ '/^site-.+$/' => 'site-*', ], ], ``` The first matching pattern wins, and the replacement may use the pattern's groups: `'/^(brand)-(.+)$/' => '$1-*'`. Connection states are never grouped: *Needs reconnecting* names the exact connection. ## What is recorded Request and response bodies are never read or stored, and neither are tokens or query parameters. The recorders store endpoint names, connection names, durations, status codes and counts. A connection's state also keeps the reason Teamleader or the SDK gave, such as `invalid_grant`. All Pulse entry types start with `teamleader_`, so they can be cleared on their own: ```bash php artisan pulse:clear --type=teamleader_request --type=teamleader_failure ``` | Type | Key | Value | |---|---|---| | `teamleader_request` | `[connection, endpoint]` | Duration in ms | | `teamleader_slow_request` | `[connection, endpoint]` | Duration in ms | | `teamleader_connection_request` | connection | Duration in ms | | `teamleader_failure` | `[connection, endpoint, reason]` | | | `teamleader_connection_failure` | connection | | | `teamleader_connection_unanswered` | connection | (failures that never reached the API) | | `teamleader_rate_limit_wait` | connection | ms waited | | `teamleader_rate_limit_gave_up` | connection | | | `teamleader_token_refreshed` | connection | | | `teamleader_token_refresh_failed` | connection | | | `teamleader_caller_time` | `[kind, name, via]` | Teamleader ms in one run | | `teamleader_caller_duration` | `[kind, name, via]` | Total ms of that run | | `teamleader_caller_calls` | `[kind, name, via]` | Calls in that run | | `teamleader_connection_state` (a Pulse value) | connection | `{"state", "reason", "at"}` | ## Cost The recorders only listen to the SDK's [events](https://hub.mcore-services.be/docs/teamleader-sdk/guides/events-and-logging); they change nothing about how requests are sent. Per API call they add two or three Pulse entries, which Pulse buffers and writes at the end of the request or job. The `ResponseReceived` event builds its sanitised body only when a listener reads it, and the recorders never do. Redacting a 100-row listing takes tens of milliseconds, so with Pulse installed this matters: that cost is not paid. For large bulk runs, consider a `sample_rate` below 1 for `TeamleaderRequests`, or Pulse's Redis ingest so workers do not write to the database themselves: ```dotenv PULSE_INGEST_DRIVER=redis ``` and run `php artisan pulse:work`. See [Pulse's documentation](https://laravel.com/docs/pulse) on ingesting. ## Pulse's cache card and token keys Pulse's *Cache* card lists the SDK's token keys, one per connection: `teamleader:{connection}:tokens`. Two things to know when reading it: * The scheduled `teamleader:tokens:refresh` command (every ten minutes) reads each connection's tokens from the database rather than the cache, so a connection that is used rarely shows about six misses an hour and few hits. That is by design: it keeps one process from renewing with an old copy. * To show them as one row, group them in Pulse's cache recorder: ```php \Laravel\Pulse\Recorders\CacheInteractions::class => [ // … 'groups' => [ '/^teamleader:(.+):tokens$/' => 'teamleader:*:tokens', // … Pulse's default groups … ], ], ``` ## Testing The recorders are ordinary listeners, so the SDK's events can be fired in a test with Pulse installed and the entries read back after `Pulse::ingest()`. With [`Teamleader::fake()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/testing), no request is sent and no event is fired, so Pulse records nothing for faked calls. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/webhooks # Webhooks Teamleader can call a URL of yours when something happens: an invoice is booked, a deal is won, a contact is added. ## Registering ```php Teamleader::webhooks()->register('https://your-app.com/webhooks/teamleader', [ 'invoice.booked', 'deal.won', ]); ``` Event types are checked against the list in the specification before the request is sent; an unknown type throws. To register a whole category: ```php $types = Teamleader::webhooks()->getInvoiceEventTypes(); // invoice.* and incomingInvoice.* Teamleader::webhooks()->register('https://your-app.com/webhooks/teamleader', $types); ``` Helpers exist for invoices, credit notes, deals, contacts, companies, projects, tasks, tickets and time tracking. `getEventTypesByCategory('meeting')` returns any other prefix, and `getAvailableEventTypes()` returns them all. The full list is under **Accepted values** on the [Webhooks reference page](https://hub.mcore-services.be/docs/teamleader-sdk/reference/other/webhooks). ```php Teamleader::webhooks()->list(); Teamleader::webhooks()->unregister('https://your-app.com/webhooks/teamleader', ['deal.won']); ``` ## Receiving The payload carries the entity id at `subject.id`, not `data.id`: ```json { "type": "invoice.booked", "subject": { "type": "invoice", "id": "..." } } ``` Keep the route fast: acknowledge, queue, and fetch the record in the job. ```php // routes/web.php Route::post('/webhooks/teamleader/{secret}', function (Request $request, string $secret) { abort_unless(hash_equals(config('services.teamleader.webhook_secret'), $secret), 404); ProcessTeamleaderEvent::dispatch($request->input('type'), $request->input('subject.id')); return response()->noContent(); }); ``` - **Exclude the route from CSRF protection** — it is a `POST` from outside your application. - **Put a secret in the URL.** The SDK does not verify where a webhook request came from, so register an unguessable URL and check it, as above. - **Fetch the record** in the job with `info()` rather than trusting the payload for anything beyond the id. ## Projects: "nextgen" event names Webhook events for the current project system are named `nextgenProject.*`, while the SDK resource is `projects()` and the API path is `projects-v2/projects.*`. They are the same thing; see the [Projects reference page](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/projects). --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/token-storage-and-security # Token Storage and Security How the SDK stores and refreshes OAuth tokens, and what to do to protect them in production. ## Two layers | Layer | Where | Purpose | |---|---|---| | Token store | `teamleader_tokens` table, one row per connection | Source of truth; survives restarts and cache flushes | | Cache | Your default Laravel cache store | Fast reads on every request | Reads check the cache first and fall back to the store. Writes go to the store first, then to the cache, so a lost cache is recoverable and a lost row means connecting again. **Both layers are encrypted** with your application key (`APP_KEY`), using Laravel's encrypter. Neither the table nor the cache store holds a readable token. ### The table Created by a migration that ships with the package: ```bash php artisan migrate ``` ``` teamleader_tokens ├── id bigint, primary key ├── connection varchar(100), unique — 'default' for a single account ├── access_token text, encrypted ├── refresh_token text, encrypted, nullable ├── token_type varchar(50), default 'Bearer' ├── expires_in integer ├── expires_at timestamp, indexed ├── status varchar(32) — 'connected' or 'needs_reauthorization' ├── account_id varchar, nullable — the connected Teamleader account ├── account_name varchar, nullable ├── last_refreshed_at timestamp, nullable ├── created_at timestamp └── updated_at timestamp ``` To change the migrations, publish them first: `php artisan vendor:publish --tag=teamleader-migrations`. Both tables use an auto-increment `id`. The SDK never reads it: rows are found by `connection` (tokens) and `name` (connections), both unique. An application with a UUID-only convention can leave the package's tables as they are, or publish the migrations and change the `id` column, as long as every insert still gets an id without the SDK providing one. Credentials of connections added with `teamleader:connections:add` are kept in a second table, `teamleader_connections`, with the client ID and secret encrypted the same way. ### Cache keys | Key | Holds | Lifetime | |---|---|---| | `teamleader:{connection}:tokens` | The token pair and expiry, encrypted | Until 2 minutes before the access token expires (at least 60 s) | | `teamleader:{connection}:refresh_lock` | Refresh lock | 60 s | ### Storing tokens elsewhere The table is the default `TokenStore`. To keep tokens somewhere else, such as a secrets manager or your own tenants table, implement `McoreServices\TeamleaderSDK\Tokens\TokenStore` and bind it: ```php $this->app->singleton(TokenStore::class, VaultTokenStore::class); ``` Your implementation is then responsible for protecting the values at rest. The SDK still caches them, encrypted. ## Refresh An access token with less than **15 minutes** left is refreshed before the request goes out: ``` getValidAccessToken() ├── read the cache, fall back to the database ├── less than 15 minutes left? │ └── take the refresh lock (Cache::add, atomic) │ ├── another process holds it → wait, then read the new token │ └── this process holds it │ ├── POST /oauth2/access_token with the refresh token │ ├── write the new tokens to the database, then the cache │ └── release the lock └── return the access token ``` The lock stops several queue workers refreshing at once and overwriting each other's tokens. **It needs a cache store shared by every process** — Redis, Memcached or the database store. The file and array stores do not coordinate across workers. If Teamleader answers the refresh with a 400 or 401, the refresh token has been revoked. The connection is marked `needs_reauthorization` and its tokens are kept but never used; a user has to connect it again. Tokens are also renewed on a schedule; see [Authentication](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/authentication#token-refresh). ## `APP_KEY` protects the tokens Anyone with `APP_KEY` and a copy of the table can read the tokens, and a refresh token gives full API access to the connected Teamleader account until it is revoked. So: - Keep `APP_KEY` out of version control and different per environment. - **Rotating `APP_KEY` makes the stored tokens unreadable.** The SDK reports a `TokenStorageException` naming the connection. Either connect the account again, or add the old key to `APP_PREVIOUS_KEYS` during the rotation. The SDK re-encrypts each token with the new key the next time it is refreshed. - A database backup is only as sensitive as the key it can be combined with: store backups and `.env` apart. Rows written by v2.x are plain text. They are encrypted automatically the first time the SDK reads them after the upgrade. ## Production checklist - [ ] Cache store is Redis (or another shared store), not `file` or `array` - [ ] Redis has a password and TLS - [ ] `php artisan migrate` has run after installing or upgrading the SDK - [ ] `.env` is not in version control, and `APP_KEY` differs per environment - [ ] `TEAMLEADER_THROW_EXCEPTIONS` is not set to `false` (the default is `true`), so failures are noticed - [ ] Token refresh log entries are monitored ## Diagnostics ```php use McoreServices\TeamleaderSDK\Services\TokenService; $tokens = app(TokenService::class); $tokens->getTokenInfo(); // [ // 'has_access_token' => true, // 'has_refresh_token' => true, // 'expires_at' => '2026-09-30 14:30:00', // 'expires_in' => 847, // 'needs_refresh' => false, // 'connection' => 'default', // 'status' => 'connected', // 'token_source' => 'cache', // 'cache', 'database' or 'none' // 'cache_has_tokens' => true, // 'database_has_tokens' => true, // 'storage_sync' => [...], // ] $tokens->hasValidTokens(); // bool $tokens->syncTokensToCache(); // re-cache from the database, after a cache flush $tokens->clearTokens(); // remove from cache and database ``` `php artisan teamleader:status` shows the same information. ## If a token leaks 1. **Revoke access** for the integration in the Teamleader account. 2. **Clear the stored tokens:** `Teamleader::logout()`, or `app(TokenService::class)->clearTokens()` from Tinker. 3. **Rotate the client secret** in the developer portal if it may have leaked too, and update `TEAMLEADER_CLIENT_SECRET`. 4. **Connect again** through your OAuth route. 5. **Review** what the token was used for in the Teamleader account's audit information. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/multiple-connections # Multiple Connections One Laravel application can talk to several Teamleader accounts: six environments, a handful of clients, or one per tenant. Each account is a **connection** with its own credentials, tokens, refresh lock and rate-limit window. > [!NOTE] > Available from v3.0. Connecting each account is covered in > [Authentication](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/authentication#several-accounts-one-callback). ## One integration per account An integration is created inside one Teamleader account, and its client ID and secret only work there. So every connection needs **its own integration** and its own `client_id` and `client_secret`. There is no fallback between connections for those two. `redirect_uri` is the one value that can be shared: register the same callback URL in each integration. ## Configuration The flat keys you already have are the `default` connection. Add one block per extra account: ```php // config/teamleader.php 'client_id' => env('TEAMLEADER_CLIENT_ID'), 'client_secret' => env('TEAMLEADER_CLIENT_SECRET'), 'redirect_uri' => env('TEAMLEADER_REDIRECT_URI'), 'default' => env('TEAMLEADER_CONNECTION', 'default'), 'connections' => [ 'antwerp' => [ 'client_id' => env('TEAMLEADER_ANTWERP_CLIENT_ID'), 'client_secret' => env('TEAMLEADER_ANTWERP_CLIENT_SECRET'), // redirect_uri left out: uses TEAMLEADER_REDIRECT_URI ], 'ghent' => [ 'client_id' => env('TEAMLEADER_GHENT_CLIENT_ID'), 'client_secret' => env('TEAMLEADER_GHENT_CLIENT_SECRET'), ], ], ``` A connection missing its `client_id` or `client_secret` fails with a `ConfigurationException` that names it, the first time it is used. A single-account application changes nothing. ## Connections stored in the database Twelve environment variables for six accounts works, but adding a seventh account then means a deploy. Instead, store a connection's credentials in the database: ```bash php artisan teamleader:connections:add bruges # prompts for client ID and secret php artisan teamleader:connections:list # with each account and its id php artisan teamleader:connections:rename bruges brugge # keeps credentials and tokens php artisan teamleader:connections:remove bruges # also removes its tokens ``` Client ID and secret are encrypted with `APP_KEY` in the `teamleader_connections` table (created by `php artisan migrate`). For scripts, pass `--client-id=`, `--client-secret=` and optionally `--redirect-uri=` and `--expected-account=`. Without `--redirect-uri`, a stored connection uses `TEAMLEADER_REDIRECT_URI`. When that is not set either, the command asks for the redirect URI. Nothing is stored until every value checks out, so a failed run leaves the table as it was. Adding an account then takes four steps: create the integration in that Teamleader account, run `connections:add`, connect it through your OAuth route, and pin the account it connected to: ```bash php artisan teamleader:connections:expect bruges --current php artisan teamleader:connections:expect --all --current # every stored connection ``` From then on a callback that connects any other account for `bruges` is refused and nothing is stored. `--current` reads the account from the connection's tokens, so the credentials are not asked again. Pass an id instead (`connections:expect bruges 0ab6c2…`) or `--clear` to remove it. ### Renaming `teamleader:connections:rename {from} {to}` moves the stored credentials and the tokens in one transaction, so the connection stays connected. The rate-limit window belongs to the integration, not the name, and is unaffected. Update what names the connection yourself: code that calls `Teamleader::connection('old')`, `TEAMLEADER_CONNECTION`, scheduled commands with `--connection`, and queued bulk jobs that have not run yet. A connection defined in `config/teamleader.php` is renamed there. Then run `connections:rename old new --tokens-only` to move its tokens along. ### Without any credentials in `.env` The first connection can be stored too. Either name it `default`: ```bash php artisan teamleader:connections:add default ``` or give it a name and make that the default connection: ```dotenv TEAMLEADER_CONNECTION=antwerp ``` Either way, `Teamleader::companies()` then uses it without a name. ### Only named connections An application can also have no default at all. Then `teamleader:status` shows every connection, `teamleader:health` checks each named connection under *Connections*, and the data commands (`list`, `info`, `export`, …) ask for `--connection=`. `Teamleader::connection('name')` works as always; code that uses `Teamleader::` without a name needs `TEAMLEADER_CONNECTION`. A connection in `config/teamleader.php` always wins over one with the same name in the database; the commands refuse to add or remove such a name. ## Usage ```php Teamleader::companies()->list(); // the default connection Teamleader::connection('antwerp')->companies()->list(); // a named one $ghent = Teamleader::connection('ghent'); $ghent->deals()->lazy(['status' => 'open'])->each(...); ``` Every resource made from a connection uses that connection. Each connection is built once per process and reused. `TEAMLEADER_CONNECTION` changes which connection `Teamleader::` uses without a name. ## Connections defined at runtime For accounts you keep in your own database: ```php // One at a time Teamleader::extend('tenant-42', fn () => [ 'client_id' => $tenant->teamleader_client_id, 'client_secret' => decrypt($tenant->teamleader_client_secret), ]); // Or every unknown name, through your own lookup — return null for "no such connection" Teamleader::resolveConnectionsUsing( fn (string $name) => Tenant::where('slug', $name)->first()?->teamleaderConfig() ); ``` Call these from a service provider's `boot()` method. ## What each connection keeps apart | | Per connection | |---|---| | Tokens | One row in `teamleader_tokens`, by `connection` | | Cache | `teamleader:{connection}:tokens` | | Refresh lock | `teamleader:{connection}:refresh_lock` — one account refreshing never blocks another | | Rate-limit window | One per **client ID** — Teamleader allows 200 requests a minute per integration, so six accounts get six windows. The ID is hashed in the Redis key | | Events | Every event has a `connection` property | ## Keeping every connection alive `teamleader:tokens:refresh` runs every ten minutes and renews each connection that is due, and `teamleader:status --all` shows them side by side. See [Token refresh](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/authentication#token-refresh). --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/artisan-commands # Artisan Commands | Command | | |---|---| | `teamleader:status` | Connection, token and rate-limit status; `--all` for every connection, `--connection=` for one | | `teamleader:resources`, `describe`, `list`, `info` | Explore and query the API from the terminal — see [Command Line](https://hub.mcore-services.be/docs/teamleader-sdk/guides/cli) | | `teamleader:export`, `import`, `call` | Export to a file, import from one (validated first, `--dry-run`), raw calls | | `teamleader:connections:add` / `list` / `remove` | Manage connections whose credentials are stored in the database | | `teamleader:connections:rename {from} {to}` | Rename a connection, keeping its credentials and tokens | | `teamleader:connections:expect {name} --current` | Pin the account a stored connection must connect to; `--all`, an id, or `--clear` | | `teamleader:tokens:refresh` | Renew tokens that expire soon — scheduled every ten minutes by the package | | `teamleader:health` | Health checks: configuration, authentication, tokens, every connection, API connectivity, rate limits, database, the token cache and dependencies | | `teamleader:config:validate` | Validate the configuration and environment | | `teamleader:export-uuids` | Print account UUIDs for `config/teamleader.php` | ## `teamleader:status` ```bash php artisan teamleader:status php artisan teamleader:status --json ``` Whether the application is connected, when the token expires, and current rate-limit usage. ## `teamleader:health` ```bash php artisan teamleader:health php artisan teamleader:health --score # the overall score only php artisan teamleader:health --json php artisan teamleader:health --fix # attempt to fix what it can ``` Suitable for a deployment check or a monitoring probe; `--json` gives machine-readable output. ## `teamleader:config:validate` ```bash php artisan teamleader:config:validate php artisan teamleader:config:validate --fix # how to fix each issue php artisan teamleader:config:validate --report # the full configuration report php artisan teamleader:config:validate --json ``` Set `TEAMLEADER_VALIDATE_ON_BOOT=true` to run the same checks whenever the application boots. ## `teamleader:export-uuids` ```bash php artisan teamleader:export-uuids php artisan teamleader:export-uuids --resource=deal-phases ``` Prints the account's reference records as configuration to paste into `config/teamleader.php`. `--resource` takes one of `departments`, `users`, `teams`, `work-types`, `pipelines`, `deal-phases`, `deal-sources`, `lost-reasons`, `payment-terms`, `tax-rates`, `price-lists`, `business-types`, `product-categories`, `activity-types`, `call-outcomes`, `units-of-measure` and `custom-fields`. See [Configuration](https://hub.mcore-services.be/docs/teamleader-sdk/getting-started/configuration#storing-reference-uuids). --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/cli # Command Line Explore and query Teamleader from the terminal, without writing code. Every command goes through the same resource methods your code uses, so filters, sort fields and includes are validated the same way, and an error tells you what the endpoint accepts. > [!NOTE] > Available from v3.0. **Nothing writes without `--write`.** With it, you are asked to confirm, and the question names the endpoint and the number of requests. `--force` skips the question, for scripts. In production `--force` is required as well, and a run with `-n` (cron, CI) is refused rather than left waiting. Every command takes `--connection=antwerp` for a connection other than the default. ## What is there ```bash php artisan teamleader:resources # every resource and what it supports php artisan teamleader:resources --category=CRM php artisan teamleader:describe deals # filters, sort fields, includes, methods ``` `describe` is the resource's [reference page](https://hub.mcore-services.be/docs/teamleader-sdk/reference) in the terminal, read from the same source. ## Listing records ```bash php artisan teamleader:list companies php artisan teamleader:list deals --filter='status[]=open' --sort=created_at:desc --fields=id,title,estimated_value.amount php artisan teamleader:list contacts --filter='tags[]=customer' --all --format=csv > customers.csv ``` | Option | | |---|---| | `--filter=key=value` | Repeatable. `key[]=value` adds to a list, `key.sub=value` builds an object | | `--sort=field` | `field:desc` for descending | | `--include=a,b` | Includes to sideload | | `--page-size=20`, `--page=1` | One page | | `--all` | Every page, 100 records a request — with `--limit=` to stop early | | `--fields=id,name` | Dot paths: `emails.0.email`, `address.city` | | `--format=table` | `table`, `json` or `csv` | | `--subject=deal:uuid` | The subject, for resources that list one subject's records | Filter values are cast: `true`, `false`, `null` and numbers become their type. Quote a value to keep it a string: `--filter='term="2024"'`. > [!WARNING] > Put a filter with `[]` in single quotes: `--filter='status[]=open'`. Without > them, zsh (the default shell on macOS) reads the brackets as a file pattern > and stops with `no matches found`. An unknown filter is refused before anything is sent, with the accepted list: ``` $ php artisan teamleader:list deals --filter=colour=blue Unsupported filter key for deals.list: colour. Supported: ids, term, customer, phase_id, … ``` ### Notes, e-mail tracking and files `notes`, `emailTracking` and `files` only list the records of one subject — a deal, contact, company and so on. Name it with `--subject=type:uuid`: ```bash php artisan teamleader:list notes --subject=deal:3f6c… --all php artisan teamleader:list files --subject=company:3f6c… php artisan teamleader:export emailTracking --subject=contact:3f6c… --format=jsonl ``` Without it, the command stops before any request and says so. `files` also lists by id instead, and searches file names with `term`: ```bash php artisan teamleader:list files --filter=ids[]=a856… --filter=ids[]=b0e1… php artisan teamleader:list files --subject=deal:3f6c… --filter=term=offerte ``` ## One record ```bash php artisan teamleader:info deals 3f6c… php artisan teamleader:info companies 3f6c… --include=custom_fields --format=json ``` A table shows every field flattened (`lead.customer.id`); `--fields=` picks some. ## Export ```bash php artisan teamleader:export contacts --filter='tags[]=customer' --fields=id,first_name,last_name,emails.0.email php artisan teamleader:export deals --format=jsonl --output=storage/deals.jsonl php artisan teamleader:export companies --delimiter=';' # Excel with a Belgian locale ``` Every page, 100 records a request, with a progress bar; the total is shown where the endpoint reports one. Without `--output` the file goes to `storage/app/teamleader/`. Filters, sorting and includes work as for `list`. See [Bulk operations](https://hub.mcore-services.be/docs/teamleader-sdk/guides/bulk-operations#export) for how values are written. ## Import ```bash php artisan teamleader:import companies companies.csv --dry-run # see exactly what would be sent php artisan teamleader:import companies companies.csv --write # send it, after confirming php artisan teamleader:import contacts updates.jsonl --update --write php artisan teamleader:import deals won.csv --method=win --write # any method, once per line ``` **Every line is validated before any is sent.** An invalid line stops the import with the line numbers and what is wrong. Nothing is sent: ``` 2 of 1200 rows are invalid. Nothing was sent. line 47: Unsupported field for companies.add: emial. Accepted: … line 802: … ``` | Option | | |---|---| | `--dry-run` | Validate and print the request bodies (`-v`: all of them); send nothing | | `--update` | Each line updates the record in its `id` column | | `--method=tag` | Call that method per line; the values, in column order, are its arguments | | `--continue-on-error` | Keep going after a line the API refuses (default: stop) | | `--unique-by=vat_number` | Only the first line per value is sent | | `--resume=file` | Skip the lines a previous run did (see below) | | `--queue`, `--chunk=50` | Dispatch to the queue — see [queued bulk](https://hub.mcore-services.be/docs/teamleader-sdk/guides/bulk-operations#queued-bulk) | | `--numeric=a,b` | CSV columns to send as numbers | | `--delimiter=;` | CSV delimiter | ### Files - **CSV**: the header names the fields as dot paths, such as `address.city` or `lead.customer.id`. Cells are text, except `true`, `false`, `null` and JSON (`["vip","b2b"]`). **Empty cells are left out**, so on `--update` an empty cell leaves the field alone. Numbers stay text unless the column is named in `--numeric`, because a postal code or phone number looks like a number. - **JSON Lines** (`.jsonl`): one JSON object per line, sent as it is. The format for typed or nested data. - **JSON** (`.json`): an array of objects. Results and errors refer to **line numbers** in your file. ### When an import stops If a line is refused, or the run is interrupted, a results file is written to `storage/app/teamleader/`. Fix the failed lines and run the same command with `--resume=` that file: the lines that succeeded are not sent again. ## Raw calls For an endpoint the SDK does not wrap: ```bash php artisan teamleader:call users.me php artisan teamleader:call companies.info --data='{"id":"3f6c…"}' echo '{"filter":{"term":"Acme"}}' | php artisan teamleader:call companies.list --data=- ``` The body is sent as it is — **no validation** — so only reading endpoints (`.list`, `.info`, `.me`, `.download`) run without `--write`. ## Errors API errors print as one line with the HTTP status, whatever `TEAMLEADER_THROW_EXCEPTIONS` says, and the command exits with 1. Invalid input exits with 2. Add `-v` for the full exception. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/testing # Testing Your Integration `Teamleader::fake()` replaces every connection with one that records requests instead of sending them. No Teamleader account, token, Redis or network is needed, and the resources still validate what they build: a test that sends a filter or field Teamleader would ignore fails the same way production would. > [!NOTE] > Available from v3.1. ## Faking and asserting ```php use McoreServices\TeamleaderSDK\Facades\Teamleader; public function test_a_signed_contract_becomes_a_deal(): void { Teamleader::fake([ 'deals.create' => ['data' => ['id' => 'deal-1', 'type' => 'deal']], ]); $this->post('/contracts/42/sign'); Teamleader::assertSent('deals.create', fn (array $body) => $body['title'] === 'Contract 42'); $this->assertSame('deal-1', Contract::find(42)->teamleader_deal_id); } ``` | Assertion | | |---|---| | `assertSent($endpoint, $check = null)` | At least one request; the optional check receives the body | | `assertNotSent($endpoint, $check = null)` | None (that pass the check) | | `assertSentCount($endpoint, $count)` | Exactly this many | | `assertNothingSent()` | No requests at all | | `recorded($endpoint = null, $check = null)` | The calls themselves: `method`, `endpoint`, `body`, `connection` | Endpoints are Teamleader's names (`deals.create`, `contacts.list`) and may be patterns: `deals.*`, `*.list`. A failed assertion says what *was* sent. ## Answering requests A stub is one of: ```php Teamleader::fake([ // an array, returned as it is 'deals.info' => ['data' => ['id' => 'deal-1', 'title' => 'Big deal']], // a closure, receiving the body and the endpoint 'contacts.info' => fn (array $body) => ['data' => ['id' => $body['id']]], // a refusal: thrown as the typed exception, or returned as an error array // when TEAMLEADER_THROW_EXCEPTIONS is off — exactly as the real client does 'invoices.book' => Teamleader::response()->status(422)->errors(['invoice_date is required']), 'companies.info' => Teamleader::response()->status(404)->message('Company not found'), // successive answers 'contacts.list' => Teamleader::sequence() ->push(['data' => [['id' => 'c1'], ['id' => 'c2']]]) ->push(['data' => []]), ]); ``` An endpoint without a stub answers an empty success, `['data' => []]`. To make an unstubbed request fail the test instead: ```php Teamleader::fake([...]); Teamleader::preventStrayRequests(); ``` An exhausted sequence throws, so an unexpected extra request shows up. `->whenEmpty($response)` answers every later request the same way instead. Calling `fake()` again adds stubs to the same fake and keeps what was recorded. ## Several connections Every connection is faked, by any name. They share the stubs given to `fake()`; one can have its own: ```php Teamleader::fake(['users.me' => ['data' => ['id' => 'shared']]]); Teamleader::connection('antwerp')->stub(['users.me' => ['data' => ['id' => 'antwerp-user']]]); Teamleader::assertSent('companies.list'); // on any connection Teamleader::connection('antwerp')->assertSent('companies.list'); // on one ``` ## Files, downloads and OAuth The two file transfers are recorded under their own names: | Recorded as | Stub with | |---|---| | `files.upload (contents)` — the bytes `uploadFile()` sends; body has `location` and `contents` | an array, like `['data' => ['id' => 'file-1', 'type' => 'file']]` | | `download (contents)` — the fetch in `downloadContents()` / `downloadTo()`; body has `location` | a string: the document's bytes | The link requests before them (`files.upload`, `invoices.download`) are ordinary endpoints. Combine with `Storage::fake('s3')` to test `downloadTo()`. `Teamleader::handleCallback()` completes without Teamleader: it records `oauth.callback` with the code and state, and returns the connection. Stub `oauth.callback` with a refusal to test the failure path. ## Bulk and queues Bulk operations run through the fake as they would for real, validation first. A refusal from a stub is counted as a failed row: ```php Teamleader::fake([ 'companies.add' => Teamleader::sequence() ->push(['data' => ['id' => 'c1']]) ->push(Teamleader::response()->status(422)->errors(['vat_number is invalid'])), ]); $result = Teamleader::bulk()->create('companies', $rows)->continueOnError()->run(); ``` Queued bulk: `dispatch()` refuses the `sync` queue driver, which is the usual one in tests. Fake the bus and name a real connection: ```php Bus::fake(); Teamleader::bulk()->create('companies', $rows)->dispatch(chunk: 50, queueConnection: 'database'); Bus::assertBatched(fn ($batch) => $batch->jobs->count() === 2); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/guides/ai-agents # AI Agents Coding agents such as Claude Code, Cursor and GitHub Copilot write better Teamleader code when they know how the SDK works. The SDK gives them that in two ways: guidelines for Laravel Boost, and the full documentation as plain text. > [!NOTE] > Boost guidelines are available from v3.3. ## Laravel Boost [Laravel Boost](https://laravel.com/docs/boost) collects guidelines from your application's packages and writes them into the instruction files your agent reads (`CLAUDE.md`, `AGENTS.md`, `.cursor/rules`, and so on). The SDK ships its guidelines in `resources/boost/guidelines/core.blade.php`, so there is nothing to copy: ```bash composer require laravel/boost --dev php artisan boost:install ``` After upgrading the SDK, refresh them: ```bash php artisan boost:update ``` The guidelines tell the agent: - to go through the `Teamleader` facade, never call the API with `Http::` directly, so token refresh, rate limiting and validation keep working - never to guess a filter, sort field, include or body field, and where to look them up: `php artisan teamleader:describe {resource}`, `getCapabilities()`, or the constants on the resource class - that an `InvalidArgumentException` from the SDK means the code is wrong, and is fixed rather than caught - to page with `lazy()`, write many records with `Teamleader::bulk()`, and test with `Teamleader::fake()` - that the CLI writes nothing without `--write`, which it adds only when you asked for a write - the API quirks that cost time to find out, such as the invoice `section` key and `subject.id` in webhook payloads ## Any other agent The documentation site serves two files written for language models: | File | Contents | |---|---| | [`llms.txt`](https://hub.mcore-services.be/docs/teamleader-sdk/llms.txt) | An index of every page, with links | | [`llms-full.txt`](https://hub.mcore-services.be/docs/teamleader-sdk/llms-full.txt) | The complete documentation in one file | Give `llms-full.txt` to an agent that has no Boost integration, or paste it into a chat. Every page is also available as Markdown by adding `.md` to its address, for example [`guides/testing.md`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/testing.md); the start page is [`README.md`](https://hub.mcore-services.be/docs/teamleader-sdk/README.md). ## Let the agent check its own work Two things make an agent's mistakes surface immediately instead of in production: - **Tests with `Teamleader::fake()`.** The resources still validate what they build, so a mistyped filter or field fails the test. See [Testing your integration](https://hub.mcore-services.be/docs/teamleader-sdk/guides/testing). - **The command line.** `php artisan teamleader:describe deals` shows what an endpoint accepts, and `php artisan teamleader:list deals --filter=…` runs a real read. Neither writes anything. See [Command line](https://hub.mcore-services.be/docs/teamleader-sdk/guides/cli). --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/project/specification-parity # Specification Parity Teamleader publishes a machine-readable description of the Focus API: [`@teamleader/focus-api-specification`](https://www.npmjs.com/package/@teamleader/focus-api-specification). The SDK is checked against it on every push. The pinned version is currently **1.229.0**. ## Why it matters The API answers `200 OK` to a filter, sort field, include or body field it does not recognise, and ignores it. Nothing tells you a name was wrong: a mistyped filter returns every record, a mistyped field on an update changes nothing. A hand-written SDK that drifts from the API fails in exactly this silent way. Between v2.2.4 and v2.3.0 every resource was read against the specification, one category per release. The audit started with **111 divergences** and ended with none open. Test methods went from 227 to 608. ## What is checked **Spec contract tests.** For each resource, the field lists, required fields and enum values the SDK validates against are compared with a fixture generated from the specification. These are the public constants shown under **Accepted values** in the [API reference](https://hub.mcore-services.be/docs/teamleader-sdk/reference). **The spec audit.** Every registered resource's endpoints, filters, sort fields, includes and pagination are compared with the specification. Differences are recorded in a baseline; CI fails on anything not in it, and on any baseline entry that no longer applies. Two differences are recorded as accepted: `users.getWeekSchedule` is not wrapped, because Teamleader deprecated it — `userSchedules()->forUser()` wraps its successor; and `dealPhases.duplicate`, which specification 1.225.0 removed, is still called by `dealPhases()->duplicate()`. That method is deprecated and goes in v4.0. **Fixture freshness.** CI regenerates the fixtures from the pinned specification and fails if they differ from what is committed. **Reference freshness.** CI regenerates this documentation's API reference from the code and fails if it differs from what is committed, so the reference describes the SDK as released. **Weekly watch.** A scheduled workflow audits the SDK against the newest published specification and opens an issue when it finds a difference. API changes arrive as a list of findings rather than as bug reports. ## When the specification and the API disagree The specification is treated as authoritative until the live API is shown to behave differently. Where it has been, such as an undeclared filter the API honours or an include the specification mentions only in a response description, the resource says so in its docblock, the reference page carries that note, and the decision is recorded in the audit baseline with a reason. If you find a call that behaves differently from its reference page, please [open an issue](https://github.com/MCore-Services-bv/teamleader-sdk/issues). Reports of calls that return *more* than expected, or ignore something passed to them, are especially useful. ## Running the checks From a clone of the repository: ```bash npm install # the pinned specification npm run spec:fixtures # regenerate the fixtures composer spec:audit -- --summary # counts per category composer spec:audit -- --new # anything not in the baseline composer spec:check # the CI gate composer docs:check # the API reference matches the code ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/project/upgrading # Upgrading ## Deprecated in 3.x, removed in 4.0 These still work in 3.x and raise an `E_USER_DEPRECATED` notice, once per process, the first time they are called. Clear them before upgrading to 4.0: | Deprecated | Since | Use instead | |---|---|---| | `dealPhases()->duplicate($id)` | 3.3.3 | `dealPhases()->create([...])` with the settings of the source phase. Teamleader removed `dealPhases.duplicate` from the API specification in 1.225.0, so the call may stop working before 4.0 | ## From 2.3 to 3.0 Most applications need four steps. Do them in this order: 1. **Check PHP.** 3.0 needs PHP 8.4 or higher. See [Requirements](#requirements). 2. **Update the package and run the migrations:** ```bash composer require mcore-services/teamleader-sdk:^3.0 php artisan migrate ``` The migration upgrades your existing token table in place; you stay connected. See [Run the migrations](#run-the-migrations). 3. **Decide on exceptions.** They are on by default now. Either add `TEAMLEADER_THROW_EXCEPTIONS=false` to `.env`, or replace your `['error']` checks with `catch` blocks. See [Exceptions are on by default](#exceptions-are-on-by-default). 4. **Run the Laravel scheduler**, so tokens are renewed before they expire. See [Token renewal](#token-renewal). Then check `php artisan teamleader:health` and `php artisan teamleader:status`. The remaining sections only matter if your code uses what they describe. New in 3.0, nothing to change for: [multiple connections](https://hub.mcore-services.be/docs/teamleader-sdk/guides/multiple-connections), [events and logging](https://hub.mcore-services.be/docs/teamleader-sdk/guides/events-and-logging), [`lazy()` pagination](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything), [bulk operations](https://hub.mcore-services.be/docs/teamleader-sdk/guides/bulk-operations) and the [command line](https://hub.mcore-services.be/docs/teamleader-sdk/guides/cli). ### Requirements **PHP 8.4 or higher.** 3.0 drops PHP 8.2 and 8.3. Laravel 12 and 13 are both still supported. | | 2.3 | 3.0 | |---|---|---| | PHP | 8.2 – 8.5 | 8.4 – 8.5 | | Laravel | 12, 13 | 12, 13 | Check your version with `php -v`. If you are on 8.2 or 8.3, stay on `^2.3` until you have upgraded PHP; 2.x receives security fixes until 1 January 2027. ### Run the migrations ```bash php artisan migrate ``` 2.x created the `teamleader_tokens` table itself on first use. 3.0 ships migrations instead, and one of them upgrades your existing table in place: its row becomes the `default` connection, and the tokens you have keep working. **Without this step the SDK cannot read its tokens**; `teamleader:health` reports it. 2.x did not record which Teamleader account was connected, so `teamleader:status --all` shows `—` under *Account* at first. The next run of `teamleader:tokens:refresh` (scheduled every ten minutes) looks it up once. The tokens are then encrypted with `APP_KEY`: in the table the first time the SDK reads them, and in the cache under new keys (`teamleader:default:tokens`). The old plain-text cache keys are removed. If you ever rotate `APP_KEY`, see [Token storage and security](https://hub.mcore-services.be/docs/teamleader-sdk/guides/token-storage-and-security). ### Exceptions are on by default **The change most likely to affect your code.** `TEAMLEADER_THROW_EXCEPTIONS` now defaults to `true`: a failed request throws a typed exception instead of returning an array with `error => true`. > [!WARNING] > **Published `config/teamleader.php`?** Your copy still says > `env('TEAMLEADER_THROW_EXCEPTIONS', false)`, and it wins over the package's > default. Exceptions then stay off until you change that line, or set the > variable in `.env`. If you never set it, choose one: **Keep 2.x behaviour for now.** Add one line to `.env`, and leave your error handling as it is: ```dotenv TEAMLEADER_THROW_EXCEPTIONS=false ``` **Or move to exceptions.** Search for `['error']` and `status_code` in your code; each check becomes a `catch`: ```php // 2.x $result = Teamleader::companies()->info($id); if ($result['error'] ?? false) { if ($result['status_code'] === 404) { return null; } Log::warning('Teamleader failed', ['message' => $result['message']]); return null; } return $result['data']; ``` ```php // 3.0 use McoreServices\TeamleaderSDK\Exceptions\NotFoundException; use McoreServices\TeamleaderSDK\Exceptions\TeamleaderException; try { return Teamleader::companies()->info($id)['data']; } catch (NotFoundException) { return null; } catch (TeamleaderException $e) { Log::warning('Teamleader failed', ['message' => $e->getMessage()]); return null; } ``` | 2.x `status_code` | 3.0 exception | |---|---| | 401 | `AuthenticationException` | | 403 | `AuthorizationException` | | 404 | `NotFoundException` | | 422 | `ValidationException` | | 429 | `RateLimitExceededException` (threw in 2.x as well) | | 500, 502, 503, 504 | `ServerException` | | 0 (no response) | `ConnectionException` | | any other | `TeamleaderException` | All extend `TeamleaderException`, so one `catch (TeamleaderException $e)` covers everything; `$e->getStatusCode()` and `$e->getAllErrors()` give what `status_code` and `errors` gave. With exceptions on, server and connection errors are **retried** before the exception reaches you: three attempts, with a growing wait in between. With the 2.x default they were not retried at all. If you already had `TEAMLEADER_THROW_EXCEPTIONS=true`, nothing changes. `false` stays supported throughout 3.x. ### Token renewal - **Run the Laravel scheduler.** The package schedules `teamleader:tokens:refresh` every ten minutes. Without the scheduler nothing breaks, because tokens are still refreshed when a request needs one, but an idle connection can expire unnoticed. `TEAMLEADER_TOKENS_AUTO_REFRESH=false` turns the schedule off. - **A refused refresh token no longer deletes the tokens.** The connection is marked `needs_reauthorization`, and requests throw `ConnectionNeedsReauthorizationException` (a subclass of `AuthenticationException`) instead of returning a 401 error array. Code that detected a lost connection by an empty `getTokenInfo()` should check `needsReauthorization()` or the `status` key. ### Everything else In rough order of how many applications they affect. #### OAuth - **`authorize()` without an argument now generates the `state`**, remembers it in the session, and `handleCallback()` checks it. If you generated and checked a state yourself, you can delete that code, or keep passing your own state to `authorize($state)`, which works as in 2.x. - `getAuthorizationUrl()` without an argument generates a state too. - `handleCallback()` returns the connected `TeamleaderSDK` instance instead of `true` (still `false` on failure). `if (Teamleader::handleCallback(...))` keeps working; `=== true` does not. - A callback with a state this session did not issue throws `OAuthStateException`. #### Configuration If you published `config/teamleader.php`, compare it with the package's copy: ```bash diff config/teamleader.php vendor/mcore-services/teamleader-sdk/config/teamleader.php ``` Your copy keeps working: keys it lacks (`default`, `connections`, `tokens`, `expected_account_id`) come from the package. Keys of your own, such as a default department id, stay where they are. The keys below were in the file but had no effect; they are removed. Deleting them from your copy and from `.env` changes nothing about how the SDK behaves. | Removed key | `.env` variable | Why | |---|---|---| | `sideloading.*` | `TEAMLEADER_SIDELOADING_ENABLED`, `TEAMLEADER_VALIDATE_INCLUDES`, `TEAMLEADER_MAX_INCLUDES` | Include validation is always on | | `caching.*` | `TEAMLEADER_CACHING_ENABLED`, `TEAMLEADER_CACHE_TTL`, `TEAMLEADER_CACHE_STORE` | The SDK caches no API responses. The flag only cached the boot-time validation result, which nothing read | | `development.*` | `TEAMLEADER_SANDBOX_MODE`, `TEAMLEADER_MOCK_RESPONSES`, `TEAMLEADER_DEBUG_MODE`, `TEAMLEADER_LOG_ALL_REQUESTS` | Only the configuration validator mentioned them | | `rate_limiting.requests_per_minute`, `throttle_threshold`, `aggressive_throttling`, `respect_retry_after` | `TEAMLEADER_RATE_LIMIT`, `TEAMLEADER_THROTTLE_THRESHOLD`, `TEAMLEADER_AGGRESSIVE_THROTTLING`, `TEAMLEADER_RESPECT_RETRY_AFTER` | The limit is Teamleader's; the throttle steps are fixed | | `logging.enabled`, `sanitize_logs`, `log_rate_limits`, `log_token_refresh` | `TEAMLEADER_LOGGING_ENABLED`, `TEAMLEADER_SANITIZE_LOGS`, `TEAMLEADER_LOG_RATE_LIMITS`, `TEAMLEADER_LOG_TOKEN_REFRESH` | Sanitising is always on; silence the SDK through its log channel | | `error_handling.log_errors`, `include_stack_trace`, `parse_teamleader_errors` | `TEAMLEADER_LOG_ERRORS`, `TEAMLEADER_INCLUDE_STACK_TRACE`, `TEAMLEADER_PARSE_TL_ERRORS` | Only shown by the health check, never applied | Three keys that were documented but ignored **now work**. Check your `.env` for them before upgrading, because a value you set long ago now takes effect: | Key | `.env` variable | Effect | |---|---|---| | `base_url` | `TEAMLEADER_BASE_URL` | API host | | `auth_url` | `TEAMLEADER_AUTH_URL` | OAuth host for authorize, code exchange and token refresh | | `api.retry_delay` | `TEAMLEADER_API_RETRY_DELAY` | First retry delay in ms (was fixed at 1000) | `logging.channel` defaults to `null` (your default channel) instead of `config('logging.default')`. #### Removed methods and resource keys Everything deprecated during the 2.2.x audit is gone. Each of these now fails with "Call to undefined method" (or, for a resource key, an exception naming the new key). Search your code for the left-hand column: | Removed | Use instead | |---|---| | `users()->getWeekSchedule($id)` | `userSchedules()->forUser($id, $from, $until)` — at most seven days | | `plannableItems()->active()` | `plannableItems()->list()`; filter on `completion_statuses` / `planned_time_statuses` | | `invoices()->draft()` | `invoices()->listDrafts()` | | `lostReasons()->search($ids)` | `lostReasons()->byIds($ids)`, or `all()` without ids | | `companies()->byName($name)` | `companies()->search($name)` — the `term` filter | | `quotations()->byStatus($status)` | `quotations()->list()`, filtered client-side on `data[].status` | | `creditNotes()->paid()`, `unpaid()` | `creditNotes()->list()`, filtered client-side on `data[].paid` | | `products()->withCustomFields()` | nothing — `info()` always returns custom fields | | `deals()->withCustomer()`, `withResponsibleUser()`, `withDepartment()`, `withCurrentPhase()`, `withSource()`, `withAll()` | nothing — the data is returned on every deal | | `calenderEvents()` | `calendarEvents()` | | `creditnotes()` | `creditNotes()` | | `payment_methods()` | `paymentMethods()` | | `payment_terms()` | `paymentTerms()` | | `external_parties()` | `externalParties()` | | `plannable_items()` | `plannableItems()` | | `user_availability()` | `userAvailability()` | | `$sdk->getDeprecatedResourceAliases()` | nothing — there are no aliases left | In 2.3 all of these still ran, most of them with an `E_USER_DEPRECATED` notice in your log. If you upgrade to 2.3 first and clear those notices, this step is a no-op. #### Connections Single-account applications need no change: the flat `client_id`, `client_secret` and `redirect_uri` are the `default` connection. - `new TeamleaderSDK(...)` takes a fifth argument, `?ConnectionConfig $connection`. Resolve instances through the container or `Teamleader::connection()` rather than constructing them. - The SDK reads the credentials when an instance is built, not on every request. Code that changes `config('teamleader.client_id')` at runtime should define a connection with `Teamleader::extend()` instead. - The rate-limit window moved to Redis keys per client ID. Its count starts at zero on the first request after the upgrade. The window is a minute long. - Configuration errors name the connection: `Teamleader connection 'default' is missing: client_secret.` instead of `Missing required configuration: teamleader.client_secret`. #### Token storage - `TokenService` stores through a `TokenStore`, which is the table unless you bind another, and takes the connection name: `new TokenService($store, 'default')`. Calling `new TokenService` without arguments still works. - `getTokenInfo()` has new keys: `connection`, `status`, `account_id`, `account_name`, `last_refreshed_at`. - `storeTokens()` takes a second argument, `bool $refreshed`, used by the refresh. #### Logging and the call log - `TEAMLEADER_LOG_CHANNEL`, `TEAMLEADER_LOG_REQUESTS` and `TEAMLEADER_LOG_RESPONSES` now work. If you set them in 2.x, where they did nothing, check the values: `LOG_REQUESTS=true` writes every request body to your log. See [Events and Logging](https://hub.mcore-services.be/docs/teamleader-sdk/guides/events-and-logging). - `TeamleaderSDK::getApiCalls()` keeps the last 100 calls instead of every call since the process started, and no longer includes `request_data` or `headers`. Listen to `RequestSending` / `ResponseReceived` if you used them. - The refresh token is no longer logged, not even its first 20 characters. #### Health and validation commands - `teamleader:health` checks your default cache store, where the tokens live, instead of a `caching.store` setting. `--fix` no longer runs `Cache::flush()`, which cleared your **whole** application cache. - `teamleader:config:validate` no longer suggests enabling caching or debug mode, and no longer warns "Laravel 11 detected" on every Laravel 12 and 13 install. #### Sorting No code changes are needed. Two small differences: - Sort validation messages name the endpoint: `Invalid sort field: title. deals.list accepts: created_at, weighted_value.` If you match on the old `Accepted:` wording, match on `Invalid sort field` instead. - `timeTracking()->list()` now also accepts a list of field names and a `['starts_on' => 'desc']` map, like every other resource. #### Removed classes `McoreServices\TeamleaderSDK\Constants\TeamleaderConstants` and `McoreServices\TeamleaderSDK\Constants\ErrorMessages` are removed. Nothing in the SDK read them. If your code used one of their constants, inline the value. ## From 2.2 to 2.3 There are no breaking API changes in the SDK itself, and 2.3 runs on the same PHP and Laravel versions. What does change across 2.2.4 – 2.3.0 is that requests which used to be silently wrong now throw. **Silent no-ops now throw `InvalidArgumentException`.** An unsupported filter, sort field, include, option or body field used to be dropped by the SDK, or passed to the API, which ignored it. Either way the call returned unfiltered data, or an update changed nothing. Now it throws, naming the accepted values. If upgrading surfaces one of these, the call was not doing what it appeared to. Fix the name rather than catching the exception; the resource's [reference page](https://hub.mcore-services.be/docs/teamleader-sdk/reference) lists what it accepts. The most common cases: | Before | Instead | |---|---| | `companies()->list(['name' => ...])` | `['term' => ...]` — searches name, VAT, email and phone | | `timeTracking()->list(['updated_since' => ...])` | `started_after` / `ended_after` | | `companies()->info($id, 'custom_fields')` | nothing — custom fields are always returned on `info` | | `contacts()->with(...)->info($id)` | nothing — `contacts.info` takes no includes | | A sort on a field the endpoint does not sort by | a field from the resource's **Sorting** section | **Stricter-than-API checks were relaxed** at the same time, where the SDK demanded fields the API does not: meeting customers, timer subjects, expense totals, and task work types on update. Code that worked around those can be simplified. The full list, release by release, is in the [changelog](https://github.com/MCore-Services-bv/teamleader-sdk/blob/main/CHANGELOG.md). ## From 1.x to 2.0 2.0 dropped Laravel 10 and 11, which are end of life with unpatched security advisories. On Laravel 12 no changes are needed. On Laravel 10 or 11, upgrade Laravel, or pin the SDK to `^1.2` until you can. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/project/contributing # Contributing Contributions are welcome. The full guide is [CONTRIBUTING.md](https://github.com/MCore-Services-bv/teamleader-sdk/blob/main/CONTRIBUTING.md) in the repository; the short version: - **Bug reports** are the most valuable contribution. Include what you expected, what happened and how you worked around it. A call that returns *more* than expected, or ignores something you passed, is worth reporting even without an error. - **Changes are checked against the specification**, not against the developer portal or the existing code. See [Specification parity](https://hub.mcore-services.be/docs/teamleader-sdk/project/specification-parity). - **Tests are expected** for new behaviour and every bug fix. ## Documentation These docs live in the repository's `docs/` folder and are published from `main`. - The **guides** are written by hand; change them in the same pull request as the behaviour they describe. - The **API reference** is generated from the resource classes. To improve a reference page, improve the code it comes from (a filter description, a docblock, a usage example) and run: ```bash composer docs:build ``` CI fails when the committed reference does not match the code. ## Security Report security issues by email to **help@mcore-services.be**, not in the issue tracker. See [SECURITY.md](https://github.com/MCore-Services-bv/teamleader-sdk/blob/main/SECURITY.md). --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference # API Reference One page per resource, generated from the SDK source. Each page lists the facade method, the API endpoints the resource calls, the filters, sort fields and includes it accepts, the constants that hold its accepted values, and every public method. Filters, sort fields, includes and body fields are checked against `@teamleader/focus-api-specification` **1.229.0** — see [Specification parity](https://hub.mcore-services.be/docs/teamleader-sdk/project/specification-parity). Anything not listed on a page throws an `InvalidArgumentException` before a request is sent. ## CRM | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Addresses](https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/addresses) | `addresses()` | — | — | — | ✓ | — | — | | [Business Types](https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/business-types) | `businessTypes()` | — | — | — | ✓ | — | — | | [Companies](https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/companies) | `companies()` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | [Contacts](https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/contacts) | `contacts()` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | [Tags](https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/tags) | `tags()` | — | — | — | — | ✓ | — | ## Deals | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Deal Phases](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/deal-phases) | `dealPhases()` | ✓ | ✓ | ✓ | ✓ | — | — | | [Deal Pipelines](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/deal-pipelines) | `dealPipelines()` | ✓ | ✓ | ✓ | ✓ | — | — | | [Deal Sources](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/deal-sources) | `dealSources()` | — | — | — | ✓ | ✓ | — | | [Deals](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/deals) | `deals()` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | [Lost Reasons](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/lost-reasons) | `lostReasons()` | — | — | — | ✓ | ✓ | — | | [Orders](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/orders) | `orders()` | — | — | — | ✓ | — | ✓ | | [Quotations](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/quotations) | `quotations()` | ✓ | ✓ | ✓ | ✓ | — | ✓ | ## Invoicing | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Commercial Discounts](https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/commercial-discounts) | `commercialDiscounts()` | — | — | — | ✓ | — | — | | [Credit Notes](https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/credit-notes) | `creditNotes()` | — | — | — | ✓ | — | — | | [Invoices](https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/invoices) | `invoices()` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | [Payment Methods](https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/payment-methods) | `paymentMethods()` | — | — | — | ✓ | — | — | | [Payment Terms](https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/payment-terms) | `paymentTerms()` | — | — | — | — | — | — | | [Subscriptions](https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/subscriptions) | `subscriptions()` | ✓ | ✓ | — | ✓ | ✓ | — | | [Tax Rates](https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/tax-rates) | `taxRates()` | — | — | — | ✓ | ✓ | — | | [Withholding Tax Rates](https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/withholding-tax-rates) | `withholdingTaxRates()` | — | — | — | ✓ | — | — | ## Expenses | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Bookkeeping Submissions](https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/bookkeeping-submissions) | `bookkeepingSubmissions()` | — | — | — | ✓ | — | — | | [Expenses](https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/expenses) | `expenses()` | — | — | — | ✓ | ✓ | — | | [Incoming Credit Notes](https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/incoming-credit-notes) | `incomingCreditNotes()` | ✓ | ✓ | ✓ | — | — | — | | [Incoming Invoices](https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/incoming-invoices) | `incomingInvoices()` | ✓ | ✓ | ✓ | — | — | — | | [Receipts](https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/receipts) | `receipts()` | ✓ | ✓ | ✓ | — | — | — | ## Products | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Price Lists](https://hub.mcore-services.be/docs/teamleader-sdk/reference/products/price-lists) | `priceLists()` | — | — | — | ✓ | — | — | | [Product Categories](https://hub.mcore-services.be/docs/teamleader-sdk/reference/products/product-categories) | `productCategories()` | — | — | — | ✓ | — | — | | [Products](https://hub.mcore-services.be/docs/teamleader-sdk/reference/products/products) | `products()` | ✓ | ✓ | ✓ | ✓ | — | ✓ | | [Units of Measure](https://hub.mcore-services.be/docs/teamleader-sdk/reference/products/units-of-measure) | `unitsOfMeasure()` | — | — | — | — | — | — | ## Projects | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [External Parties](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/external-parties) | `externalParties()` | — | — | — | — | — | — | | [Groups](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/groups) | `groups()` | ✓ | ✓ | ✓ | ✓ | — | — | | [Legacy Milestones](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/legacy-milestones) | `legacyMilestones()` | ✓ | ✓ | ✓ | ✓ | ✓ | — | | [Legacy Projects](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/legacy-projects) | `legacyProjects()` | ✓ | ✓ | ✓ | ✓ | ✓ | — | | [Materials](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/materials) | `materials()` | ✓ | ✓ | ✓ | ✓ | — | — | | [Project Lines](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/project-lines) | `projectLines()` | — | — | — | ✓ | — | — | | [Project Tasks](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/project-tasks) | `projectTasks()` | ✓ | ✓ | ✓ | ✓ | — | — | | [Projects](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/projects) | `projects()` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ## Tasks | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Tasks](https://hub.mcore-services.be/docs/teamleader-sdk/reference/tasks/tasks) | `tasks()` | ✓ | ✓ | ✓ | ✓ | ✓ | — | ## Time Tracking | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Time Tracking](https://hub.mcore-services.be/docs/teamleader-sdk/reference/time-tracking/time-tracking) | `timeTracking()` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | [Timers](https://hub.mcore-services.be/docs/teamleader-sdk/reference/time-tracking/timers) | `timers()` | ✓ | ✓ | — | — | — | — | ## Planning | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Plannable Items](https://hub.mcore-services.be/docs/teamleader-sdk/reference/planning/plannable-items) | `plannableItems()` | — | — | — | ✓ | ✓ | — | | [Reservations](https://hub.mcore-services.be/docs/teamleader-sdk/reference/planning/reservations) | `reservations()` | ✓ | ✓ | ✓ | ✓ | — | — | | [User Availability](https://hub.mcore-services.be/docs/teamleader-sdk/reference/planning/user-availability) | `userAvailability()` | — | — | — | ✓ | — | — | ## Calendar | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Activity Types](https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/activity-types) | `activityTypes()` | — | — | — | ✓ | — | — | | [Calendar Events](https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/calendar-events) | `calendarEvents()` | ✓ | ✓ | ✓ | ✓ | ✓ | — | | [Call Outcomes](https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/call-outcomes) | `callOutcomes()` | — | — | — | — | — | — | | [Calls](https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/calls) | `calls()` | ✓ | ✓ | ✓ | ✓ | — | — | | [Meetings](https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/meetings) | `meetings()` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ## Tickets | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Ticket Status](https://hub.mcore-services.be/docs/teamleader-sdk/reference/tickets/ticket-status) | `ticketStatus()` | — | — | — | ✓ | — | — | | [Tickets](https://hub.mcore-services.be/docs/teamleader-sdk/reference/tickets/tickets) | `tickets()` | ✓ | ✓ | — | ✓ | — | — | ## General | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Closing Days](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/closing-days) | `closingDays()` | ✓ | — | ✓ | ✓ | — | — | | [Currencies](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/currencies) | `currencies()` | — | — | — | — | — | — | | [Custom Fields](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/custom-fields) | `customFields()` | ✓ | — | — | ✓ | ✓ | — | | [Day Off Types](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/day-off-types) | `dayOffTypes()` | ✓ | ✓ | ✓ | — | — | — | | [Days Off](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/days-off) | `daysOff()` | — | — | — | — | — | — | | [Departments](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/departments) | `departments()` | — | — | — | ✓ | ✓ | — | | [Document Templates](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/document-templates) | `documentTemplates()` | — | — | — | ✓ | — | — | | [Email Tracking](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/email-tracking) | `emailTracking()` | ✓ | — | — | ✓ | — | — | | [Notes](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/notes) | `notes()` | ✓ | ✓ | ✓ | ✓ | — | — | | [Teams](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/teams) | `teams()` | — | — | — | ✓ | ✓ | — | | [User Schedules](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/user-schedules) | `userSchedules()` | — | — | — | ✓ | — | — | | [Users](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/users) | `users()` | — | — | — | ✓ | ✓ | ✓ | | [Work Types](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/work-types) | `workTypes()` | — | — | — | ✓ | — | — | ## Files | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Files](https://hub.mcore-services.be/docs/teamleader-sdk/reference/files/files) | `files()` | ✓ | — | ✓ | ✓ | ✓ | — | ## Templates | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Mail Templates](https://hub.mcore-services.be/docs/teamleader-sdk/reference/templates/mail-templates) | `mailTemplates()` | — | — | — | ✓ | — | — | ## Other | Resource | Facade | Create | Update | Delete | Filter | Sort | Includes | |---|---|:-:|:-:|:-:|:-:|:-:|:-:| | [Accounts](https://hub.mcore-services.be/docs/teamleader-sdk/reference/other/accounts) | `accounts()` | — | — | — | — | — | — | | [Cloud Platforms](https://hub.mcore-services.be/docs/teamleader-sdk/reference/other/cloud-platforms) | `cloudPlatforms()` | — | — | — | — | — | — | | [Migrate](https://hub.mcore-services.be/docs/teamleader-sdk/reference/other/migrate) | `migrate()` | — | — | — | — | — | — | | [Webhooks](https://hub.mcore-services.be/docs/teamleader-sdk/reference/other/webhooks) | `webhooks()` | — | — | — | — | — | — | ## Includes Every resource that accepts sideloaded includes. No other resource takes any; see [Sideloading](https://hub.mcore-services.be/docs/teamleader-sdk/guides/sideloading). | Resource | `list()` | `info()` | |---|---|---| | [Companies](https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/companies) | `custom_fields` | `related_companies`, `related_contacts` | | [Contacts](https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/contacts) | `custom_fields` | — | | [Deals](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/deals) | `custom_fields`, `second_responsible_user` | `second_responsible_user` | | [Orders](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/orders) | `custom_fields` | `custom_fields` | | [Quotations](https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/quotations) | `expiry` | `expiry` | | [Invoices](https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/invoices) | `late_fees`, `totals.due_incasso_inclusive`, `totals.fixed_late_fee`, `totals.interest` | `late_fees`, `totals.due_incasso_inclusive`, `totals.fixed_late_fee`, `totals.interest` | | [Products](https://hub.mcore-services.be/docs/teamleader-sdk/reference/products/products) | — | `suppliers` | | [Projects](https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/projects) | `custom_fields`, `legacy_project` | `legacy_project` | | [Time Tracking](https://hub.mcore-services.be/docs/teamleader-sdk/reference/time-tracking/time-tracking) | `materials`, `relates_to` | `materials`, `relates_to` | | [Meetings](https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/meetings) | `estimated_time`, `tracked_time` | `estimated_time`, `tracked_time` | | [Users](https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/users) | — | `external_rate` | --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/addresses # Addresses Get geographical area information for addresses (level two areas like provinces, states, departments). ```php Teamleader::addresses() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\CRM\Addresses` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `levelTwoAreas.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `country` | ISO country code (required) - e.g., BE, NL, FR | | `language` | Language for area names (optional) - defaults to the primary language of the country | ## Methods ### `levelTwoAreas()` ```php levelTwoAreas(string $countryCode, ?string $language = null): array ``` Get level two areas (provinces, states, departments) for a country - `$countryCode` — ISO country code (e.g., "BE", "NL", "FR") - `$language` — Language code for area names (e.g., "nl", "fr", "en") ### `list()` ```php list(array $filters = [], array $options = []): array ``` List level two areas for a country - `$filters` — `country` (required) and optionally `language` - `$options` — Not supported — the endpoint has no paging or sorting **Throws** `\InvalidArgumentException` When country is missing, or another key or option is passed ### `belgianProvinces()` ```php belgianProvinces(string $language = 'nl'): array ``` Get Belgian provinces - `$language` — Language code (nl, fr, de, en) ### `dutchProvinces()` ```php dutchProvinces(string $language = 'nl'): array ``` Get Dutch provinces - `$language` — Language code ### `frenchDepartments()` ```php frenchDepartments(string $language = 'fr'): array ``` Get French departments - `$language` — Language code ### `germanStates()` ```php germanStates(string $language = 'de'): array ``` Get German states (Bundesländer) - `$language` — Language code ### `ukRegions()` ```php ukRegions(string $language = 'en'): array ``` Get UK countries/regions - `$language` — Language code ### `usStates()` ```php usStates(string $language = 'en'): array ``` Get US states - `$language` — Language code ### `canadianProvinces()` ```php canadianProvinces(string $language = 'en'): array ``` Get Canadian provinces - `$language` — Language code ### `forCountries()` ```php forCountries(array $countries, ?string $defaultLanguage = null): array ``` Get level two areas for multiple countries - `$countries` — Array of country codes or country/language pairs - `$defaultLanguage` — Default language if not specified per country ### `search()` ```php search(string $countryCode, string $query, ?string $language = null, bool $exactMatch = false): array ``` Search level two areas by name (client-side search) - `$countryCode` — Country to search in - `$query` — Search query - `$language` — Language for results - `$exactMatch` — Whether to match exactly or use partial matching ### `findById()` ```php findById(string $countryCode, string $areaId, ?string $language = null): ?array ``` Get area by ID within a country - `$countryCode` — Country code - `$areaId` — Area ID to find - `$language` — Language for result ### `getSupportedCountries()` ```php getSupportedCountries(): array ``` Get supported countries for level two areas (This is a helper method - actual supported countries depend on Teamleader) ### `getSupportedLanguages()` ```php getSupportedLanguages(): array ``` Get supported languages ### `isValidCountryCode()` ```php isValidCountryCode(string $countryCode): bool ``` Validate country code format ### `isValidLanguageCode()` ```php isValidLanguageCode(string $languageCode): bool ``` Validate language code format ### `info()` ```php info($id, $includes = null): array ``` Override info method as it's not supported for level two areas **Throws** `\InvalidArgumentException` ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get provinces for Belgium in Dutch: ```php $areas = Teamleader::addresses()->levelTwoAreas("BE", "nl"); ``` Get states for Germany: ```php $areas = Teamleader::addresses()->levelTwoAreas("DE"); ``` Get departments for France in French: ```php $areas = Teamleader::addresses()->levelTwoAreas("FR", "fr"); ``` Get level two areas for multiple countries: ```php $belgianProvinces = Teamleader::addresses()->levelTwoAreas("BE"); $dutchProvinces = Teamleader::addresses()->levelTwoAreas("NL"); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/business-types # Business Types Get business types (legal structures) for companies in specific countries. ```php Teamleader::businessTypes() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\CRM\BusinessTypes` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `businessTypes.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `country` | ISO country code - e.g., BE, NL, FR | ## Methods ### `forCountry()` ```php forCountry(string $countryCode): array ``` Get business types for a specific country - `$countryCode` — ISO country code (e.g., "BE", "NL") ### `list()` ```php list(array $filters = [], array $options = []): array ``` List business types for a country The specification does not mark `country` as required, but business types are country-specific legal forms, so the SDK asks for one rather than guess what the API does without it. - `$filters` — `country` - `$options` — Not supported — the endpoint has no paging or sorting **Throws** `\InvalidArgumentException` When country is missing, or another key or option is passed ### `belgium()` ```php belgium(): array ``` Get business types for Belgium (convenience method) ### `netherlands()` ```php netherlands(): array ``` Get business types for Netherlands (convenience method) ### `france()` ```php france(): array ``` Get business types for France (convenience method) ### `germany()` ```php germany(): array ``` Get business types for Germany (convenience method) ### `unitedKingdom()` ```php unitedKingdom(): array ``` Get business types for United Kingdom (convenience method) ### `forCountries()` ```php forCountries(array $countryCodes): array ``` Get business types for multiple countries at once - `$countryCodes` — Array of ISO country codes ### `getSupportedCountries()` ```php getSupportedCountries(): array ``` Get available countries that support business types (This is a helper method - actual supported countries depend on Teamleader) ### `isValidCountryCode()` ```php isValidCountryCode(string $countryCode): bool ``` Validate country code format ### `info()` ```php info($id, $includes = null): array ``` Override info method as it's not supported for business types **Throws** `\InvalidArgumentException` ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get business types for Belgium: ```php $businessTypes = Teamleader::businessTypes()->forCountry("BE"); ``` Get business types for Netherlands: ```php $businessTypes = Teamleader::businessTypes()->forCountry("NL"); ``` Get business types for multiple countries: ```php $beTypes = Teamleader::businessTypes()->forCountry("BE"); $nlTypes = Teamleader::businessTypes()->forCountry("NL"); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/companies # Companies Manage companies in Teamleader Focus CRM. ```php Teamleader::companies() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\CRM\Companies` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `companies.add` - `companies.delete` - `companies.info` - `companies.list` - `companies.tag` - `companies.untag` - `companies.update` - `companies.uploadLogo` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `email` | Email address — a string, or ["type" => "primary", "email" => ...]. Only primary is searchable | | `ids` | Array of company UUIDs (a single UUID string is wrapped) | | `marketing_mails_consent` | Marketing mails consent (boolean) | | `national_identification_number` | National identification number | | `status` | active or deactivated (a single value, not an array) | | `tags` | Array of tag names — companies coupled to all given tags | | `term` | Search term (searches name, VAT number, emails and telephones) | | `updated_since` | ISO 8601 datetime | | `vat_number` | VAT number | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `added_at` | Date the company was added | | `name` | Company name | | `updated_at` | Date the company was last updated | ## Includes | Endpoint | Includes | |---|---| | `list()` | `custom_fields` | | `info()` | `related_companies`, `related_contacts` | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ADDRESS_TYPES` | `primary`, `invoicing`, `delivery`, `visiting` | | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `EMAIL_TYPES` | `primary`, `invoicing` | | `FILTER_EMAIL_TYPES` | `primary` | | `STATUSES` | `active`, `deactivated` | | `TELEPHONE_TYPES` | `phone`, `fax` | | `WRITE_FIELDS` | `name`, `business_type_id`, `vat_number`, `national_identification_number`, `emails`, `telephones`, `website`, `addresses`, `iban`, `bic`, `language`, `preferred_currency`, `price_list_id`, `responsible_user_id`, `remarks`, `tags`, `custom_fields`, `marketing_mails_consent` | ## Methods ### `search()` ```php search(string $term, array $options = []): array ``` Enhanced search method with better field handling ### `list()` ```php list(array $filters = [], array $options = []): array ``` List companies - `$filters` — See $commonFilters; unknown keys throw - `$options` — page_size, page_number, sort, sort_order, include **Throws** `InvalidArgumentException` On an unknown filter, sort field or include ### `byEmail()` ```php byEmail(string $email, array $options = []): array ``` Search by email with proper structure ### `byVatNumber()` ```php byVatNumber(string $vatNumber, array $options = []): array ``` Search by VAT number ### `byNationalIdentificationNumber()` ```php byNationalIdentificationNumber(string $number, array $options = []): array ``` Search by national identification number ### `searchAll()` ```php searchAll(string $query, array $options = []): array ``` General search across multiple fields (name, VAT, email, phone) ### `info()` ```php info($id, $includes = null): array ``` Get company information `companies.info` accepts `related_companies` and `related_contacts` as includes — not `custom_fields`, which is a companies.list include. Custom fields and the price list are returned automatically; `price_list` is present whenever the account has access to price lists, and null when no price list is set on the company. - `$id` — Company UUID - `$includes` — related_companies and/or related_contacts **Throws** `InvalidArgumentException` When an include is not valid for this endpoint ### `create()` ```php create(array $data) ``` Create a new company A select custom field takes the option **label** as its value (a string, or a list of strings for multi select), not the option id: Teamleader refuses the id with "has an invalid single selection value". Resolve either form with customFields()->selectValue($fieldId, $labelOrId). ### `update()` ```php update($id, array $data) ``` Update a company ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a company ### `uploadLogo()` ```php uploadLogo(string $id, ?string $image): array ``` Upload or remove a company logo. Pass a base64 data URI string to set the logo, or null to remove it. - `$id` — Company UUID - `$image` — Base64 data URI (e.g. data:image/png;base64,...) or null to remove ### `manageTags()` ```php manageTags(string $id, array $tagsToAdd = [], array $tagsToRemove = []): array ``` Manage tags (add/remove) ### `tag()` ```php tag(string $id, $tags): array ``` Tag a company ### `untag()` ```php untag(string $id, $tags): array ``` Untag a company ### `withTags()` ```php withTags($tags, array $options = []): array ``` Get companies with specific tags ### `updatedSince()` ```php updatedSince(string $date, array $options = []): array ``` Get companies updated since a specific date ### `withCustomFields()` ```php withCustomFields() ``` Include custom fields in the next list() request The only include companies.list accepts. ### `withRelatedCompanies()` ```php withRelatedCompanies() ``` Include related companies in the next info() request companies.info only — not accepted by companies.list. ### `withRelatedContacts()` ```php withRelatedContacts() ``` Include related contacts in the next info() request companies.info only — not accepted by companies.list. --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/contacts # Contacts Manage contacts in Teamleader Focus CRM. ```php Teamleader::contacts() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\CRM\Contacts` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `contacts.add` - `contacts.delete` - `contacts.info` - `contacts.linkToCompany` - `contacts.list` - `contacts.tag` - `contacts.unlinkFromCompany` - `contacts.untag` - `contacts.update` - `contacts.updateCompanyLink` - `contacts.uploadAvatar` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `company_id` | Company UUID, or null for contacts linked to no company | | `email` | Email address — a string, or ["type" => "primary", "email" => ...] | | `ids` | Array of contact UUIDs (a single UUID string is wrapped) | | `marketing_mails_consent` | Marketing mails consent (boolean) | | `status` | active or deactivated | | `tags` | Array of tag names (filters on contacts coupled to all given tags) | | `term` | Search term (searches first_name, last_name, email and telephone) | | `updated_since` | ISO 8601 datetime | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `added_at` | Date the contact was added | | `name` | Contact name | | `updated_at` | Date the contact was last updated | ## Includes | Endpoint | Includes | |---|---| | `list()` | `custom_fields` | | `info()` | none | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ADDRESS_TYPES` | `primary`, `invoicing`, `delivery`, `visiting` | | `EMAIL_TYPES` | `primary` | | `FILTER_EMAIL_TYPES` | `primary` | | `GENDERS` | `female`, `male`, `non_binary`, `prefers_not_to_say`, `unknown` | | `STATUSES` | `active`, `deactivated` | | `TELEPHONE_TYPES` | `phone`, `mobile`, `fax` | | `WRITE_FIELDS` | `first_name`, `last_name`, `salutation`, `emails`, `telephones`, `website`, `addresses`, `gender`, `birthdate`, `iban`, `bic`, `national_identification_number`, `language`, `price_list_id`, `remarks`, `tags`, `custom_fields`, `marketing_mails_consent` | ## Methods ### `info()` ```php info($id, $includes = null): array ``` Get contact information `contacts.info` accepts no includes parameter — the spec declares none. Custom fields and the price list are returned automatically; `price_list` is present whenever the account has access to price lists, and null when no price list is set on the contact. - `$id` — Contact UUID - `$includes` — Not supported by this endpoint **Throws** `InvalidArgumentException` When includes are requested ### `create()` ```php create(array $data): array ``` Create a new contact A select custom field takes the option **label** as its value (a string, or a list of strings for multi select), not the option id: Teamleader refuses the id with "has an invalid single selection value". Resolve either form with customFields()->selectValue($fieldId, $labelOrId). ### `update()` ```php update($id, array $data): array ``` Update a contact ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a contact ### `uploadAvatar()` ```php uploadAvatar(string $id, ?string $image): array ``` Upload or remove a contact avatar. Pass a base64 data URI string to set the avatar, or null to remove it. - `$id` — Contact UUID - `$image` — Base64 data URI (e.g. data:image/png;base64,...) or null to remove ### `search()` ```php search(string $term, array $options = []): array ``` Search contacts by term (searches first_name, last_name, email and telephone) ### `list()` ```php list(array $filters = [], array $options = []): array ``` List contacts - `$filters` — See $commonFilters; unknown keys throw - `$options` — page_size, page_number, sort, sort_order, include **Throws** `InvalidArgumentException` On an unknown filter, sort field or include ### `byEmail()` ```php byEmail(string $email, array $options = []): array ``` Find contacts by email ### `forCompany()` ```php forCompany(string $companyId, array $options = []): array ``` Get contacts for a specific company ### `withoutCompany()` ```php withoutCompany(array $options = []): array ``` Get contacts linked to no company at all Sends `filter.company_id: null`, which the API has accepted since specification 1.221.0. A contact whose only linked company has been deleted is part of this set. ### `withTags()` ```php withTags($tags, array $options = []): array ``` Get contacts with specific tags ### `updatedSince()` ```php updatedSince(string $date, array $options = []): array ``` Get contacts updated since a specific date ### `active()` ```php active(array $options = []): array ``` Get active contacts only ### `deactivated()` ```php deactivated(array $options = []): array ``` Get deactivated contacts only ### `manageTags()` ```php manageTags(string $id, array $tagsToAdd = [], array $tagsToRemove = []): array ``` Manage tags (add/remove) ### `tag()` ```php tag(string $id, $tags): array ``` Tag a contact ### `untag()` ```php untag(string $id, $tags): array ``` Untag a contact ### `linkToCompany()` ```php linkToCompany(string $id, string $companyId, array $data = []): array ``` Link a contact to a company ### `unlinkFromCompany()` ```php unlinkFromCompany(string $id, string $companyId): array ``` Unlink a contact from a company ### `updateCompanyLink()` ```php updateCompanyLink(string $id, string $companyId, array $data = []): array ``` Update contact to company link ### `withCustomFields()` ```php withCustomFields(): self ``` Include custom fields in the next list() request The only include contacts.list accepts. Note that contacts.info takes no includes at all, so this must not be chained into info(). ## Examples Get all contacts: ```php $contacts = Teamleader::contacts()->list(); ``` Search contacts by term: ```php $contacts = Teamleader::contacts()->search("John"); ``` Get contacts for specific company: ```php $contacts = Teamleader::contacts()->forCompany("company-uuid"); ``` Get contacts linked to no company: ```php $contacts = Teamleader::contacts()->withoutCompany(); ``` Find contact by email: ```php $contacts = Teamleader::contacts()->byEmail("john@example.com"); ``` Get contacts with custom fields: ```php $contacts = Teamleader::contacts()->withCustomFields()->list(); ``` Create a new contact: ```php $contact = Teamleader::contacts()->create(["first_name" => "John", "last_name" => "Doe"]); ``` Link a contact to a price list: ```php Teamleader::contacts()->update("contact-uuid", ["price_list_id" => "price-list-uuid"]); ``` Remove the price list from a contact: ```php Teamleader::contacts()->update("contact-uuid", ["price_list_id" => null]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/crm/tags # Tags Manage tags in Teamleader Focus. ```php Teamleader::tags() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\CRM\Tags` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `tags.list` ## Filters `list()` takes no filters. ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `tag` | Tag name (the only sort field; ascending only) | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List tags with pagination and sorting `tags.list` accepts `page` and `sort` but has no filter object at all, so filters are rejected rather than discarded. The API declares exactly one sort field (`tag`) and one order (`asc`); anything else is rejected too, rather than being silently rewritten to the supported value as it was before v2.1.2 — a caller asking for descending order got ascending back with no indication the request had been changed. - `$filters` — Must be empty — this endpoint accepts no filters - `$options` — page_size, page_number, sort, sort_order **Throws** `\InvalidArgumentException` When filters are passed or the sort is unsupported ### `all()` ```php all(): array ``` Get all tags without pagination ### `search()` ```php search(string $query, bool $exactMatch = false): array ``` Search tags by name (client-side filtering) Note: The API doesn't support server-side filtering, so we fetch all and filter - `$query` — Search query - `$exactMatch` — Whether to match exactly or use partial matching ### `containing()` ```php containing(string $text): array ``` Get tags containing specific text - `$text` — Text to search for ### `startingWith()` ```php startingWith(string $prefix): array ``` Get tags starting with specific text - `$prefix` — Prefix to search for ### `paginate()` ```php paginate(int $pageSize = 20, int $pageNumber = 1): array ``` Get paginated results with better metadata The `pagination` key this adds is computed client-side from the page size and the number of rows returned. The API sends no pagination metadata for tags — `tags.list` declares no meta block — so `has_more_pages` is an inference from a full page, not a fact from the server. A complete final page therefore reports has_more_pages = true and costs one extra empty request to disprove. - `$pageSize` — Items per page - `$pageNumber` — Page number (1-based) ### `getStatistics()` ```php getStatistics(): array ``` Get tag statistics ### `info()` ```php info($id, $includes = null): array ``` Override info method as individual tag info is not supported **Throws** `\InvalidArgumentException` ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all tags: ```php $tags = Teamleader::tags()->list(); ``` Get tags with pagination: ```php $tags = Teamleader::tags()->list([], ['page_size' => 50, 'page_number' => 2]); ``` Get tags sorted alphabetically: ```php $tags = Teamleader::tags()->list([], ['sort' => 'tag', 'sort_order' => 'asc']); ``` Search for specific tags: ```php $campaignTags = Teamleader::tags()->search('campaign'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/deal-phases # Deal Phases Manage deal phases in Teamleader Focus. ```php Teamleader::dealPhases() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Deals\Phases` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `dealPhases.create` - `dealPhases.delete` - `dealPhases.duplicate` - `dealPhases.list` - `dealPhases.move` - `dealPhases.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `deal_pipeline_id` | Filter phases by specific pipeline UUID | | `ids` | Array of deal phase UUIDs to filter by | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ATTENTION_UNITS` | `days`, `weeks` | | `CREATE_FIELDS` | `name`, `deal_pipeline_id`, `estimated_probability`, `follow_up_actions`, `requires_attention_after` | | `FOLLOW_UP_ACTIONS` | `create_event`, `create_call`, `create_task` | | `UPDATE_FIELDS` | `id`, `name`, `estimated_probability`, `follow_up_actions`, `requires_attention_after` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List deal phases with enhanced filtering and pagination - `$filters` — Filters to apply - `$options` — Additional options (pagination) ### `create()` ```php create(array $data): array ``` Create a new deal phase dealPhases.create requires name, deal_pipeline_id and requires_attention_after. - `$data` — Phase data **Throws** `InvalidArgumentException` ### `update()` ```php update($id, array $data): array ``` Update a deal phase dealPhases.update requires requires_attention_after on every call, not only when it changes. Before v2.2.5 the SDK treated it as optional, so an update without it passed client-side and was rejected by the API. - `$id` — Phase UUID - `$data` — Update data **Throws** `InvalidArgumentException` ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a deal phase, moving its deals to another phase `new_phase_id` is optional in the specification. Before v2.2.5 this method required it and then called parent::delete(), which does not exist — so every call was a fatal "Call to undefined method", and dealPhases.delete could not be reached at all. The same defect was fixed on Pipelines in v2.2.2. Every pipeline has four fixed phases (in a Dutch account: Nieuw, Offerte verzonden, Aanvaard, Geweigerd). They can be renamed in Teamleader, never deleted: Teamleader answers "Unable to delete fixed deal phase.", which this turns into a ValidationException that says so. - `$id` — Phase UUID to delete - `$additionalParams` — Optional UUID of the phase that takes over the deals **Throws** `ValidationException` When the phase is one of the fixed phases ### `duplicate()` > **Deprecated** 3.3.3 dealPhases.duplicate is no longer in the Teamleader API specification. Create the phase with create() instead, copying the settings of the source phase. Raises E_USER_DEPRECATED once per process and goes in v4.0. ```php duplicate(string $id): array ``` Duplicate an existing deal phase Teamleader removed dealPhases.duplicate from the API specification in 1.225.0, without a successor. The request is still sent, so code that calls this keeps working for as long as Teamleader keeps answering it, but nothing guarantees that it does. - `$id` — Source phase UUID ### `move()` ```php move(string $id, string $afterPhaseId): array ``` Move a phase to a new position in the pipeline - `$id` — Phase UUID to move - `$afterPhaseId` — Phase UUID to place this phase after ### `forPipeline()` ```php forPipeline(string $pipelineId): array ``` Get phases for a specific pipeline - `$pipelineId` — Pipeline UUID ### `byIds()` ```php byIds(array $ids): array ``` Get phases by specific IDs - `$ids` — Array of phase UUIDs ### `getAvailableFollowUpActions()` ```php getAvailableFollowUpActions(): array ``` Get available follow-up actions ### `getAvailableAttentionAfterUnits()` ```php getAvailableAttentionAfterUnits(): array ``` Get available attention after units ## Examples Get all phases across all pipelines: ```php $phases = Teamleader::dealPhases()->list(); ``` Get phases for specific pipeline: ```php $phases = Teamleader::dealPhases()->list(['deal_pipeline_id' => 'pipeline-uuid']); ``` Delete a phase, moving its deals to another phase: ```php Teamleader::dealPhases()->delete('phase-uuid', 'new-phase-uuid'); ``` Create a new phase: ```php $phase = Teamleader::dealPhases()->create(['name' => 'New Phase', 'deal_pipeline_id' => 'uuid', 'requires_attention_after' => ['amount' => 7, 'unit' => 'days']]); ``` Move phase to new position: ```php Teamleader::dealPhases()->move('phase-uuid', 'after-phase-uuid'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/deal-pipelines # Deal Pipelines Manage deal pipelines in Teamleader Focus. ```php Teamleader::dealPipelines() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Deals\Pipelines` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; the response includes `meta` with totals. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `dealPipelines.create` - `dealPipelines.delete` - `dealPipelines.duplicate` - `dealPipelines.list` - `dealPipelines.markAsDefault` - `dealPipelines.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of deal pipeline UUIDs to filter by | | `status` | Filter by pipeline status (open, pending_deletion) — a string is wrapped into an array | | `term` | Search the pipeline name | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `STATUSES` | `open`, `pending_deletion` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List deal pipelines with enhanced filtering and pagination - `$filters` — Filters to apply - `$options` — Additional options (pagination, includes) ### `create()` ```php create(array $data): array ``` Create a new deal pipeline Teamleader gives every new pipeline four fixed phases (in a Dutch account: Nieuw, Offerte verzonden, Aanvaard, Geweigerd). They exist as soon as this returns, can be renamed in Teamleader, and can never be deleted — dealPhases.delete refuses them. Add your own phases with dealPhases()->create(), and map onto the fixed ones rather than recreating them. - `$data` — Pipeline data ### `update()` ```php update($id, array $data): array ``` Update a deal pipeline - `$id` — Pipeline UUID - `$data` — Update data ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a deal pipeline, optionally migrating its phases Deals in the deleted pipeline's phases are moved to the phases named in $migratePhases, each entry being ['old_phase_id' => ..., 'new_phase_id' => ...]. - `$id` — Pipeline UUID to delete - `$additionalParams` — Expects an array of phase migrations as the first param **Throws** `InvalidArgumentException` When the migration argument is not an array ### `duplicate()` ```php duplicate(string $id): array ``` Duplicate an existing deal pipeline - `$id` — Source pipeline UUID ### `markAsDefault()` ```php markAsDefault(string $id): array ``` Mark a pipeline as default - `$id` — Pipeline UUID ### `open()` ```php open(): array ``` Get only open pipelines ### `search()` ```php search(string $term, array $options = []): array ``` Search pipelines by name ### `pendingDeletion()` ```php pendingDeletion(): array ``` Get pipelines pending deletion ### `byIds()` ```php byIds(array $ids): array ``` Get pipelines by specific IDs - `$ids` — Array of pipeline UUIDs ### `getAvailableStatuses()` ```php getAvailableStatuses(): array ``` Get available status values for filtering --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/deal-sources # Deal Sources Manage deal sources in Teamleader Focus. ```php Teamleader::dealSources() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Deals\Sources` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `dealSources.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of deal source UUIDs to filter by | | `term` | Search the deal source name | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `name` | Deal source name (ascending only) | ## Methods ### `search()` ```php search(string $query, array $options = []): array ``` Search deal sources by name Uses the `term` filter, which dealSources.list has declared since specification 1.221.0. Before v2.2.5 this fetched the first page and filtered it in PHP, so a source past the first 20 was never found. - `$query` — Search query ### `all()` ```php all(int $maxPages = 50): array ``` Get every deal source, walking all pages Before v2.2.5 this was list() with no options — the first 20 sources only — so selectOptions() and getStatistics() silently dropped the rest. - `$maxPages` — Runaway guard: 50 pages of 100 **Throws** `InvalidArgumentException` When $maxPages is reached with sources still pending ### `list()` ```php list(array $filters = [], array $options = []): array ``` List deal sources with filtering, pagination and sorting - `$filters` — `ids`, `term` - `$options` — page_size, page_number, sort (name), sort_order (asc) **Throws** `InvalidArgumentException` On an unknown filter, or a sort other than name/asc ### `selectOptions()` ```php selectOptions(): array ``` Get deal sources in select option format for forms ### `info()` ```php info($id, $includes = null): array ``` Override info method to clarify it's not available ### `getStatistics()` ```php getStatistics(): array ``` Get statistics about deal sources ### `exists()` ```php exists(string $sourceId): bool ``` Validate if a deal source ID exists ### `byIds()` ```php byIds(array $ids): array ``` Get deal sources by specific IDs - `$ids` — Array of deal source UUIDs ### `getName()` ```php getName(string $sourceId): ?string ``` Get deal source name by ID ## Examples Get all deal sources: ```php $sources = Teamleader::dealSources()->list(); ``` Get specific deal sources by ID: ```php $sources = Teamleader::dealSources()->list(['ids' => ['uuid1', 'uuid2']]); ``` Get deal sources sorted by name (default): ```php $sources = Teamleader::dealSources()->list([], ['sort' => [['field' => 'name', 'order' => 'asc']]]); ``` Get deal sources with pagination: ```php $sources = Teamleader::dealSources()->list([], ['page_size' => 50, 'page_number' => 1]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/deals # Deals Manage sales deals in Teamleader Focus. ```php Teamleader::deals() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Deals\Deals` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `deals.create` - `deals.delete` - `deals.info` - `deals.list` - `deals.lose` - `deals.move` - `deals.update` - `deals.win` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `created_before` | Filter by creation date (inclusive) | | `customer` | Filter by customer (requires type and id) | | `estimated_closing_date` | Filter by exact closing date | | `estimated_closing_date_from` | Filter by closing date from (inclusive) | | `estimated_closing_date_until` | Filter by closing date until (inclusive) | | `ids` | Array of deal UUIDs to filter by | | `phase_id` | Filter by specific phase UUID | | `pipeline_ids` | Array of pipeline UUIDs | | `responsible_user_id` | Filter by responsible user UUID (string or array) | | `status` | Filter by deal status (open, won, lost) — a string is wrapped into an array | | `term` | Search term (filters on title, reference, and customer name) | | `updated_since` | Filter by last update date (inclusive) | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `created_at` | Sort by creation date | | `weighted_value` | Sort by weighted deal value | ## Includes | Endpoint | Includes | |---|---| | `list()` | `custom_fields`, `second_responsible_user` | | `info()` | `second_responsible_user` | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `CUSTOMER_TYPES` | `contact`, `company` | | `STATUSES` | `open`, `won`, `lost` | | `UPDATE_FIELDS` | `lead`, `title`, `summary`, `source_id`, `department_id`, `responsible_user_id`, `second_responsible_user_id`, `estimated_value`, `estimated_probability`, `estimated_closing_date`, `currency`, `custom_fields`, `purchase_order_number` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List deals with filtering, sorting, pagination and sideloading Unknown filter keys throw rather than being forwarded — the API ignores keys it does not recognise and returns a full unfiltered result set with a 200, which is indistinguishable from a correct response. - `$filters` — Filters to apply — see $commonFilters - `$options` — sort, sort_order, page_size, page_number, include **Throws** `InvalidArgumentException` When a filter key or sort field is not supported ### `info()` ```php info($id, $includes = null): array ``` Get deal information deals.info accepts one include, `second_responsible_user`. Custom fields are returned without being asked; the lead, responsible user, department, phase and source are always returned as references. - `$id` — Deal UUID - `$includes` — second_responsible_user **Throws** `InvalidArgumentException` When an include is not valid for this endpoint ### `create()` ```php create(array $data): array ``` Create a new deal Optional pass-through fields include: - purchase_order_number (string|null): the customer's purchase order number A select custom field takes the option **label** as its value (a string, or a list of strings for multi select), not the option id: Teamleader refuses the id with "has an invalid single selection value". Resolve either form with customFields()->selectValue($fieldId, $labelOrId). - `$data` — Deal data **Throws** `InvalidArgumentException` ### `update()` ```php update($id, array $data): array ``` Update a deal Optional pass-through fields include: - purchase_order_number (string|null): the customer's purchase order number - `$id` — Deal UUID - `$data` — Data to update **Throws** `InvalidArgumentException` ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a deal - `$id` — Deal UUID - `$additionalParams` — Additional parameters (not used) ### `win()` ```php win(string $id): array ``` Mark a deal as won - `$id` — Deal UUID ### `lose()` ```php lose(string $id, ?string $reasonId = null, ?string $extraInfo = null): array ``` Mark a deal as lost - `$id` — Deal UUID - `$reasonId` — Lost reason UUID (optional) - `$extraInfo` — Additional information (optional) ### `move()` ```php move(string $id, string $phaseId): array ``` Move a deal to a different phase - `$id` — Deal UUID - `$phaseId` — Target phase UUID **Throws** `InvalidArgumentException` ### `open()` ```php open(array $additionalFilters = []): array ``` Get only open deals - `$additionalFilters` — Additional filters to apply ### `won()` ```php won(array $additionalFilters = []): array ``` Get only won deals - `$additionalFilters` — Additional filters to apply ### `lost()` ```php lost(array $additionalFilters = []): array ``` Get only lost deals - `$additionalFilters` — Additional filters to apply ### `forCustomer()` ```php forCustomer(string $customerType, string $customerId, array $additionalFilters = []): array ``` Get deals for a specific customer - `$customerType` — Customer type ('contact' or 'company') - `$customerId` — Customer UUID - `$additionalFilters` — Additional filters to apply **Throws** `InvalidArgumentException` ### `byPhase()` ```php byPhase(string $phaseId, array $additionalFilters = []): array ``` Get deals in a specific phase - `$phaseId` — Phase UUID - `$additionalFilters` — Additional filters to apply ### `byIds()` ```php byIds(array $ids): array ``` Get deals by specific IDs - `$ids` — Array of deal UUIDs ### `updatedSince()` ```php updatedSince(string $date, array $additionalFilters = []): array ``` Get deals updated since a specific date - `$date` — Date in ISO format - `$additionalFilters` — Additional filters to apply ### `forUser()` ```php forUser(string $userId, array $additionalFilters = []): array ``` Get deals for a specific responsible user - `$userId` — User UUID - `$additionalFilters` — Additional filters to apply ### `search()` ```php search(string $term, array $additionalFilters = []): array ``` Search deals by term - `$term` — Search term (searches title, reference, customer name) - `$additionalFilters` — Additional filters to apply ### `closingBetween()` ```php closingBetween(string $from, string $until, array $additionalFilters = []): array ``` Get deals closing in a date range - `$from` — Start date (inclusive) - `$until` — End date (inclusive) - `$additionalFilters` — Additional filters to apply ### `withCustomFields()` ```php withCustomFields(): self ``` Fluent method to include custom fields — deals.list only; deals.info returns them without being asked ### `withSecondResponsibleUser()` ```php withSecondResponsibleUser(): self ``` Fluent method to include the second responsible user, on list() or info(). Requires the second deal responsible feature on the account. ## Examples Get all open deals: ```php $deals = Teamleader::deals()->open(); ``` Create a new deal: ```php $deal = Teamleader::deals()->create([ "lead" => ["customer" => ["type" => "company", "id" => "company-uuid"]], "title" => "New Business Deal", "estimated_value" => ["amount" => 10000, "currency" => "EUR"] ]); ``` Update a deal: ```php $deal = Teamleader::deals()->update("deal-uuid", [ "title" => "Updated Deal Title", "estimated_probability" => 0.75 ]); ``` Mark a deal as won: ```php $result = Teamleader::deals()->win("deal-uuid"); ``` Mark a deal as lost with reason: ```php $result = Teamleader::deals()->lose("deal-uuid", "reason-uuid", "Price too high"); ``` Move a deal to a different phase: ```php $result = Teamleader::deals()->move("deal-uuid", "phase-uuid"); ``` Get open deals with custom fields: ```php $deals = Teamleader::deals() ->withCustomFields() ->list(["status" => ["open"]]); ``` Get deals sorted by weighted value: ```php $deals = Teamleader::deals()->list([], ["sort" => "weighted_value", "sort_order" => "desc"]); ``` Get deals with custom fields sideloaded: ```php $deals = Teamleader::deals()->list([], ["include" => "custom_fields"]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/lost-reasons # Lost Reasons Manage lost reasons for deals in Teamleader Focus. ```php Teamleader::lostReasons() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Deals\LostReasons` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `lostReasons.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of lost reason UUIDs to filter by | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `name` | Sort by lost reason name | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List lost reasons with filtering, sorting and pagination - `$filters` — `ids` only - `$options` — page_size, page_number, sort (name), sort_order (asc) **Throws** `\InvalidArgumentException` On an unknown filter, or a sort other than name/asc ### `byIds()` ```php byIds(array $ids): array ``` Get lost reasons by specific IDs - `$ids` — Array of lost reason UUIDs ### `all()` ```php all(string $sortOrder = 'asc', int $maxPages = 50): array ``` Get every lost reason, walking all pages lostReasons.list sorts by name ascending only. Before v2.2.5 this took a $sortOrder and passed 'desc' straight through; the argument is kept for compatibility and must be 'asc'. It also returned only the first page of 100. - `$sortOrder` — Must be 'asc' - `$maxPages` — Runaway guard: 50 pages of 100 **Throws** `\InvalidArgumentException` On a sort order other than asc, or when $maxPages is reached ### `isValidSortField()` ```php isValidSortField(string $field): bool ``` Validate sort field (only 'name' is supported) ### `info()` ```php info($id, $includes = null): array ``` Override info method since this resource doesn't support individual item retrieval ### `getStats()` ```php getStats(): array ``` Get statistics about available lost reasons (convenience method) ### `exists()` ```php exists(string $id): bool ``` Check if a lost reason exists by ID ### `getSelectOptions()` ```php getSelectOptions(): array ``` Get lost reasons as select options for forms ## Examples Get all lost reasons: ```php $lostReasons = Teamleader::lostReasons()->list(); ``` Get specific lost reasons by ID: ```php $lostReasons = Teamleader::lostReasons()->list(['ids' => ['uuid1', 'uuid2']]); ``` Get lost reasons sorted by name: ```php $lostReasons = Teamleader::lostReasons()->list([], ['sort' => [['field' => 'name', 'order' => 'asc']]]); ``` Get lost reasons with custom pagination: ```php $lostReasons = Teamleader::lostReasons()->list([], ['page_size' => 50, 'page_number' => 2]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/orders # Orders Retrieve and view orders in Teamleader Focus. ```php Teamleader::orders() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Deals\Orders` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `orders.info` - `orders.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of order UUIDs to filter by | ## Includes `list()` and `info()` accept the same includes: `custom_fields` ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List orders with optional filtering and pagination Passing neither page option sends no `page` key and returns the API default of 20 records. all() walks every page. Filtering is `ids` only. The API accepts any other filter key, ignores it, and answers 200 with the full unfiltered first page — `department_id`, `updated_since`, `order_date_after`, `term` and `status` were each confirmed to have no effect. Unknown keys therefore throw rather than being sent; see buildFilters(). Sorting is not supported. A sort passed here is rejected, because the API accepts one and discards it — sorting by `order_date` returns the same first record as no sort at all. - `$filters` — Filters to apply — `ids` only - `$options` — page_size, page_number, include **Throws** `InvalidArgumentException` When a sort or an unknown filter key is passed ### `info()` ```php info($id, $includes = null): array ``` Get order information - `$id` — Order UUID - `$includes` — Includes to load (e.g., 'custom_fields') ### `byIds()` ```php byIds(array $ids): array ``` Get orders by specific IDs - `$ids` — Array of order UUIDs ### `all()` ```php all(array $filters = [], array $options = [], int $maxPages = 100): array ``` Retrieve every order, walking the endpoint's pages orders.list returns no total count, so the end of the list is inferred from a page shorter than the one requested — which means a complete final page costs one extra empty request, and the number of requests a full pass needs cannot be known in advance. $maxPages is a runaway guard, not a limit: reaching it with a full page still coming throws, rather than returning a partial set that looks complete. Silently returning 10,000 of 11,000 orders is the failure this method exists to prevent, so it is not repeated here. The signature differs from PaymentMethods::all() and TaxRates::all(), which take (array $filters, int $maxPages). $options sits in the middle because custom_fields is worth sideloading during a full pass, and because applyPendingIncludes() consumes the fluent state after the first request — so with('custom_fields')->all() would otherwise sideload page 1 and nothing after it. The sideload is resolved once here and replayed on every page. - `$filters` — Filters to apply — `ids` only - `$options` — include (applied to every page) - `$maxPages` — Safety cap — 100 pages of 100 is 10,000 orders **Throws** `TeamleaderException` When $maxPages is reached with records still pending ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `getPaymentTermTypes()` ```php getPaymentTermTypes(): array ``` Get payment term types ### `getSupplierTypes()` ```php getSupplierTypes(): array ``` Get supplier types ## Examples Get the first page of orders (20 records — the API default): ```php $orders = Teamleader::orders()->list(); ``` Get orders with an explicit page size and number: ```php $orders = Teamleader::orders()->list([], ['page_size' => 100, 'page_number' => 1]); ``` Get every order in the account, walking all pages: ```php $orders = Teamleader::orders()->all(); ``` Get specific orders by ID: ```php $orders = Teamleader::orders()->list(['ids' => ['uuid1', 'uuid2']]); ``` Get a single order with custom fields: ```php $order = Teamleader::orders()->with('custom_fields')->info('order-uuid'); ``` Get orders by IDs using convenience method: ```php $orders = Teamleader::orders()->byIds(['uuid1', 'uuid2']); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/deals/quotations # Quotations Manage quotations in Teamleader Focus. ```php Teamleader::quotations() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Deals\Quotations` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `quotations.accept` - `quotations.create` - `quotations.delete` - `quotations.download` - `quotations.info` - `quotations.list` - `quotations.send` - `quotations.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of quotation UUIDs to filter by | ## Includes `list()` and `info()` accept the same includes: `expiry` ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `DISCOUNT_TYPES` | `percentage` | | `EXPIRY_ACTIONS` | `lock`, `none` | | `RECIPIENT_TYPES` | `contact`, `company` | | `SENDER_TYPES` | `user`, `department` | | `SEND_FIELDS` | `quotations`, `from`, `recipients`, `subject`, `content`, `language`, `attachments` | | `SEND_LANGUAGES` | `en`, `nl`, `fr`, `ch`, `jp`, `de`, `es`, `pt`, `it`, `gr`, `tr`, `cs`, `so`, `sk`, `ru`, `ko`, `ir`, `iq`, `hu`, `gh`, `bg`, `bs`, `br`, `ar`, `ag`, `al`, `af`, `ro`, `pl`, `ca`, `da`, `uk`, `no`, `fi`, `sv` | | `STATUSES` | `open`, `accepted`, `refused`, `expired` | | `UPDATE_FIELDS` | `name`, `currency`, `grouped_lines`, `text`, `document_template_id`, `discounts`, `expiry` | ## Methods ### `info()` ```php info($id, $includes = null): array ``` Get quotation information - `$id` — Quotation UUID - `$includes` — `expiry` — returned when the account has access to quotation expiry **Throws** `InvalidArgumentException` When another include is requested ### `withExpiry()` ```php withExpiry(): self ``` Include the `expiry` block in the next list() or info() request ### `create()` ```php create(array $data): array ``` Create a new quotation quotations.create requires `deal_id` and nothing else. Before v2.2.5 the SDK also required grouped_lines or text, which the specification does not. - `$data` — Quotation data **Throws** `InvalidArgumentException` ### `update()` ```php update(string $id, array $data): array ``` Update a quotation - `$id` — Quotation UUID - `$data` — Updated quotation data **Throws** `InvalidArgumentException` ### `delete()` ```php delete(string $id): array ``` Delete a quotation - `$id` — Quotation UUID ### `accept()` ```php accept(string $id): array ``` Mark a quotation as accepted - `$id` — Quotation UUID ### `send()` ```php send(array $data): array ``` Send one or more quotations via email - `$data` — Send parameters including quotations, sender, recipients, subject, content, and language ### `download()` ```php download(string $id, string $format = 'pdf'): array ``` Download a quotation in a specific format Returns a temporary link, which expires. For the document itself use downloadContents(), or downloadTo() to store it on a Laravel disk. - `$id` — Quotation UUID - `$format` — Download format (default: 'pdf') ### `byIds()` ```php byIds(array $ids): array ``` Get quotations by specific IDs - `$ids` — Array of quotation UUIDs ### `list()` ```php list(array $filters = [], array $options = []): array ``` List quotations with enhanced filtering and pagination - `$filters` — Filters to apply - `$options` — Additional options (pagination via page.size / page.number) ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `downloadContents()` ```php downloadContents(string $id, string $format = 'pdf'): string ``` The document's bytes, in one call. $pdf = Teamleader::invoices()->downloadContents($invoiceId); - `$id` — The record's UUID - `$format` — The format download() accepts, e.g. pdf **Throws** `TeamleaderException` When no link comes back, or the download is refused ### `downloadTo()` ```php downloadTo(string $id, string $disk, string $path, string $format = 'pdf'): string ``` Download the document onto a Laravel disk, and return the path. Teamleader::invoices()->downloadTo($invoiceId, 's3', "invoices/{$number}.pdf"); Bulk-able: `bulk()->call('invoices', 'downloadTo', [[$id, 's3', $path], …])`. On a dry run nothing is written. **Throws** `TeamleaderException` When no link comes back, or the download is refused **Throws** `RuntimeException` When the disk refuses the file ## Examples Get all quotations: ```php $quotations = Teamleader::quotations()->list(); ``` Get specific quotations by ID: ```php $quotations = Teamleader::quotations()->list(['ids' => ['uuid1', 'uuid2']]); ``` Get a single quotation: ```php $quotation = Teamleader::quotations()->info('quotation-uuid'); ``` Get a quotation with its expiry settings: ```php $quotation = Teamleader::quotations()->withExpiry()->info('quotation-uuid'); ``` Create a new quotation: ```php $quotation = Teamleader::quotations()->create(['deal_id' => 'deal-uuid', 'currency' => ['code' => 'EUR'], 'grouped_lines' => [...]]); ``` Update a quotation: ```php Teamleader::quotations()->update('quotation-uuid', ['grouped_lines' => [...]]); ``` Accept a quotation: ```php Teamleader::quotations()->accept('quotation-uuid'); ``` Send a quotation via email: ```php Teamleader::quotations()->send(['quotations' => ['uuid1'], 'from' => [...], 'recipients' => [...], 'subject' => '...', 'content' => '...', 'language' => 'en']); ``` Download a quotation as PDF: ```php $download = Teamleader::quotations()->download('quotation-uuid', 'pdf'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/commercial-discounts # Commercial Discounts Manage commercial discounts in Teamleader Focus. ```php Teamleader::commercialDiscounts() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Invoicing\CommercialDiscounts` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `commercialDiscounts.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `department_id` | Filter by department UUID | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List commercial discounts with optional filtering The endpoint accepts a department_id filter but no sorting or pagination, so those arguments are rejected rather than discarded. - `$filters` — department_id - `$options` — Must be empty — no sorting or pagination **Throws** `\InvalidArgumentException` When an unsupported argument is passed ### `forDepartment()` ```php forDepartment(string $departmentId): array ``` Get commercial discounts for a specific department - `$departmentId` — Department UUID ### `findByName()` ```php findByName(string $name, ?string $departmentId = null, bool $exactMatch = true): ?array ``` Find a commercial discount by name - `$name` — Discount name to search for - `$departmentId` — Optional department filter - `$exactMatch` — Whether to match exactly or search partial ### `asOptions()` ```php asOptions(?string $departmentId = null): array ``` Get commercial discounts formatted as options for select dropdowns Keyed by discount UUID. Teamleader added `id` to the commercialDiscounts.list response in July 2026; before that the endpoint returned only name and department, so this method used the name as its own key and two discounts sharing a name across departments collapsed into one entry. - `$departmentId` — Optional department filter ### `groupedByDepartment()` ```php groupedByDepartment(): array ``` Get commercial discounts grouped by department ### `exists()` ```php exists(string $name, ?string $departmentId = null): bool ``` Check if a commercial discount exists by name - `$name` — Discount name - `$departmentId` — Optional department filter ### `search()` ```php search(string $searchTerm, ?string $departmentId = null): array ``` Search for commercial discounts by partial name match - `$searchTerm` — Search term to match against discount names - `$departmentId` — Optional department filter ### `names()` ```php names(?string $departmentId = null): array ``` Get all discount names as a simple array - `$departmentId` — Optional department filter ### `find()` ```php find(string $id): ?array ``` Find a commercial discount by ID The endpoint has no info() counterpart, so this resolves against the list. `id` was added to the commercialDiscounts.list response in July 2026. - `$id` — Commercial discount UUID ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all commercial discounts: ```php $discounts = Teamleader::commercialDiscounts()->list(); ``` Get commercial discounts for a specific department: ```php $discounts = Teamleader::commercialDiscounts()->forDepartment('department-uuid'); ``` Find a commercial discount by name: ```php $discount = Teamleader::commercialDiscounts()->findByName("Holiday discount"); ``` Get commercial discounts as key-value pairs for dropdowns: ```php $options = Teamleader::commercialDiscounts()->asOptions(); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/credit-notes # Credit Notes Manage credit notes in Teamleader Focus. ```php Teamleader::creditNotes() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Invoicing\Creditnotes` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `creditNotes.download` - `creditNotes.info` - `creditNotes.list` - `creditNotes.sendViaPeppol` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `credit_note_date_after` | Date (inclusive, YYYY-MM-DD) | | `credit_note_date_before` | Date (exclusive, YYYY-MM-DD) | | `customer` | Customer object with type and id | | `department_id` | Filter on department (company entity) | | `ids` | Array of credit note UUIDs | | `invoice_id` | Filter on invoice UUID | | `project_id` | Filter on project UUID | | `updated_since` | ISO 8601 datetime | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List credit notes with filtering and pagination - `$filters` — Filter parameters - `$options` — Pagination options ### `info()` ```php info($id, $includes = null): array ``` Get detailed information about a specific credit note Response includes: - id, department, credit_note_number, credit_note_date, status - invoice (object|null): related invoice {id, type} - paid (bool), paid_at (string|null) - invoicee: name, vat_number, customer {email, national_identification_number} - discounts, total, taxes, grouped_lines - currency, currency_exchange_rate - created_at, updated_at - document_template: {id, type} - peppol_status (string|null): Peppol submission status, populated after sendViaPeppol() - `$id` — Credit note UUID - `$includes` — Optional includes (not used for credit notes) ### `download()` ```php download(string $id, string $format = 'pdf'): array ``` Download a credit note in a specific format Returns a temporary link, which expires. For the document itself use downloadContents(), or downloadTo() to store it on a Laravel disk. - `$id` — Credit note UUID - `$format` — Format (pdf, ubl/e-fff) ### `sendViaPeppol()` ```php sendViaPeppol(string $id): array ``` Send a credit note via the Peppol network After calling this method, use info() to poll peppol_status until it transitions from 'sending' to a final state (e.g. 'sent', 'sending_failed'). - `$id` — Credit note UUID ### `booked()` ```php booked(array $additionalFilters = [], array $options = []): array ``` Get booked credit notes (convenience method) - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `forInvoice()` ```php forInvoice(string $invoiceId, array $additionalFilters = [], array $options = []): array ``` Get credit notes for a specific invoice - `$invoiceId` — Invoice UUID - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `forCustomer()` ```php forCustomer(string $customerType, string $customerId, array $additionalFilters = [], array $options = []): array ``` Get credit notes for a specific customer - `$customerType` — Customer type (contact or company) - `$customerId` — Customer UUID - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `forProject()` ```php forProject(string $projectId, array $additionalFilters = [], array $options = []): array ``` Get credit notes for a specific project - `$projectId` — Project UUID - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `forDepartment()` ```php forDepartment(string $departmentId, array $additionalFilters = [], array $options = []): array ``` Get credit notes for a specific department - `$departmentId` — Department UUID - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `betweenDates()` ```php betweenDates(string $dateAfter, string $dateBefore, array $additionalFilters = [], array $options = []): array ``` Get credit notes between specific dates - `$dateAfter` — Start date (inclusive, YYYY-MM-DD) - `$dateBefore` — End date (exclusive, YYYY-MM-DD) - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `updatedSince()` ```php updatedSince(string $since, array $additionalFilters = [], array $options = []): array ``` Get credit notes created/updated since a specific date - `$since` — ISO 8601 datetime - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `downloadContents()` ```php downloadContents(string $id, string $format = 'pdf'): string ``` The document's bytes, in one call. $pdf = Teamleader::invoices()->downloadContents($invoiceId); - `$id` — The record's UUID - `$format` — The format download() accepts, e.g. pdf **Throws** `TeamleaderException` When no link comes back, or the download is refused ### `downloadTo()` ```php downloadTo(string $id, string $disk, string $path, string $format = 'pdf'): string ``` Download the document onto a Laravel disk, and return the path. Teamleader::invoices()->downloadTo($invoiceId, 's3', "invoices/{$number}.pdf"); Bulk-able: `bulk()->call('invoices', 'downloadTo', [[$id, 's3', $path], …])`. On a dry run nothing is written. **Throws** `TeamleaderException` When no link comes back, or the download is refused **Throws** `RuntimeException` When the disk refuses the file ## Examples Get all credit notes: ```php $creditNotes = Teamleader::creditNotes()->list(); ``` Get credit notes for a specific invoice: ```php $creditNotes = Teamleader::creditNotes()->forInvoice('invoice-uuid'); ``` Get credit notes for a specific customer: ```php $creditNotes = Teamleader::creditNotes()->forCustomer('company', 'customer-uuid'); ``` Get credit notes for a specific project: ```php $creditNotes = Teamleader::creditNotes()->forProject('project-uuid'); ``` Get credit notes within a date range: ```php $creditNotes = Teamleader::creditNotes()->betweenDates('2022-01-01', '2023-01-01'); ``` Get detailed credit note information: ```php $creditNote = Teamleader::creditNotes()->info('credit-note-uuid'); ``` Download credit note as PDF: ```php $download = Teamleader::creditNotes()->download('credit-note-uuid', 'pdf'); ``` Download credit note as UBL e-fff format: ```php $download = Teamleader::creditNotes()->download('credit-note-uuid', 'ubl/e-fff'); ``` Send credit note via Peppol network: ```php $result = Teamleader::creditNotes()->sendViaPeppol('credit-note-uuid'); ``` Send via Peppol and check submission status: ```php Teamleader::creditNotes()->sendViaPeppol('credit-note-uuid'); // Poll info() until peppol_status is no longer 'sending' $creditNote = Teamleader::creditNotes()->info('credit-note-uuid'); $peppolStatus = $creditNote['data']['peppol_status'] ?? null; // e.g. 'sent', 'sending_failed', 'receiver_accepted', etc. ``` Get only booked credit notes: ```php $creditNotes = Teamleader::creditNotes()->booked(); ``` Get unpaid credit notes (client-side — creditNotes.list has no paid filter): ```php $unpaid = array_filter(Teamleader::creditNotes()->list()['data'], fn ($note) => $note['paid'] === false); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/invoices # Invoices Manage invoices in Teamleader Focus. Invoices in Teamleader Focus. **Untitled sections.** In `grouped_lines`, the `section` key is optional. A group with no title must omit the key entirely — Teamleader rejects both `['section' => ['title' => null]]` and `['section' => ['title' => '']]` with HTTP 400. This matters on read-modify-write: `info()` returns `section.title` as `null` for untitled sections, so echoing that value straight back is rejected. Drop the key instead: foreach ($info['data']['grouped_lines'] as $group) { $out = ['line_items' => $lineItems]; if (! empty($group['section']['title'])) { $out['section'] = ['title' => $group['section']['title']]; } $groupedLines[] = $out; } Untitled sections are common: Teamleader's UI creates them by default when the user does not name a section. ```php Teamleader::invoices() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Invoicing\Invoices` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `invoices.book` - `invoices.copy` - `invoices.credit` - `invoices.creditPartially` - `invoices.delete` - `invoices.download` - `invoices.draft` - `invoices.info` - `invoices.list` - `invoices.registerPayment` - `invoices.removePayments` - `invoices.send` - `invoices.sendViaPeppol` - `invoices.update` - `invoices.updateBooked` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `customer` | Customer object with type and id | | `deal_id` | Filter on deal UUID | | `department_id` | Filter on department (company entity) | | `ids` | Array of invoice UUIDs | | `invoice_date_after` | Date (inclusive, YYYY-MM-DD) | | `invoice_date_before` | Date (inclusive, YYYY-MM-DD) | | `invoice_number` | Full invoice number (fiscal year / number) | | `payment_reference` | Payment reference | | `project_id` | Filter on project UUID | | `purchase_order_number` | Purchase order number | | `status` | Array of statuses (draft, outstanding, matched) | | `subscription_id` | Filter on subscription UUID | | `term` | Search on invoice number, purchase order number, payment reference and invoicee | | `updated_since` | ISO 8601 datetime | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `invoice_date` | Invoice date | | `invoice_number` | Invoice number | ## Includes `list()` and `info()` accept the same includes: `late_fees`, `totals.due_incasso_inclusive`, `totals.fixed_late_fee`, `totals.interest` ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `DRAFT_FIELDS` | `invoicee`, `department_id`, `payment_term`, `grouped_lines`, `invoice_date`, `discounts`, `note`, `currency`, `purchase_order_number`, `project_id`, `quotation_id`, `expected_payment_method`, `custom_fields`, `document_template_id`, `delivery_date`, `invoice_content` | | `INCLUDES` | `late_fees`, `totals.due_incasso_inclusive`, `totals.fixed_late_fee`, `totals.interest` | | `INVOICE_CONTENT` | `goods`, `services`, `goods_and_services` | | `UPDATE_BOOKED_FIELDS` | `invoicee`, `payment_term`, `grouped_lines`, `invoice_date`, `note`, `project_id`, `expected_payment_method`, `custom_fields`, `invoice_content` | | `UPDATE_FIELDS` | `invoicee`, `payment_term`, `grouped_lines`, `invoice_date`, `discounts`, `note`, `currency`, `purchase_order_number`, `project_id`, `expected_payment_method`, `custom_fields`, `document_template_id`, `delivery_date`, `invoice_content` | ## Methods ### `info()` ```php info($id, $includes = null): array ``` Get detailed information about a specific invoice Response includes all list fields plus: discounts, grouped_lines (with full line item detail), payment_term, payments, payment_reference, note, currency, currency_exchange_rate, expected_payment_method, on_hold_since, custom_fields, document_template, delivery_date (nullable), peppol_status (nullable) - `$id` — Invoice UUID - `$includes` — late_fees, totals.due_incasso_inclusive, totals.fixed_late_fee, totals.interest **Throws** `InvalidArgumentException` When an include is not valid for this endpoint ### `create()` ```php create(array $data): array ``` Draft a new invoice Required fields: - invoicee (object): customer {type, id} and optional for_attention_of - department_id (string): Department UUID - payment_term (object): type and optional days - grouped_lines (array): sections with line_items Optional fields: - currency (object): code and optional exchange_rate - project_id (string): Project UUID - quotation_id (string): links the invoice to a source quotation and its deal, and marks the deal as won - purchase_order_number (string) - invoice_date (string): YYYY-MM-DD - discounts (array) - note (string) - expected_payment_method (object): method and optional reference - custom_fields (array) - document_template_id (string) - delivery_date (string|null): YYYY-MM-DD — the delivery/service date shown on the invoice - `$data` — Invoice data ### `update()` ```php update($id, array $data): array ``` Update a draft invoice Note: Booked invoices cannot be updated with this method Updatable fields: - invoicee (object): customer {type, id} and optional for_attention_of - payment_term (object): nullable, type and optional days - currency (object): code and optional exchange_rate - project_id (string) - purchase_order_number (string) - grouped_lines (array): sections with line_items - invoice_date (string): YYYY-MM-DD - note (string|null) - discounts (array) - expected_payment_method (object|null) - custom_fields (array) - document_template_id (string) - delivery_date (string|null): YYYY-MM-DD — the delivery/service date shown on the invoice - `$id` — Invoice UUID - `$data` — Invoice data to update ### `updateBooked()` ```php updateBooked(string $id, array $data): array ``` Update a booked invoice Note: Only available when editing booked invoices is allowed through the settings - `$id` — Invoice UUID - `$data` — Invoice data to update ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete an existing invoice Only possible for draft invoices or the last booked invoice - `$id` — Invoice UUID - `$additionalParams` — Additional parameters (not used for invoices) ### `book()` ```php book(string $id, string $on): array ``` Book a draft invoice - `$id` — Invoice UUID - `$on` — Booking date (YYYY-MM-DD format) ### `copy()` ```php copy(string $id): array ``` Copy an invoice to create a new draft - `$id` — Invoice UUID to copy ### `credit()` ```php credit(string $id, string $creditNoteDate): array ``` Credit an invoice completely The credit note takes its layout from the invoice it credits; the API has no way to choose another. - `$id` — Invoice UUID - `$creditNoteDate` — Credit note date (YYYY-MM-DD format) ### `creditPartially()` ```php creditPartially(string $id, string $creditNoteDate, array $groupedLines, ?array $discounts = null): array ``` Credit an invoice partially The credit note takes its layout from the invoice it credits; the API has no way to choose another. - `$id` — Invoice UUID - `$creditNoteDate` — Credit note date (YYYY-MM-DD format) - `$groupedLines` — Grouped lines to credit - `$discounts` — Optional discounts ### `download()` ```php download(string $id, string $format = 'pdf'): array ``` Download an invoice in a specific format Returns a temporary link, which expires. For the document itself use downloadContents(), or downloadTo() to store it on a Laravel disk. - `$id` — Invoice UUID - `$format` — Format (pdf, ubl/e-fff, ubl/peppol_bis_3, ubl/xrechnung) ### `send()` ```php send(string $id, array $content, array $recipients = [], ?array $attachments = null): array ``` Send an invoice via email - `$id` — Invoice UUID - `$content` — Email content (subject, body, optional mail_template_id) - `$recipients` — Recipients (to, cc, bcc arrays) - `$attachments` — Optional file IDs to attach ### `sendViaPeppol()` ```php sendViaPeppol(string $id): array ``` Send an invoice via the Peppol network - `$id` — Invoice UUID ### `registerPayment()` ```php registerPayment(string $id, array $payment, string $paidAt, ?string $paymentMethodId = null): array ``` Register a payment for an invoice - `$id` — Invoice UUID - `$payment` — Payment data (amount, currency) - `$paidAt` — Payment date (ISO 8601 format) - `$paymentMethodId` — Optional payment method UUID ### `removePayments()` ```php removePayments(string $id): array ``` Remove all payments from an invoice (marks as unpaid) - `$id` — Invoice UUID ### `listDrafts()` ```php listDrafts(array $additionalFilters = [], array $options = []): array ``` List draft invoices. - `$additionalFilters` — Additional filters to apply - `$options` — Pagination and sorting options ### `list()` ```php list(array $filters = [], array $options = []): array ``` List invoices with filtering and sorting Response includes: id, department, invoice_number, invoice_date, status, due_on, paid, paid_at, sent, purchase_order_number, payment_reference, invoicee, total, currency_exchange_rate, created_at, updated_at, web_url, file, deal, project, subscription (nullable object: {id, type}), delivery_date (nullable), peppol_status (nullable) - `$filters` — Filter parameters - `$options` — Pagination and sorting options ### `outstanding()` ```php outstanding(array $additionalFilters = [], array $options = []): array ``` Get outstanding invoices - `$additionalFilters` — Additional filters to apply - `$options` — Pagination and sorting options ### `matched()` ```php matched(array $additionalFilters = [], array $options = []): array ``` Get matched invoices - `$additionalFilters` — Additional filters to apply - `$options` — Pagination and sorting options ### `forCustomer()` ```php forCustomer(string $customerType, string $customerId, array $additionalFilters = [], array $options = []): array ``` Get invoices for a specific customer - `$customerType` — Customer type (contact or company) - `$customerId` — Customer UUID - `$additionalFilters` — Additional filters to apply - `$options` — Pagination and sorting options ### `forProject()` ```php forProject(string $projectId, array $additionalFilters = [], array $options = []): array ``` Get invoices for a specific project - `$projectId` — Project UUID - `$additionalFilters` — Additional filters to apply - `$options` — Pagination and sorting options ### `forDeal()` ```php forDeal(string $dealId, array $additionalFilters = [], array $options = []): array ``` Get invoices for a specific deal - `$dealId` — Deal UUID - `$additionalFilters` — Additional filters to apply - `$options` — Pagination and sorting options ### `forDepartment()` ```php forDepartment(string $departmentId, array $additionalFilters = [], array $options = []): array ``` Get invoices for a specific department - `$departmentId` — Department UUID - `$additionalFilters` — Additional filters to apply - `$options` — Pagination and sorting options ### `search()` ```php search(string $term, array $additionalFilters = [], array $options = []): array ``` Search invoices by term - `$term` — Search term - `$additionalFilters` — Additional filters to apply - `$options` — Pagination and sorting options ### `updatedSince()` ```php updatedSince(string $datetime, array $additionalFilters = [], array $options = []): array ``` Get invoices updated since a specific date - `$datetime` — ISO 8601 datetime - `$additionalFilters` — Additional filters to apply - `$options` — Pagination and sorting options ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `getValidPeppolStatuses()` ```php getValidPeppolStatuses(): array ``` Get the possible Peppol submission statuses. These are the values that may appear in the (nullable) `peppol_status` field of info()/list() responses. Peppol status is read-only — it is not a request parameter or a list filter. ### `downloadContents()` ```php downloadContents(string $id, string $format = 'pdf'): string ``` The document's bytes, in one call. $pdf = Teamleader::invoices()->downloadContents($invoiceId); - `$id` — The record's UUID - `$format` — The format download() accepts, e.g. pdf **Throws** `TeamleaderException` When no link comes back, or the download is refused ### `downloadTo()` ```php downloadTo(string $id, string $disk, string $path, string $format = 'pdf'): string ``` Download the document onto a Laravel disk, and return the path. Teamleader::invoices()->downloadTo($invoiceId, 's3', "invoices/{$number}.pdf"); Bulk-able: `bulk()->call('invoices', 'downloadTo', [[$id, 's3', $path], …])`. On a dry run nothing is written. **Throws** `TeamleaderException` When no link comes back, or the download is refused **Throws** `RuntimeException` When the disk refuses the file ## Examples Get all invoices: ```php $invoices = Teamleader::invoices()->list(); ``` Draft a new invoice: ```php $invoice = Teamleader::invoices()->create([...]); ``` Draft a new invoice with a delivery date: ```php $invoice = Teamleader::invoices()->create([..., 'delivery_date' => '2025-12-08']); ``` Get outstanding invoices: ```php $invoices = Teamleader::invoices()->outstanding(); ``` Get invoices for a specific customer: ```php $invoices = Teamleader::invoices()->forCustomer('company', 'customer-uuid'); ``` Get detailed invoice information: ```php $invoice = Teamleader::invoices()->info('invoice-uuid'); ``` Get invoice with late fee calculations: ```php $invoice = Teamleader::invoices()->info('invoice-uuid', 'late_fees'); ``` Get the most recent invoices first: ```php $invoices = Teamleader::invoices()->list([], ['sort' => 'invoice_date', 'sort_order' => 'desc']); ``` Book a draft invoice: ```php $result = Teamleader::invoices()->book('invoice-uuid', '2024-01-15'); ``` Create a new draft based on existing invoice: ```php $newInvoice = Teamleader::invoices()->copy('invoice-uuid'); ``` Update a draft invoice: ```php $result = Teamleader::invoices()->update('invoice-uuid', [...]); ``` Update the delivery date on a draft invoice: ```php $result = Teamleader::invoices()->update('invoice-uuid', ['delivery_date' => '2025-12-08']); ``` Update a booked invoice (if allowed in settings): ```php $result = Teamleader::invoices()->updateBooked('invoice-uuid', [...]); ``` Credit an invoice completely: ```php $creditNote = Teamleader::invoices()->credit('invoice-uuid', '2024-02-04'); ``` Credit an invoice partially: ```php $creditNote = Teamleader::invoices()->creditPartially('invoice-uuid', '2024-02-04', [...]); ``` Download invoice as PDF: ```php $download = Teamleader::invoices()->download('invoice-uuid', 'pdf'); ``` Send invoice via email: ```php $result = Teamleader::invoices()->send('invoice-uuid', [...]); ``` Send invoice via Peppol: ```php $result = Teamleader::invoices()->sendViaPeppol('invoice-uuid'); ``` Register a payment for an invoice: ```php $result = Teamleader::invoices()->registerPayment('invoice-uuid', [...]); ``` Remove all payments from an invoice: ```php $result = Teamleader::invoices()->removePayments('invoice-uuid'); ``` Delete a draft or last booked invoice: ```php $result = Teamleader::invoices()->delete('invoice-uuid'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/payment-methods # Payment Methods Manage payment methods in Teamleader Focus. ```php Teamleader::paymentMethods() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Invoicing\PaymentMethods` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `paymentMethods.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of payment method UUIDs | | `status` | Array of statuses (active, archived) | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List payment methods with filtering and pagination - `$filters` — Filter parameters - `$options` — Pagination options ### `active()` ```php active(array $additionalFilters = [], array $options = []): array ``` Get active payment methods - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `archived()` ```php archived(array $additionalFilters = [], array $options = []): array ``` Get archived payment methods - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `byIds()` ```php byIds(array $ids, array $options = []): array ``` Get payment methods by specific IDs - `$ids` — Array of payment method UUIDs - `$options` — Pagination options ### `findByName()` ```php findByName(string $name, bool $activeOnly = true): ?array ``` Find a payment method by name - `$name` — Payment method name to search for - `$activeOnly` — Whether to search only active payment methods ### `exists()` ```php exists(string $id): bool ``` Check if a payment method exists by ID - `$id` — Payment method UUID ### `all()` ```php all(array $filters = [], int $maxPages = 10): array ``` Get all payment methods (convenience method that handles pagination) - `$filters` — Filters to apply - `$maxPages` — Maximum number of pages to fetch (default: 10) ### `asOptions()` ```php asOptions(bool $activeOnly = true): array ``` Get payment methods formatted as options for select dropdowns - `$activeOnly` — Whether to include only active payment methods ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all payment methods: ```php $paymentMethods = Teamleader::paymentMethods()->list(); ``` Get specific payment methods by IDs: ```php $paymentMethods = Teamleader::paymentMethods()->byIds(['uuid1', 'uuid2']); ``` Get only active payment methods: ```php $paymentMethods = Teamleader::paymentMethods()->active(); ``` Get only archived payment methods: ```php $paymentMethods = Teamleader::paymentMethods()->archived(); ``` Find a payment method by name: ```php $method = Teamleader::paymentMethods()->findByName("Credit Card"); ``` Get payment methods as key-value pairs for dropdowns: ```php $options = Teamleader::paymentMethods()->asOptions(); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/payment-terms # Payment Terms Manage payment terms in Teamleader Focus. ```php Teamleader::paymentTerms() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Invoicing\PaymentTerms` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `paymentTerms.list` ## Filters `list()` takes no filters. ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List all payment terms `paymentTerms.list` takes no request body — no filter, page or sort parameter exists for it. Arguments are rejected rather than discarded. The response carries a `meta.default` key holding the UUID of the account's default payment term. That is a bespoke meta block, unrelated to the `includes=pagination` metadata other endpoints return. - `$filters` — Must be empty — this endpoint accepts no filters - `$options` — Must be empty — this endpoint accepts no sorting or pagination **Throws** `InvalidArgumentException` When any argument is passed ### `getDefault()` ```php getDefault(): ?array ``` Get the default payment term ### `getDefaultId()` ```php getDefaultId(): ?string ``` Get the default payment term ID ### `findByType()` ```php findByType(string $type): array ``` Find payment terms by type - `$type` — Payment term type (cash, end_of_month, after_invoice_date) ### `findByDays()` ```php findByDays(int $days, ?string $type = null): ?array ``` Find a payment term by number of days - `$days` — Number of days - `$type` — Optional payment term type to filter by ### `find()` ```php find(string $id): ?array ``` Find a payment term by ID - `$id` — Payment term UUID ### `exists()` ```php exists(string $id): bool ``` Check if a payment term exists by ID - `$id` — Payment term UUID ### `asOptions()` ```php asOptions(): array ``` Get payment terms formatted as options for select dropdowns ### `formatPaymentTermDescription()` ```php formatPaymentTermDescription(array $term): string ``` Format a payment term as a human-readable description - `$term` — Payment term data ### `cash()` ```php cash(): array ``` Get cash payment terms (immediate payment) ### `endOfMonth()` ```php endOfMonth(): array ``` Get end of month payment terms ### `afterInvoiceDate()` ```php afterInvoiceDate(): array ``` Get after invoice date payment terms ### `isValidType()` ```php isValidType(string $type): bool ``` Validate payment term type ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all payment terms: ```php $paymentTerms = Teamleader::paymentTerms()->list(); ``` Get the default payment term: ```php $defaultTerm = Teamleader::paymentTerms()->getDefault(); ``` Find payment terms by type: ```php $cashTerms = Teamleader::paymentTerms()->findByType("cash"); ``` Find payment terms by number of days: ```php $term = Teamleader::paymentTerms()->findByDays(30); ``` Get payment terms as key-value pairs for dropdowns: ```php $options = Teamleader::paymentTerms()->asOptions(); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/subscriptions # Subscriptions Manage subscriptions in Teamleader Focus. ```php Teamleader::subscriptions() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Invoicing\Subscriptions` | | Create / update / delete | ✓ / ✓ / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `subscriptions.create` - `subscriptions.deactivate` - `subscriptions.info` - `subscriptions.list` - `subscriptions.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `customer` | Customer object: ["type" => "contact"\|"company", "id" => "..."] | | `deal_id` | Filter on subscriptions created from a deal | | `department_id` | Filter on subscriptions of a specific department | | `ids` | Array of subscription UUIDs | | `invoice_id` | Find subscriptions that generated the given invoice | | `status` | Array of statuses (active, deactivated) | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `created_at` | Creation date | | `status` | Status | | `title` | Subscription title | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `DAYS_IN_ADVANCE` | `0`, `7`, `14`, `21`, `28` | | `DELIVERY_TYPES` | `set_days_after_invoice_date` | | `INVOICE_CONTENT` | `goods`, `services`, `goods_and_services` | | `INVOICE_PAYMENT_METHODS` | `direct_debit` | | `REQUIRED_ON_CREATE` | `invoicee`, `department_id`, `starts_on`, `billing_cycle`, `title`, `grouped_lines`, `payment_term`, `invoice_generation` | | `WRITE_FIELDS` | `invoicee`, `department_id`, `deal_id`, `project_id`, `purchase_order_number`, `title`, `note`, `starts_on`, `ends_on`, `billing_cycle`, `payment_term`, `grouped_lines`, `invoice_generation`, `custom_fields`, `document_template_id`, `invoice_content`, `delivery_information` | ## Methods ### `info()` ```php info($id, $includes = null): array ``` Get detailed information about a subscription Response includes: - id, title, note (nullable), status, department, invoicee, project (nullable) - starts_on, ends_on (nullable), next_renewal_date (nullable) - billing_cycle (periodicity, days_in_advance, payment_term) - total (tax_exclusive, tax_inclusive, taxes) - grouped_lines, invoice_generation (action, sending_methods, payment_method) - custom_fields, document_template, currency - web_url - created_at (string|null): ISO 8601 creation timestamp - purchase_order_number (string|null): PO number on the subscription - delivery_information (object|null): Delivery details (name, address, etc.) ### `create()` ```php create(array $data): array ``` Create a new subscription Required fields: - invoicee (object): customer {type, id}, optional for_attention_of - department_id (string): department UUID - starts_on (string): YYYY-MM-DD - billing_cycle (object): periodicity {unit, period}, days_in_advance - title (string) - grouped_lines (array): sections with line_items - payment_term (object): type (cash|end_of_month|after_invoice_date), days - invoice_generation (object): action (draft|book|book_and_send) - sending_methods: required when action is 'book_and_send' - method: email|peppol|postal_service Optional fields: - ends_on (string|null): YYYY-MM-DD - deal_id (string|null) - project_id (string|null) - note (string|null) - payment_method: direct_debit - custom_fields (array) - document_template_id (string) - purchase_order_number (string|null): PO number to include on generated invoices - delivery_information (object|null): Delivery details passed to generated invoices Returns HTTP 201 with data.{id, type} ### `update()` ```php update($id, array $data): array ``` Update an existing subscription The `subscription.updated` webhook fires for changes to the subscription's own fields, not for changes to its lines. Code that keeps a copy of the lines needs a periodic re-sync as well. All fields except id are optional. Note: - starts_on and billing_cycle can only be updated if no invoices have been generated yet Updatable fields: - starts_on (string): YYYY-MM-DD (only if no invoices created yet) - billing_cycle (object): only if no invoices created yet - ends_on (string|null): YYYY-MM-DD - title (string) - invoicee (object) - department_id (string) - payment_term (object|null) - project_id (string|null) - deal_id (string|null) - note (string|null) - grouped_lines (array) - invoice_generation (object): action (draft|book|book_and_send) - sending_methods: required when action is 'book_and_send' - method: email|peppol|postal_service - payment_method: direct_debit - custom_fields (array) - document_template_id (string) - purchase_order_number (string|null): PO number to include on generated invoices - delivery_information (object|null): Delivery details passed to generated invoices Returns HTTP 204 (no body) ### `deactivate()` ```php deactivate($id): array ``` Deactivate a subscription ### `active()` ```php active(array $additionalFilters = [], array $options = []): array ``` Get active subscriptions ### `list()` ```php list(array $filters = [], array $options = []): array ``` List subscriptions with filtering, sorting, and pagination Response includes per item: - id, title, note, status, department, invoicee, project - starts_on, ends_on (nullable), next_renewal_date (nullable) - billing_cycle, total, taxes, web_url - created_at (string|null): ISO 8601 creation timestamp - purchase_order_number (string|null): PO number on the subscription - delivery_information (object|null): Delivery details (name, address, etc.) ### `deactivated()` ```php deactivated(array $additionalFilters = [], array $options = []): array ``` Get deactivated subscriptions ### `forCustomer()` ```php forCustomer(string $type, string $id, array $options = []): array ``` Get subscriptions for a specific customer ### `forDepartment()` ```php forDepartment(string $departmentId, array $options = []): array ``` Get subscriptions for a specific department ### `forDeal()` ```php forDeal(string $dealId, array $options = []): array ``` Get subscriptions for a specific deal ### `forInvoice()` ```php forInvoice(string $invoiceId, array $options = []): array ``` Get subscriptions that generated a specific invoice ### `byIds()` ```php byIds(array $ids, array $options = []): array ``` Get subscriptions by specific IDs ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all subscriptions: ```php $subscriptions = Teamleader::subscriptions()->list(); ``` Get active subscriptions: ```php $subscriptions = Teamleader::subscriptions()->active(); ``` Get subscriptions for a specific customer: ```php $subscriptions = Teamleader::subscriptions()->forCustomer('company', 'company-uuid'); ``` Get subscriptions for a specific department: ```php $subscriptions = Teamleader::subscriptions()->forDepartment('department-uuid'); ``` Create a new subscription: ```php $subscription = Teamleader::subscriptions()->create([...]); ``` Create a subscription that sends invoices via Peppol: ```php $subscription = Teamleader::subscriptions()->create([ 'invoicee' => [ 'customer' => ['type' => 'company', 'id' => 'company-uuid'], ], 'department_id' => 'dept-uuid', 'starts_on' => '2024-01-01', 'billing_cycle' => [ 'periodicity' => ['unit' => 'month', 'period' => 1], 'days_in_advance' => 7, ], 'title' => 'Monthly support', 'grouped_lines' => [[ 'section' => ['title' => 'Support'], 'line_items' => [[ 'quantity' => 1, 'description' => 'Monthly support fee', 'unit_price' => ['amount' => 500.00, 'tax' => 'excluding'], 'tax_rate_id' => 'tax-rate-uuid', ]], ]], 'payment_term' => ['type' => 'cash'], 'invoice_generation' => [ 'action' => 'book_and_send', 'sending_methods' => [ ['method' => 'peppol'], ], ], ]); ``` Update an existing subscription: ```php $subscription = Teamleader::subscriptions()->update('subscription-uuid', [...]); ``` Deactivate a subscription: ```php $result = Teamleader::subscriptions()->deactivate('subscription-uuid'); ``` Get detailed information about a subscription: ```php $subscription = Teamleader::subscriptions()->info('subscription-uuid'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/tax-rates # Tax Rates Manage tax rates in Teamleader Focus. ```php Teamleader::taxRates() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Invoicing\TaxRates` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `taxRates.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `department_id` | Filter by department UUID | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `department_id` | Department | | `description` | Description | | `rate` | Tax rate | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List tax rates with filtering, sorting, and pagination - `$filters` — Filter parameters - `$options` — Pagination and sorting options ### `forDepartment()` ```php forDepartment(string $departmentId, array $options = []): array ``` Get tax rates for a specific department - `$departmentId` — Department UUID - `$options` — Pagination and sorting options ### `find()` ```php find(string $id): ?array ``` Find a tax rate by ID - `$id` — Tax rate UUID ### `findByRate()` ```php findByRate(float $rate, ?string $departmentId = null): ?array ``` Find a tax rate by exact rate value - `$rate` — Tax rate (e.g., 0.21 for 21%) - `$departmentId` — Optional department filter ### `findByRateRange()` ```php findByRateRange(float $minRate, float $maxRate, ?string $departmentId = null): array ``` Find tax rates by rate range - `$minRate` — Minimum rate (inclusive) - `$maxRate` — Maximum rate (inclusive) - `$departmentId` — Optional department filter ### `findByDescription()` ```php findByDescription(string $description, ?string $departmentId = null, bool $exactMatch = true): ?array ``` Find a tax rate by description - `$description` — Tax rate description (e.g., "21%") - `$departmentId` — Optional department filter - `$exactMatch` — Whether to match exactly or search partial ### `exists()` ```php exists(string $id): bool ``` Check if a tax rate exists by ID - `$id` — Tax rate UUID ### `all()` ```php all(array $filters = [], int $maxPages = 10): array ``` Get all tax rates (handles pagination automatically) - `$filters` — Filters to apply - `$maxPages` — Maximum number of pages to fetch (default: 10) ### `asOptions()` ```php asOptions(?string $departmentId = null): array ``` Get tax rates formatted as options for select dropdowns - `$departmentId` — Optional department filter ### `groupedByDepartment()` ```php groupedByDepartment(): array ``` Get tax rates grouped by department ### `sortedByRate()` ```php sortedByRate(array $filters = []): array ``` Get tax rates sorted by rate ascending - `$filters` — Additional filters ### `sortedByDescription()` ```php sortedByDescription(array $filters = [], string $order = 'asc'): array ``` Get tax rates sorted by description - `$filters` — Additional filters - `$order` — Sort order (asc or desc) ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all tax rates: ```php $taxRates = Teamleader::taxRates()->list(); ``` Get tax rates for a specific department: ```php $taxRates = Teamleader::taxRates()->forDepartment('department-uuid'); ``` Get tax rates sorted by rate: ```php $taxRates = Teamleader::taxRates()->list([], ['sort' => [['field' => 'rate', 'order' => 'asc']]]); ``` Find tax rate by exact rate value: ```php $taxRate = Teamleader::taxRates()->findByRate(0.21); ``` Find tax rate by description: ```php $taxRate = Teamleader::taxRates()->findByDescription("21%"); ``` Get tax rates as key-value pairs for dropdowns: ```php $options = Teamleader::taxRates()->asOptions(); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/invoicing/withholding-tax-rates # Withholding Tax Rates Manage withholding tax rates in Teamleader Focus. ```php Teamleader::withholdingTaxRates() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Invoicing\WithholdingTaxRates` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `withholdingTaxRates.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `department_id` | Filter by department UUID | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List withholding tax rates `department_id` is the one filter. Before v2.2.7 list() ignored its arguments entirely, so a department filter returned every rate. - `$filters` — department_id - `$options` — Not supported — the endpoint has no paging or sorting **Throws** `\InvalidArgumentException` On an unknown filter, or any paging or sorting option ### `forDepartment()` ```php forDepartment(string $departmentId): array ``` List withholding tax rates for one department ### `find()` ```php find(string $id): ?array ``` Find a withholding tax rate by ID - `$id` — Withholding tax rate UUID ### `findByRate()` ```php findByRate(float $rate): ?array ``` Find a withholding tax rate by exact rate value - `$rate` — Withholding tax rate (e.g., 0.15 for 15%) ### `findByRateRange()` ```php findByRateRange(float $minRate, float $maxRate): array ``` Find withholding tax rates by rate range - `$minRate` — Minimum rate (inclusive) - `$maxRate` — Maximum rate (inclusive) ### `findByDescription()` ```php findByDescription(string $description, bool $exactMatch = true): ?array ``` Find a withholding tax rate by description - `$description` — Withholding tax rate description (e.g., "15%") - `$exactMatch` — Whether to match exactly or search partial ### `exists()` ```php exists(string $id): bool ``` Check if a withholding tax rate exists by ID - `$id` — Withholding tax rate UUID ### `asOptions()` ```php asOptions(): array ``` Get withholding tax rates formatted as options for select dropdowns ### `groupedByDepartment()` ```php groupedByDepartment(): array ``` Get withholding tax rates grouped by department ### `sortedByRate()` ```php sortedByRate(): array ``` Get withholding tax rates sorted by rate ascending ### `sortedByDescription()` ```php sortedByDescription(string $order = 'asc'): array ``` Get withholding tax rates sorted by description - `$order` — Sort order (asc or desc) ### `format()` ```php format(array $rate): string ``` Format a withholding tax rate as a human-readable string - `$rate` — Withholding tax rate data ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all withholding tax rates: ```php $rates = Teamleader::withholdingTaxRates()->list(); ``` Find withholding tax rate by exact rate value: ```php $rate = Teamleader::withholdingTaxRates()->findByRate(0.15); ``` Find withholding tax rate by description: ```php $rate = Teamleader::withholdingTaxRates()->findByDescription("15%"); ``` Get withholding tax rates as key-value pairs for dropdowns: ```php $options = Teamleader::withholdingTaxRates()->asOptions(); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/bookkeeping-submissions # Bookkeeping Submissions Manage bookkeeping submissions for expense documents in Teamleader Focus. ```php Teamleader::bookkeepingSubmissions() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Expenses\BookkeepingSubmissions` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `bookkeepingSubmissions.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `subject` | REQUIRED: Filter by financial document (id and type) | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `STATUSES` | `sending`, `confirmed`, `failed` | | `SUBJECT_TYPES` | `incomingInvoice`, `incomingCreditNote`, `receipt` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List bookkeeping submissions for a specific financial document Note: The subject filter (document id and type) is REQUIRED by the API - `$filters` — Filters to apply (must include subject with id and type) - `$options` — Additional options (not used for this endpoint) **Throws** `InvalidArgumentException` When subject filter is missing or invalid ### `forDocument()` ```php forDocument(string $documentId, string $documentType): array ``` Get bookkeeping submissions for a specific financial document - `$documentId` — UUID of the financial document - `$documentType` — incomingInvoice, incomingCreditNote or receipt **Throws** `InvalidArgumentException` When document type is invalid ### `forInvoice()` ```php forInvoice(string $invoiceId): array ``` Get bookkeeping submissions for an incoming invoice - `$invoiceId` — UUID of the incoming invoice ### `forCreditNote()` ```php forCreditNote(string $creditNoteId): array ``` Get bookkeeping submissions for an incoming credit note - `$creditNoteId` — UUID of the incoming credit note ### `forReceipt()` ```php forReceipt(string $receiptId): array ``` Get bookkeeping submissions for a receipt - `$receiptId` — UUID of the receipt ### `byStatus()` ```php byStatus(string $documentId, string $documentType, string $status): array ``` Filter submissions by status - `$documentId` — UUID of the financial document - `$documentType` — Type of document - `$status` — Status to filter by: sending, confirmed, or failed **Throws** `InvalidArgumentException` When status is invalid ### `confirmed()` ```php confirmed(string $documentId, string $documentType): array ``` Get confirmed submissions for a document - `$documentId` — UUID of the financial document - `$documentType` — Type of document ### `failed()` ```php failed(string $documentId, string $documentType): array ``` Get failed submissions for a document - `$documentId` — UUID of the financial document - `$documentType` — Type of document ### `sending()` ```php sending(string $documentId, string $documentType): array ``` Get sending submissions for a document - `$documentId` — UUID of the financial document - `$documentType` — Type of document ### `latest()` ```php latest(string $documentId, string $documentType): ?array ``` Get the latest submission for a document - `$documentId` — UUID of the financial document - `$documentType` — Type of document ### `hasConfirmed()` ```php hasConfirmed(string $documentId, string $documentType): bool ``` Check if a document has any confirmed submissions - `$documentId` — UUID of the financial document - `$documentType` — Type of document ### `hasFailed()` ```php hasFailed(string $documentId, string $documentType): bool ``` Check if a document has any failed submissions - `$documentId` — UUID of the financial document - `$documentType` — Type of document ### `statistics()` ```php statistics(string $documentId, string $documentType): array ``` Get submission statistics for a document - `$documentId` — UUID of the financial document - `$documentType` — Type of document ### `info()` ```php info($id, $includes = null): array ``` Override info method as individual submission info is not supported **Throws** `InvalidArgumentException` ## Examples Get all bookkeeping submissions for a specific document: ```php $submissions = Teamleader::bookkeepingSubmissions()->forDocument("document-uuid", "incomingInvoice"); ``` Get submissions for an incoming invoice: ```php $submissions = Teamleader::bookkeepingSubmissions()->forInvoice("invoice-uuid"); ``` Get submissions for an incoming credit note: ```php $submissions = Teamleader::bookkeepingSubmissions()->forCreditNote("credit-note-uuid"); ``` Get submissions for a receipt: ```php $submissions = Teamleader::bookkeepingSubmissions()->forReceipt("receipt-uuid"); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/expenses # Expenses Manage expenses in Teamleader Focus. ```php Teamleader::expenses() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Expenses\Expenses` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; the response includes `meta` with totals. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `expenses.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `bookkeeping_statuses` | Filter by bookkeeping status(es): sent, not_sent | | `department_ids` | Filter by one or more department UUIDs | | `document_date` | Filter by document date with operators: is_empty, between, equals, before, after | | `paid_at` | Filter by payment date with operators: is_empty, between, equals, before, after | | `payment_statuses` | Filter by payment status(es): unknown, paid, partially_paid, credited, not_paid | | `review_statuses` | Filter by review status(es): pending, approved, refused | | `source_types` | Filter by expense source type(s): incomingInvoice, incomingCreditNote, receipt | | `supplier` | Filter by a specific supplier (object with type and id) | | `term` | Search by document number, title and supplier name (case-insensitive) | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `document_date` | Document date | | `due_date` | Due date | | `supplier_name` | Supplier name | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `BOOKKEEPING_STATUSES` | `sent`, `not_sent` | | `DATE_OPERATORS` | `is_empty`, `between`, `equals`, `before`, `after` | | `PAYMENT_STATUSES` | `unknown`, `paid`, `partially_paid`, `credited`, `not_paid` | | `REVIEW_STATUSES` | `pending`, `approved`, `refused` | | `SOURCE_TYPES` | `incomingInvoice`, `incomingCreditNote`, `receipt` | | `SUPPLIER_TYPES` | `company`, `contact` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List expenses with filtering, sorting, and pagination - `$filters` — Filters to apply - `$options` — Additional options (pagination, sort) ### `pending()` ```php pending(): array ``` Get expenses with pending review status ### `approved()` ```php approved(): array ``` Get expenses with approved review status ### `refused()` ```php refused(): array ``` Get expenses with refused review status ### `paid()` ```php paid(): array ``` Get expenses with paid payment status ### `unpaid()` ```php unpaid(): array ``` Get expenses not (fully) paid: not_paid and partially_paid Before v2.2.8 this sent `unpaid`, which is not a payment status the API knows. ### `bySourceType()` ```php bySourceType($sourceTypes, array $additionalFilters = []): array ``` Get expenses by source type - `$sourceTypes` — Source type(s): incomingInvoice, incomingCreditNote, receipt - `$additionalFilters` — Additional filters to apply ### `bySupplier()` ```php bySupplier(string $type, string $id, array $additionalFilters = []): array ``` Get expenses for a specific supplier - `$type` — Supplier type: company or contact - `$id` — Supplier UUID - `$additionalFilters` — Additional filters to apply ### `byDepartment()` ```php byDepartment($departmentIds, array $additionalFilters = []): array ``` Get expenses for one or more departments - `$departmentIds` — Department UUID or array of UUIDs - `$additionalFilters` — Additional filters to apply ### `searchByTerm()` ```php searchByTerm(string $term, array $additionalFilters = []): array ``` Search expenses by term (document number or supplier name) - `$term` — Search term (case-insensitive) - `$additionalFilters` — Additional filters to apply ### `byDateRange()` ```php byDateRange(string $startDate, string $endDate, array $additionalFilters = []): array ``` Get expenses within a document date range - `$startDate` — Start date (ISO format: YYYY-MM-DD) - `$endDate` — End date (ISO format: YYYY-MM-DD) - `$additionalFilters` — Additional filters to apply ### `byPaidAtRange()` ```php byPaidAtRange(string $startDate, string $endDate, array $additionalFilters = []): array ``` Get expenses paid within a date range - `$startDate` — Start date (ISO format: YYYY-MM-DD) - `$endDate` — End date (ISO format: YYYY-MM-DD) - `$additionalFilters` — Additional filters to apply ### `sent()` ```php sent(): array ``` Get expenses sent to bookkeeping ### `notSent()` ```php notSent(): array ``` Get expenses not sent to bookkeeping ### `getValidPaymentStatuses()` ```php getValidPaymentStatuses(): array ``` Get valid payment statuses for expenses ### `getValidSortFields()` ```php getValidSortFields(): array ``` Get valid sort fields for expenses ## Examples Get all expenses: ```php $expenses = Teamleader::expenses()->list(); ``` Get pending expenses: ```php $expenses = Teamleader::expenses()->pending(); ``` Get approved expenses: ```php $expenses = Teamleader::expenses()->approved(); ``` Get unpaid expenses: ```php $expenses = Teamleader::expenses()->unpaid(); ``` Search expenses by document number or supplier name: ```php $expenses = Teamleader::expenses()->searchByTerm("Office Supplies Inc"); ``` Get incoming invoices only: ```php $expenses = Teamleader::expenses()->bySourceType("incomingInvoice"); ``` Get expenses from a specific supplier: ```php $expenses = Teamleader::expenses()->bySupplier("company", "company-uuid"); ``` Get expenses for a specific department: ```php $expenses = Teamleader::expenses()->byDepartment("department-uuid"); ``` Get expenses within document date range: ```php $expenses = Teamleader::expenses()->byDateRange("2024-01-01", "2024-12-31"); ``` Get expenses paid within a date range: ```php $expenses = Teamleader::expenses()->byPaidAtRange("2024-01-01", "2024-12-31"); ``` Get expenses not sent to bookkeeping: ```php $expenses = Teamleader::expenses()->notSent(); ``` Get expenses sorted by document date descending: ```php $expenses = Teamleader::expenses()->list([], ["sort" => "document_date", "sort_order" => "desc"]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/incoming-credit-notes # Incoming Credit Notes Manage incoming credit notes from suppliers in Teamleader Focus. ```php Teamleader::incomingCreditNotes() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Expenses\IncomingCreditNotes` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | No | ## Endpoints - `incomingCreditNotes.add` - `incomingCreditNotes.approve` - `incomingCreditNotes.delete` - `incomingCreditNotes.info` - `incomingCreditNotes.listPayments` - `incomingCreditNotes.markAsPendingReview` - `incomingCreditNotes.refuse` - `incomingCreditNotes.registerPayment` - `incomingCreditNotes.removePayment` - `incomingCreditNotes.sendToBookkeeping` - `incomingCreditNotes.update` - `incomingCreditNotes.updatePayment` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `PAYMENT_STATUSES` | `unknown`, `paid`, `not_paid` | | `REGISTER_PAYMENT_FIELDS` | `payment`, `paid_at`, `payment_method_id`, `remark` | | `REVIEW_STATUSES` | `pending`, `approved`, `refused` | | `TOTAL_KEYS` | `tax_exclusive`, `tax_inclusive` | | `UPDATE_PAYMENT_FIELDS` | `payment_id`, `payment`, `paid_at`, `payment_method_id`, `remark` | | `WRITE_FIELDS` | `title`, `supplier_id`, `document_number`, `invoice_date`, `due_date`, `currency`, `total`, `company_entity_id`, `file_id`, `payment_reference`, `iban_number` | ## Methods ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `add()` ```php add(array $data): array ``` Create a new expense document `{base}.add` requires `title` and `currency.code`. Everything else — including `total` — is optional. - `$data` — Document data **Throws** `InvalidArgumentException` When a required field is missing, or a field or value is not accepted ### `create()` ```php create(array $data): array ``` Alias for add() - `$data` — Document data ### `update()` ```php update(string $id, array $data): array ``` Update an expense document - `$id` — Document UUID - `$data` — Fields to update; null clears a nullable field **Throws** `InvalidArgumentException` When the ID is empty, or a field or value is not accepted ### `info()` ```php info(string $id, $includes = null): array ``` Get one expense document - `$id` — Document UUID - `$includes` — Not supported — the info endpoint takes no includes **Throws** `InvalidArgumentException` When the ID is empty or includes are requested ### `delete()` ```php delete(string $id): array ``` Delete an expense document ### `approve()` ```php approve(string $id): array ``` Approve an expense document ### `refuse()` ```php refuse(string $id): array ``` Refuse an expense document ### `markAsPendingReview()` ```php markAsPendingReview(string $id): array ``` Put an expense document back to pending review ### `sendToBookkeeping()` ```php sendToBookkeeping(string $id): array ``` Send an expense document to bookkeeping ### `listPayments()` ```php listPayments(string $id): array ``` List the payments registered on an expense document Returns `data[]` — id, payment {amount, currency}, paid_at, payment_method, remark — and `meta.total` with the amount paid and, since specification 1.221.0, its currency. ### `registerPayment()` ```php registerPayment(string $id, array $payment, string $paidAt, ?string $paymentMethodId = null, ?string $remark = null): array ``` Register a payment - `$id` — Document UUID - `$payment` — ['amount' => float, 'currency' => 'EUR'] - `$paidAt` — ISO 8601 date-time - `$paymentMethodId` — Payment method UUID - `$remark` — Free text **Throws** `InvalidArgumentException` When a required value is missing or invalid ### `removePayment()` ```php removePayment(string $id, string $paymentId): array ``` Remove one payment **Throws** `InvalidArgumentException` When either ID is empty ### `updatePayment()` ```php updatePayment(string $id, string $paymentId, ?array $payment = null, ?string $paidAt = null, ?string $paymentMethodId = null, ?string $remark = null): array ``` Update one payment Only the document and payment IDs are required: pass null for $payment to change only the date, method or remark. Before v2.2.8 the payment amount was required on every update. - `$id` — Document UUID - `$paymentId` — Payment UUID - `$payment` — ['amount' => float, 'currency' => 'EUR'], or null to leave it - `$paidAt` — ISO 8601 date-time - `$paymentMethodId` — Payment method UUID - `$remark` — Free text **Throws** `InvalidArgumentException` When an ID is empty or the payment is invalid ### `list()` ```php list(array $filters = [], array $options = []): array ``` Not available — there is no list endpoint for a single expense type **Throws** `InvalidArgumentException` Always ### `getValidCurrencyCodes()` ```php getValidCurrencyCodes(): array ``` ### `getValidReviewStatuses()` ```php getValidReviewStatuses(): array ``` ### `getValidPaymentStatuses()` ```php getValidPaymentStatuses(): array ``` ## Examples Create a basic incoming credit note: ```php $creditNote = Teamleader::incomingCreditNotes()->add(['title' => 'Credit Note', 'currency' => ['code' => 'EUR'], 'total' => ['tax_exclusive' => ['amount' => 500.00]]]); ``` Approve a credit note: ```php Teamleader::incomingCreditNotes()->approve('creditnote-uuid'); ``` Refuse a credit note: ```php Teamleader::incomingCreditNotes()->refuse('creditnote-uuid'); ``` Send credit note to bookkeeping: ```php Teamleader::incomingCreditNotes()->sendToBookkeeping('creditnote-uuid'); ``` Delete a credit note: ```php Teamleader::incomingCreditNotes()->delete('creditnote-uuid'); ``` List all payments for a credit note: ```php $payments = Teamleader::incomingCreditNotes()->listPayments('creditnote-uuid'); ``` Register a payment for a credit note: ```php $payment = Teamleader::incomingCreditNotes()->registerPayment('creditnote-uuid', ['amount' => 500.00, 'currency' => 'EUR'], '2024-01-20T10:00:00+00:00'); ``` Remove a specific payment from a credit note: ```php Teamleader::incomingCreditNotes()->removePayment('creditnote-uuid', 'payment-uuid'); ``` Update an existing payment on a credit note: ```php Teamleader::incomingCreditNotes()->updatePayment('creditnote-uuid', 'payment-uuid', ['amount' => 450.00, 'currency' => 'EUR']); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/incoming-invoices # Incoming Invoices Manage incoming invoices from suppliers in Teamleader Focus. ```php Teamleader::incomingInvoices() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Expenses\IncomingInvoices` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | No | ## Endpoints - `incomingInvoices.add` - `incomingInvoices.approve` - `incomingInvoices.delete` - `incomingInvoices.info` - `incomingInvoices.listPayments` - `incomingInvoices.markAsPendingReview` - `incomingInvoices.refuse` - `incomingInvoices.registerPayment` - `incomingInvoices.removePayment` - `incomingInvoices.sendToBookkeeping` - `incomingInvoices.update` - `incomingInvoices.updatePayment` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `PAYMENT_STATUSES` | `unknown`, `paid`, `partially_paid`, `credited`, `not_paid` | | `REGISTER_PAYMENT_FIELDS` | `payment`, `paid_at`, `payment_method_id`, `remark` | | `REVIEW_STATUSES` | `pending`, `approved`, `refused` | | `TOTAL_KEYS` | `tax_exclusive`, `tax_inclusive` | | `UPDATE_PAYMENT_FIELDS` | `payment_id`, `payment`, `paid_at`, `payment_method_id`, `remark` | | `WRITE_FIELDS` | `title`, `supplier_id`, `document_number`, `invoice_date`, `due_date`, `currency`, `total`, `company_entity_id`, `file_id`, `payment_reference`, `iban_number` | ## Methods ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `add()` ```php add(array $data): array ``` Create a new expense document `{base}.add` requires `title` and `currency.code`. Everything else — including `total` — is optional. - `$data` — Document data **Throws** `InvalidArgumentException` When a required field is missing, or a field or value is not accepted ### `create()` ```php create(array $data): array ``` Alias for add() - `$data` — Document data ### `update()` ```php update(string $id, array $data): array ``` Update an expense document - `$id` — Document UUID - `$data` — Fields to update; null clears a nullable field **Throws** `InvalidArgumentException` When the ID is empty, or a field or value is not accepted ### `info()` ```php info(string $id, $includes = null): array ``` Get one expense document - `$id` — Document UUID - `$includes` — Not supported — the info endpoint takes no includes **Throws** `InvalidArgumentException` When the ID is empty or includes are requested ### `delete()` ```php delete(string $id): array ``` Delete an expense document ### `approve()` ```php approve(string $id): array ``` Approve an expense document ### `refuse()` ```php refuse(string $id): array ``` Refuse an expense document ### `markAsPendingReview()` ```php markAsPendingReview(string $id): array ``` Put an expense document back to pending review ### `sendToBookkeeping()` ```php sendToBookkeeping(string $id): array ``` Send an expense document to bookkeeping ### `listPayments()` ```php listPayments(string $id): array ``` List the payments registered on an expense document Returns `data[]` — id, payment {amount, currency}, paid_at, payment_method, remark — and `meta.total` with the amount paid and, since specification 1.221.0, its currency. ### `registerPayment()` ```php registerPayment(string $id, array $payment, string $paidAt, ?string $paymentMethodId = null, ?string $remark = null): array ``` Register a payment - `$id` — Document UUID - `$payment` — ['amount' => float, 'currency' => 'EUR'] - `$paidAt` — ISO 8601 date-time - `$paymentMethodId` — Payment method UUID - `$remark` — Free text **Throws** `InvalidArgumentException` When a required value is missing or invalid ### `removePayment()` ```php removePayment(string $id, string $paymentId): array ``` Remove one payment **Throws** `InvalidArgumentException` When either ID is empty ### `updatePayment()` ```php updatePayment(string $id, string $paymentId, ?array $payment = null, ?string $paidAt = null, ?string $paymentMethodId = null, ?string $remark = null): array ``` Update one payment Only the document and payment IDs are required: pass null for $payment to change only the date, method or remark. Before v2.2.8 the payment amount was required on every update. - `$id` — Document UUID - `$paymentId` — Payment UUID - `$payment` — ['amount' => float, 'currency' => 'EUR'], or null to leave it - `$paidAt` — ISO 8601 date-time - `$paymentMethodId` — Payment method UUID - `$remark` — Free text **Throws** `InvalidArgumentException` When an ID is empty or the payment is invalid ### `list()` ```php list(array $filters = [], array $options = []): array ``` Not available — there is no list endpoint for a single expense type **Throws** `InvalidArgumentException` Always ### `getValidCurrencyCodes()` ```php getValidCurrencyCodes(): array ``` ### `getValidReviewStatuses()` ```php getValidReviewStatuses(): array ``` ### `getValidPaymentStatuses()` ```php getValidPaymentStatuses(): array ``` ## Examples Create a basic incoming invoice: ```php $invoice = Teamleader::incomingInvoices()->add(['title' => 'Invoice', 'currency' => ['code' => 'EUR'], 'total' => ['tax_exclusive' => ['amount' => 500.00]]]); ``` Register a payment for an invoice: ```php Teamleader::incomingInvoices()->registerPayment('invoice-uuid', ['amount' => 500.00, 'currency' => 'EUR'], '2024-01-15T10:00:00Z'); ``` Remove a payment from an invoice: ```php Teamleader::incomingInvoices()->removePayment('invoice-uuid', 'payment-uuid'); ``` Update a payment on an invoice: ```php Teamleader::incomingInvoices()->updatePayment('invoice-uuid', 'payment-uuid', ['amount' => 600.00, 'currency' => 'EUR']); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/expenses/receipts # Receipts Manage expense receipts in Teamleader Focus. ```php Teamleader::receipts() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Expenses\Receipts` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | No | ## Endpoints - `receipts.add` - `receipts.approve` - `receipts.delete` - `receipts.info` - `receipts.listPayments` - `receipts.markAsPendingReview` - `receipts.refuse` - `receipts.registerPayment` - `receipts.removePayment` - `receipts.sendToBookkeeping` - `receipts.update` - `receipts.updatePayment` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `PAYMENT_STATUSES` | `unknown`, `paid`, `not_paid` | | `REGISTER_PAYMENT_FIELDS` | `payment`, `paid_at`, `payment_method_id`, `remark` | | `REVIEW_STATUSES` | `pending`, `approved`, `refused` | | `TOTAL_KEYS` | `tax_inclusive` | | `UPDATE_PAYMENT_FIELDS` | `payment_id`, `payment`, `paid_at`, `payment_method_id`, `remark` | | `WRITE_FIELDS` | `title`, `supplier_id`, `document_number`, `receipt_date`, `currency`, `total`, `company_entity_id`, `file_id` | ## Methods ### `add()` ```php add(array $data): array ``` Create a new expense document `{base}.add` requires `title` and `currency.code`. Everything else — including `total` — is optional. - `$data` — Document data **Throws** `InvalidArgumentException` When a required field is missing, or a field or value is not accepted ### `create()` ```php create(array $data): array ``` Alias for add() - `$data` — Document data ### `update()` ```php update(string $id, array $data): array ``` Update an expense document - `$id` — Document UUID - `$data` — Fields to update; null clears a nullable field **Throws** `InvalidArgumentException` When the ID is empty, or a field or value is not accepted ### `info()` ```php info(string $id, $includes = null): array ``` Get one expense document - `$id` — Document UUID - `$includes` — Not supported — the info endpoint takes no includes **Throws** `InvalidArgumentException` When the ID is empty or includes are requested ### `delete()` ```php delete(string $id): array ``` Delete an expense document ### `approve()` ```php approve(string $id): array ``` Approve an expense document ### `refuse()` ```php refuse(string $id): array ``` Refuse an expense document ### `markAsPendingReview()` ```php markAsPendingReview(string $id): array ``` Put an expense document back to pending review ### `sendToBookkeeping()` ```php sendToBookkeeping(string $id): array ``` Send an expense document to bookkeeping ### `listPayments()` ```php listPayments(string $id): array ``` List the payments registered on an expense document Returns `data[]` — id, payment {amount, currency}, paid_at, payment_method, remark — and `meta.total` with the amount paid and, since specification 1.221.0, its currency. ### `registerPayment()` ```php registerPayment(string $id, array $payment, string $paidAt, ?string $paymentMethodId = null, ?string $remark = null): array ``` Register a payment - `$id` — Document UUID - `$payment` — ['amount' => float, 'currency' => 'EUR'] - `$paidAt` — ISO 8601 date-time - `$paymentMethodId` — Payment method UUID - `$remark` — Free text **Throws** `InvalidArgumentException` When a required value is missing or invalid ### `removePayment()` ```php removePayment(string $id, string $paymentId): array ``` Remove one payment **Throws** `InvalidArgumentException` When either ID is empty ### `updatePayment()` ```php updatePayment(string $id, string $paymentId, ?array $payment = null, ?string $paidAt = null, ?string $paymentMethodId = null, ?string $remark = null): array ``` Update one payment Only the document and payment IDs are required: pass null for $payment to change only the date, method or remark. Before v2.2.8 the payment amount was required on every update. - `$id` — Document UUID - `$paymentId` — Payment UUID - `$payment` — ['amount' => float, 'currency' => 'EUR'], or null to leave it - `$paidAt` — ISO 8601 date-time - `$paymentMethodId` — Payment method UUID - `$remark` — Free text **Throws** `InvalidArgumentException` When an ID is empty or the payment is invalid ### `list()` ```php list(array $filters = [], array $options = []): array ``` Not available — there is no list endpoint for a single expense type **Throws** `InvalidArgumentException` Always ### `getValidCurrencyCodes()` ```php getValidCurrencyCodes(): array ``` ### `getValidReviewStatuses()` ```php getValidReviewStatuses(): array ``` ### `getValidPaymentStatuses()` ```php getValidPaymentStatuses(): array ``` ## Examples Create a basic receipt: ```php $receipt = Teamleader::receipts()->add(['title' => 'Office Lunch', 'currency' => ['code' => 'EUR'], 'total' => ['tax_inclusive' => ['amount' => 45.50]]]); ``` Create a complete receipt with all details: ```php $receipt = Teamleader::receipts()->add(['title' => 'Business Dinner', 'supplier_id' => 'uuid', 'document_number' => 'REC-001', 'receipt_date' => '2024-01-15', 'currency' => ['code' => 'EUR'], 'total' => ['tax_inclusive' => ['amount' => 125.00]]]); ``` Get receipt details: ```php $receipt = Teamleader::receipts()->info('receipt-uuid'); ``` Update an existing receipt: ```php Teamleader::receipts()->update('receipt-uuid', ['title' => 'Updated Title', 'receipt_date' => '2024-01-16']); ``` Approve a receipt: ```php Teamleader::receipts()->approve('receipt-uuid'); ``` Refuse a receipt: ```php Teamleader::receipts()->refuse('receipt-uuid'); ``` Mark a receipt as pending review: ```php Teamleader::receipts()->markAsPendingReview('receipt-uuid'); ``` Send receipt to bookkeeping: ```php Teamleader::receipts()->sendToBookkeeping('receipt-uuid'); ``` List payments for a receipt: ```php $payments = Teamleader::receipts()->listPayments('receipt-uuid'); ``` Register a payment for a receipt: ```php Teamleader::receipts()->registerPayment('receipt-uuid', ['amount' => 45.50, 'currency' => 'EUR'], '2024-01-15T10:00:00Z'); ``` Remove a payment from a receipt: ```php Teamleader::receipts()->removePayment('receipt-uuid', 'payment-uuid'); ``` Update a payment on a receipt: ```php Teamleader::receipts()->updatePayment('receipt-uuid', 'payment-uuid', ['amount' => 50.00, 'currency' => 'EUR']); ``` Delete a receipt: ```php Teamleader::receipts()->delete('receipt-uuid'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/products/price-lists # Price Lists Manage price lists in Teamleader Focus. ```php Teamleader::priceLists() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Products\PriceLists` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `priceLists.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of price list UUIDs to filter by | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List price lists with filtering - `$filters` — Filters to apply - `$options` — Additional options (not used for this endpoint) ### `byIds()` ```php byIds(array $ids): array ``` Get specific price lists by their UUIDs - `$ids` — Array of price list UUIDs ## Examples Get all price lists: ```php $priceLists = Teamleader::priceLists()->list(); ``` Get specific price lists by IDs: ```php $priceLists = Teamleader::priceLists()->list(['ids' => ['uuid1', 'uuid2']]); ``` Get specific price lists (alias method): ```php $priceLists = Teamleader::priceLists()->byIds(['uuid1', 'uuid2']); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/products/product-categories # Product Categories Manage product categories in Teamleader Focus. ```php Teamleader::productCategories() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Products\Categories` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `productCategories.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `department_id` | The ID of the department to filter on (UUID format) | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List product categories with filtering - `$filters` — Filters to apply - `$options` — Additional options (not used for this endpoint) ### `forDepartment()` ```php forDepartment(string $departmentId): array ``` Get product categories for a specific department - `$departmentId` — Department UUID ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `getAvailableFilters()` ```php getAvailableFilters(): array ``` Get available filters for product categories ## Examples Get all product categories: ```php $categories = Teamleader::productCategories()->list(); ``` Get product categories for specific department: ```php $categories = Teamleader::productCategories()->forDepartment("080aac72-ff1a-4627-bfe3-146b6eee979c"); ``` Get categories with custom filtering: ```php $categories = Teamleader::productCategories()->list([ "department_id" => "department-uuid-here" ]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/products/products # Products Manage products in Teamleader Focus. ```php Teamleader::products() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Products\Products` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `products.add` - `products.delete` - `products.info` - `products.list` - `products.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of product UUIDs | | `term` | Search term (will filter on the name or the code) | | `updated_since` | ISO 8601 datetime | ## Includes | Endpoint | Includes | |---|---| | `list()` | none | | `info()` | `suppliers` | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ADD_FIELDS` | `name`, `code`, `description`, `purchase_price`, `selling_price`, `unit_of_measure_id`, `price_list_prices`, `stock`, `configuration`, `department_id`, `product_category_id`, `tax_rate_id`, `custom_fields` | | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `INFO_INCLUDES` | `suppliers` | | `STOCK_THRESHOLD_ACTIONS` | `notify` | | `UPDATE_FIELDS` | `name`, `code`, `description`, `purchase_price`, `selling_price`, `unit_of_measure_id`, `price_list_prices`, `stock`, `configuration`, `department_id`, `product_category_id`, `tax_rate_id`, `custom_fields` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List products - `$filters` — ids, term (name or code), updated_since - `$options` — page_size, page_number **Throws** `InvalidArgumentException` On an unknown filter key or option ### `info()` ```php info($id, $includes = null): array ``` Get one product Custom fields are part of every info response; `suppliers` is the only include. - `$id` — Product UUID - `$includes` — suppliers **Throws** `InvalidArgumentException` On an include products.info does not accept ### `create()` ```php create(array $data): array ``` Create a product Requires a name or a code. A product has no department of its own. Which department and ledger account apply comes from its product category: `product_category.ledgers[].department` on productCategories.list. **Throws** `InvalidArgumentException` When neither is given, or a field or value is not accepted ### `update()` ```php update($id, array $data): array ``` Update a product Every field is optional; name, code, description, prices, unit_of_measure_id and configuration take null to clear them. **Throws** `InvalidArgumentException` When a field or value is not accepted ### `delete()` ```php delete($id, ...$additionalParams): array ``` ### `search()` ```php search(string $term, array $options = []): array ``` Search products by name or code ### `updatedSince()` ```php updatedSince(string $date, array $options = []): array ``` Get products updated since a datetime - `$date` — ISO 8601 datetime ### `withSuppliers()` ```php withSuppliers(): self ``` ## Examples Get all products: ```php $products = Teamleader::products()->list(); ``` Search products by name or code: ```php $products = Teamleader::products()->search("cookies"); ``` Get a product with its suppliers: ```php $product = Teamleader::products()->withSuppliers()->info("product-uuid"); ``` Create a new product: ```php $product = Teamleader::products()->create(["name" => "Dark Chocolate Cookies", "code" => "COOK-DARK-001"]); ``` Set a price on a price list: ```php Teamleader::products()->update("product-uuid", [ "price_list_prices" => [ ["price_list_id" => "price-list-uuid", "price" => ["amount" => 9.95, "currency" => "EUR"]], ], ]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/products/units-of-measure # Units of Measure Manage units of measure in Teamleader Focus — read-only, and unpaginated: list() returns every unit in one response and accepts no arguments. ```php Teamleader::unitsOfMeasure() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Products\UnitOfMeasure` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `unitsOfMeasure.list` ## Filters `list()` takes no filters. ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List all units of measure `unitsOfMeasure.list` takes no request body at all — the API specification declares no filter, page or sort parameter for it. The inherited signature still offers them, so arguments are rejected rather than discarded: before v2.1.2 they were dropped silently, and every call returned the complete list regardless of what was asked for. - `$filters` — Must be empty — this endpoint accepts no filters - `$options` — Must be empty — this endpoint accepts no sorting or pagination **Throws** `\InvalidArgumentException` When any argument is passed ### `info()` ```php info($id, $includes = null): array ``` Units of measure have no info endpoint `unitsOfMeasure.list` is the only endpoint Teamleader exposes for this resource. Use findById(), which resolves against the full list. **Throws** `\InvalidArgumentException` Always ### `findByName()` ```php findByName(string $name): ?array ``` Find a unit of measure by name (case-insensitive search) - `$name` — The name to search for ### `findById()` ```php findById(string $id): ?array ``` Find a unit of measure by ID - `$id` — The ID to search for ### `asOptions()` ```php asOptions(): array ``` Get units of measure as key-value pairs (id => name) for use in dropdowns ### `asCollection()` ```php asCollection(): Collection ``` Get units of measure as collection for easier manipulation ### `exists()` ```php exists(string $name): bool ``` Check if a unit exists by name ### `count()` ```php count(): int ``` Get the total count of units of measure ## Examples Get all units of measure: ```php $unitsOfMeasure = Teamleader::unitsOfMeasure()->list(); ``` Find unit of measure by name: ```php $unit = Teamleader::unitsOfMeasure()->findByName("piece"); ``` Get units as key-value pairs for dropdowns: ```php $options = Teamleader::unitsOfMeasure()->asOptions(); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/external-parties # External Parties Manage external parties on projects in Teamleader Focus. ```php Teamleader::externalParties() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Projects\ExternalParties` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `projects-v2/externalParties.addToProject` - `projects-v2/externalParties.delete` - `projects-v2/externalParties.update` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ADD_FIELDS` | `project_id`, `customer`, `function`, `sub_function` | | `CUSTOMER_TYPES` | `contact`, `company` | | `UPDATE_FIELDS` | `customer`, `function`, `sub_function` | ## Methods ### `addToProject()` ```php addToProject($projectIdOrData, ?string $customerType = null, ?string $customerId = null, ?string $function = null, ?string $subFunction = null): array ``` Add an external party to a project Can be called with individual parameters or with a data array: - addToProject($projectId, $customerType, $customerId, $function, $subFunction) - addToProject($dataArray) - `$projectIdOrData` — Project UUID or full data array - `$customerType` — Customer type (contact, company) - required if using individual params - `$customerId` — Customer UUID - required if using individual params - `$function` — Function/role description - `$subFunction` — Sub-function/role description **Throws** `InvalidArgumentException` ### `update()` ```php update($id, array $data): array ``` Update an external party - `$id` — External party UUID - `$data` — Update data containing customer, function, and/or sub_function **Throws** `InvalidArgumentException` ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete an external party - `$id` — External party UUID - `$additionalParams` — Not used for external parties **Throws** `InvalidArgumentException` ### `removeFromProject()` ```php removeFromProject(string $id): array ``` Remove an external party from a project (alias for delete) - `$id` — External party UUID ### `updateRole()` ```php updateRole(string $id, ?string $function = null, ?string $subFunction = null): array ``` Update the function/role of an external party - `$id` — External party UUID - `$function` — Function/role - `$subFunction` — Sub-function/role ## Examples Add a contact as external party to a project: ```php $result = Teamleader::externalParties()->addToProject( "project-uuid", "contact", "contact-uuid", "Project Manager" ); ``` Add a company as external party to a project: ```php $result = Teamleader::externalParties()->addToProject( "project-uuid", "company", "company-uuid", "Contractor", "Lead Contractor" ); ``` Add external party with full array structure: ```php $result = Teamleader::externalParties()->addToProject([ "project_id" => "project-uuid", "customer" => [ "type" => "contact", "id" => "contact-uuid" ], "function" => "Project Manager", "sub_function" => "Senior PM" ]); ``` Update an external party: ```php $result = Teamleader::externalParties()->update( "external-party-uuid", [ "customer" => [ "type" => "contact", "id" => "contact-uuid" ], "function" => "Lead Designer", "sub_function" => null ] ); ``` Delete an external party: ```php $result = Teamleader::externalParties()->delete("external-party-uuid"); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/groups # Groups Manage project groups in Teamleader Focus (New Projects API). Project groups in the current ("nextgen") project system — `projects-v2/projectGroups.*`. ```php Teamleader::groups() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Projects\Groups` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `projects-v2/projectGroups.assign` - `projects-v2/projectGroups.create` - `projects-v2/projectGroups.delete` - `projects-v2/projectGroups.duplicate` - `projects-v2/projectGroups.info` - `projects-v2/projectGroups.list` - `projects-v2/projectGroups.unassign` - `projects-v2/projectGroups.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of group UUIDs to filter by | | `project_id` | Filter groups by project UUID | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ASSIGNEE_TYPES` | `team`, `user` | | `BILLING_METHODS` | `time_and_materials`, `fixed_price`, `parent_fixed_price`, `non_billable` | | `BILLING_STATUSES` | `not_billable`, `not_billed`, `partially_billed`, `fully_billed` | | `COLORS` | `#00B2B2`, `#008A8C`, `#992600`, `#ED9E00`, `#D157D3`, `#A400B2`, `#0071F2`, `#004DA6`, `#64788F`, `#C0C0C4`, `#82828C`, `#1A1C20` | | `CREATE_FIELDS` | `project_id`, `title`, `description`, `color`, `billing_method`, `fixed_price`, `external_budget`, `internal_budget`, `start_date`, `end_date`, `assignees` | | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `DELETE_STRATEGIES` | `ungroup_tasks_and_materials`, `delete_tasks_and_materials`, `delete_tasks_materials_and_unbilled_timetrackings` | | `TIME_UNITS` | `hours`, `minutes`, `seconds` | | `UPDATE_FIELDS` | `title`, `description`, `color`, `billing_method`, `fixed_price`, `external_budget`, `internal_budget`, `start_date`, `end_date` | | `UPDATE_STRATEGIES` | `none`, `cascade` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List project groups Before v2.2.9 unknown filter keys and a string `ids` were dropped without a word, as were the paging options. - `$filters` — ids (a UUID or a list of UUIDs), project_id - `$options` — page_size, page_number **Throws** `InvalidArgumentException` On an unknown filter key or option ### `forProject()` ```php forProject(string $projectId, array $options = []): array ``` Get all groups of a project - `$projectId` — Project UUID - `$options` — page_size, page_number ### `byIds()` ```php byIds(array $ids): array ``` Get project groups by ID - `$ids` — Group UUIDs ### `info()` ```php info($id, $includes = null): array ``` Get one group - `$id` — Group UUID - `$includes` — projectGroups.info takes no includes; passing any throws ### `create()` ```php create(array $data): array ``` Create a project group Requires project_id and title. fixed_price is only allowed with billing method fixed_price, external_budget only with time_and_materials. **Throws** `InvalidArgumentException` When a required field is missing, or a field or value is not accepted ### `update()` ```php update($id, array $data): array ``` Update a project group billing_method is sent as {value, update_strategy}. A plain method name is accepted and sent with update_strategy `none` (this group only); `cascade` applies it to the group's tasks and materials as well. - `$id` — Group UUID **Throws** `InvalidArgumentException` When a field or value is not accepted ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a project group - `$id` — Group UUID - `$additionalParams` — Delete strategy: ungroup_tasks_and_materials (default), delete_tasks_and_materials or delete_tasks_materials_and_unbilled_timetrackings **Throws** `InvalidArgumentException` ### `duplicate()` ```php duplicate(string $originId): array ``` Duplicate a project group and its entities (without time trackings) - `$originId` — UUID of the group to duplicate ### `getAvailableBillingMethods()` ```php getAvailableBillingMethods(): array ``` ### `getAvailableDeleteStrategies()` ```php getAvailableDeleteStrategies(): array ``` ### `assign()` ```php assign(string $id, string $assigneeType, string $assigneeId): array ``` Assign a user or team - `$id` — UUID of the project, group, task or material - `$assigneeType` — user or team - `$assigneeId` — UUID of the user or team **Throws** `InvalidArgumentException` When the assignee type is not user or team ### `unassign()` ```php unassign(string $id, string $assigneeType, string $assigneeId): array ``` Unassign a user or team - `$id` — UUID of the project, group, task or material - `$assigneeType` — user or team - `$assigneeId` — UUID of the user or team **Throws** `InvalidArgumentException` When the assignee type is not user or team ### `assignUser()` ```php assignUser(string $id, string $userId): array ``` ### `assignTeam()` ```php assignTeam(string $id, string $teamId): array ``` ### `unassignUser()` ```php unassignUser(string $id, string $userId): array ``` ### `unassignTeam()` ```php unassignTeam(string $id, string $teamId): array ``` ### `getAvailableAssigneeTypes()` ```php getAvailableAssigneeTypes(): array ``` ## Examples Get all groups for a specific project: ```php $groups = Teamleader::groups()->forProject("project-uuid"); ``` Create a new project group: ```php $group = Teamleader::groups()->create([ "project_id" => "project-uuid", "title" => "Phase 1: Design", "description" => "Initial design phase", "color" => "#00B2B2", "billing_method" => "fixed_price", "fixed_price" => ["amount" => 5000, "currency" => "EUR"] ]); ``` Update a project group: ```php $group = Teamleader::groups()->update("group-uuid", [ "title" => "Phase 1: Design & Planning", "start_date" => "2023-01-18", "end_date" => "2023-03-22" ]); ``` Assign a user to a group: ```php $result = Teamleader::groups()->assign("group-uuid", "user", "user-uuid"); ``` Duplicate a group: ```php $newGroup = Teamleader::groups()->duplicate("origin-group-uuid"); ``` Delete a group: ```php $result = Teamleader::groups()->delete("group-uuid", "ungroup_tasks_and_materials"); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/legacy-milestones # Legacy Milestones Manage legacy project milestones in Teamleader Focus. Milestones of the legacy project system — `milestones.*`. ```php Teamleader::legacyMilestones() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Projects\LegacyMilestones` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `milestones.close` - `milestones.create` - `milestones.delete` - `milestones.info` - `milestones.list` - `milestones.open` - `milestones.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `due_after` | Filter milestones due after date (Y-m-d format) | | `due_before` | Filter milestones due before date (Y-m-d format) | | `ids` | Array of milestone UUIDs to filter by | | `project_id` | Filter milestones by project UUID | | `status` | Filter by milestone status (open, closed) | | `term` | Search term - searches in milestone title | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `due_on` | Due date | | `starts_on` | Start date | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `BILLING_METHODS` | `non_invoiceable`, `time_and_materials`, `fixed_price` | | `CREATE_FIELDS` | `project_id`, `starts_on`, `due_on`, `name`, `description`, `responsible_user_id`, `depends_on`, `custom_fields`, `billing_method`, `budget`, `price` | | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `STATUSES` | `open`, `closed` | | `UPDATE_FIELDS` | `starts_on`, `due_on`, `name`, `description`, `responsible_user_id`, `depends_on`, `propagate_date_changes`, `custom_fields`, `billing_method`, `budget`, `price` | ## Methods ### `info()` ```php info($id, $includes = null): array ``` Get detailed information about a specific milestone - `$id` — Milestone UUID - `$includes` — Not used for milestones ### `create()` ```php create(array $data): array ``` Create a new milestone - `$data` — Milestone data **Throws** `InvalidArgumentException` ### `update()` ```php update($id, array $data): array ``` Update an existing milestone Since specification 1.229.0 the billing can change too: send billing_method with a budget (non_invoiceable, time_and_materials) or with a price (fixed_price). - `$id` — Milestone UUID - `$data` — Data to update **Throws** `InvalidArgumentException` ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a milestone - `$id` — Milestone UUID - `$additionalParams` — Not used for legacy milestones ### `close()` ```php close(string $id): array ``` Close a milestone All open tasks will be closed, open meetings will remain open Closing the last open milestone will also close the project - `$id` — Milestone UUID ### `open()` ```php open(string $id): array ``` (Re)open a milestone If the milestone's project is closed, the project will be reopened - `$id` — Milestone UUID ### `forProject()` ```php forProject(string $projectId, array $options = []): array ``` Get all milestones for a specific project - `$projectId` — Project UUID - `$options` — Additional options (pagination, sorting) ### `list()` ```php list(array $filters = [], array $options = []): array ``` List milestones - `$filters` — ids, project_id, status (open|closed), due_before, due_after, term - `$options` — page_size, page_number, sort (due_on|starts_on), sort_order **Throws** `InvalidArgumentException` On an unknown filter key, option, value or sort field ### `getOpen()` ```php getOpen(array $additionalFilters = [], array $options = []): array ``` Get open milestones - `$additionalFilters` — Additional filters to apply - `$options` — Additional options ### `getClosed()` ```php getClosed(array $additionalFilters = [], array $options = []): array ``` Get closed milestones - `$additionalFilters` — Additional filters to apply - `$options` — Additional options ### `search()` ```php search(string $term, array $options = []): array ``` Search milestones by term - `$term` — Search term - `$options` — Additional options ### `dueBefore()` ```php dueBefore(string $date, array $additionalFilters = [], array $options = []): array ``` Get milestones due before a specific date - `$date` — Date in Y-m-d format - `$additionalFilters` — Additional filters - `$options` — Additional options ### `dueAfter()` ```php dueAfter(string $date, array $additionalFilters = [], array $options = []): array ``` Get milestones due after a specific date - `$date` — Date in Y-m-d format - `$additionalFilters` — Additional filters - `$options` — Additional options ### `dueBetween()` ```php dueBetween(string $startDate, string $endDate, array $additionalFilters = [], array $options = []): array ``` Get milestones due within a date range - `$startDate` — Start date in Y-m-d format - `$endDate` — End date in Y-m-d format - `$additionalFilters` — Additional filters - `$options` — Additional options ### `getAvailableBillingMethods()` ```php getAvailableBillingMethods(): array ``` Get available billing methods ### `getAvailableStatuses()` ```php getAvailableStatuses(): array ``` Get available status values ## Examples Get all milestones: ```php $milestones = Teamleader::legacyMilestones()->list(); ``` Get milestones for a specific project: ```php $milestones = Teamleader::legacyMilestones()->forProject('project-uuid'); ``` Get only open milestones: ```php $milestones = Teamleader::legacyMilestones()->list(['status' => 'open']); ``` Get a single milestone: ```php $milestone = Teamleader::legacyMilestones()->info('milestone-uuid'); ``` Create a new milestone: ```php $milestone = Teamleader::legacyMilestones()->create([ 'project_id' => 'project-uuid', 'name' => 'Initial setup', 'due_on' => '2024-12-31', 'responsible_user_id' => 'user-uuid', 'billing_method' => 'time_and_materials' ]); ``` Update a milestone: ```php $result = Teamleader::legacyMilestones()->update('milestone-uuid', [ 'name' => 'Updated name', 'due_on' => '2024-12-31' ]); ``` Change a milestone to a fixed price: ```php $result = Teamleader::legacyMilestones()->update('milestone-uuid', [ 'billing_method' => 'fixed_price', 'price' => ['amount' => 1500, 'currency' => 'EUR'] ]); ``` Close a milestone: ```php $result = Teamleader::legacyMilestones()->close('milestone-uuid'); ``` Open/reopen a milestone: ```php $result = Teamleader::legacyMilestones()->open('milestone-uuid'); ``` Delete a milestone: ```php $result = Teamleader::legacyMilestones()->delete('milestone-uuid'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/legacy-projects # Legacy Projects Manage legacy projects in Teamleader Focus — the original project system (API path projects, webhook events project.*). See Projects for the current system. LegacyProjects — the original project system in Teamleader Focus. The names do not line up with the class names; to be explicit: SDK method Teamleader::legacyProjects() API path projects.* (the bare path belongs to the OLD system) Webhooks project.created, project.updated, project.deleted The current system is `Projects`, on the `projects-v2/projects.*` path, with `nextgenProject.*` webhook events. So the class names and the endpoint paths run in opposite directions: `Projects` is the newer class on the longer path, `LegacyProjects` is the older class on the shorter one. **Do not infer which system an account is on from whether a list call returns rows.** Both endpoints answer. Ask directly: Teamleader::accounts()->getProjectsVersion(); // "projects-v2" or "legacy" Teamleader::accounts()->isUsingLegacyProjects(); // bool Accounts are migrated to the new system over time; Accounts::getAutoSwitchDate() reports when, if it is scheduled. ```php Teamleader::legacyProjects() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Projects\LegacyProjects` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `projects.addParticipant` - `projects.close` - `projects.create` - `projects.delete` - `projects.info` - `projects.list` - `projects.reopen` - `projects.update` - `projects.updateParticipant` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `customer.id` | Customer UUID | | `customer.type` | Customer type (contact, company) | | `participant_id` | Filter by participant UUID | | `status` | Project status (active, on_hold, done, cancelled) | | `term` | Search term (searches title or description) | | `updated_since` | ISO 8601 datetime | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `created_at` | Sort by creation date | | `due_on` | Sort by project due date | | `title` | Sort by project title | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CREATE_FIELDS` | `title`, `description`, `starts_on`, `milestones`, `participants`, `customer`, `purchase_order_number`, `custom_fields` | | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `CUSTOMER_TYPES` | `contact`, `company` | | `ROLES` | `decision_maker`, `member` | | `STATUSES` | `active`, `on_hold`, `done`, `cancelled` | | `UPDATE_FIELDS` | `title`, `description`, `status`, `starts_on`, `customer`, `budget`, `purchase_order_number`, `custom_fields` | ## Methods ### `info()` ```php info($id, $includes = null) ``` Get detailed information about a specific project - `$id` — Project UUID - `$includes` — Not used for legacy projects ### `create()` ```php create(array $data): array ``` Create a new project - `$data` — Project data ### `update()` ```php update($id, array $data): array ``` Update an existing project - `$id` — Project UUID - `$data` — Project data to update ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a project - `$id` — Project UUID - `$additionalParams` — Not used for legacy projects ### `close()` ```php close(string $id): array ``` Close a project (also closes all phases and tasks) - `$id` — Project UUID ### `reopen()` ```php reopen(string $id): array ``` Reopen a closed project - `$id` — Project UUID ### `addParticipant()` ```php addParticipant(string $id, array $participant, ?string $role = 'member'): array ``` Add a participant to a project - `$id` — Project UUID - `$participant` — Participant data - `$role` — Participant role (decision_maker, member) ### `updateParticipant()` ```php updateParticipant(string $id, array $participant, string $role): array ``` Update a participant's role in a project - `$id` — Project UUID - `$participant` — Participant data - `$role` — New role (decision_maker, member) ### `active()` ```php active(array $options = []): array ``` Get active projects - `$options` — Additional options ### `list()` ```php list(array $filters = [], array $options = []): array ``` List projects with filtering and sorting - `$filters` — Filter parameters - `$options` — Pagination and sorting options ### `byStatus()` ```php byStatus(string $status, array $options = []): array ``` Get projects by status - `$status` — Status (active, on_hold, done, cancelled) - `$options` — Additional options ### `forCustomer()` ```php forCustomer(string $customerId, string $customerType = 'company', array $options = []): array ``` Get projects for a specific customer - `$customerId` — Customer UUID - `$customerType` — Customer type (contact, company) - `$options` — Additional options ### `forParticipant()` ```php forParticipant(string $participantId, array $options = []): array ``` Get projects for a specific participant - `$participantId` — Participant UUID - `$options` — Additional options ### `search()` ```php search(string $term, array $options = []): array ``` Search projects by term - `$term` — Search term - `$options` — Additional options ### `updatedSince()` ```php updatedSince(string $datetime, array $options = []): array ``` Get projects updated since a specific date - `$datetime` — ISO 8601 datetime - `$options` — Additional options ## Examples Get all projects: ```php $projects = Teamleader::legacyProjects()->list(); ``` Get active projects: ```php $projects = Teamleader::legacyProjects()->active(); ``` Create a new project: ```php $project = Teamleader::legacyProjects()->create([...]); ``` Close a project: ```php $result = Teamleader::legacyProjects()->close("project-uuid"); ``` Check whether this account is on the legacy system at all: ```php if (Teamleader::accounts()->isUsingLegacyProjects()) { $projects = Teamleader::legacyProjects()->list(); } else { $projects = Teamleader::projects()->list(); } ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/materials # Materials Manage materials in Teamleader Focus projects. Materials in the current ("nextgen") project system — `projects-v2/materials.*`. ```php Teamleader::materials() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Projects\Materials` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `projects-v2/materials.assign` - `projects-v2/materials.create` - `projects-v2/materials.delete` - `projects-v2/materials.duplicate` - `projects-v2/materials.info` - `projects-v2/materials.list` - `projects-v2/materials.unassign` - `projects-v2/materials.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of material UUIDs | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ASSIGNEE_TYPES` | `team`, `user` | | `BILLING_METHODS` | `fixed_price`, `unit_price`, `non_billable`, `parent_fixed_price` | | `COLORS` | `#00B2B2`, `#008A8C`, `#992600`, `#ED9E00`, `#D157D3`, `#A400B2`, `#0071F2`, `#004DA6`, `#64788F`, `#C0C0C4`, `#82828C`, `#1A1C20` | | `CREATE_FIELDS` | `project_id`, `title`, `group_id`, `after_id`, `description`, `billing_method`, `quantity`, `quantity_estimated`, `unit_price`, `unit_cost`, `unit_id`, `fixed_price`, `external_budget`, `internal_budget`, `start_date`, `end_date`, `product_id`, `assignees` | | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `STATUSES` | `to_do`, `in_progress`, `on_hold`, `done` | | `TIME_UNITS` | `hours`, `minutes`, `seconds` | | `UPDATE_FIELDS` | `title`, `description`, `status`, `billing_method`, `quantity`, `quantity_estimated`, `unit_price`, `unit_cost`, `unit_id`, `fixed_price`, `external_budget`, `internal_budget`, `start_date`, `end_date`, `product_id` | | `UPDATE_STRATEGIES` | `none`, `cascade` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List materials Before v2.2.9 filter keys other than `ids` were dropped without a word, as were the paging options. Response fields per item: - id, project {id, type}, group (nullable) {id, type: nextgenProjectGroup} - title, description (nullable), status, billing_method, billing_status - quantity, quantity_estimated (nullable numbers) - unit_price, unit_cost, amount_billed, external_budget, external_budget_spent, internal_budget, price, fixed_price, cost, margin (nullable {amount, currency}) - unit (nullable) {id, type: priceunit} — null if the default unit is used - margin_percentage (nullable) — null without "Costs on projects" access - assignees [{assignee: {type, id}, assign_type}] - start_date, end_date (nullable), product (nullable) {id, type: product} - `$filters` — ids (a UUID or a list of UUIDs) - `$options` — page_size, page_number **Throws** `InvalidArgumentException` On an unknown filter key or option ### `info()` ```php info($id, $includes = null): array ``` Get one material (same fields as a list() item) - `$id` — Material UUID - `$includes` — materials.info takes no includes; passing any throws ### `create()` ```php create(array $data): array ``` Create a material Requires project_id and title. `after_id` null places the material at the top of its project or group; omitting it places it at the bottom. Returns HTTP 201 with data.{id, type} **Throws** `InvalidArgumentException` When a required field is missing, or a field or value is not accepted ### `update()` ```php update($id, array $data): array ``` Update a material Every field is optional; null clears a nullable field. Returns HTTP 204. - `$id` — Material UUID **Throws** `InvalidArgumentException` When a field or value is not accepted ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a material (new in v2.2.9) - `$id` — Material UUID - `$additionalParams` — Not used ### `duplicate()` ```php duplicate(string $originId): array ``` Duplicate a material (new in v2.2.9) - `$originId` — UUID of the material to duplicate ### `byIds()` ```php byIds(array $ids): array ``` Get materials by ID ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `assign()` ```php assign(string $id, string $assigneeType, string $assigneeId): array ``` Assign a user or team - `$id` — UUID of the project, group, task or material - `$assigneeType` — user or team - `$assigneeId` — UUID of the user or team **Throws** `InvalidArgumentException` When the assignee type is not user or team ### `unassign()` ```php unassign(string $id, string $assigneeType, string $assigneeId): array ``` Unassign a user or team - `$id` — UUID of the project, group, task or material - `$assigneeType` — user or team - `$assigneeId` — UUID of the user or team **Throws** `InvalidArgumentException` When the assignee type is not user or team ### `assignUser()` ```php assignUser(string $id, string $userId): array ``` ### `assignTeam()` ```php assignTeam(string $id, string $teamId): array ``` ### `unassignUser()` ```php unassignUser(string $id, string $userId): array ``` ### `unassignTeam()` ```php unassignTeam(string $id, string $teamId): array ``` ### `getAvailableAssigneeTypes()` ```php getAvailableAssigneeTypes(): array ``` ## Examples Create a new material with unit pricing and quantity tracking: ```php $material = Teamleader::materials()->create([ "project_id" => "49b403be-a32e-0901-9b1c-25214f9027c6", "title" => "WD-40 Multi-Use Product", "description" => "Industrial size lubricant", "billing_method" => "unit_price", "quantity_estimated" => 12, "quantity" => 10, "unit_price" => [ "amount" => 25.50, "currency" => "EUR" ] ]); ``` Update an existing material: ```php $material = Teamleader::materials()->update( "material-uuid", [ "title" => "Updated Material Name", "status" => "in_progress", "quantity" => 15, "quantity_estimated" => 20 ] ); ``` Get detailed information about a material: ```php $material = Teamleader::materials()->info("material-uuid"); ``` List materials by IDs: ```php $materials = Teamleader::materials()->list([ "ids" => ["uuid1", "uuid2"] ]); ``` Create a material with an estimate then update with actual quantity: ```php $result = Teamleader::materials()->create([ "project_id" => "project-uuid", "title" => "Copper pipe (meters)", "billing_method" => "unit_price", "quantity_estimated" => 25, "unit_price" => ["amount" => 8.50, "currency" => "EUR"], ]); // Later, update with actual usage Teamleader::materials()->update($result["data"]["id"], [ "quantity" => 22, "status" => "done", ]); ``` Duplicate a material: ```php $copy = Teamleader::materials()->duplicate("material-uuid"); ``` Assign a user to a material: ```php Teamleader::materials()->assignUser("material-uuid", "user-uuid"); ``` Delete a material: ```php Teamleader::materials()->delete("material-uuid"); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/project-lines # Project Lines Manage project lines (tasks, materials, groups) in Teamleader Focus projects. The lines of a project — its tasks, materials and groups, in order — `projects-v2/projectLines.*`. Lines are created through `ProjectTasks`, `Materials` and `Groups`; this resource lists them and moves tasks and materials in and out of groups. ```php Teamleader::projectLines() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Projects\ProjectLines` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `projects-v2/projectLines.addToGroup` - `projects-v2/projectLines.list` - `projects-v2/projectLines.removeFromGroup` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `assignees` | Assignees [{type: user\|team, id}]; a null entry matches unassigned lines | | `project_id` | UUID of the project (required) | | `types` | Line types: nextgenTask, nextgenMaterial, nextgenProjectGroup | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ASSIGNEE_TYPES` | `team`, `user` | | `LINE_TYPES` | `nextgenTask`, `nextgenMaterial`, `nextgenProjectGroup` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List a project's lines list(['project_id' => $id]) list(['project_id' => $id, 'types' => ['nextgenTask']]) list(['project_id' => $id, 'assignees' => [['type' => 'user', 'id' => $userId]]]) list(['project_id' => $id, 'assignees' => [null]]) // unassigned lines - `$filters` — project_id (required), types, assignees — or the pre-2.2.9 form with types / assignees nested under `filter` - `$options` — None; the endpoint takes no paging, sorting or includes **Throws** `InvalidArgumentException` When project_id is missing, or on an unknown key or value ### `addToGroup()` ```php addToGroup(string $lineId, string $groupId): array ``` Add an existing task or material to a group - `$lineId` — The ID of the task or material (may not be a group) - `$groupId` — The ID of the group ### `removeFromGroup()` ```php removeFromGroup(string $lineId): array ``` Remove a task or material from the group it is currently in - `$lineId` — The ID of the task or material (may not be a group) ### `forProject()` ```php forProject(string $projectId) ``` ### `ofType()` ```php ofType(array $types) ``` - `$types` — nextgenTask, nextgenMaterial and/or nextgenProjectGroup ### `tasksOnly()` ```php tasksOnly() ``` ### `materialsOnly()` ```php materialsOnly() ``` ### `groupsOnly()` ```php groupsOnly() ``` ### `assignedTo()` ```php assignedTo(string $type, string $id) ``` Add an assignee to match; call more than once to match any of several - `$type` — user or team ### `unassigned()` ```php unassigned(?string $projectId = null): array ``` Get lines nobody is assigned to Before v2.2.9 this sent no assignee filter at all and so returned every line: `assignees => null` was dropped by an isset() check. It now sends `assignees: [null]`, as the API documents. Combined with assignedTo(), it returns the unassigned lines plus that assignee's. - `$projectId` — Optional project ID if not already set ### `get()` ```php get(): array ``` Execute the query with pending filters (fluent interface terminator) ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all lines for a project: ```php $lines = Teamleader::projectLines()->list([ "project_id" => "49b403be-a32e-0901-9b1c-25214f9027c6" ]); ``` Get only tasks for a project: ```php $tasks = Teamleader::projectLines()->list([ "project_id" => "49b403be-a32e-0901-9b1c-25214f9027c6", "types" => ["nextgenTask"], ]); ``` Get lines assigned to specific user: ```php $lines = Teamleader::projectLines()->list([ "project_id" => "49b403be-a32e-0901-9b1c-25214f9027c6", "assignees" => [ ["type" => "user", "id" => "66abace2-62af-0836-a927-fe3f44b9b47b"], ], ]); ``` Get unassigned lines: ```php $unassigned = Teamleader::projectLines()->unassigned("project-uuid"); ``` Add a task or material to a group: ```php $result = Teamleader::projectLines()->addToGroup( "a14a464d-320a-49bb-b6ee-b510c7f4f66c", "0daf76e6-5141-4fb0-866f-01916a873a38" ); ``` Remove a task or material from its current group: ```php $result = Teamleader::projectLines()->removeFromGroup( "a14a464d-320a-49bb-b6ee-b510c7f4f66c" ); ``` Use fluent methods for filtering: ```php $tasks = Teamleader::projectLines() ->forProject("project-uuid") ->ofType(["nextgenTask"]) ->assignedTo("user", "user-uuid") ->get(); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/project-tasks # Project Tasks Manage tasks in Teamleader Focus projects. Tasks in the current ("nextgen") project system — `projects-v2/tasks.*`. For the older task system, see `Tasks`. ```php Teamleader::projectTasks() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Projects\ProjectTasks` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `projects-v2/tasks.assign` - `projects-v2/tasks.create` - `projects-v2/tasks.delete` - `projects-v2/tasks.duplicate` - `projects-v2/tasks.info` - `projects-v2/tasks.list` - `projects-v2/tasks.unassign` - `projects-v2/tasks.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of task UUIDs | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ASSIGNEE_TYPES` | `team`, `user` | | `BILLING_METHODS` | `user_rate`, `work_type_rate`, `custom_rate`, `fixed_price`, `parent_fixed_price`, `non_billable` | | `COLORS` | `#00B2B2`, `#008A8C`, `#992600`, `#ED9E00`, `#D157D3`, `#A400B2`, `#0071F2`, `#004DA6`, `#64788F`, `#C0C0C4`, `#82828C`, `#1A1C20` | | `CREATE_FIELDS` | `project_id`, `title`, `group_id`, `work_type_id`, `task_type_id`, `description`, `billing_method`, `fixed_price`, `external_budget`, `internal_budget`, `custom_rate`, `start_date`, `end_date`, `time_estimated`, `assignees` | | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `DELETE_STRATEGIES` | `unlink_time_tracking`, `delete_time_tracking` | | `STATUSES` | `to_do`, `in_progress`, `on_hold`, `done` | | `TIME_UNITS` | `hours`, `minutes`, `seconds` | | `UPDATE_FIELDS` | `work_type_id`, `task_type_id`, `status`, `title`, `description`, `billing_method`, `fixed_price`, `external_budget`, `internal_budget`, `custom_rate`, `start_date`, `end_date`, `time_estimated` | | `UPDATE_STRATEGIES` | `none`, `cascade` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List tasks Before v2.2.9 any filter was a fatal error: list() called a buildFilters() method the class did not define. - `$filters` — ids (a UUID or a list of UUIDs) - `$options` — page_size, page_number **Throws** `InvalidArgumentException` On an unknown filter key or option ### `info()` ```php info($id, $includes = null): array ``` Get one task - `$id` — Task UUID - `$includes` — tasks.info takes no includes; passing any throws ### `create()` ```php create(array $data): array ``` Create a task Requires project_id and title. With billing_method `work_type_rate`, a work_type_id is required as well. **Throws** `InvalidArgumentException` When a required field is missing, or a field or value is not accepted ### `update()` ```php update($id, array $data): array ``` Update a task Every field is optional; null clears a nullable field. - `$id` — Task UUID **Throws** `InvalidArgumentException` When a field or value is not accepted ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a task - `$id` — Task UUID - `$additionalParams` — Delete strategy: unlink_time_tracking (default) or delete_time_tracking **Throws** `InvalidArgumentException` ### `duplicate()` ```php duplicate(string $originId): array ``` Duplicate a task (without its time trackings) - `$originId` — UUID of the task to duplicate ### `byIds()` ```php byIds(array $ids): array ``` Get tasks by ID - `$ids` — Task UUIDs ### `updateStatus()` ```php updateStatus(string $taskId, string $status): array ``` Update a task's status - `$status` — to_do, in_progress, on_hold or done ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `assign()` ```php assign(string $id, string $assigneeType, string $assigneeId): array ``` Assign a user or team - `$id` — UUID of the project, group, task or material - `$assigneeType` — user or team - `$assigneeId` — UUID of the user or team **Throws** `InvalidArgumentException` When the assignee type is not user or team ### `unassign()` ```php unassign(string $id, string $assigneeType, string $assigneeId): array ``` Unassign a user or team - `$id` — UUID of the project, group, task or material - `$assigneeType` — user or team - `$assigneeId` — UUID of the user or team **Throws** `InvalidArgumentException` When the assignee type is not user or team ### `assignUser()` ```php assignUser(string $id, string $userId): array ``` ### `assignTeam()` ```php assignTeam(string $id, string $teamId): array ``` ### `unassignUser()` ```php unassignUser(string $id, string $userId): array ``` ### `unassignTeam()` ```php unassignTeam(string $id, string $teamId): array ``` ### `getAvailableAssigneeTypes()` ```php getAvailableAssigneeTypes(): array ``` ## Examples Get all tasks: ```php $tasks = Teamleader::projectTasks()->list(); ``` Create a new task: ```php $task = Teamleader::projectTasks()->create([ 'project_id' => 'uuid', 'title' => 'Write API documentation', 'billing_method' => 'user_rate' ]); ``` Update task status: ```php $task = Teamleader::projectTasks()->update('task-uuid', [ 'status' => 'in_progress' ]); ``` Assign a user to a task: ```php Teamleader::projectTasks()->assign('task-uuid', 'user', 'user-uuid'); ``` Delete a task and unlink time tracking: ```php Teamleader::projectTasks()->delete('task-uuid', 'unlink_time_tracking'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/projects/projects # Projects Manage projects in Teamleader Focus — the current "nextgen" project system (API path projects-v2/projects, webhook events nextgenProject.*). Projects — the current ("nextgen") project system in Teamleader Focus. Teamleader uses three different names for this one thing, and they do not line up. To be explicit: SDK method Teamleader::projects() (alias: Teamleader::nextgenProjects()) API path projects-v2/projects.* Webhooks nextgenProject.created, nextgenProject.updated, nextgenProject.closed, nextgenProject.deleted Reading the webhook names, it is natural to assume `projects()` must be the legacy resource and that some `nextgenProjects()` covers the new system. It is the other way round: this class is what Teamleader calls "nextgen", and `nextgenProjects()` is registered as an alias for exactly that reason. The old system is `LegacyProjects`, which uses the bare `projects.*` path — so the shorter path belongs to the older resource. Teamleader chose the `projects-v2` prefix for the new module to avoid colliding with those existing endpoints. **Do not infer which system an account is on from whether a list call returns rows.** Both endpoints answer, so querying this resource and receiving real projects does not mean the account is on nextgen. Ask directly: Teamleader::accounts()->getProjectsVersion(); // "projects-v2" or "legacy" Teamleader::accounts()->isUsingProjectsV2(); // bool ```php Teamleader::projects() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Projects\Projects` | | Also available as | `nextgenProjects()` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; the response includes `meta` with totals. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `projects-v2/projects.addCustomer` - `projects-v2/projects.addDeal` - `projects-v2/projects.addOwner` - `projects-v2/projects.addQuotation` - `projects-v2/projects.assign` - `projects-v2/projects.close` - `projects-v2/projects.create` - `projects-v2/projects.delete` - `projects-v2/projects.duplicate` - `projects-v2/projects.info` - `projects-v2/projects.list` - `projects-v2/projects.removeCustomer` - `projects-v2/projects.removeDeal` - `projects-v2/projects.removeOwner` - `projects-v2/projects.removeQuotation` - `projects-v2/projects.reopen` - `projects-v2/projects.unassign` - `projects-v2/projects.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `customers` | Array of customer objects [{type, id}] | | `deal_ids` | Array of deal UUIDs | | `ids` | Array of project UUIDs to filter by | | `quotation_ids` | Array of quotation UUIDs | | `status` | Project status (open, planned, running, overdue, over_budget, closed) | | `term` | Search term (searches project number, title, customer names, assignee names, owner names) | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `amount_billed` | Amount billed | | `amount_paid` | Amount paid | | `amount_unbilled` | Amount not yet billed | | `cost` | Cost | | `customer` | Customer name | | `end_date` | End date | | `external_budget` | External budget | | `external_budget_spent` | External budget spent | | `internal_budget` | Internal budget | | `margin` | Margin | | `price` | Price | | `project_key` | Project number | | `start_date` | Start date | | `status` | Status | | `time_budget` | Time budget | | `time_estimated` | Estimated time | | `time_tracked` | Tracked time | | `title` | Title | ## Includes | Endpoint | Includes | |---|---| | `list()` | `custom_fields`, `legacy_project` | | `info()` | `legacy_project` | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ASSIGNEE_TYPES` | `team`, `user` | | `BILLING_METHODS` | `time_and_materials`, `fixed_price`, `non_billable` | | `CLOSING_STRATEGIES` | `mark_tasks_and_materials_as_done`, `none` | | `COLORS` | `#00B2B2`, `#008A8C`, `#992600`, `#ED9E00`, `#D157D3`, `#A400B2`, `#0071F2`, `#004DA6`, `#64788F`, `#C0C0C4`, `#82828C`, `#1A1C20` | | `CREATE_FIELDS` | `title`, `description`, `owner_ids`, `time_budget`, `billing_method`, `external_budget`, `internal_budget`, `fixed_price`, `start_date`, `end_date`, `purchase_order_number`, `company_entity_id`, `color`, `customers`, `assignees`, `deal_ids`, `quotation_ids`, `initial_time_tracked`, `initial_price`, `initial_cost`, `initial_amount_billed`, `initial_amount_paid`, `custom_fields` | | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | | `CUSTOMER_TYPES` | `contact`, `company` | | `DELETE_STRATEGIES` | `unlink_tasks_and_time_trackings`, `delete_tasks_and_time_trackings`, `delete_tasks_unlink_time_trackings` | | `INCLUDES` | `custom_fields`, `legacy_project` | | `INFO_INCLUDES` | `legacy_project` | | `STATUSES` | `open`, `planned`, `running`, `overdue`, `over_budget`, `closed` | | `TIME_UNITS` | `hours`, `minutes`, `seconds` | | `UPDATE_FIELDS` | `title`, `description`, `time_budget`, `billing_method`, `external_budget`, `internal_budget`, `fixed_price`, `start_date`, `end_date`, `purchase_order_number`, `company_entity_id`, `color`, `initial_time_tracked`, `initial_price`, `initial_cost`, `initial_amount_billed`, `initial_amount_paid`, `custom_fields` | | `UPDATE_STRATEGIES` | `none`, `cascade` | ## Methods ### `info()` ```php info($id, $includes = null): array ``` Get one project - `$id` — Project UUID - `$includes` — legacy_project only — custom fields are always returned **Throws** `InvalidArgumentException` On an include projects.info does not accept ### `list()` ```php list(array $filters = [], array $options = []): array ``` List projects `includes=pagination` is always sent, so the response carries `meta.matches` with the total number of matching projects. - `$filters` — ids, status, quotation_ids, deal_ids, term, customers - `$options` — page_size, page_number, sort, sort_order, include(s) **Throws** `InvalidArgumentException` On an unknown filter key, option, sort field or include ### `create()` ```php create(array $data): array ``` Create a project Only title is required. **Throws** `InvalidArgumentException` When title is missing, or a field or value is not accepted ### `update()` ```php update($id, array $data): array ``` Update a project billing_method is sent as {value, update_strategy}. A plain method name is accepted and sent with update_strategy `none`. The initial_* fields (2026-05-06) seed a project with figures migrated from another system: initial_time_tracked {value, unit} and initial_price / initial_cost / initial_amount_billed / initial_amount_paid {amount, currency}. Null clears them. Customers, deals, quotations and owners are not update fields; use addCustomer(), addDeal(), addQuotation(), addOwner() and their remove* counterparts. - `$id` — Project UUID **Throws** `InvalidArgumentException` When a field or value is not accepted ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a project - `$id` — Project UUID - `$additionalParams` — Delete strategy: unlink_tasks_and_time_trackings (default), delete_tasks_and_time_trackings or delete_tasks_unlink_time_trackings **Throws** `InvalidArgumentException` ### `duplicate()` ```php duplicate(string $id, string $title): array ``` Duplicate a project - `$id` — Project UUID - `$title` — Title for the new project ### `close()` ```php close(string $id, string $closingStrategy = 'none'): array ``` Close a project - `$id` — Project UUID - `$closingStrategy` — none (default) or mark_tasks_and_materials_as_done **Throws** `InvalidArgumentException` ### `reopen()` ```php reopen(string $id): array ``` Reopen a closed project - `$id` — Project UUID ### `addCustomer()` ```php addCustomer(string $id, string $customerType, string $customerId): array ``` Add a customer to a project - `$id` — Project UUID - `$customerType` — Customer type (contact, company) - `$customerId` — Customer UUID ### `removeCustomer()` ```php removeCustomer(string $id, string $customerType, string $customerId): array ``` Remove a customer from a project - `$id` — Project UUID - `$customerType` — Customer type (contact, company) - `$customerId` — Customer UUID ### `addDeal()` ```php addDeal(string $id, string $dealId): array ``` Add a deal to a project - `$id` — Project UUID - `$dealId` — Deal UUID ### `removeDeal()` ```php removeDeal(string $id, string $dealId): array ``` Remove a deal from a project - `$id` — Project UUID - `$dealId` — Deal UUID ### `addQuotation()` ```php addQuotation(string $id, string $quotationId): array ``` Add a quotation to a project - `$id` — Project UUID - `$quotationId` — Quotation UUID ### `removeQuotation()` ```php removeQuotation(string $id, string $quotationId): array ``` Remove a quotation from a project - `$id` — Project UUID - `$quotationId` — Quotation UUID ### `addOwner()` ```php addOwner(string $id, string $userId): array ``` Add an owner to a project - `$id` — Project UUID - `$userId` — User UUID ### `removeOwner()` ```php removeOwner(string $id, string $userId): array ``` Remove an owner from a project - `$id` — Project UUID - `$userId` — User UUID ### `open()` ```php open(array $options = []): array ``` Get open projects ### `closed()` ```php closed(array $options = []): array ``` Get closed projects ### `running()` ```php running(array $options = []): array ``` Get running projects ### `overdue()` ```php overdue(array $options = []): array ``` Get overdue projects ### `overBudget()` ```php overBudget(array $options = []): array ``` Get over budget projects ### `search()` ```php search(string $term, array $options = []): array ``` Search projects by term - `$term` — Search term - `$options` — Additional options ### `byIds()` ```php byIds(array $ids, array $options = []): array ``` Get projects by IDs - `$ids` — Array of project UUIDs - `$options` — Additional options ### `forCustomer()` ```php forCustomer(string $customerType, string $customerId, array $options = []): array ``` Get projects for a customer - `$customerType` — Customer type (contact, company) - `$customerId` — Customer UUID - `$options` — Additional options ### `forDeal()` ```php forDeal(string $dealId, array $options = []): array ``` Get projects linked to a deal - `$dealId` — Deal UUID - `$options` — Additional options ### `forQuotation()` ```php forQuotation(string $quotationId, array $options = []): array ``` Get projects linked to a quotation - `$quotationId` — Quotation UUID - `$options` — Additional options ### `assign()` ```php assign(string $id, string $assigneeType, string $assigneeId): array ``` Assign a user or team - `$id` — UUID of the project, group, task or material - `$assigneeType` — user or team - `$assigneeId` — UUID of the user or team **Throws** `InvalidArgumentException` When the assignee type is not user or team ### `unassign()` ```php unassign(string $id, string $assigneeType, string $assigneeId): array ``` Unassign a user or team - `$id` — UUID of the project, group, task or material - `$assigneeType` — user or team - `$assigneeId` — UUID of the user or team **Throws** `InvalidArgumentException` When the assignee type is not user or team ### `assignUser()` ```php assignUser(string $id, string $userId): array ``` ### `assignTeam()` ```php assignTeam(string $id, string $teamId): array ``` ### `unassignUser()` ```php unassignUser(string $id, string $userId): array ``` ### `unassignTeam()` ```php unassignTeam(string $id, string $teamId): array ``` ### `getAvailableAssigneeTypes()` ```php getAvailableAssigneeTypes(): array ``` ## Examples Get all projects: ```php $projects = Teamleader::projects()->list(); ``` Get open projects: ```php $projects = Teamleader::projects()->open(); ``` Create a new project: ```php $project = Teamleader::projects()->create([...]); ``` Close a project: ```php $result = Teamleader::projects()->close("project-uuid"); ``` Same resource under the name Teamleader uses for its webhook events: ```php $projects = Teamleader::nextgenProjects()->list(); // identical to projects() ``` Check which project system the account is on before querying: ```php if (Teamleader::accounts()->isUsingProjectsV2()) { $projects = Teamleader::projects()->list(); } else { $projects = Teamleader::legacyProjects()->list(); } ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/tasks/tasks # Tasks Manage tasks in Teamleader Focus. Tasks — `tasks.*`, the stand-alone task list (with optional links to a customer, deal, ticket, legacy milestone or project). Tasks inside a project in the current project system are a different resource: `ProjectTasks`. ```php Teamleader::tasks() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Tasks\Tasks` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `tasks.complete` - `tasks.create` - `tasks.delete` - `tasks.info` - `tasks.list` - `tasks.reopen` - `tasks.schedule` - `tasks.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `completed` | Filter by completion status (boolean) | | `customer` | Filter by customer: [type => contact\|company, id => uuid] | | `due_by` | Tasks due on or before this date (YYYY-MM-DD) | | `due_from` | Tasks due on or after this date (YYYY-MM-DD) | | `ids` | Array of task UUIDs | | `milestone_id` | Filter by milestone UUID (old projects module) | | `scheduled` | Filter by scheduled status (boolean) | | `term` | Search term (searches the description) | | `user_id` | Tasks assigned to this user or to a team they belong to; null for unassigned tasks | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `created_at` | Creation date | | `due_on` | Due date | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ASSIGNEE_TYPES` | `team`, `user` | | `CREATE_FIELDS` | `title`, `description`, `due_on`, `work_type_id`, `milestone_id`, `project_id`, `deal_id`, `ticket_id`, `estimated_duration`, `assignee`, `customer`, `custom_fields` | | `CUSTOMER_TYPES` | `contact`, `company` | | `DURATION_UNITS` | `min` | | `PRIORITIES` | `A`, `B`, `C`, `D` | | `REQUIRED_ON_CREATE` | `title`, `due_on`, `work_type_id` | | `UPDATE_FIELDS` | `title`, `description`, `due_on`, `work_type_id`, `milestone_id`, `project_id`, `deal_id`, `ticket_id`, `estimated_duration`, `assignee`, `customer`, `custom_fields` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List tasks - `$filters` — ids, user_id (null = unassigned), milestone_id, completed, scheduled, due_by, due_from, term, customer - `$options` — page_size, page_number, sort (created_at|due_on), sort_order **Throws** `InvalidArgumentException` On an unknown filter key, option, value or sort field ### `info()` ```php info($id, $includes = null): array ``` Get one task - `$id` — Task UUID - `$includes` — tasks.info takes no includes; passing any throws ### `create()` ```php create(array $data): array ``` Create a task Requires title, due_on and work_type_id. **Throws** `InvalidArgumentException` When a required field is missing, or a field or value is not accepted ### `update()` ```php update($id, array $data): array ``` Update a task Every field is optional. `assignee` null unassigns the task; milestone_id, project_id, deal_id and ticket_id take null to unlink. **Throws** `InvalidArgumentException` When a field or value is not accepted ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a task ### `complete()` ```php complete(string $id): array ``` Mark a task as complete - `$id` — Task UUID ### `reopen()` ```php reopen(string $id): array ``` Reopen a task that had been marked as complete - `$id` — Task UUID ### `schedule()` ```php schedule(string $id, string $startsAt, string $endsAt): array ``` Schedule a task in your calendar - `$id` — Task UUID - `$startsAt` — ISO 8601 datetime, e.g. 2025-02-04T16:00:00+00:00 - `$endsAt` — ISO 8601 datetime, after $startsAt **Throws** `InvalidArgumentException` On a malformed datetime, or an end before the start ### `forUser()` ```php forUser(string $userId, array $options = []): array ``` Get tasks for a specific user - `$userId` — User UUID - `$options` — Additional options ### `unassigned()` ```php unassigned(array $options = []): array ``` Get unassigned tasks - `$options` — Additional options ### `completed()` ```php completed(array $options = []): array ``` Get completed tasks - `$options` — Additional options ### `incomplete()` ```php incomplete(array $options = []): array ``` Get incomplete tasks - `$options` — Additional options ### `scheduled()` ```php scheduled(array $options = []): array ``` Get scheduled tasks - `$options` — Additional options ### `forMilestone()` ```php forMilestone(string $milestoneId, array $options = []): array ``` Get tasks for a milestone - `$milestoneId` — Milestone UUID - `$options` — Additional options ### `forCustomer()` ```php forCustomer(string $customerType, string $customerId, array $options = []): array ``` Get tasks for a customer - `$customerType` — Type of customer ('contact' or 'company') - `$customerId` — UUID of the customer - `$options` — Additional options ### `dueBetween()` ```php dueBetween(string $dueFrom, string $dueBy, array $options = []): array ``` Get tasks due within a date range - `$dueFrom` — Start date (YYYY-MM-DD) - `$dueBy` — End date (YYYY-MM-DD) - `$options` — Additional options ### `search()` ```php search(string $term, array $options = []): array ``` Search tasks by term (searches in description) - `$term` — Search term - `$options` — Additional options ### `byIds()` ```php byIds(array $ids, array $options = []): array ``` Get tasks by specific IDs - `$ids` — Array of task UUIDs - `$options` — Additional options ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all tasks: ```php $tasks = Teamleader::tasks()->list(); ``` Get tasks for a specific user: ```php $tasks = Teamleader::tasks()->forUser("user-uuid"); ``` Get incomplete tasks: ```php $tasks = Teamleader::tasks()->incomplete(); ``` Create a new task: ```php $task = Teamleader::tasks()->create([ "title" => "Review code changes", "due_on" => "2025-02-15", "work_type_id" => "work-type-uuid" ]); ``` Update a task: ```php $task = Teamleader::tasks()->update("task-uuid", [ "title" => "Updated task title" ]); ``` Mark a task as complete: ```php $result = Teamleader::tasks()->complete("task-uuid"); ``` Reopen a completed task: ```php $result = Teamleader::tasks()->reopen("task-uuid"); ``` Schedule a task in calendar: ```php $event = Teamleader::tasks()->schedule( "task-uuid", "2025-02-15T09:00:00+00:00", "2025-02-15T10:00:00+00:00" ); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/time-tracking/time-tracking # Time Tracking Manage time tracking entries in Teamleader Focus. ```php Teamleader::timeTracking() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\TimeTracking\TimeTracking` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `timeTracking.add` - `timeTracking.delete` - `timeTracking.info` - `timeTracking.list` - `timeTracking.resume` - `timeTracking.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ended_after` | Start of period for ended entries (ISO 8601 datetime) | | `ended_before` | End of period for ended entries (ISO 8601 datetime) | | `ids` | Array of time tracking entry UUIDs | | `relates_to` | Filter by related entity (milestone, project, nextgenProject, nextgenProjectGroup) | | `started_after` | Start of period (ISO 8601 datetime) | | `started_before` | End of period (ISO 8601 datetime) | | `subject` | Filter by subject (id and type) | | `subject_types` | Filter by subject types array | | `user_id` | Filter by user UUID | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `starts_on` | Sort by the date the entry started | ## Includes `list()` and `info()` accept the same includes: `materials`, `relates_to` ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ADD_FIELDS` | `started_at`, `started_on`, `ended_at`, `duration`, `work_type_id`, `description`, `subject`, `invoiceable`, `user_id` | | `INCLUDES` | `materials`, `relates_to` | | `RELATES_TO_TYPES` | `milestone`, `project`, `nextgenProject`, `nextgenProjectGroup` | | `UPDATE_FIELDS` | `started_at`, `started_on`, `duration`, `work_type_id`, `description`, `subject`, `invoiceable` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List time tracking entries with enhanced filtering and sorting ### `info()` ```php info($id, $includes = null): array ``` Get time tracking entry information ### `create()` ```php create(array $data): array ``` Create a new time tracking entry Supports three variants: 1. started_at + duration 2. started_at + ended_at 3. started_on + duration (duration tracking mode) ### `update()` ```php update($id, array $data): array ``` Update a time tracking entry ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a time tracking entry ### `resume()` ```php resume(string $id, ?string $startedAt = null): array ``` Resume a timer based on previously tracked time ### `forUser()` ```php forUser(string $userId, array $options = []): array ``` Filter entries by user ### `forSubject()` ```php forSubject(string $subjectId, string $subjectType, array $options = []): array ``` Filter entries by subject Note that `nextgenTask` is not accepted here even though time can be tracked against one — the API offers no such filter value. Use relatedTo() for project-side filtering. **Throws** `InvalidArgumentException` When the subject type is not a valid filter value ### `forSubjectTypes()` ```php forSubjectTypes(array $subjectTypes, array $options = []): array ``` Filter entries by subject types **Throws** `InvalidArgumentException` When a subject type is not a valid filter value ### `betweenDates()` ```php betweenDates(string $startDate, string $endDate, array $options = []): array ``` Filter entries by date range (started) ### `endedBetween()` ```php endedBetween(string $startDate, string $endDate, array $options = []): array ``` Filter entries by ended date range ### `relatedTo()` ```php relatedTo(string $entityId, string $entityType, array $options = []): array ``` Filter entries related to a milestone, project, or new projects entity Valid type values: milestone, project, nextgenProject, nextgenProjectGroup ### `withMaterials()` ```php withMaterials(): self ``` Fluent interface: Include materials ### `withRelations()` ```php withRelations(): self ``` Fluent interface: Include relates_to ## Examples Get all time tracking entries: ```php $entries = Teamleader::timeTracking()->list(); ``` Get entries for specific user: ```php $entries = Teamleader::timeTracking()->forUser("user-uuid"); ``` Get entries within date range: ```php $entries = Teamleader::timeTracking()->betweenDates("2024-01-01", "2024-01-31"); ``` Get entries with materials: ```php $entries = Teamleader::timeTracking()->withMaterials()->list(); ``` Create a time tracking entry: ```php $entry = Teamleader::timeTracking()->create([ "started_at" => "2024-01-15T10:00:00+00:00", "duration" => 3600, "subject" => [ "id" => "company-uuid", "type" => "company" ] ]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/time-tracking/timers # Timers Manage time tracking timers in Teamleader Focus. ```php Teamleader::timers() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\TimeTracking\Timers` | | Create / update / delete | ✓ / ✓ / — | | Pagination | No | ## Endpoints - `timers.current` - `timers.start` - `timers.stop` - `timers.update` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `SUBJECT_TYPES` | `company`, `contact`, `event`, `todo`, `milestone`, `ticket` | | `WRITE_FIELDS` | `work_type_id`, `started_at`, `description`, `subject`, `invoiceable` | ## Methods ### `start()` ```php start(array $data): array ``` Start a new timer - `$data` — Timer data **Throws** `InvalidArgumentException` ### `startForSubject()` ```php startForSubject(string $subjectType, string $subjectId, string $workTypeId, array $options = []): array ``` Start a timer for a specific subject (convenience method) - `$subjectType` — Type of subject (company, contact, event, todo, milestone, ticket) - `$subjectId` — UUID of the subject - `$workTypeId` — UUID of the work type - `$options` — Additional options (description, invoiceable, started_at) ### `current()` ```php current(): array ``` Get the current running timer ### `stop()` ```php stop(): array ``` Stop the current timer This will add a new time tracking entry in the background ### `updateCurrent()` ```php updateCurrent(array $data): array ``` Update the current timer Only possible if there is a timer running ### `update()` ```php update(array $data): array ``` Alias for updateCurrent() The usage examples called `timers()->update()` until v2.2.11, when no such method existed; it is kept so code written from them works. - `$data` — Fields to change on the running timer ### `isRunning()` ```php isRunning(): bool ``` Check if there is a timer currently running ### `getAvailableSubjectTypes()` ```php getAvailableSubjectTypes(): array ``` Get available subject types ## Examples Start a new timer for a company: ```php $timer = Teamleader::timers()->start([ 'work_type_id' => 'work-type-uuid', 'subject' => [ 'type' => 'company', 'id' => 'company-uuid' ], 'description' => 'Working on project', 'invoiceable' => true ]); ``` Start a timer for a ticket: ```php $timer = Teamleader::timers()->startForSubject( 'ticket', 'ticket-uuid', 'work-type-uuid', ['description' => 'Fixing bug', 'invoiceable' => true] ); ``` Get the currently running timer: ```php $currentTimer = Teamleader::timers()->current(); ``` Update the current timer description: ```php $result = Teamleader::timers()->updateCurrent(['description' => 'Updated description']); ``` Stop the current timer: ```php $result = Teamleader::timers()->stop(); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/planning/plannable-items # Plannable Items Retrieve plannable items from Teamleader Focus. ```php Teamleader::plannableItems() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Planning\PlannableItems` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `plannableItems.info` - `plannableItems.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `assignees` | Filter by assignees (list of [type, id]; a null entry matches unassigned items) | | `completion_statuses` | Filter by completion status: to_do, done | | `end_date` | Filter items up to this date (YYYY-MM-DD) | | `ids` | Filter by array of plannable item UUIDs | | `planned_time_statuses` | Filter by planned time status: unplanned, partially_planned, fully_planned, overbooked | | `project_ids` | Filter by array of project UUIDs | | `start_date` | Filter items from this date (YYYY-MM-DD) | | `term` | Search term (matches item title/name) | | `types` | Filter by item type: closingDay, dayOffType, meeting, task, call, externalEvent | | `work_type_ids` | Filter by array of work type UUIDs | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `end_date` | Sort by end date | | `id` | Sort by plannable item ID (default) | | `total_duration` | Sort by total duration | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `COMPLETION_STATUSES` | `to_do`, `done` | | `PLANNED_TIME_STATUSES` | `unplanned`, `partially_planned`, `fully_planned`, `overbooked` | | `TYPES` | `closingDay`, `dayOffType`, `meeting`, `task`, `call`, `externalEvent` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List plannable items with optional filters, sorting, and pagination - `$filters` — Filters to apply - `$options` — Pagination and sorting options - page_size (int): Results per page (default: 20) - page_number (int): Page number (default: 1) - sort (array): Array of sort objects with 'field' and 'order' keys **Throws** `InvalidArgumentException` ### `info()` ```php info($id, $includes = null): array ``` Get a single plannable item by its ID or by source Either `id` or `source` must be provided. If the plannable item ID is unknown, use `source` with the underlying entity's type and ID. - `$id` — Plannable item UUID, or null when looking up by source - `$includes` — Unused — retained for base class signature compatibility **Throws** `InvalidArgumentException` ### `infoBySource()` ```php infoBySource(string $sourceType, string $sourceId): array ``` Get a single plannable item by its source entity type and ID Use this when the plannable item's own ID is unknown but you have the underlying source entity (e.g. a task UUID). - `$sourceType` — Type of the source entity (e.g. 'task') - `$sourceId` — UUID of the source entity **Throws** `InvalidArgumentException` ### `ofTypes()` ```php ofTypes(array $types, array $filters = [], array $options = []): array ``` Get plannable items of the given types - `$types` — closingDay, dayOffType, meeting, task, call, externalEvent ### `unassigned()` ```php unassigned(array $filters = [], array $options = []): array ``` Get plannable items nobody is assigned to ### `unplanned()` ```php unplanned(array $filters = [], array $options = []): array ``` Convenience method: get unplanned plannable items - `$filters` — Additional filters - `$options` — Pagination and sorting options ### `overbooked()` ```php overbooked(array $filters = [], array $options = []): array ``` Convenience method: get overbooked plannable items - `$filters` — Additional filters - `$options` — Pagination and sorting options ### `forProject()` ```php forProject(string $projectId, array $filters = [], array $options = []): array ``` Convenience method: get plannable items for a specific project - `$projectId` — Project UUID - `$filters` — Additional filters - `$options` — Pagination and sorting options ### `forUser()` ```php forUser(string $userId, array $filters = [], array $options = []): array ``` Convenience method: get plannable items assigned to a specific user - `$userId` — User UUID - `$filters` — Additional filters - `$options` — Pagination and sorting options ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all plannable items: ```php $items = Teamleader::plannableItems()->list(); ``` Get only tasks and meetings: ```php $items = Teamleader::plannableItems()->ofTypes(['task', 'meeting']); ``` Get plannable items for specific projects: ```php $items = Teamleader::plannableItems()->list([ 'project_ids' => ['project-uuid-1', 'project-uuid-2'], ]); ``` Get items that have no planned time yet: ```php $items = Teamleader::plannableItems()->list([ 'planned_time_statuses' => ['unplanned'], ]); ``` Get plannable items assigned to a specific user: ```php $items = Teamleader::plannableItems()->list([ 'assignees' => [ ['type' => 'user', 'id' => '66abace2-62af-0836-a927-fe3f44b9b47b'], ], ]); ``` Get plannable items sorted by end date ascending: ```php $items = Teamleader::plannableItems()->list([], [ 'sort' => [['field' => 'end_date', 'order' => 'asc']], ]); ``` Get a single plannable item by its ID: ```php $item = Teamleader::plannableItems()->info('018d79a1-2b99-7fbd-b323-500b01305371'); ``` Get a plannable item by its source (when the plannable item ID is unknown): ```php $item = Teamleader::plannableItems()->infoBySource('task', 'eab232c6-49b2-4b7e-a977-5e1148dad471'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/planning/reservations # Reservations Manage planning reservations in Teamleader Focus. ```php Teamleader::reservations() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Planning\Reservations` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `reservations.create` - `reservations.delete` - `reservations.list` - `reservations.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `assignees` | Filter by assignees (array of objects with type and id; pass null for unassigned) | | `end_date` | Filter reservations up to this date (YYYY-MM-DD) | | `plannable_item_ids` | Filter by array of plannable item UUIDs | | `project_ids` | Filter by array of project UUIDs | | `source_types` | Filter by source types (array of SourceType strings) | | `sources` | Filter by sources (array of objects with id and type) | | `start_date` | Filter reservations from this date (YYYY-MM-DD) | | `term` | Search term | | `work_type_ids` | Filter by array of work type UUIDs | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CREATE_FIELDS` | `plannable_item_id`, `date`, `duration`, `assignee` | | `DURATION_UNITS` | `minutes` | | `SOURCE_TYPES` | `call`, `closingDay`, `dayOffType`, `externalEvent`, `meeting`, `task` | | `UPDATE_FIELDS` | `date`, `duration`, `assignee` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List reservations with optional filters and pagination - `$filters` — Filters to apply (plannable_item_ids, start_date, end_date, assignees, sources, source_types) - `$options` — Pagination options (page_size, page_number) **Throws** `InvalidArgumentException` ### `create()` ```php create(array $data): array ``` Create a new reservation - `$data` — Reservation data - plannable_item_id (string, required): UUID of the plannable item - date (string, required): Date in YYYY-MM-DD format - duration (array, required): Object with 'value' (number) and 'unit' (string: 'minutes') - assignee (array, required): Object with 'type' ('user' or 'team') and 'id' (UUID) **Throws** `InvalidArgumentException` ### `update()` ```php update($id, array $data): array ``` Update an existing reservation - `$id` — Reservation UUID - `$data` — Data to update - date (string, optional): Date in YYYY-MM-DD format - duration (array, optional): Object with 'value' (number) and 'unit' (string: 'minutes') - assignee (array, optional): Object with 'type' ('user' or 'team') and 'id' (UUID) **Throws** `InvalidArgumentException` ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a reservation - `$id` — Reservation UUID - `$additionalParams` — Unused — signature-compatible with parent **Throws** `InvalidArgumentException` ### `forUser()` ```php forUser(string $userId, array $options = []): array ``` Convenience method: get reservations for a specific user - `$userId` — User UUID - `$options` — Additional filters or pagination ### `forTeam()` ```php forTeam(string $teamId, array $options = []): array ``` Convenience method: get reservations for a specific team - `$teamId` — Team UUID - `$options` — Additional filters or pagination ### `forDateRange()` ```php forDateRange(string $startDate, string $endDate, array $options = []): array ``` Convenience method: get reservations for a date range - `$startDate` — Start date in YYYY-MM-DD format - `$endDate` — End date in YYYY-MM-DD format - `$options` — Additional filters or pagination ### `unassigned()` ```php unassigned(array $options = []): array ``` Convenience method: get unassigned reservations - `$options` — Additional filters or pagination ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all reservations: ```php $reservations = Teamleader::reservations()->list(); ``` Get reservations within a date range: ```php $reservations = Teamleader::reservations()->list([ 'start_date' => '2024-01-01', 'end_date' => '2024-01-31', ]); ``` Get reservations for specific plannable items: ```php $reservations = Teamleader::reservations()->list([ 'plannable_item_ids' => [ '46156648-87c6-478d-8aa7-1dc3a00dacab', ], ]); ``` Get reservations for a specific user: ```php $reservations = Teamleader::reservations()->list([ 'assignees' => [ ['type' => 'user', 'id' => '66abace2-62af-0836-a927-fe3f44b9b47b'], ], ]); ``` Get unassigned reservations (pass null in assignees): ```php $reservations = Teamleader::reservations()->list([ 'assignees' => [null], ]); ``` Create a new reservation: ```php $reservation = Teamleader::reservations()->create([ 'plannable_item_id' => '46156648-87c6-478d-8aa7-1dc3a00dacab', 'date' => '2024-01-12', 'duration' => [ 'value' => 60, 'unit' => 'minutes', ], 'assignee' => [ 'type' => 'user', 'id' => '66abace2-62af-0836-a927-fe3f44b9b47b', ], ]); ``` Update an existing reservation: ```php $result = Teamleader::reservations()->update('01878019-c72c-70dc-b097-7e519c775e35', [ 'date' => '2024-01-15', 'duration' => [ 'value' => 120, 'unit' => 'minutes', ], ]); ``` Delete a reservation: ```php Teamleader::reservations()->delete('01878019-c72c-70dc-b097-7e519c775e35'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/planning/user-availability # User Availability Retrieve user availability information from Teamleader Focus. ```php Teamleader::userAvailability() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Planning\UserAvailability` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `userAvailability.daily` - `userAvailability.total` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `assignees` | Filter by assignees (array of objects with type and id) | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `PARAM_KEYS` | `period`, `filter`, `page`, `page_size`, `page_number` | ## Methods ### `daily()` ```php daily(array $params): array ``` Returns the daily availability for all users. Maximum period duration: 100 days. - `$params` — Request parameters - period (array, required): Period object with start_date and end_date (YYYY-MM-DD) - filter (array, optional): Filter object - assignees (array, optional): Array of assignee objects with type ('user'|'team') and id - page (array, optional): Pagination object with size (default: 20) and number (default: 1) **Throws** `InvalidArgumentException` ### `total()` ```php total(array $params): array ``` Returns the total availability for all users. Maximum period duration: 20,000 days. - `$params` — Request parameters - period (array, required): Period object with start_date and end_date (YYYY-MM-DD) - filter (array, optional): Filter object - assignees (array, optional): Array of assignee objects with type ('user'|'team') and id - page (array, optional): Pagination object with size (default: 20) and number (default: 1) **Throws** `InvalidArgumentException` ### `dailyForUser()` ```php dailyForUser(string $userId, string $startDate, string $endDate, array $options = []): array ``` Convenience method: get daily availability for a specific user - `$userId` — User UUID - `$startDate` — Start date in YYYY-MM-DD format - `$endDate` — End date in YYYY-MM-DD format - `$options` — Additional options (page, etc.) ### `totalForUser()` ```php totalForUser(string $userId, string $startDate, string $endDate, array $options = []): array ``` Convenience method: get total availability for a specific user - `$userId` — User UUID - `$startDate` — Start date in YYYY-MM-DD format - `$endDate` — End date in YYYY-MM-DD format - `$options` — Additional options (page, etc.) ### `dailyForTeam()` ```php dailyForTeam(string $teamId, string $startDate, string $endDate, array $options = []): array ``` Convenience method: get daily availability for a specific team - `$teamId` — Team UUID - `$startDate` — Start date in YYYY-MM-DD format - `$endDate` — End date in YYYY-MM-DD format - `$options` — Additional options (page, etc.) ### `totalForTeam()` ```php totalForTeam(string $teamId, string $startDate, string $endDate, array $options = []): array ``` Convenience method: get total availability for a specific team - `$teamId` — Team UUID - `$startDate` — Start date in YYYY-MM-DD format - `$endDate` — End date in YYYY-MM-DD format - `$options` — Additional options (page, etc.) ### `list()` ```php list(array $filters = [], array $options = []): array ``` Override list() — not supported for this resource ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get daily availability for all users over a date range: ```php $availability = Teamleader::userAvailability()->daily([ 'period' => [ 'start_date' => '2024-01-01', 'end_date' => '2024-01-07', ], ]); ``` Get daily availability for specific users: ```php $availability = Teamleader::userAvailability()->daily([ 'period' => [ 'start_date' => '2024-01-01', 'end_date' => '2024-01-07', ], 'filter' => [ 'assignees' => [ ['type' => 'user', 'id' => '66abace2-62af-0836-a927-fe3f44b9b47b'], ], ], ]); ``` Get daily availability with pagination: ```php $availability = Teamleader::userAvailability()->daily([ 'period' => [ 'start_date' => '2024-01-01', 'end_date' => '2024-01-31', ], 'page' => [ 'size' => 50, 'number' => 1, ], ]); ``` Get total availability for all users over a date range: ```php $availability = Teamleader::userAvailability()->total([ 'period' => [ 'start_date' => '2024-01-01', 'end_date' => '2024-03-31', ], ]); ``` Get total availability for a specific team: ```php $availability = Teamleader::userAvailability()->total([ 'period' => [ 'start_date' => '2024-01-01', 'end_date' => '2024-12-31', ], 'filter' => [ 'assignees' => [ ['type' => 'team', 'id' => 'team-uuid'], ], ], ]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/activity-types # Activity Types Manage activity types in Teamleader Focus Calendar. ```php Teamleader::activityTypes() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Calendar\ActivityTypes` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `activityTypes.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of activity type UUIDs | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List activity types with filtering and pagination ### `byIds()` ```php byIds(array $ids, array $options = []): array ``` Get activity types by specific IDs ### `findByName()` ```php findByName(string $name, array $options = []): ?array ``` Find activity type by name (case-insensitive search) ### `all()` ```php all(array $options = []): array ``` Get all activity types (convenience method) ### `exists()` ```php exists(string $id): bool ``` Check if activity type exists by ID ### `selectOptions()` ```php selectOptions(array $options = []): array ``` Get activity types as select options for forms ## Examples Get all activity types: ```php $activityTypes = Teamleader::activityTypes()->list(); ``` Get specific activity types by IDs: ```php $activityTypes = Teamleader::activityTypes()->byIds(["uuid1", "uuid2"]); ``` Find activity type by name: ```php $activityType = Teamleader::activityTypes()->findByName("Meeting"); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/calendar-events # Calendar Events Manage calendar events in Teamleader Focus. ```php Teamleader::calendarEvents() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Calendar\Events` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `events.cancel` - `events.create` - `events.info` - `events.list` - `events.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `activity_type_id` | Filter by activity type UUID | | `attendee` | Filter by attendee: [type => contact, id => uuid] (contacts only) | | `done` | Filter by completion status (boolean) | | `ends_after` | Start of the period for which to return events (ISO 8601 format) | | `ids` | Array of event UUIDs | | `link` | Filter by linked entity (object with id and type) | | `starts_before` | End of the period for which to return events (ISO 8601 format) | | `task_id` | Filter events by task UUID | | `term` | Searches for a term in title or description | | `user_id` | Filter events by user UUID | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `starts_at` | Sort by event start date/time | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ATTENDEE_TYPES` | `user`, `contact` | | `CREATE_FIELDS` | `title`, `description`, `activity_type_id`, `starts_at`, `ends_at`, `location`, `work_type_id`, `attendees`, `links` | | `FILTER_ATTENDEE_TYPES` | `contact` | | `LINK_TYPES` | `contact`, `company`, `deal` | | `REQUIRED_ON_CREATE` | `title`, `activity_type_id`, `starts_at`, `ends_at` | | `UPDATE_FIELDS` | `title`, `description`, `starts_at`, `ends_at`, `location`, `work_type_id`, `attendees`, `links` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List events with filtering, sorting, and pagination ### `info()` ```php info($id, $includes = null): array ``` Get event information ### `create()` ```php create(array $data): array ``` Create a new calendar event Required fields: title, activity_type_id, starts_at, ends_at ### `update()` ```php update($id, array $data): array ``` Update an existing calendar event All fields except id are optional. ### `cancel()` ```php cancel(string $id): array ``` Cancel a calendar event (for all attendees) Note: This is the delete operation for events ### `delete()` ```php delete($id, ...$additionalParams): array ``` Override delete to use cancel ### `forUser()` ```php forUser(string $userId, array $options = []): array ``` Get events for a specific user - `$userId` — User UUID - `$options` — Additional options ### `forActivityType()` ```php forActivityType(string $activityTypeId, array $options = []): array ``` Get events for a specific activity type - `$activityTypeId` — Activity type UUID - `$options` — Additional options ### `search()` ```php search(string $term, array $options = []): array ``` Search events by term (searches title and description) - `$term` — Search term - `$options` — Additional options ### `betweenDates()` ```php betweenDates(string $startsAfter, string $endsBefore, array $options = []): array ``` Get events within a date range - `$startsAfter` — ISO 8601 datetime - `$endsBefore` — ISO 8601 datetime - `$options` — Additional options ### `byIds()` ```php byIds(array $ids, array $options = []): array ``` Get events by specific IDs - `$ids` — Array of event UUIDs - `$options` — Additional options ### `forAttendee()` ```php forAttendee(string $attendeeType, string $attendeeId, array $options = []): array ``` Get events for a specific attendee events.list filters on contact attendees only. Before v2.2.12 `user` was accepted here too; use forUser() for a user's events. - `$attendeeType` — Type of attendee: contact - `$attendeeId` — UUID of the attendee - `$options` — Additional options ### `forLink()` ```php forLink(string $linkType, string $linkId, array $options = []): array ``` Get events linked to a specific entity - `$linkType` — Type of link (contact, company, deal) - `$linkId` — UUID of the linked entity - `$options` — Additional options ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all events: ```php $events = Teamleader::calendarEvents()->list(); ``` Get events for a specific user: ```php $events = Teamleader::calendarEvents()->forUser("user-uuid"); ``` Get events within a date range: ```php $events = Teamleader::calendarEvents()->list([ "ends_after" => "2025-01-01T00:00:00+00:00", "starts_before" => "2025-12-31T23:59:59+00:00" ]); ``` Create a new calendar event: ```php $event = Teamleader::calendarEvents()->create([ "title" => "Meeting with stakeholders", "activity_type_id" => "activity-type-uuid", "starts_at" => "2025-02-04T16:00:00+00:00", "ends_at" => "2025-02-04T18:00:00+00:00", "attendees" => [ ["type" => "user", "id" => "user-uuid"] ] ]); ``` Update an existing event: ```php $event = Teamleader::calendarEvents()->update("event-uuid", [ "title" => "Updated meeting title", "starts_at" => "2025-02-04T17:00:00+00:00" ]); ``` Cancel an event (for all attendees): ```php $result = Teamleader::calendarEvents()->cancel("event-uuid"); ``` Search events by term: ```php $events = Teamleader::calendarEvents()->search("coffee"); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/call-outcomes # Call Outcomes Manage call outcomes in Teamleader Focus Calendar. ```php Teamleader::callOutcomes() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Calendar\CallOutcomes` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `callOutcomes.list` ## Filters `list()` takes no filters. ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List call outcomes - `$filters` — None; callOutcomes.list takes no filter. Passing any throws. - `$options` — page_size, page_number **Throws** `InvalidArgumentException` On a filter or an unsupported option ### `byIds()` ```php byIds(array $ids, array $options = []): array ``` Get call outcomes by ID The API cannot filter outcomes, so this reads every page and keeps the requested IDs. Outcomes are a short, account-level list, so that is one request in practice. - `$ids` — Call outcome UUIDs ### `findByName()` ```php findByName(string $name, array $options = []): ?array ``` Find call outcome by name ## Examples Get all call outcomes: ```php $callOutcomes = Teamleader::callOutcomes()->list(); ``` Get specific call outcomes by IDs: ```php $callOutcomes = Teamleader::callOutcomes()->byIds(["uuid1", "uuid2"]); ``` Find call outcome by name: ```php $outcome = Teamleader::callOutcomes()->findByName("Succesvol gesprek"); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/calls # Calls Manage calls in Teamleader Focus Calendar. ```php Teamleader::calls() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Calendar\Calls` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; the response includes `meta` with totals. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `calls.add` - `calls.complete` - `calls.delete` - `calls.info` - `calls.list` - `calls.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `call_outcome_id` | Filter on completed calls by outcome | | `relates_to` | Filter calls by related object (company) | | `scheduled_after` | Filter on calls occurring on or after a given date (YYYY-MM-DD) | | `scheduled_before` | Filter on calls occurring on or before a given date (YYYY-MM-DD) | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ADD_FIELDS` | `description`, `participant`, `due_at`, `assignee`, `deal_id`, `custom_fields` | | `ASSIGNEE_TYPES` | `user` | | `CUSTOMER_TYPES` | `contact`, `company` | | `REQUIRED_ON_ADD` | `participant`, `due_at`, `assignee` | | `UPDATE_FIELDS` | `description`, `participant`, `due_at`, `assignee`, `deal_id`, `custom_fields` | ## Methods ### `info()` ```php info(string $id, $includes = null): array ``` Get call information ### `complete()` ```php complete(string $id, ?string $outcomeId = null, ?string $outcomeSummary = null): array ``` Mark a call as complete ### `upcoming()` ```php upcoming(array $options = []): array ``` Get upcoming calls (scheduled after today) ### `list()` ```php list(array $filters = [], array $options = []): array ``` List calls `includes=pagination` is always sent, so the response carries the total match count in `meta`. - `$filters` — scheduled_after, scheduled_before (YYYY-MM-DD), relates_to [type, id], call_outcome_id - `$options` — page_size, page_number **Throws** `InvalidArgumentException` On an unknown filter key or option ### `overdue()` ```php overdue(array $options = []): array ``` Get overdue calls (scheduled before today, not completed) ### `forCompany()` ```php forCompany(string $companyId, array $options = []): array ``` Get calls for a specific company ### `today()` ```php today(array $options = []): array ``` Get today's calls ### `betweenDates()` ```php betweenDates(string $startDate, string $endDate, array $options = []): array ``` Get calls within a date range ### `thisWeek()` ```php thisWeek(array $options = []): array ``` Get this week's calls ### `schedule()` ```php schedule(array $data): array ``` Schedule a call (alias for create with more intuitive naming) ### `create()` ```php create(array $data): array ``` Create a call Requires participant [customer => [type, id]], due_at and assignee [type => user, id]. **Throws** `InvalidArgumentException` When a required field is missing, or a field or value is not accepted ### `reschedule()` ```php reschedule(string $id, string $newDateTime): array ``` Reschedule a call (update only the due_at field) ### `update()` ```php update(string $id, array $data): array ``` Update an existing call ### `withOutcome()` ```php withOutcome(string $outcomeId, array $options = []): array ``` Get completed calls with a specific outcome ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a call. - `$id` — Call UUID - `$additionalParams` — Unused **Throws** `InvalidArgumentException` When the ID is not a valid UUID --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/calendar/meetings # Meetings Manage meetings in Teamleader Focus Calendar. ```php Teamleader::meetings() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Calendar\Meetings` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `meetings.complete` - `meetings.createReport` - `meetings.delete` - `meetings.info` - `meetings.list` - `meetings.schedule` - `meetings.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `employee_id` | Filter by assigned employee UUID | | `end_date` | Filter meetings up to this date (YYYY-MM-DD) | | `group_id` | Filter by nextgen project group UUID (cannot be combined with milestone_id) | | `ids` | Array of meeting UUIDs to filter by | | `milestone_id` | Filter by legacy project milestone UUID (cannot be combined with group_id) | | `recurrence_id` | Filter by recurring meeting series UUID | | `start_date` | Filter meetings from this date (YYYY-MM-DD) | | `term` | Search meetings by title or description | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `scheduled_at` | Scheduled start | ## Includes `list()` and `info()` accept the same includes: `estimated_time`, `tracked_time` ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ATTENDEE_TYPES` | `user`, `contact` | | `CUSTOMER_TYPES` | `contact`, `company` | | `INCLUDES` | `tracked_time`, `estimated_time` | | `REPORT_FIELDS` | `attach_to`, `summary`, `custom_fields` | | `REPORT_TARGET_TYPES` | `contact`, `company`, `deal` | | `REQUIRED_ON_SCHEDULE` | `title`, `starts_at`, `ends_at`, `attendees` | | `SCHEDULE_FIELDS` | `title`, `starts_at`, `ends_at`, `description`, `attendees`, `customer`, `location`, `project_id`, `group_id`, `milestone_id`, `deal_id`, `work_order_id`, `custom_fields` | | `UPDATE_FIELDS` | `title`, `starts_at`, `ends_at`, `description`, `attendees`, `customer`, `location`, `project_id`, `group_id`, `milestone_id`, `deal_id`, `custom_fields` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List meetings - `$filters` — ids, employee_id, start_date, end_date, milestone_id, group_id, term, recurrence_id. group_id (nextgen project group) cannot be combined with milestone_id (legacy). - `$options` — page_size, page_number, sort (scheduled_at), sort_order, include(s) **Throws** `InvalidArgumentException` On an unknown filter key, option, sort field or include ### `info()` ```php info($id, $includes = null): array ``` Get one meeting - `$id` — Meeting UUID - `$includes` — tracked_time and/or estimated_time **Throws** `InvalidArgumentException` On an include meetings.info does not accept ### `schedule()` ```php schedule(array $data): array ``` Schedule a meeting Requires title, starts_at, ends_at and attendees, with at least one user attendee. `customer` is optional; until v2.2.12 the SDK required it. project_id and milestone_id are mutually exclusive, and group_id requires project_id. **Throws** `InvalidArgumentException` When a required field is missing, or a field or value is not accepted ### `update()` ```php update($id, array $data): array ``` Update a meeting If attendees is given it must include at least one user. customer, description, deal_id, project_id, group_id and milestone_id take null to clear them. **Throws** `InvalidArgumentException` When a field or value is not accepted ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a meeting ### `complete()` ```php complete($id): array ``` Mark meeting as complete ### `createReport()` ```php createReport($meetingId, array $reportData): array ``` Create a report for a meeting - `$reportData` — attach_to [type => contact|company|deal, id] (required), summary, custom_fields **Throws** `InvalidArgumentException` ### `withTrackedTime()` ```php withTrackedTime(): self ``` Include tracked time in the next request ### `withEstimatedTime()` ```php withEstimatedTime(): self ``` Include estimated time in the next request ### `forEmployee()` ```php forEmployee($employeeId, array $options = []): array ``` Get meetings for a specific employee ### `today()` ```php today(array $options = []): array ``` Get meetings for today ### `inDateRange()` ```php inDateRange(string $startDate, string $endDate, array $options = []): array ``` Get meetings in date range ### `search()` ```php search(string $term, array $options = []): array ``` Search meetings by term ## Examples Get list of meetings with filtering: ```php $meetings = Teamleader::meetings()->list(['employee_id' => 'employee-uuid']); ``` Get meeting details with tracked time: ```php $meeting = Teamleader::meetings()->withTrackedTime()->info('meeting-uuid'); ``` Schedule a new meeting: ```php $meeting = Teamleader::meetings()->schedule([...]); ``` Mark a meeting as complete: ```php Teamleader::meetings()->complete('meeting-uuid'); ``` Create a report for completed meeting: ```php $report = Teamleader::meetings()->createReport('meeting-uuid', [...]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/tickets/ticket-status # Ticket Status Manage ticket statuses in Teamleader Focus. ```php Teamleader::ticketStatus() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Tickets\TicketStatus` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `ticketStatus.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of ticket status UUIDs | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `STATUS_TYPES` | `new`, `open`, `waiting_for_client`, `escalated_thirdparty`, `closed`, `custom` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List ticket statuses with optional filtering - `$filters` — Filter parameters - `$options` — Not used for ticket statuses ### `byIds()` ```php byIds(array $ids): array ``` Get ticket statuses by specific IDs - `$ids` — Array of ticket status UUIDs ### `findByType()` ```php findByType(string $type): ?array ``` Find ticket statuses by type - `$type` — Status type (new, open, waiting_for_client, escalated_thirdparty, closed, custom) ### `customStatuses()` ```php customStatuses(): ?array ``` Get all custom ticket statuses ### `asOptions()` ```php asOptions(): array ``` Get statuses formatted as key-value pairs for dropdowns ### `allIds()` ```php allIds(): array ``` Get all status IDs ### `findByLabel()` ```php findByLabel(string $label): ?array ``` Find a status by its label (for custom statuses) - `$label` — Status label to search for ### `isValidStatusType()` ```php isValidStatusType(string $type): bool ``` Check if a status type is valid ### `getValidStatusTypes()` ```php getValidStatusTypes(): array ``` Get all valid status types ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all ticket statuses: ```php $statuses = Teamleader::ticketStatus()->list(); ``` Get specific ticket statuses by IDs: ```php $statuses = Teamleader::ticketStatus()->byIds(['uuid1', 'uuid2']); ``` Find statuses by type: ```php $openStatuses = Teamleader::ticketStatus()->findByType("open"); ``` Get only custom statuses: ```php $customStatuses = Teamleader::ticketStatus()->customStatuses(); ``` Get statuses as key-value pairs for dropdowns: ```php $options = Teamleader::ticketStatus()->asOptions(); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/tickets/tickets # Tickets Manage tickets (support cases) in Teamleader Focus. ```php Teamleader::tickets() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Tickets\Tickets` | | Create / update / delete | ✓ / ✓ / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `tickets.addInternalMessage` - `tickets.addReply` - `tickets.create` - `tickets.getMessage` - `tickets.importMessage` - `tickets.info` - `tickets.list` - `tickets.listMessages` - `tickets.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `assignee_ids` | Array of user UUIDs; a null entry matches unassigned tickets | | `exclude.status_ids` | Array of status UUIDs to exclude | | `ids` | Array of ticket UUIDs | | `project_ids` | Array of project UUIDs | | `relates_to.id` | Related entity UUID | | `relates_to.type` | Related entity type (contact, company) | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CREATE_FIELDS` | `subject`, `customer`, `ticket_status_id`, `assignee`, `custom_fields`, `description`, `participant`, `initial_reply`, `milestone_id`, `project_id` | | `CUSTOMER_TYPES` | `contact`, `company` | | `INITIAL_REPLY_OPTIONS` | `automatic`, `disabled` | | `MESSAGE_FILTERS` | `type`, `created_before`, `created_after` | | `MESSAGE_TYPES` | `customer`, `internal`, `thirdParty` | | `REQUIRED_ON_CREATE` | `subject`, `customer`, `ticket_status_id` | | `SENT_BY_TYPES` | `company`, `contact`, `user` | | `UPDATE_FIELDS` | `subject`, `description`, `ticket_status_id`, `customer`, `assignee`, `participant`, `custom_fields`, `milestone_id`, `project_id` | ## Methods ### `info()` ```php info($id, $includes = null): array ``` Get detailed ticket information - `$id` — Ticket UUID - `$includes` — Optional includes ### `create()` ```php create(array $data): array ``` Create a new ticket Required fields: subject, customer, ticket_status_id Optional linkage (mutually exclusive): - milestone_id (string): link to a legacy-projects milestone - project_id (string): link to a new-projects project - `$data` — Ticket data ### `update()` ```php update($id, array $data): array ``` Update an existing ticket Optional linkage (mutually exclusive; pass null to unlink): - milestone_id (string|null): link to a legacy-projects milestone - project_id (string|null): link to a new-projects project - `$id` — Ticket UUID - `$data` — Data to update ### `addReply()` ```php addReply(string $ticketId, string $body, ?string $ticketStatusId = null, array $attachments = []): array ``` Add a customer-facing reply to a ticket - `$ticketId` — Ticket UUID - `$body` — HTML formatted message body - `$ticketStatusId` — Optional status UUID to update - `$attachments` — Optional array of file UUIDs ### `addInternalMessage()` ```php addInternalMessage(string $ticketId, string $body, ?string $ticketStatusId = null, array $attachments = []): array ``` Add an internal message (note) to a ticket - `$ticketId` — Ticket UUID - `$body` — HTML formatted message body - `$ticketStatusId` — Optional status UUID to update - `$attachments` — Optional array of file UUIDs ### `importMessage()` ```php importMessage(string $ticketId, string $body, string $sentByType, string $sentById, string $sentAt, array $attachments = []): array ``` Import an existing message to a ticket (e.g., from email) - `$ticketId` — Ticket UUID - `$body` — HTML formatted message body - `$sentByType` — Type of sender (company, contact, user) - `$sentById` — UUID of sender - `$sentAt` — ISO 8601 datetime when message was sent - `$attachments` — Optional array of file UUIDs ### `getMessage()` ```php getMessage(string $messageId): array ``` Get a specific message from a ticket - `$messageId` — Message UUID ### `listMessages()` ```php listMessages(string $ticketId, array $filters = [], array $options = []): array ``` List all messages for a ticket - `$ticketId` — Ticket UUID - `$filters` — Optional message filters (type, created_before, created_after) - `$options` — Pagination options ### `forCustomer()` ```php forCustomer(string $customerType, string $customerId, array $additionalFilters = [], array $options = []): array ``` Get tickets for a specific customer (contact or company) - `$customerType` — Customer type (contact or company) - `$customerId` — Customer UUID - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `list()` ```php list(array $filters = [], array $options = []): array ``` List tickets with filtering and pagination - `$filters` — Filter parameters - `$options` — Pagination options ### `assignedTo()` ```php assignedTo(array $userIds, array $additionalFilters = [], array $options = []): array ``` Get tickets assigned to any of the given users Pass null as an entry to include unassigned tickets as well. ### `unassigned()` ```php unassigned(array $additionalFilters = [], array $options = []): array ``` Get tickets nobody is assigned to ### `forProjects()` ```php forProjects(array $projectIds, array $additionalFilters = [], array $options = []): array ``` Get tickets for specific projects - `$projectIds` — Array of project UUIDs - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `byIds()` ```php byIds(array $ids, array $options = []): array ``` Get tickets by specific IDs - `$ids` — Array of ticket UUIDs - `$options` — Pagination options ### `excludeStatuses()` ```php excludeStatuses(array $statusIds, array $additionalFilters = [], array $options = []): array ``` Exclude tickets with specific statuses - `$statusIds` — Array of status UUIDs to exclude - `$additionalFilters` — Additional filters to apply - `$options` — Pagination options ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all tickets: ```php $tickets = Teamleader::tickets()->list(); ``` Get tickets for a specific customer: ```php $tickets = Teamleader::tickets()->forCustomer("company", "company-uuid"); ``` Get tickets for specific projects: ```php $tickets = Teamleader::tickets()->forProjects(["project-uuid-1", "project-uuid-2"]); ``` Get detailed ticket information: ```php $ticket = Teamleader::tickets()->info("ticket-uuid"); ``` Create a new ticket: ```php $ticket = Teamleader::tickets()->create([ "subject" => "Customer issue", "customer" => ["type" => "company", "id" => "company-uuid"], "ticket_status_id" => "status-uuid", "assignee" => ["type" => "user", "id" => "user-uuid"] ]); ``` Update an existing ticket: ```php $result = Teamleader::tickets()->update("ticket-uuid", [ "subject" => "Updated subject", "ticket_status_id" => "new-status-uuid" ]); ``` Add a customer-facing reply to a ticket: ```php $result = Teamleader::tickets()->addReply( "ticket-uuid", "

Thank you for your inquiry...

", "status-uuid" ); ``` Add an internal note to a ticket: ```php $result = Teamleader::tickets()->addInternalMessage( "ticket-uuid", "

Internal note about this ticket...

" ); ``` Get all messages for a ticket: ```php $messages = Teamleader::tickets()->listMessages("ticket-uuid"); ``` Get a specific message: ```php $message = Teamleader::tickets()->getMessage("message-uuid"); ``` Import an existing message (e.g., from email): ```php $result = Teamleader::tickets()->importMessage( "ticket-uuid", "

Message content...

", "contact", "contact-uuid", "2024-02-29T11:11:11+00:00" ); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/closing-days # Closing Days Manage closing days in Teamleader Focus. ```php Teamleader::closingDays() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\ClosingDays` | | Create / update / delete | ✓ / — / ✓ | | Pagination | `page_size` / `page_number`; the response includes `meta` with totals. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `closingDays.add` - `closingDays.delete` - `closingDays.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `date_after` | Start of the period for which to return closing days (inclusive) | | `date_before` | End of the period for which to return closing days (inclusive) | ## Methods ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a closing day - `$id` — Closing day UUID ### `forMonth()` ```php forMonth(string $yearMonth): array ``` Get closing days for a specific month - `$yearMonth` — Format: YYYY-MM ### `list()` ```php list(array $filters = [], array $options = []): array ``` List closing days with enhanced filtering and pagination Pagination metadata is always requested. The API only returns `meta.matches` when `includes=pagination` is sent, it costs nothing extra, and without it there is no total count — the only end-of-list signal would be a page shorter than the requested page size. - `$filters` — Filters to apply - `$options` — Additional options (pagination) ### `forYear()` ```php forYear($year): array ``` Get closing days for a specific year ### `forDateRange()` ```php forDateRange(string $startDate, string $endDate): array ``` Get closing days within a date range - `$startDate` — Start date (YYYY-MM-DD) - `$endDate` — End date (YYYY-MM-DD) ### `upcoming()` ```php upcoming(int $daysAhead = 30): array ``` Get upcoming closing days (from today forward) - `$daysAhead` — Number of days to look ahead (default: 30) ### `isClosingDay()` ```php isClosingDay(string $date): bool ``` Check if a specific date is a closing day - `$date` — Date in YYYY-MM-DD format ### `getAvailableFilters()` ```php getAvailableFilters(): array ``` Get available filter fields ### `bulkAdd()` ```php bulkAdd(array $dates): array ``` Bulk add multiple closing days - `$dates` — Array of dates in YYYY-MM-DD format ### `add()` ```php add(string $day): array ``` Add a closing day (alias for create) - `$day` — Date in YYYY-MM-DD format ### `create()` ```php create(array $data): array ``` Add a closing day (create method override) - `$data` — Data containing 'day' field ### `getCommonHolidays()` ```php getCommonHolidays(int $year, string $country = 'BE'): array ``` Belgian public holidays for a year, as name => Y-m-d The ten legal holidays (wettelijke feestdagen). Until v2.2.14 this returned three fixed dates plus Easter Monday — and Boxing Day, which is not a Belgian public holiday — and Easter Monday was left out on PHP builds without ext-calendar. The movable feasts are now computed here, so no extension is needed. Only Belgium is supported; any other country code throws rather than returning Belgian dates under another name. - `$country` — BE **Throws** `InvalidArgumentException` For a country other than BE ## Examples Get all closing days: ```php $closingDays = Teamleader::closingDays()->list(); ``` Get closing days within a date range: ```php $closingDays = Teamleader::closingDays()->list(['date_after' => '2023-12-01', 'date_before' => '2023-12-31']); ``` Add a new closing day: ```php $result = Teamleader::closingDays()->create(['day' => '2024-02-01']); ``` Delete a closing day: ```php $result = Teamleader::closingDays()->delete('closing-day-uuid'); ``` Get closing days for current month: ```php $closingDays = Teamleader::closingDays()->forMonth(date('Y-m')); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/currencies # Currencies Manage currency exchange rates in Teamleader Focus. ```php Teamleader::currencies() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\Currencies` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `currencies.exchangeRates` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CURRENCIES` | `BAM`, `CAD`, `CHF`, `CLP`, `CNY`, `COP`, `CZK`, `DKK`, `EUR`, `GBP`, `INR`, `ISK`, `JPY`, `MAD`, `MXN`, `NOK`, `PEN`, `PLN`, `RON`, `SEK`, `TRY`, `USD`, `ZAR` | ## Methods ### `eurRates()` ```php eurRates(): array ``` Get exchange rates for EUR (convenience method) ### `exchangeRates()` ```php exchangeRates(string $baseCurrency): array ``` Get exchange rates for a specific base currency - `$baseCurrency` — The base currency code (e.g., 'EUR', 'USD') ### `isValidCurrencyCode()` ```php isValidCurrencyCode(string $currencyCode): bool ``` Check if a currency code is supported ### `getSupportedCurrencyCodes()` ```php getSupportedCurrencyCodes(): array ``` Get supported currency codes only ### `getSupportedCurrencies()` ```php getSupportedCurrencies(): array ``` Get all supported currency codes ### `usdRates()` ```php usdRates(): array ``` Get exchange rates for USD (convenience method) ### `gbpRates()` ```php gbpRates(): array ``` Get exchange rates for GBP (convenience method) ### `convert()` ```php convert(float $amount, string $fromCurrency, string $toCurrency): array ``` Convert amount from one currency to another using current exchange rates ### `list()` ```php list(array $filters = [], array $options = []) ``` Override the list method to prevent confusion (not applicable for currencies) ### `info()` ```php info($id, $includes = null) ``` Override the info method to prevent confusion (not applicable for currencies) ### `getCommonPairs()` ```php getCommonPairs(): array ``` Get common currency pairs for quick access ### `getRate()` ```php getRate(string $baseCurrency, string $targetCurrency): array ``` Get rate for a specific currency pair ### `getCurrencyName()` ```php getCurrencyName(string $currencyCode): ?string ``` Get currency name by code ## Examples Get exchange rates for EUR as base currency: ```php $rates = Teamleader::currencies()->exchangeRates('EUR'); ``` Get exchange rates for USD as base currency: ```php $rates = Teamleader::currencies()->exchangeRates('USD'); ``` Get specific currency rate from response: ```php $rates = Teamleader::currencies()->exchangeRates('EUR'); $usdRate = collect($rates['data'])->firstWhere('code', 'USD')['exchange_rate'] ?? null; ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/custom-fields # Custom Fields Manage custom field definitions in Teamleader Focus. ```php Teamleader::customFields() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\CustomFields` | | Create / update / delete | ✓ / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `customFieldDefinitions.create` - `customFieldDefinitions.info` - `customFieldDefinitions.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `context` | Filter by context (contact, company, deal, project, milestone, product, invoice, subscription, ticket) | | `ids` | Array of custom field UUIDs to filter by | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `context` | Sort by context | | `label` | Sort by field label | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CREATE_FIELDS` | `label`, `type`, `context`, `required`, `configuration` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List custom fields with optional filtering, sorting and pagination. The Teamleader API defaults to a page size of 20. Pass page_size and page_number via $options, or use all() to page automatically. Deal definitions come back from the API with `context: sale`; this method normalises that to `deal` — see $contextResponseAliases. - `$filters` — Filters to apply (ids, context) - `$options` — page_size, page_number, sort, sort_order **Throws** `InvalidArgumentException` When a filter key or sort field is not supported ### `all()` ```php all(array $filters = [], int $pageSize = 100): array ``` Get every custom field definition, paging until the list is exhausted. The API returns no total count, so the end of the list is inferred from a page shorter than the requested page size. A full final page therefore costs one extra empty request. Makes multiple API calls. The returned array carries `data` and `total_count` but no `headers`, since there is no single response to take them from. - `$filters` — Filters to apply to every page - `$pageSize` — Records per request ### `info()` ```php info($id, $includes = null): array ``` Get custom field information Deal definitions come back from the API with `context: sale`; this method normalises that to `deal` — see $contextResponseAliases. - `$id` — Custom field UUID - `$includes` — Not supported by this endpoint **Throws** `InvalidArgumentException` When includes are requested ### `create()` ```php create(array $data): array ``` Create a new custom field definition. Requires the 'settings' OAuth scope. - `$data` — Custom field data ### `forContext()` ```php forContext(string $context, array $options = []): array ``` Get custom fields for a specific context - `$context` — The context to filter by - `$options` — Pagination and sorting options **Throws** `InvalidArgumentException` When the context is not one the API accepts ### `forContacts()` ```php forContacts(array $options = []): array ``` Get contact custom fields ### `forCompanies()` ```php forCompanies(array $options = []): array ``` Get company custom fields ### `forDeals()` ```php forDeals(array $options = []): array ``` Get deal custom fields The API returns these with `context: sale`; the SDK normalises that to `deal` so the value you filter by and the value you read back match. ### `forSales()` ```php forSales(array $options = []): array ``` Get sale custom fields — alias for forDeals() Kept for callers who think in the API's response vocabulary. `sale` is what the API returns; `deal` is what it accepts, and what the SDK returns after normalisation. ### `forSubscriptions()` ```php forSubscriptions(array $options = []): array ``` Get subscription custom fields ### `forProjects()` ```php forProjects(array $options = []): array ``` Get project custom fields ### `forInvoices()` ```php forInvoices(array $options = []): array ``` Get invoice custom fields ### `forProducts()` ```php forProducts(array $options = []): array ``` Get product custom fields ### `forMilestones()` ```php forMilestones(array $options = []): array ``` Get milestone custom fields ### `forTickets()` ```php forTickets(array $options = []): array ``` Get ticket custom fields ### `byType()` ```php byType(string $type): array ``` Get custom fields of a specific type The API has no `type` filter, so this pages through every definition and filters client-side. It therefore makes multiple API calls and returns `data` and `total_count` without `headers`. - `$type` — The field type **Throws** `InvalidArgumentException` When the type is not one the API defines ### `byIds()` ```php byIds(array $ids): array ``` Get custom fields by specific IDs - `$ids` — Array of custom field UUIDs ### `getAvailableContexts()` ```php getAvailableContexts(): array ``` Get available contexts for custom fields ### `getAvailableTypes()` ```php getAvailableTypes(): array ``` Get available field types ### `typeHasOptions()` ```php typeHasOptions(string $type): bool ``` Check if a field type supports the 'options' configuration key - `$type` — Field type ### `typeIsSearchable()` ```php typeIsSearchable(string $type): bool ``` Check if a field type supports the 'searchable' configuration key - `$type` — Field type ### `typeIsReference()` ```php typeIsReference(string $type): bool ``` Check if a field type is a reference type (links to another entity) - `$type` — Field type ### `options()` ```php options(string $id): array ``` The options of a single or multi select field, as [label => option id]. Read once per field and kept for the lifetime of this resource instance, so mapping many rows costs one request per field. - `$id` — Custom field UUID **Throws** `InvalidArgumentException` When the field is not a select field **Throws** `TeamleaderException` When the definition cannot be read ### `selectValue()` ```php selectValue(string $id, array|string $value): array|string ``` The value to send for a select field: the option label. Teamleader takes the option's **label** as a select field's value on create and update (a string, or a list of strings for multi select) and refuses the option id with "has an invalid single selection value". This accepts either the label or the option id and returns the label(s), so mapping code can keep working with ids. An unknown value throws here, before any request is sent, naming the labels that exist. 'custom_fields' => [[ 'id' => $fieldId, 'value' => Teamleader::customFields()->selectValue($fieldId, 'Woonkrediet'), ]], - `$id` — Custom field UUID - `$value` — Label(s) or option id(s) **Throws** `InvalidArgumentException` When a value matches no option ### `getAllSupportedContexts()` ```php getAllSupportedContexts(): array ``` Get all supported contexts ### `getAllSupportedTypes()` ```php getAllSupportedTypes(): array ``` Get all supported field types ## Examples Get the first page of custom fields: ```php $customFields = Teamleader::customFields()->list(); ``` Get every custom field definition, paging automatically: ```php $customFields = Teamleader::customFields()->all(); ``` Get custom fields for specific context: ```php $contactFields = Teamleader::customFields()->list(['context' => 'contact']); ``` Get specific custom fields by ID: ```php $fields = Teamleader::customFields()->list(['ids' => ['uuid1', 'uuid2']]); ``` Get a single custom field: ```php $field = Teamleader::customFields()->info('field-uuid-here'); ``` Create a single-line text custom field for contacts: ```php $field = Teamleader::customFields()->create(['label' => 'VAT Number', 'type' => 'single_line', 'context' => 'contact']); ``` Create a single-select dropdown for deals with options: ```php $field = Teamleader::customFields()->create(['label' => 'Lead Source', 'type' => 'single_select', 'context' => 'deal', 'configuration' => ['options' => ['Referral', 'Website', 'Cold Call']]]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/day-off-types # Day Off Types Manage day off types in Teamleader Focus. ```php Teamleader::dayOffTypes() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\DayOffTypes` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | No | ## Endpoints - `dayOffTypes.create` - `dayOffTypes.delete` - `dayOffTypes.list` - `dayOffTypes.update` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `WRITE_FIELDS` | `name`, `color`, `date_validity` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List all day off types `dayOffTypes.list` takes no request body — no filter, page or sort parameter exists for it. Arguments are rejected rather than discarded. - `$filters` — Must be empty — this endpoint accepts no filters - `$options` — Must be empty — this endpoint accepts no sorting or pagination **Throws** `InvalidArgumentException` When any argument is passed ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a day off type - `$id` — Day off type UUID ### `createWithValidity()` ```php createWithValidity(string $name, ?string $color = null, ?string $fromDate = null, ?string $untilDate = null): array ``` Create a day off type with date validity period - `$name` — Name of the day off type - `$color` — Hex color code (optional) - `$fromDate` — Start date (YYYY-MM-DD) - `$untilDate` — End date (YYYY-MM-DD, optional) ### `create()` ```php create(array $data) ``` Create a new day off type - `$data` — Day off type data ### `updateColor()` ```php updateColor(string $id, string $color): array ``` Update day off type color - `$id` — Day off type UUID - `$color` — Hex color code ### `update()` ```php update($id, array $data): array ``` Update an existing day off type - `$id` — Day off type UUID - `$data` — Updated data ### `updateValidity()` ```php updateValidity(string $id, string $fromDate, ?string $untilDate = null): array ``` Update day off type validity dates - `$id` — Day off type UUID - `$fromDate` — Start date (YYYY-MM-DD) - `$untilDate` — End date (YYYY-MM-DD, optional) ### `getCommonColors()` ```php getCommonColors(): array ``` Get available color options for day off types ### `bulkCreate()` ```php bulkCreate(array $dayOffTypes): array ``` Bulk create day off types - `$dayOffTypes` — Array of day off type data ### `getValidationRules()` ```php getValidationRules(): array ``` Get validation rules for day off type data ## Examples Get all day off types: ```php $dayOffTypes = Teamleader::dayOffTypes()->list(); ``` Create a basic day off type: ```php $dayOffType = Teamleader::dayOffTypes()->create(['name' => 'Vacation', 'color' => '#00B2B2']); ``` Create day off type with date validity: ```php $dayOffType = Teamleader::dayOffTypes()->create([ 'name' => 'Summer Leave', 'color' => '#FFB600', 'date_validity' => [ 'from' => '2024-06-01', 'until' => '2024-08-31' ] ]); ``` Update an existing day off type: ```php $result = Teamleader::dayOffTypes()->update('uuid-here', ['name' => 'Updated Name', 'color' => '#FF0000']); ``` Delete a day off type: ```php $result = Teamleader::dayOffTypes()->delete('uuid-here'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/days-off # Days Off Manage days off for users in Teamleader Focus. ```php Teamleader::daysOff() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\DaysOff` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `daysOff.bulkDelete` - `daysOff.import` ## Filters `list()` takes no filters. ## Methods ### `bulkDelete()` ```php bulkDelete(string $userId, array $dayOffIds): array ``` Delete multiple days off for a user - `$userId` — The user ID that owns the days off - `$dayOffIds` — Array of day off IDs to delete ### `importSingleDay()` ```php importSingleDay(string $userId, string $leaveTypeId, string $startsAt, string $endsAt): array ``` Import a single day off - convenience method - `$userId` — The user ID - `$leaveTypeId` — The leave type ID - `$startsAt` — Start datetime (ISO 8601 format) - `$endsAt` — End datetime (ISO 8601 format) ### `bulkImport()` ```php bulkImport(string $userId, string $leaveTypeId, array $days): array ``` Import (create) multiple days off for a user - `$userId` — The user ID to create days off for - `$leaveTypeId` — The leave type ID (from dayOffTypes resource) - `$days` — Array of day objects with starts_at and ends_at ### `importFullDays()` ```php importFullDays(string $userId, string $leaveTypeId, array $dates): array ``` Import full days off Each date is sent as a full day; the user must have a working schedule on it. Chunked into calls of at most 100 days, the API's limit. - `$dates` — Y-m-d dates ### `importDateRange()` ```php importDateRange(string $userId, string $leaveTypeId, string $startDate, string $endDate, string $startTime = '08:00:00', string $endTime = '18:00:00', string $timezone = '+00:00', bool $excludeWeekends = true): array ``` Import a date range of days off - `$userId` — The user ID - `$leaveTypeId` — The leave type ID - `$startDate` — Start date (Y-m-d format) - `$endDate` — End date (Y-m-d format) - `$startTime` — Start time (H:i:s format) - `$endTime` — End time (H:i:s format) - `$timezone` — Timezone (default: +00:00) - `$excludeWeekends` — Whether to exclude weekends (default: true) ### `importMultipleDays()` ```php importMultipleDays(string $userId, string $leaveTypeId, array $dates, string $startTime = '08:00:00', string $endTime = '18:00:00', string $timezone = '+00:00'): array ``` Import multiple days off with the same duration pattern - `$userId` — The user ID - `$leaveTypeId` — The leave type ID - `$dates` — Array of date strings (Y-m-d format) - `$startTime` — Start time (H:i:s format, e.g., '08:00:00') - `$endTime` — End time (H:i:s format, e.g., '18:00:00') - `$timezone` — Timezone (default: +00:00) ### `list()` ```php list(array $filters = [], array $options = []) ``` Override standard methods since this resource doesn't support them ### `info()` ```php info($id, $includes = null) ``` ### `create()` ```php create(array $data) ``` ### `update()` ```php update($id, array $data) ``` ### `delete()` ```php delete($id) ``` ### `getFormattingHelpers()` ```php getFormattingHelpers(): array ``` Get available date/time formatting helpers ### `formatDatetime()` ```php formatDatetime(DateTime $datetime): string ``` Format datetime for API ### `createDatetime()` ```php createDatetime(string $date, string $time, string $timezone = '+00:00'): string ``` Create datetime from date and time strings ## Examples Import multiple days off for a user: ```php $result = Teamleader::daysOff()->bulkImport($userId, $leaveTypeId, $days); ``` Delete multiple days off for a user: ```php $result = Teamleader::daysOff()->bulkDelete($userId, $dayOffIds); ``` Import a single day off: ```php $result = Teamleader::daysOff()->importSingleDay($userId, $leaveTypeId, $startsAt, $endsAt); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/departments # Departments Manage departments in Teamleader Focus. ```php Teamleader::departments() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\Departments` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `departments.info` - `departments.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of department UUIDs to filter by | | `status` | Filter by department status (active, archived) | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `created_at` | Creation date | | `default_department` | Ascending lists the default department first | | `name` | Department name | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `STATUSES` | `active`, `archived` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List departments - `$filters` — ids, status (active and/or archived) - `$options` — sort (default_department|name|created_at), sort_order **Throws** `InvalidArgumentException` On an unknown filter key, option, value or sort field ### `info()` ```php info($id, $includes = null): array ``` Get one department - `$id` — Department UUID - `$includes` — departments.info takes no includes; passing any throws ### `active()` ```php active(): array ``` Get active departments only ### `archived()` ```php archived(): array ``` Get archived departments only ### `byIds()` ```php byIds(array $ids): array ``` Get departments by specific IDs - `$ids` — Array of department UUIDs ### `getAvailableStatuses()` ```php getAvailableStatuses(): array ``` Get available status values for filtering ## Examples Get all departments: ```php $departments = Teamleader::departments()->list(); ``` Get only active departments: ```php $departments = Teamleader::departments()->list(['status' => ['active']]); ``` Get specific departments by ID: ```php $departments = Teamleader::departments()->list(['ids' => ['uuid1', 'uuid2']]); ``` Get departments sorted by name: ```php $departments = Teamleader::departments()->list([], ['sort' => [['field' => 'name', 'order' => 'asc']]]); ``` Get a single department: ```php $department = Teamleader::departments()->info('department-uuid-here'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/document-templates # Document Templates Manage document templates in Teamleader Focus. ```php Teamleader::documentTemplates() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\DocumentTemplates` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `documentTemplates.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `department_id` | Department UUID (required) | | `document_type` | Type of document template (required) | | `status` | Filter by template status (active, archived) | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `DOCUMENT_TYPES` | `delivery_note`, `invoice`, `order`, `order_confirmation`, `quotation`, `timetracking_report`, `workorder` | | `STATUSES` | `active`, `archived` | ## Methods ### `activeForDepartment()` ```php activeForDepartment(string $departmentId, string $documentType): array ``` Get active document templates for a specific department and type - `$departmentId` — Department UUID - `$documentType` — Document type ### `byType()` ```php byType(string $departmentId, string $documentType, array $additionalFilters = []): array ``` Get document templates by document type for a specific department - `$departmentId` — Department UUID - `$documentType` — Document type - `$additionalFilters` — Additional filters (like status) ### `list()` ```php list(array $filters = [], array $options = []): array ``` List document templates with filtering Note: Both department_id and document_type are REQUIRED parameters - `$filters` — Filters to apply (department_id and document_type are required) - `$options` — Additional options (not used for this endpoint) **Throws** `InvalidArgumentException` When required parameters are missing ### `archivedForDepartment()` ```php archivedForDepartment(string $departmentId, string $documentType): array ``` Get archived document templates for a specific department and type - `$departmentId` — Department UUID - `$documentType` — Document type ### `allForDepartment()` ```php allForDepartment(string $departmentId, ?array $documentTypes = null): array ``` Get all document templates for a department (across all types) Note: This will make multiple API calls since document_type is required - `$departmentId` — Department UUID - `$documentTypes` — Array of document types to fetch ### `getAvailableDocumentTypes()` ```php getAvailableDocumentTypes(): array ``` Get available document types based on API documentation ### `getAvailableStatuses()` ```php getAvailableStatuses(): array ``` Get available status values for filtering ### `getDocumentTypeDisplayNames()` ```php getDocumentTypeDisplayNames(): array ``` Get document type display names for UI ### `info()` ```php info($id, $includes = null) ``` Override info method since it's not supported for document templates ### `create()` ```php create(array $data) ``` Override create method since it's not supported ### `update()` ```php update($id, array $data) ``` Override update method since it's not supported ### `delete()` ```php delete($id, ...$additionalParams): array ``` Override delete method since it's not supported ## Examples Get document templates for a specific department and type: ```php $templates = Teamleader::documentTemplates()->list([ 'department_id' => 'a344c251-2494-0013-b433-ccee8e8435e5', 'document_type' => 'invoice' ]); ``` Get only active document templates: ```php $templates = Teamleader::documentTemplates()->list([ 'department_id' => 'a344c251-2494-0013-b433-ccee8e8435e5', 'document_type' => 'invoice', 'status' => ['active'] ]); ``` Get templates by document type for a department: ```php $invoiceTemplates = Teamleader::documentTemplates()->byType('department-id', 'invoice'); ``` Get active templates for a department: ```php $activeTemplates = Teamleader::documentTemplates()->activeForDepartment('department-id', 'quotation'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/email-tracking # Email Tracking Manage email tracking in Teamleader Focus - track emails sent to various entities. ```php Teamleader::emailTracking() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\EmailTracking` | | Create / update / delete | ✓ / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `emailTracking.create` - `emailTracking.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `subject.id` | UUID of the subject entity | | `subject.type` | Type of subject entity (contact, company, deal, etc.) | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CREATE_FIELDS` | `subject`, `title`, `content`, `attachments` | | `SUBJECT_TYPES` | `contact`, `company`, `deal`, `invoice`, `creditNote`, `subscription`, `product`, `quotation`, `nextgenProject` | ## Methods ### `forSubject()` ```php forSubject(string $subjectType, string $subjectId, array $options = []): array ``` Get email tracking for a specific subject - `$subjectType` — Type of subject (contact, company, deal, etc.) - `$subjectId` — UUID of the subject - `$options` — Additional options (pagination) ### `list()` ```php list(array $filters = [], array $options = []): array ``` List email tracking entries with filtering - `$filters` — Filters to apply - `$options` — Additional options (pagination) ### `createForContact()` ```php createForContact(string $contactId, string $title, string $content, array $attachments = []): array ``` Create email tracking for a contact - `$contactId` — Contact UUID - `$title` — Email subject - `$content` — Email content - `$attachments` — Array of attachment file UUIDs ### `create()` ```php create(array $data): array ``` Create new email tracking entry Teamleader files every item as a **received** e-mail, also when it was one you sent; the API has no direction field. Items cannot be updated or deleted through the API afterwards, so try one record before a bulk run. The item is dated at the moment of the request — keep the original date in the content when it matters. - `$data` — Email tracking data ### `createForCompany()` ```php createForCompany(string $companyId, string $title, string $content, array $attachments = []): array ``` Create email tracking for a company - `$companyId` — Company UUID - `$title` — Email subject - `$content` — Email content - `$attachments` — Array of attachment file UUIDs ### `createForDeal()` ```php createForDeal(string $dealId, string $title, string $content, array $attachments = []): array ``` Create email tracking for a deal - `$dealId` — Deal UUID - `$title` — Email subject - `$content` — Email content - `$attachments` — Array of attachment file UUIDs ### `getAvailableSubjectTypes()` ```php getAvailableSubjectTypes(): array ``` Get available subject types ## Examples Get all email tracking for a specific contact: ```php $emails = Teamleader::emailTracking()->forSubject("contact", "contact-uuid"); ``` Get all email tracking for a specific company: ```php $emails = Teamleader::emailTracking()->forSubject("company", "company-uuid"); ``` Create email tracking for a contact: ```php $email = Teamleader::emailTracking()->createForContact("contact-uuid", "Subject", "Email content"); ``` Create email tracking with attachments: ```php $email = Teamleader::emailTracking()->create(["subject" => ["type" => "contact", "id" => "uuid"], "title" => "Subject", "content" => "Content", "attachments" => ["file-uuid"]]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/notes # Notes Manage notes in Teamleader Focus - attach notes to various entities like companies, contacts, deals, etc. ```php Teamleader::notes() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\Notes` | | Create / update / delete | ✓ / ✓ / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `notes.create` - `notes.delete` - `notes.list` - `notes.update` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `subject.id` | UUID of the subject entity the note belongs to | | `subject.type` | Type of subject entity (company, contact, deal, etc.) | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `CREATE_FIELDS` | `subject`, `content`, `notify` | | `CREATE_SUBJECT_TYPES` | `company`, `contact`, `creditNote`, `deal`, `invoice`, `meeting`, `nextgenProject`, `product`, `quotation`, `subscription` | | `LIST_SUBJECT_TYPES` | `company`, `contact`, `creditNote`, `deal`, `invoice`, `meeting`, `nextgenProject`, `product`, `project`, `quotation`, `subscription` | | `UPDATE_FIELDS` | `content` | ## Methods ### `update()` ```php update($id, array $data) ``` Update an existing note - `$id` — Note ID - `$data` — Data to update ### `forCompany()` ```php forCompany(string $companyId, array $options = []) ``` Get notes for a company - `$companyId` — Company UUID - `$options` — Additional options ### `forSubject()` ```php forSubject(string $subjectType, string $subjectId, array $options = []) ``` Get notes for a specific subject - `$subjectType` — Type of subject (company, contact, deal, etc.) - `$subjectId` — UUID of the subject - `$options` — Additional options (pagination) ### `list()` ```php list(array $filters = [], array $options = []) ``` List notes with filtering support - `$filters` — Filters to apply - `$options` — Additional options (pagination) ### `createForCompany()` ```php createForCompany(string $companyId, string $content, array $notify = []) ``` Create a note for a company - `$companyId` — Company UUID - `$content` — Note content - `$notify` — Users to notify ### `createForSubject()` ```php createForSubject(string $subjectType, string $subjectId, string $content, array $notify = []) ``` Create a note for a specific subject - `$subjectType` — Type of subject - `$subjectId` — UUID of the subject - `$content` — Note content - `$notify` — Optional array of users to notify ### `create()` ```php create(array $data) ``` Create a new note A note has no type: in Teamleader every note shows as a plain note ("Notitie" in a Dutch account), authored by the connected user and dated at the moment of the request. To carry a type or an original date over from another system, put it in the content, e.g. a first line "Simulatie — 12/03/2024". - `$data` — Note data ### `forContact()` ```php forContact(string $contactId, array $options = []) ``` Get notes for a contact - `$contactId` — Contact UUID - `$options` — Additional options ### `createForContact()` ```php createForContact(string $contactId, string $content, array $notify = []) ``` Create a note for a contact - `$contactId` — Contact UUID - `$content` — Note content - `$notify` — Users to notify ### `forDeal()` ```php forDeal(string $dealId, array $options = []) ``` Get notes for a deal - `$dealId` — Deal UUID - `$options` — Additional options ### `createForDeal()` ```php createForDeal(string $dealId, string $content, array $notify = []) ``` Create a note for a deal - `$dealId` — Deal UUID - `$content` — Note content - `$notify` — Users to notify ### `getAvailableSubjectTypes()` ```php getAvailableSubjectTypes(): array ``` Get available subject types ### `info()` ```php info($id, $includes = null) ``` Override parent method since Notes use different structure ### `delete()` ```php delete($id, ...$additionalParams): array ``` Delete a note. - `$id` — Note UUID - `$additionalParams` — Unused **Throws** `InvalidArgumentException` When the ID is not a valid UUID ## Examples Get all notes for a specific contact: ```php $notes = Teamleader::notes()->list(['subject' => ['type' => 'contact', 'id' => 'contact-uuid']]); ``` Get all notes for a specific company: ```php $notes = Teamleader::notes()->list(['subject' => ['type' => 'company', 'id' => 'company-uuid']]); ``` Create a note for a contact: ```php $note = Teamleader::notes()->create(['subject' => ['type' => 'contact', 'id' => 'contact-uuid'], 'content' => 'Meeting notes from today']); ``` Create a note and notify users: ```php $note = Teamleader::notes()->create(['subject' => ['type' => 'deal', 'id' => 'deal-uuid'], 'content' => 'Important update', 'notify' => [['type' => 'user', 'id' => 'user-uuid']]]); ``` Update an existing note: ```php $note = Teamleader::notes()->update('note-uuid', ['content' => 'Updated note content']); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/teams # Teams Manage teams in Teamleader Focus. ```php Teamleader::teams() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\Teams` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `teams.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of team UUIDs to filter by | | `team_lead_id` | Filter teams by team leader user ID | | `term` | Filter by team name | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `name` | Team name | ## Methods ### `search()` ```php search(string $term): array ``` Search teams by name - `$term` — Search term ### `list()` ```php list(array $filters = [], array $options = []): array ``` List teams with filtering and sorting - `$filters` — Filters to apply - `$options` — Additional options (sorting) ### `byIds()` ```php byIds(array $ids): array ``` Get teams by specific IDs - `$ids` — Array of team UUIDs ### `byTeamLead()` ```php byTeamLead(string $teamLeadId): array ``` Get teams by team leader - `$teamLeadId` — User UUID of the team leader ### `sortedByName()` ```php sortedByName(string $order = 'asc'): array ``` Get teams sorted by name - `$order` — Sort order (asc or desc) ### `info()` ```php info($id, $includes = null) ``` Override info method with compatible signature but throw exception ### `create()` ```php create(array $data) ``` Override create method with compatible signature but throw exception ### `update()` ```php update($id, array $data) ``` Override update method with compatible signature but throw exception ### `delete()` ```php delete($id, ...$additionalParams): array ``` Override delete method with compatible signature but throw exception Fixed signature to match parent class ## Examples Get all teams: ```php $teams = Teamleader::teams()->list(); ``` Search teams by name: ```php $teams = Teamleader::teams()->list(['term' => 'Designers']); ``` Get teams by team leader: ```php $teams = Teamleader::teams()->list(['team_lead_id' => 'user-uuid']); ``` Get teams sorted by name: ```php $teams = Teamleader::teams()->list([], ['sort' => [['field' => 'name', 'order' => 'asc']]]); ``` Get specific teams by ID: ```php $teams = Teamleader::teams()->list(['ids' => ['team-uuid-1', 'team-uuid-2']]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/user-schedules # User Schedules Retrieve per-day working schedules for one or more users in Teamleader Focus. UserSchedules resource. Wraps the `userSchedules.list` endpoint (added by Teamleader in the 2026-06-19 changelog). Returns the working schedules of one or more users, expanded per day over a date range of at most 7 days. This is the successor to `users.getWeekSchedule`, which Teamleader deprecated. The SDK's wrapper for it, users()->getWeekSchedule(), was removed in v3.0. Only available on accounts with the *Weekly working schedule* feature. ```php Teamleader::userSchedules() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\UserSchedules` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; the response includes `meta` with totals. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `userSchedules.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `from` | Required. Start of the date range (inclusive), YYYY-MM-DD | | `until` | Required. End of the date range (inclusive), YYYY-MM-DD. On or after "from"; range may span at most 7 days | | `user_ids` | Required. Array of user UUIDs to return schedules for | ## Methods ### `forUser()` ```php forUser(string $userId, string $from, string $until, array $options = []): array ``` Convenience: schedule for a single user over a date range. - `$userId` — User UUID - `$from` — Start date (inclusive), YYYY-MM-DD - `$until` — End date (inclusive), YYYY-MM-DD - `$options` — Pagination options ### `list()` ```php list(array $filters = [], array $options = []): array ``` List the per-day working schedules of one or more users. Posts to `userSchedules.list`. Non-working days are omitted from each user's schedule. Only available with the *Weekly working schedule* feature. - `$filters` — Required filter keys: - user_ids (array<string>, required): user UUIDs - from (string, required): start date, YYYY-MM-DD (inclusive) - until (string, required): end date, YYYY-MM-DD (inclusive, <= 7 days after "from") - `$options` — Pagination options: page_size (default 20), page_number (default 1) **Throws** `InvalidArgumentException` When required filters are missing or the date range is invalid ### `forUsers()` ```php forUsers(array $userIds, string $from, string $until, array $options = []): array ``` Convenience: schedules for several users over a date range. - `$userIds` — Array of user UUIDs - `$from` — Start date (inclusive), YYYY-MM-DD - `$until` — End date (inclusive), YYYY-MM-DD - `$options` — Pagination options ### `info()` ```php info($id, $includes = null): array ``` info() is not supported — this endpoint only exposes .list. ### `create()` ```php create(array $data): array ``` create() is not supported. ### `update()` ```php update($id, array $data): array ``` update() is not supported. ### `delete()` ```php delete($id, ...$additionalParams): array ``` delete() is not supported. ## Examples Get per-day schedules for several users over a week: ```php $schedules = Teamleader::userSchedules()->list([ 'user_ids' => ['user-uuid-1', 'user-uuid-2'], 'from' => '2026-06-01', 'until' => '2026-06-07', ]); ``` Get the schedule for a single user: ```php $schedule = Teamleader::userSchedules()->forUser('user-uuid', '2026-06-01', '2026-06-07'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/users # Users Manage users in Teamleader Focus. ```php Teamleader::users() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\Users` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `users.info` - `users.list` - `users.listDaysOff` - `users.me` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of user UUIDs to filter by | | `status` | Filter by user status (active, deactivated) | | `term` | Search filter on first name, last name, email and function | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `email` | Email address | | `first_name` | First name | | `function` | Function | | `last_name` | Last name | ## Includes | Endpoint | Includes | |---|---| | `list()` | none | | `info()` | `external_rate` | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `DAYS_OFF_FILTERS` | `starts_after`, `ends_before` | | `INFO_INCLUDES` | `external_rate` | | `STATUSES` | `active`, `deactivated` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List users with enhanced filtering and sorting - `$filters` — Filters to apply - `$options` — Additional options (sorting, pagination) ### `info()` ```php info($id, $includes = null): array ``` Get one user - `$id` — User UUID - `$includes` — external_rate **Throws** `InvalidArgumentException` On an include users.info does not accept ### `me()` ```php me(): array ``` Get current authenticated user ### `listDaysOff()` ```php listDaysOff(string $id, array $filters = [], array $options = []): array ``` List user days off - `$id` — User UUID - `$filters` — Filter options (starts_after, ends_before) - `$options` — Pagination options ### `active()` ```php active(): array ``` Get active users only ### `deactivated()` ```php deactivated(): array ``` Get deactivated users only ### `search()` ```php search(string $term): array ``` Search users by term - `$term` — Search term ### `byIds()` ```php byIds(array $ids): array ``` Get users by specific IDs - `$ids` — Array of user UUIDs ### `withExternalRate()` ```php withExternalRate() ``` Include external rate in response ### `getAvailableStatuses()` ```php getAvailableStatuses(): array ``` Get available status values for filtering ## Examples Get all users: ```php $users = Teamleader::users()->list(); ``` Get only active users: ```php $users = Teamleader::users()->list(['status' => ['active']]); ``` Search users by term: ```php $users = Teamleader::users()->list(['term' => 'John']); ``` Get users sorted by first name: ```php $users = Teamleader::users()->list([], ['sort' => [['field' => 'first_name', 'order' => 'asc']]]); ``` Get a single user with external rate: ```php $user = Teamleader::users()->info('user-uuid-here', 'external_rate'); ``` Get current authenticated user: ```php $currentUser = Teamleader::users()->me(); ``` Get user days off: ```php $daysOff = Teamleader::users()->listDaysOff('user-uuid-here', ['starts_after' => '2023-10-01']); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/general/work-types # Work Types Manage work types in Teamleader Focus. ```php Teamleader::workTypes() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\General\WorkTypes` | | Create / update / delete | — / — / — | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `workTypes.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of work type UUIDs to filter by | | `term` | Search term - searches in the work type name only | ## Methods ### `search()` ```php search(string $term): array ``` Search work types by term - `$term` — Search term ### `list()` ```php list(array $filters = [], array $options = []): array ``` List work types - `$filters` — ids, term - `$options` — page_size, page_number (no sort — see sortedByName()) **Throws** `InvalidArgumentException` On an unknown filter key or option ### `byIds()` ```php byIds(array $ids): array ``` Get work types by specific IDs - `$ids` — Array of work type UUIDs ### `paginate()` ```php paginate(int $pageSize = 20, int $pageNumber = 1, array $filters = []): array ``` Get work types with pagination - `$pageSize` — Number of items per page - `$pageNumber` — Page number - `$filters` — Optional filters ### `sortedByName()` ```php sortedByName(string $order = 'asc', array $filters = []): array ``` Get work types sorted by name - `$order` — Sort order (asc or desc) - `$filters` — Optional filters ### `info()` ```php info($id, $includes = null): array ``` Work types don't support info method as per API docs, but we keep it for consistency and throw a helpful exception - `$id` — Work type UUID - `$includes` — Includes (not used) **Throws** `InvalidArgumentException` ## Examples Get all work types: ```php $workTypes = Teamleader::workTypes()->list(); ``` Search work types by name: ```php $workTypes = Teamleader::workTypes()->list(['term' => 'design']); ``` Get specific work types by ID: ```php $workTypes = Teamleader::workTypes()->list(['ids' => ['uuid1', 'uuid2']]); ``` Get work types sorted by name: ```php $workTypes = Teamleader::workTypes()->sortedByName(); ``` Get work types with pagination: ```php $workTypes = Teamleader::workTypes()->list([], ['page_size' => 50, 'page_number' => 2]); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/files/files # Files Manage files in Teamleader Focus. ```php Teamleader::files() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Files\Files` | | Create / update / delete | ✓ / — / ✓ | | Pagination | `page_size` / `page_number`; no totals — a short page is the last one. Every page: [`lazy()` / `cursor()`](https://hub.mcore-services.be/docs/teamleader-sdk/guides/pagination#fetching-everything) | ## Endpoints - `files.delete` - `files.download` - `files.info` - `files.list` - `files.upload` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `ids` | Array of file UUIDs. Required unless subject is given; files the user has no access to are left out | | `subject` | Object containing subject type and id — see $listSubjectTypes for accepted types. Required unless ids is given | | `term` | Search the file name. Accents and special characters are transliterated, so cafe also matches Café.pdf | ## Sorting Pass `sort` (and optionally `sort_order`) in the options. Any other field throws. | Field | Description | |---|---| | `updated_at` | Sort by file update date | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `SORT_ORDERS` | `desc` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List files for a subject, or by id. subject or ids is required; term searches the file name within either — see buildQueryParams(). - `$filters` — 'subject' => ['type' => ..., 'id' => ...] and/or 'ids' => [...]; optionally 'term' - `$options` — sort, sort_order, page_size, page_number **Throws** `InvalidArgumentException` When neither subject nor ids is given, or a filter is invalid ### `info()` ```php info(string $id): array ``` Get detailed information about a file ### `upload()` ```php upload(string $name, string $subjectType, ?string $subjectId = null, ?string $folder = null): array ``` Request upload link for a file Note that files.upload accepts a narrower set of subject types than files.list — see $uploadSubjectTypes. This only returns a temporary link; the contents still have to be POSTed to it. To upload a local file in one call, use uploadFile(). - `$name` — File name with extension - `$subjectType` — Subject type — see $uploadSubjectTypes - `$subjectId` — Subject UUID — not required when subjectType is 'temporary' - `$folder` — Optional folder name (defaults to General in account language) **Throws** `InvalidArgumentException` When the name, subject type or subject id is missing or invalid ### `uploadFile()` ```php uploadFile(string $path, string $subjectType, ?string $subjectId = null, ?string $folder = null, ?string $name = null, bool $checkType = true): array ``` Upload a local file in one call: request the link, send the contents. upload() only returns a temporary link, which expires, so a bulk upload needs both steps per row. This does both, and works with bulk(): Teamleader::bulk()->call('files', 'uploadFile', [ 'lead-12' => [storage_path('docs/offerte.pdf'), 'deal', $dealId, 'Offertes'], ])->run(); The file is sent as raw bytes (`application/octet-stream`). Its name in Teamleader is $name, or the file's own name, and its extension must be one of MIME_TYPES — anything else is refused before the link is requested. A failed upload throws, whatever throw_exceptions says, so a bulk run records it as a failed row. Queued bulk: the path is read by the worker, so it must exist there. - `$path` — Path to the local file - `$subjectType` — Subject type — see $uploadSubjectTypes - `$subjectId` — Subject UUID — not required when subjectType is 'temporary' - `$folder` — Folder in Teamleader (defaults to General in the account's language) - `$name` — Name in Teamleader, with extension; defaults to the file's own name - `$checkType` — False to skip the MIME_TYPES check **Throws** `InvalidArgumentException` When the file cannot be read, or its type is not accepted **Throws** `TeamleaderException` When Teamleader returns no link or refuses the contents ### `download()` ```php download(string $id): array ``` Request download link for a file Returns a temporary link, which expires. For the document itself use downloadContents(), or downloadTo() to store it on a Laravel disk. - `$id` — File UUID ### `delete()` ```php delete(string $id): array ``` Delete a file - `$id` — File UUID ### `forSubject()` ```php forSubject(string $subjectType, string $subjectId, array $options = []): array ``` Helper method to get files for a specific subject - `$subjectType` — Subject type — see $listSubjectTypes - `$subjectId` — Subject UUID - `$options` — Additional options (sort, sort_order, page_size, page_number, filters) — filters takes term to search the file name: ['filters' => ['term' => 'offerte']] **Throws** `InvalidArgumentException` When the subject type is not valid for files.list ### `byIds()` ```php byIds(array $ids, array $options = []): array ``` Get files by id Uses the `ids` filter, added to files.list in specification 1.223.0: the one way to list files without naming their subject. Files the user has no access to are left out of the result rather than reported. - `$options` — sort, sort_order, page_size, page_number, filters (e.g. term) **Throws** `InvalidArgumentException` When $ids is empty or holds anything but non-empty strings ### `forCompany()` ```php forCompany(string $companyId, array $options = []): array ``` Get files for a company ### `forContact()` ```php forContact(string $contactId, array $options = []): array ``` Get files for a contact ### `forCreditNote()` ```php forCreditNote(string $creditNoteId, array $options = []): array ``` Get files for a credit note ### `forDeal()` ```php forDeal(string $dealId, array $options = []): array ``` Get files for a deal ### `forInvoice()` ```php forInvoice(string $invoiceId, array $options = []): array ``` Get files for an invoice ### `forMeeting()` ```php forMeeting(string $meetingId, array $options = []): array ``` Get files for a meeting Listing only — files cannot be uploaded to a meeting through the API. ### `forProduct()` ```php forProduct(string $productId, array $options = []): array ``` Get files for a product Products carry technical attachments — specification sheets, EPB documentation, installation instructions — which an integration mirroring the product catalogue needs to enumerate alongside the product record. Listing only — files cannot be uploaded to a product through the API. ### `forProject()` ```php forProject(string $projectId, array $options = []): array ``` Get files for a project on the current (nextgen) project system This is the counterpart to Teamleader::projects(). For accounts still on the old project system use forLegacyProject() instead. ### `forLegacyProject()` ```php forLegacyProject(string $projectId, array $options = []): array ``` Get files for a project on the legacy project system This is the counterpart to Teamleader::legacyProjects(). For accounts on the current project system use forProject() instead. Listing only — files cannot be uploaded to a legacy project through the API. ### `forTicket()` ```php forTicket(string $ticketId, array $options = []): array ``` Get files for a ticket ### `downloadContents()` ```php downloadContents(string $id, string $format = 'pdf'): string ``` The document's bytes, in one call. $pdf = Teamleader::invoices()->downloadContents($invoiceId); - `$id` — The record's UUID - `$format` — The format download() accepts, e.g. pdf **Throws** `TeamleaderException` When no link comes back, or the download is refused ### `downloadTo()` ```php downloadTo(string $id, string $disk, string $path, string $format = 'pdf'): string ``` Download the document onto a Laravel disk, and return the path. Teamleader::invoices()->downloadTo($invoiceId, 's3', "invoices/{$number}.pdf"); Bulk-able: `bulk()->call('invoices', 'downloadTo', [[$id, 's3', $path], …])`. On a dry run nothing is written. **Throws** `TeamleaderException` When no link comes back, or the download is refused **Throws** `RuntimeException` When the disk refuses the file ## Examples Get all files for a company: ```php $files = Teamleader::files()->list(['subject' => ['type' => 'company', 'id' => 'company-uuid']]); ``` Search the files of a deal by file name: ```php $files = Teamleader::files()->forDeal('deal-uuid', ['filters' => ['term' => 'offerte']]); ``` Get files by id, without naming their subject: ```php $files = Teamleader::files()->byIds(['file-uuid-1', 'file-uuid-2']); ``` Get all files attached to a product (technical sheets, EPB documentation): ```php $files = Teamleader::files()->forProduct('product-uuid'); ``` Upload a file to a company: ```php $upload = Teamleader::files()->upload('document.pdf', 'company', 'company-uuid', 'Documents'); ``` Upload a temporary file (no subject id required): ```php $upload = Teamleader::files()->upload('attachment.pdf', 'temporary'); ``` Get download link for a file: ```php $link = Teamleader::files()->download('file-uuid'); ``` Delete a file: ```php Teamleader::files()->delete('file-uuid'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/templates/mail-templates # Mail Templates Manage mail templates in Teamleader Focus. ```php Teamleader::mailTemplates() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Templates\MailTemplates` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `mailTemplates.list` ## Filters Passed as the first argument to `list()`. Any other key throws. | Filter | Description | |---|---| | `department_id` | Filter by department UUID (optional) | | `type` | Template type (REQUIRED): invoice, quotation, work_order, credit_note | ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `TYPES` | `invoice`, `quotation`, `work_order`, `credit_note` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List mail templates with filtering Note: The 'type' parameter is REQUIRED by the API - `$filters` — Filters to apply (type is required) - `$options` — Additional options (not used for this endpoint) **Throws** `InvalidArgumentException` When required type parameter is missing or invalid ### `forType()` ```php forType(string $type, ?string $departmentId = null): array ``` Get mail templates for a specific type - `$type` — Template type (invoice, quotation, work_order, credit_note) - `$departmentId` — Optional department UUID **Throws** `InvalidArgumentException` ### `forInvoices()` ```php forInvoices(?string $departmentId = null): array ``` Get mail templates for invoices - `$departmentId` — Optional department UUID ### `forQuotations()` ```php forQuotations(?string $departmentId = null): array ``` Get mail templates for quotations - `$departmentId` — Optional department UUID ### `forWorkOrders()` ```php forWorkOrders(?string $departmentId = null): array ``` Get mail templates for work orders - `$departmentId` — Optional department UUID ### `forCreditNotes()` ```php forCreditNotes(?string $departmentId = null): array ``` Get mail templates for credit notes - `$departmentId` — Optional department UUID ### `findByName()` ```php findByName(string $name, string $type, ?string $departmentId = null): ?array ``` Find a mail template by name - `$name` — Template name to search for - `$type` — Template type (required) - `$departmentId` — Optional department UUID **Throws** `InvalidArgumentException` ### `findByLanguage()` ```php findByLanguage(string $language, string $type, ?string $departmentId = null): ?array ``` Find a mail template by language - `$language` — Language code (e.g., 'en', 'nl', 'fr') - `$type` — Template type (required) - `$departmentId` — Optional department UUID **Throws** `InvalidArgumentException` ### `asOptions()` ```php asOptions(string $type, ?string $departmentId = null, string $labelField = 'name'): array ``` Get all mail templates as key-value pairs for dropdown options - `$type` — Template type (required) - `$departmentId` — Optional department UUID - `$labelField` — Field to use as label (default: 'name') **Throws** `InvalidArgumentException` ### `groupedByLanguage()` ```php groupedByLanguage(string $type, ?string $departmentId = null): array ``` Get template names grouped by language - `$type` — Template type (required) - `$departmentId` — Optional department UUID **Throws** `InvalidArgumentException` ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all mail templates for invoices: ```php $templates = Teamleader::mailTemplates()->forType('invoice'); ``` Get mail templates for quotations in a specific department: ```php $templates = Teamleader::mailTemplates()->forType('quotation', 'department-uuid'); ``` Find a mail template by name: ```php $template = Teamleader::mailTemplates()->findByName('Send link in english', 'invoice'); ``` Get mail templates as key-value pairs for dropdowns: ```php $options = Teamleader::mailTemplates()->asOptions('invoice'); ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/other/accounts # Accounts Fetch account information such as Projects version status. ```php Teamleader::accounts() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Other\Accounts` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `accounts.projects-v2-status` ## Filters `list()` takes no filters. ## Methods ### `projectsV2Status()` ```php projectsV2Status(): array ``` Fetch which version of Projects the account is using ### `isUsingProjectsV2()` ```php isUsingProjectsV2(): bool ``` Check if the account is using Projects v2 ### `isUsingLegacyProjects()` ```php isUsingLegacyProjects(): bool ``` Check if the account is using legacy Projects ### `getProjectsVersion()` ```php getProjectsVersion(): string ``` Get the current Projects version status ### `getAutoSwitchDate()` ```php getAutoSwitchDate(): ?string ``` Get the date when account will be automatically switched to Projects v2 ### `hasScheduledAutoSwitch()` ```php hasScheduledAutoSwitch(): bool ``` Check if account is scheduled for automatic switch to Projects v2 ### `getAccountStatus()` ```php getAccountStatus(): array ``` Get complete account status information ### `getDaysUntilAutoSwitch()` ```php getDaysUntilAutoSwitch(): ?int ``` Get days until automatic switch (if scheduled) ### `isAutoSwitchApproaching()` ```php isAutoSwitchApproaching(int $days = 30): bool ``` Check if auto-switch date is approaching (within specified days) - `$days` — Number of days to consider as "approaching" ### `getProjectVersions()` ```php getProjectVersions(): array ``` Get all valid project version statuses ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `list()` ```php list(array $filters = [], array $options = []): array ``` Override list method as it's not supported for accounts ### `info()` ```php info($id, $includes = null): array ``` Override info method as it's not supported for accounts ## Examples Check which Projects version the account is using: ```php $status = Teamleader::accounts()->projectsV2Status(); $version = $status["data"]["status"]; // "projects-v2" or "legacy" ``` Check if account will be automatically switched to Projects v2: ```php $status = Teamleader::accounts()->projectsV2Status(); if (isset($status["data"]["will_be_automatically_switched_on"])) { $switchDate = $status["data"]["will_be_automatically_switched_on"]; echo "Account will be switched on: {$switchDate}"; } ``` Check if account is using Projects v2: ```php $isV2 = Teamleader::accounts()->isUsingProjectsV2(); if ($isV2) { // Use Projects v2 endpoints } else { // Use legacy project endpoints } ``` Use different logic based on Projects version: ```php $accounts = Teamleader::accounts(); if ($accounts->isUsingProjectsV2()) { $projects = Teamleader::projects()->list(); } else { // Handle legacy projects $projects = Teamleader::legacyProjects()->list(); } ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/other/cloud-platforms # Cloud Platforms Fetch cloud platform URLs for deals, invoices, quotations and tickets. ```php Teamleader::cloudPlatforms() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Other\CloudPlatforms` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `cloudPlatforms.url` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `TYPES` | `deal`, `invoice`, `quotation`, `ticket` | ## Methods ### `url()` ```php url(string $type, string $id): array ``` Fetch cloud platform URL for a specific resource type and ID - `$type` — Resource type (invoice, quotation, or ticket) - `$id` — Resource UUID **Throws** `InvalidArgumentException` ### `dealUrl()` ```php dealUrl(string $dealId): array ``` Get cloud platform URL for an invoice Convenience method for invoice-specific URLs - `$invoiceId` — Invoice UUID ### `invoiceUrl()` ```php invoiceUrl(string $invoiceId): array ``` ### `quotationUrl()` ```php quotationUrl(string $quotationId): array ``` Get cloud platform URL for a quotation Convenience method for quotation-specific URLs - `$quotationId` — Quotation UUID ### `ticketUrl()` ```php ticketUrl(string $ticketId): array ``` Get cloud platform URL for a ticket Convenience method for ticket-specific URLs - `$ticketId` — Ticket UUID ### `batchUrls()` ```php batchUrls(string $type, array $ids): array ``` Get cloud platform URLs for multiple resources of the same type This is a convenience method that calls the API multiple times - `$type` — Resource type - `$ids` — Array of resource UUIDs **Throws** `InvalidArgumentException` ### `getUrl()` ```php getUrl(string $type, string $id): string ``` **Throws** `TeamleaderException` When the call returns no URL — an error response used to raise an "undefined array key" warning and a TypeError instead ### `getDealUrl()` ```php getDealUrl(string $dealId): string ``` ### `getInvoiceUrl()` ```php getInvoiceUrl(string $invoiceId): string ``` Extract just the URL string for an invoice - `$invoiceId` — Invoice UUID ### `getQuotationUrl()` ```php getQuotationUrl(string $quotationId): string ``` Extract just the URL string for a quotation - `$quotationId` — Quotation UUID ### `getTicketUrl()` ```php getTicketUrl(string $ticketId): string ``` Extract just the URL string for a ticket - `$ticketId` — Ticket UUID ### `getSupportedTypes()` ```php getSupportedTypes(): array ``` Get all supported resource types ### `isTypeSupported()` ```php isTypeSupported(string $type): bool ``` Check if a resource type is supported ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `list()` ```php list(array $filters = [], array $options = []): array ``` Override list method as it's not supported for cloud platforms ### `info()` ```php info($id, $includes = null): array ``` Override info method as it's not supported for cloud platforms ## Examples Get cloud platform URL for an invoice: ```php $result = Teamleader::cloudPlatforms()->url("invoice", "invoice-uuid"); $url = $result["data"]["url"]; ``` Get cloud platform URL for a quotation: ```php $result = Teamleader::cloudPlatforms()->url("quotation", "quotation-uuid"); $url = $result["data"]["url"]; ``` Get cloud platform URL for a ticket: ```php $result = Teamleader::cloudPlatforms()->url("ticket", "ticket-uuid"); $url = $result["data"]["url"]; ``` Redirect user to invoice in cloud platform: ```php $result = Teamleader::cloudPlatforms()->url("invoice", $invoiceId); return redirect($result["data"]["url"]); ``` Get cloud platform URLs for multiple resources: ```php $invoiceIds = ["uuid1", "uuid2", "uuid3"]; $urls = []; foreach ($invoiceIds as $id) { $result = Teamleader::cloudPlatforms()->url("invoice", $id); $urls[$id] = $result["data"]["url"]; } ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/other/migrate # Migrate Utility endpoints for migrating from the deprecated Teamleader API to the new UUID-based API. ```php Teamleader::migrate() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Other\Migrate` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `migrate.activityType` - `migrate.id` - `migrate.taxRate` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `ACTIVITY_TYPES` | `meeting`, `call`, `task` | | `RESOURCE_TYPES` | `account`, `user`, `department`, `product`, `contact`, `company`, `deal`, `dealPhase`, `project`, `milestone`, `task`, `meeting`, `call`, `ticket`, `invoice`, `creditNote`, `subscription`, `quotation`, `timeTracking`, `customField` | ## Methods ### `activityType()` ```php activityType(string $type): array ``` Translate activity type (meeting, call, task) into activity type UUID - `$type` — Activity type (meeting, call, or task) **Throws** `InvalidArgumentException` ### `taxRate()` ```php taxRate(string $departmentId, string $taxRate): array ``` Translate old tax rate to new UUID tax rate - `$departmentId` — Department UUID - `$taxRate` — Tax rate as string (e.g., "21", "6", "0") **Throws** `InvalidArgumentException` ### `id()` ```php id(string $type, int $id): array ``` Translate old numeric ID to new UUID - `$type` — Resource type (contact, company, invoice, etc.) - `$id` — Old numeric ID **Throws** `InvalidArgumentException` ### `batchIds()` ```php batchIds(string $type, array $ids): array ``` Batch migrate multiple IDs of the same type This is a convenience method that calls the API multiple times - `$type` — Resource type - `$ids` — Array of old numeric IDs **Throws** `InvalidArgumentException` ### `getActivityTypes()` ```php getActivityTypes(): array ``` Get all valid activity types ### `getResourceTypes()` ```php getResourceTypes(): array ``` Get all valid resource types for ID migration ### `isValidActivityType()` ```php isValidActivityType(string $type): bool ``` Check if an activity type is valid ### `isValidResourceType()` ```php isValidResourceType(string $type): bool ``` Check if a resource type is valid for migration ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ### `list()` ```php list(array $filters = [], array $options = []): array ``` Override list method as it's not supported for migration endpoints ### `info()` ```php info($id, $includes = null): array ``` Override info method as it's not supported for migration endpoints ## Examples Translate meeting, call, or task into activity type UUID: ```php $result = Teamleader::migrate()->activityType("meeting"); // Returns: ["data" => ["id" => "uuid", "type" => "meeting"]] ``` Translate old tax rate to new UUID tax rate: ```php $result = Teamleader::migrate()->taxRate( "department-uuid", "21" ); ``` Translate old contact ID to new UUID: ```php $result = Teamleader::migrate()->id("contact", 123); // Returns: ["data" => ["type" => "contact", "id" => "new-uuid"]] ``` Translate old invoice ID to new UUID: ```php $result = Teamleader::migrate()->id("invoice", 456); ``` Migrate multiple IDs in a loop: ```php $oldIds = [1, 2, 3, 4, 5]; $newIds = []; foreach ($oldIds as $oldId) { $result = Teamleader::migrate()->id("contact", $oldId); $newIds[$oldId] = $result["data"]["id"]; } ``` --- Source: https://hub.mcore-services.be/docs/teamleader-sdk/reference/other/webhooks # Webhooks Manage webhooks for real-time event notifications in Teamleader Focus. ```php Teamleader::webhooks() ``` | | | |---|---| | Class | `McoreServices\TeamleaderSDK\Resources\Other\Webhooks` | | Create / update / delete | — / — / — | | Pagination | No | ## Endpoints - `webhooks.list` - `webhooks.register` - `webhooks.unregister` ## Filters `list()` takes no filters. ## Accepted values Public constants on the class. Body fields and enum values outside these lists throw before the request is sent. | Constant | Values | |---|---| | `EVENT_TYPES` | `account.deactivated`, `account.deleted`, `call.added`, `call.completed`, `call.deleted`, `call.updated`, `company.added`, `company.deleted`, `company.updated`, `contact.added`, `contact.deleted`, `contact.linkedToCompany`, `contact.unlinkedFromCompany`, `contact.updatedLinkToCompany`, `contact.updated`, `creditNote.booked`, `creditNote.deleted`, `creditNote.peppolSubmissionFailed`, `creditNote.peppolSubmissionSucceeded`, `creditNote.sent`, `creditNote.updated`, `deal.created`, `deal.deleted`, `deal.lost`, `deal.moved`, `deal.updated`, `deal.won`, `incomingCreditNote.added`, `incomingCreditNote.approved`, `incomingCreditNote.bookkeepingSubmissionFailed`, `incomingCreditNote.bookkeepingSubmissionSucceeded`, `incomingCreditNote.deleted`, `incomingCreditNote.refused`, `incomingCreditNote.updated`, `incomingInvoice.added`, `incomingInvoice.approved`, `incomingInvoice.bookkeepingSubmissionFailed`, `incomingInvoice.bookkeepingSubmissionSucceeded`, `incomingInvoice.deleted`, `incomingInvoice.refused`, `incomingInvoice.updated`, `invoice.booked`, `invoice.deleted`, `invoice.drafted`, `invoice.paymentRegistered`, `invoice.paymentRemoved`, `invoice.peppolSubmissionFailed`, `invoice.peppolSubmissionSucceeded`, `invoice.sent`, `invoice.updated`, `meeting.completed`, `meeting.created`, `meeting.deleted`, `meeting.updated`, `milestone.created`, `milestone.updated`, `nextgenProject.closed`, `nextgenProject.created`, `nextgenProject.deleted`, `nextgenProject.updated`, `nextgenTask.completed`, `nextgenTask.created`, `nextgenTask.deleted`, `nextgenTask.updated`, `product.added`, `product.deleted`, `product.updated`, `project.created`, `project.deleted`, `project.updated`, `receipt.added`, `receipt.approved`, `receipt.bookkeepingSubmissionFailed`, `receipt.bookkeepingSubmissionSucceeded`, `receipt.deleted`, `receipt.refused`, `receipt.updated`, `subscription.added`, `subscription.deactivated`, `subscription.deleted`, `subscription.updated`, `task.completed`, `task.created`, `task.deleted`, `task.updated`, `ticket.closed`, `ticket.created`, `ticket.deleted`, `ticket.reopened`, `ticket.updated`, `ticketMessage.added`, `timeTracking.added`, `timeTracking.deleted`, `timeTracking.updated`, `user.deactivated` | ## Methods ### `list()` ```php list(array $filters = [], array $options = []): array ``` List all registered webhooks Webhooks are returned ordered by URL `webhooks.list` takes no request body — no filter, page or sort parameter exists for it. Arguments are rejected rather than discarded. - `$filters` — Must be empty — this endpoint accepts no filters - `$options` — Must be empty — this endpoint accepts no sorting or pagination **Throws** `InvalidArgumentException` When any argument is passed ### `register()` ```php register(string $url, array $types): array ``` Register a new webhook Webhooks are not a complete record of changes: `subscription.updated`, for one, fires for the subscription's own fields but not for its lines. Pair them with a periodic re-sync. - `$url` — Your webhook URL (must be a valid HTTPS URL) - `$types` — Array of event types that should trigger this webhook **Throws** `InvalidArgumentException` ### `unregister()` ```php unregister(string $url, array $types): array ``` Unregister a webhook Removes the specified event types from the webhook URL - `$url` — Your webhook URL - `$types` — Array of event types to unregister **Throws** `InvalidArgumentException` ### `getAvailableEventTypes()` ```php getAvailableEventTypes(): array ``` Get all available webhook event types ### `getEventTypesByCategory()` ```php getEventTypesByCategory(string $category): array ``` Get event types filtered by category prefix - `$category` — Category prefix (e.g. 'invoice', 'contact', 'deal') ### `getInvoiceEventTypes()` ```php getInvoiceEventTypes(): array ``` Get all invoice-related event types (invoice + incomingInvoice) ### `getCreditNoteEventTypes()` ```php getCreditNoteEventTypes(): array ``` Get all credit note-related event types (creditNote + incomingCreditNote) ### `getDealEventTypes()` ```php getDealEventTypes(): array ``` Get all deal-related event types ### `getContactEventTypes()` ```php getContactEventTypes(): array ``` Get all contact-related event types ### `getCompanyEventTypes()` ```php getCompanyEventTypes(): array ``` Get all company-related event types ### `getProjectEventTypes()` ```php getProjectEventTypes(): array ``` Get all project-related event types (project + nextgenProject) ### `getTaskEventTypes()` ```php getTaskEventTypes(): array ``` Get all task-related event types (task + nextgenTask) ### `getTicketEventTypes()` ```php getTicketEventTypes(): array ``` Get all ticket-related event types ### `getTimeTrackingEventTypes()` ```php getTimeTrackingEventTypes(): array ``` Get all time tracking-related event types ### `getResponseStructure()` ```php getResponseStructure(): array ``` Get response structure documentation ## Examples Get all registered webhooks: ```php $webhooks = Teamleader::webhooks()->list(); ``` Register a webhook for a single event type: ```php $result = Teamleader::webhooks()->register( "https://example.com/webhook", ["invoice.booked"] ); ``` Register a webhook for multiple event types: ```php $result = Teamleader::webhooks()->register( "https://example.com/webhook", [ "invoice.booked", "invoice.sent", "invoice.paymentRegistered" ] ); ``` Register a webhook for all invoice-related events: ```php $types = Teamleader::webhooks()->getInvoiceEventTypes(); $result = Teamleader::webhooks()->register("https://example.com/webhook", $types); ``` Unregister specific event types from a webhook: ```php $result = Teamleader::webhooks()->unregister( "https://example.com/webhook", ["invoice.booked"] ); ``` Unregister all events for a webhook URL: ```php $webhooks = Teamleader::webhooks()->list(); $url = "https://example.com/webhook"; $types = []; foreach ($webhooks["data"] as $webhook) { if ($webhook["url"] === $url) { $types = $webhook["types"]; break; } } $result = Teamleader::webhooks()->unregister($url, $types); ``` Get event types for a specific category: ```php $contactEvents = Teamleader::webhooks()->getEventTypesByCategory("contact"); $dealEvents = Teamleader::webhooks()->getEventTypesByCategory("deal"); ```