telli-laravel-wrapper maintained by john-wink
Telli Laravel Wrapper
A Laravel wrapper for the telli API v2 - AI phone call agents. Fully typed DTOs via spatie/laravel-data, a typed exception per documented API error code, cursor pagination as lazy collections, and an idempotent contact create.
Requirements
- PHP 8.3+
- Laravel 12
Installation
composer require john-wink/telli-laravel-wrapper
Publish the config file (optional):
php artisan vendor:publish --tag="telli-config"
Publish the translations (optional, ships with en and de):
php artisan vendor:publish --tag="telli-translations"
Set your API key in .env:
TELLI_API_KEY=your-api-key
Usage
Contacts
use JohnWink\Telli\Facades\Telli;
use JohnWink\Telli\Data\CreateContactData;
use JohnWink\Telli\Data\UpdateContactData;
use JohnWink\Telli\Data\ContactPropertyInputData;
use Spatie\LaravelData\DataCollection;
$contact = Telli::contacts()->create(new CreateContactData(
firstName: 'Max',
lastName: 'Mustermann',
phoneNumber: '+4915112345678',
externalId: 'lead-4711',
properties: new DataCollection(ContactPropertyInputData::class, [
new ContactPropertyInputData(key: 'lead_score', value: 87),
]),
));
$page = Telli::contacts()->list(limit: 100); // one page + pageInfo/meta
Telli::contacts()->all()->each(fn ($contact) => ...); // lazy, follows the cursor
$contact = Telli::contacts()->get($id);
$contact = Telli::contacts()->getByExternalId('lead-4711');
$contact = Telli::contacts()->update($id, new UpdateContactData(email: 'new@example.com'));
Telli::contacts()->delete($id);
Replace semantics: Telli::contacts()->replace($id, $data) issues a PUT. Optional fields you omit are reset to null (respectively [] for properties) on the server - unlike update(), which only touches the fields you send.
Idempotent create: when externalId is set and the API answers with a 5xx or the connection drops, the wrapper looks the contact up by external ID before retrying - you never create duplicates. Without an externalId there is nothing to check against, so a failed create is NOT retried; set an externalId whenever you can.
Contact properties
use JohnWink\Telli\Data\CreateContactPropertyData;
use JohnWink\Telli\Data\UpdateContactPropertyData;
use JohnWink\Telli\Enums\PropertyDataType;
$properties = Telli::contactProperties()->list();
$property = Telli::contactProperties()->create(new CreateContactPropertyData(
key: 'lead_score',
dataType: PropertyDataType::Number,
label: 'Lead Score',
));
$property = Telli::contactProperties()->get('lead_score');
$property = Telli::contactProperties()->update('lead_score', new UpdateContactPropertyData(label: 'Score'));
Agents & account health
$agents = Telli::agents()->all(); // lazy collection over all pages
$agent = Telli::agents()->get($agentId);
$health = Telli::account()->health(); // AccountHealthStatus::Operational|Degraded
Multi-tenant / per-team API keys
The config key is just the default account. Scope any call to a different telli account at runtime - withApiKey() returns a NEW client and never mutates the shared singleton, so there is no key bleed between requests, queued jobs or Octane workers:
Telli::withApiKey($team->telli_api_key)->contacts()->create($data);
Error handling
Every documented telli error code maps to its own exception with typed fields, all extending TelliRequestException (which extends TelliException):
use JohnWink\Telli\Exceptions\ContactNotFoundException;
use JohnWink\Telli\Exceptions\TelliValidationException;
use JohnWink\Telli\Exceptions\TelliException;
try {
Telli::contacts()->get($id);
} catch (ContactNotFoundException $exception) {
$exception->contactId; // typed payload from the API
} catch (TelliValidationException $exception) {
$exception->issues; // list of ValidationIssueData (code, message, path)
} catch (TelliException $exception) {
// configuration, connection or any other API error
}
Exception messages are translated (en, de); the raw API message stays available via $exception->apiMessage, the raw code via $exception->rawCode.
Retries
429 responses are retried for every request; connection errors and 5xx responses are retried for non-POST requests only — a blind POST retry could create duplicates. Failed POST creates recover through the verify-then-retry mechanism instead (see "Idempotent create" above) when a natural key (externalId / property key) is present. The backoff is configurable (config/telli.php); a Retry-After header on 429 responses takes precedence.
Testing your integration
The wrapper uses Laravel's HTTP client, so Http::fake() works out of the box:
Http::fake(['api.telli.com/*' => Http::response([...])]);
Or swap the whole client with a mock:
use JohnWink\Telli\Facades\Telli;
Telli::swap($mock);
Changelog
See CHANGELOG.
License
MIT, see LICENSE.