lettr / lettr-php
Lettr PHP SDK - Send emails via Lettr API
Requires
- php: ^8.4
- guzzlehttp/guzzle: ^7.5 || ^8.0
Requires (Dev)
- laravel/pint: ^1.18
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- v2.8.0
- 2.7.0
- 2.6.0
- v2.5.2
- 2.5.1
- 2.5.0
- v2.4.0
- v2.3.0
- v2.2.0
- v2.1.0
- 2.0.0
- v1.3.0
- v1.2.0
- v1.1.0
- v1.0.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.4
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- dev-main / 0.1.x-dev
- v0.1.7
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- v0.1.0-alpha.1
- dev-fix/legacy-transmission-id
- dev-chore/bump-version-2.8.0
- dev-feat/scheduled-emails-2.8.0
- dev-feat/tpl-2541-preparation-status-idempotency
- dev-feat/tpl-2456-sdk-template-purpose
- dev-fix/segment-condition-logic-docs
- dev-feat/bulk-contacts-tpl-2105
- dev-feat/user-agent-suffix
- dev-feat/campaigns-campaign-detail
- dev-feat/campaigns-endpoints
- dev-feat/audience-endpoints
- dev-release/2.0.0
- dev-feat/openapi-v1.4-sync
- dev-vojtech-transactional-default-fix
- dev-vojtech-custom-headers
- dev-vojtech-default-subject
- dev-vojtech-list-projects
This package is auto-updated.
Last update: 2026-09-21 13:55:52 UTC
README
Official PHP SDK for the Lettr email API.
Requirements
- PHP 8.4+
- Guzzle HTTP client 7.5+ or 8.x
Installation
composer require lettr/lettr-php
Quick Start
use Lettr\Lettr; $lettr = Lettr::client('your-api-key'); // Send an email $response = $lettr->emails()->send( $lettr->emails()->create() ->from('sender@example.com', 'Sender Name') ->to(['recipient@example.com']) ->subject('Hello from Lettr') ->html('<h1>Hello!</h1><p>This is a test email.</p>') ); echo $response->requestId; // Request ID for tracking echo $response->accepted; // Number of accepted recipients // Sending quota (free tier teams only) if ($response->quota !== null) { echo $response->quota->monthlyLimit; // e.g. 3000 echo $response->quota->monthlyRemaining; // e.g. 2500 echo $response->quota->dailyLimit; // e.g. 100 echo $response->quota->dailyRemaining; // e.g. 75 }
Sending Emails
Using the Email Builder (Recommended)
The fluent builder provides a clean API for constructing emails:
$response = $lettr->emails()->send( $lettr->emails()->create() ->from('sender@example.com', 'Sender Name') ->to(['recipient@example.com']) ->cc(['cc@example.com']) ->bcc(['bcc@example.com']) ->replyTo('reply@example.com') ->subject('Welcome!') ->html('<h1>Welcome</h1>') ->text('Welcome (plain text fallback)') ->transactional() ->withClickTracking(true) ->withOpenTracking(true) ->metadata(['user_id' => '123', 'campaign' => 'welcome']) ->substitutionData(['name' => 'John', 'company' => 'Acme']) ->tag('welcome') );
Using SendEmailData DTO
For programmatic email construction:
use Lettr\Dto\Email\SendEmailData; use Lettr\Dto\Email\EmailOptions; use Lettr\ValueObjects\EmailAddress; use Lettr\ValueObjects\Subject; use Lettr\Collections\EmailAddressCollection; $email = new SendEmailData( from: new EmailAddress('sender@example.com', 'Sender'), to: EmailAddressCollection::from(['recipient@example.com']), subject: new Subject('Hello'), html: '<p>Email content</p>', ); $response = $lettr->emails()->send($email);
Quick Send Methods
For simple use cases:
The from parameter accepts a plain email string or an EmailAddress value object when you need a sender name:
use Lettr\ValueObjects\EmailAddress; // Pass a string — validated as an email address $response = $lettr->emails()->sendHtml( from: 'sender@example.com', to: 'recipient@example.com', subject: 'Hello', html: '<p>HTML content</p>', ); // Pass an EmailAddress — includes sender name $response = $lettr->emails()->sendHtml( from: new EmailAddress('sender@example.com', 'Sender Name'), to: 'recipient@example.com', subject: 'Hello', html: '<p>HTML content</p>', ); // Plain text email $response = $lettr->emails()->sendText( from: 'sender@example.com', to: ['recipient1@example.com', 'recipient2@example.com'], subject: 'Hello', text: 'Plain text content', ); // Template email — subject is optional; if omitted, the template's own subject is used $response = $lettr->emails()->sendTemplate( from: 'sender@example.com', to: 'recipient@example.com', templateSlug: 'welcome-email', templateVersion: 2, projectId: 123, substitutionData: ['name' => 'John'], ); // Override the template's subject $response = $lettr->emails()->sendTemplate( from: 'sender@example.com', to: 'recipient@example.com', templateSlug: 'welcome-email', subject: 'Welcome!', );
Attachments
use Lettr\Dto\Email\Attachment; $email = $lettr->emails()->create() ->from('sender@example.com') ->to(['recipient@example.com']) ->subject('Document attached') ->html('<p>Please find the document attached.</p>') // From file path ->attachFile('/path/to/document.pdf') // With custom name and mime type ->attachFile('/path/to/file', 'custom-name.pdf', 'application/pdf') // From binary data ->attachData($binaryContent, 'report.csv', 'text/csv') // Using Attachment DTO ->attach(Attachment::fromFile('/path/to/image.png')); $response = $lettr->emails()->send($email);
Templates with Substitution Data
$response = $lettr->emails()->send( $lettr->emails()->create() ->from('sender@example.com') ->to(['recipient@example.com']) ->useTemplate('order-confirmation', version: 1, projectId: 123) // subject() is optional when using a template — if omitted, the template must have a subject // defined, otherwise the API will return an error ->subject('Your Order #{{order_id}}') ->substitutionData([ 'order_id' => '12345', 'customer_name' => 'John Doe', 'items' => [ ['name' => 'Product A', 'price' => 29.99], ['name' => 'Product B', 'price' => 49.99], ], 'total' => 79.98, ]) );
Custom Headers
You can add custom email headers (e.g. X-Custom-ID, X-Entity-Ref-ID) to your emails. Maximum 10 headers, each value up to 998 characters:
$email = $lettr->emails()->create() ->from('sender@example.com') ->to(['recipient@example.com']) ->subject('Hello') ->html('<p>Content</p>') // Bulk set ->headers(['X-Custom-ID' => 'abc-123', 'X-Entity-Ref-ID' => 'order-456']) // Or add individually ->addHeader('X-Custom-ID', 'abc-123');
Note: Some standard headers (e.g.
List-Unsubscribefor non-transactional emails) may be overwritten by the email delivery provider. Use custom headers for application-specific headers likeX-Custom-ID.
Email Options
Emails are sent as transactional by default, matching the API's default behavior. For marketing emails, explicitly set transactional(false):
$email = $lettr->emails()->create() ->from('sender@example.com') ->to(['recipient@example.com']) ->subject('Newsletter') ->html($htmlContent) // Tracking ->withClickTracking(true) ->withOpenTracking(true) // Mark as marketing (non-transactional) ->transactional(false) // CSS inlining ->withInlineCss(true) // Template variable substitution ->withSubstitutions(true);
Idempotent Sends
Attach an Idempotency-Key and a retry of the same send returns the original
result instead of delivering a second email:
use Lettr\Exceptions\IdempotencyConflictException; use Lettr\Exceptions\IdempotencyInProgressException; $response = $lettr->emails()->send( $lettr->emails()->create() ->from('sender@example.com') ->to(['customer@example.com']) ->subject('Your order') ->html($html), idempotencyKey: 'order-confirmation-12345', ); $response->replayed; // true when this replayed an earlier send — no second email went out
You choose the key; the SDK never generates one. It only works if the same
key is used on both attempts, and the SDK does not retry — one send() is one
HTTP request — so the retry is yours. Use something stable for one logical send:
an order id, a job id, anything you can regenerate. A fresh value per attempt
protects nothing.
If you have no natural id, IdempotencyKey::forPayload($data) derives one from
the payload. Note the trade: two deliberately identical sends within 24 hours
then collapse into one.
Handle the two conflicts differently — one is retryable and the other is not:
try { $lettr->emails()->send($email, idempotencyKey: $key); } catch (IdempotencyInProgressException $e) { // The original send is still running. Retry with the SAME key. sleep($e->retryAfter ?? 1); } catch (IdempotencyConflictException $e) { // That key was already used with a different payload. Retrying fails forever. }
Both extend ConflictException, so existing handlers keep catching them.
| Format | 1–255 characters, [A-Za-z0-9._-] — validated locally before the request |
| Retention | 24 hours |
| Scope | Per team and API key — the same string through a different API key is a different key |
Scheduled Emails
Schedule an email for any time between 5 minutes and 30 days out. Lettr holds it until then, so it can be listed, read back and cancelled right up to the moment it is sent.
use Lettr\Dto\Email\ListScheduledEmailsFilter; use Lettr\Enums\ScheduledEmailState; $scheduled = $lettr->emails()->schedule( $lettr->emails()->create() ->from('sender@yourdomain.com') ->to(['recipient@example.com']) ->subject('Your weekly digest') ->html('<h1>This week</h1>') ->scheduledAt('2026-10-15T09:00:00Z') ); $scheduled->requestId; // sch_01JQZ3... — use this to read it back or cancel it $scheduled->state; // ScheduledEmailState::Scheduled $scheduled->transmissionId; // null until the email is actually sent
The two ids are not interchangeable. requestId identifies the scheduled email for its whole life. transmissionId is the sending provider's id: it stays null until the email goes out, and it is the value that appears on your webhook events, so use that one to correlate them.
// Read it back at any point — including after it was cancelled. $scheduled = $lettr->emails()->getScheduled($scheduled->requestId); if ($scheduled->isCancellable()) { $cancelled = $lettr->emails()->cancelScheduled($scheduled->requestId); $cancelled->state; // ScheduledEmailState::Cancelled } // List what is queued. $page = $lettr->emails()->listScheduled( ListScheduledEmailsFilter::create() ->status(ScheduledEmailState::Scheduled) ->perPage(25) ); foreach ($page->scheduledEmails as $email) { echo $email->requestId.' → '.$email->scheduledAt.PHP_EOL; } $page->hasMore();
States are Scheduled, Sending, Sent, Cancelled and Failed. Cancelling is only possible while Scheduled; once it is being sent or has been sent, cancelScheduled() throws a ConflictException. A Failed email carries a failureReason.
Once an email has been sent, events fills in from its delivery events — empty before that, and for a few minutes afterwards while they are indexed.
Marketing Emails & Unsubscribe
When sending marketing emails (transactional(false)), the email provider automatically adds List-Unsubscribe and List-Unsubscribe-Post headers for compliance. To allow recipients to unsubscribe from your marketing emails:
- Add an unsubscribe link in your HTML using the
data-msys-unsubscribeattribute:
<a data-msys-unsubscribe="1" href="https://yourapp.com/unsubscribe" title="Unsubscribe">Unsubscribe from these emails</a>
The href must use https:// — when clicked, the user will be redirected to your URL. The actual unsubscribe handling should be done server-side by listening for webhook events.
- Listen for unsubscribe events via webhooks — subscribe to
link_unsubscribeandlist_unsubscribeevent types to process unsubscribes in your application.
Health Check
// Check API health (no authentication required) $status = $lettr->health()->check(); echo $status->status; // 'ok' echo $status->timestamp; // Timestamp object echo $status->isHealthy(); // true/false // Verify API key is valid and get team info $auth = $lettr->health()->authCheck(); echo $auth->teamId; // Your team ID echo $auth->timestamp; // Timestamp object
Value Objects
The SDK uses value objects for type safety and validation:
use Lettr\ValueObjects\EmailAddress; use Lettr\ValueObjects\DomainName; use Lettr\ValueObjects\RequestId; use Lettr\ValueObjects\Timestamp; // Email addresses with optional name $email = new EmailAddress('user@example.com', 'User Name'); echo $email->address; // user@example.com echo $email->name; // User Name // Domain names (validated) $domain = new DomainName('example.com'); // Request IDs $requestId = new RequestId('req_abc123'); // Timestamps $timestamp = Timestamp::fromString('2024-01-15T10:30:00Z'); echo $timestamp->toIso8601(); // ISO 8601 string $timestamp->value; // DateTimeImmutable instance (not echoable directly) echo $timestamp->format('Y-m-d'); // Custom format
Error Handling
use Lettr\Exceptions\ApiException; use Lettr\Exceptions\TransporterException; use Lettr\Exceptions\ValidationException; use Lettr\Exceptions\NotFoundException; use Lettr\Exceptions\UnauthorizedException; use Lettr\Exceptions\ForbiddenException; use Lettr\Exceptions\ConflictException; use Lettr\Exceptions\QuotaExceededException; use Lettr\Exceptions\RateLimitException; use Lettr\Exceptions\InvalidValueException; try { $response = $lettr->emails()->send($email); } catch (ValidationException $e) { // Invalid request data (422) echo "Validation failed: " . $e->getMessage(); } catch (UnauthorizedException $e) { // Invalid API key (401) echo "Authentication failed: " . $e->getMessage(); } catch (ForbiddenException $e) { // Insufficient API key permissions (403) echo "Forbidden: " . $e->getMessage(); } catch (NotFoundException $e) { // Resource not found (404) echo "Not found: " . $e->getMessage(); } catch (ConflictException $e) { // Resource conflict (409) echo "Conflict: " . $e->getMessage(); } catch (QuotaExceededException $e) { // Sending quota exceeded (429) - monthly or daily limit reached echo "Quota exceeded: " . $e->getMessage(); if ($e->quota !== null) { echo $e->quota->monthlyLimit; // Total monthly limit echo $e->quota->monthlyRemaining; // 0 when exhausted echo $e->quota->monthlyReset; // Unix timestamp - start of next month echo $e->quota->dailyLimit; // Total daily limit echo $e->quota->dailyRemaining; // 0 when exhausted echo $e->quota->dailyReset; // Unix timestamp - tomorrow midnight UTC } } catch (RateLimitException $e) { // API rate limit exceeded (429) - too many requests per second echo "Rate limited: " . $e->getMessage(); if ($e->rateLimit !== null) { echo $e->rateLimit->limit; // Max requests per second echo $e->rateLimit->remaining; // Remaining requests echo $e->rateLimit->reset; // Unix timestamp when limit resets } if ($e->retryAfter !== null) { sleep($e->retryAfter); // Seconds to wait before retrying } } catch (ApiException $e) { // Other API errors echo "API error ({$e->getCode()}): " . $e->getMessage(); } catch (TransporterException $e) { // Network/transport errors echo "Network error: " . $e->getMessage(); } catch (InvalidValueException $e) { // Invalid value object (e.g., invalid email format) echo "Invalid value: " . $e->getMessage(); }
Rate Limits
The API enforces a rate limit of 3 requests per second per team, shared across all API keys. Rate limit headers are included in every authenticated API response:
| Header | Description |
|---|---|
X-Ratelimit-Limit |
Maximum requests per second |
X-Ratelimit-Remaining |
Remaining requests in current window |
X-Ratelimit-Reset |
Unix timestamp when the limit resets |
Retry-After |
Seconds to wait (only on 429 responses) |
You can read rate limit info after any API call:
$lettr->domains()->list(); $rateLimit = $lettr->lastRateLimit(); if ($rateLimit !== null) { echo $rateLimit->limit; // 3 echo $rateLimit->remaining; // 2 echo $rateLimit->reset; // Unix timestamp }
Sending Quotas
Free tier teams have monthly and daily sending limits. Quota headers are included in send email responses:
| Header | Description |
|---|---|
X-Monthly-Limit |
Total monthly email limit |
X-Monthly-Remaining |
Remaining emails this month |
X-Monthly-Reset |
Unix timestamp when monthly quota resets |
X-Daily-Limit |
Total daily email limit |
X-Daily-Remaining |
Remaining emails today |
X-Daily-Reset |
Unix timestamp when daily quota resets |
Quota information is available on successful responses via $response->quota and on quota exceeded errors via the QuotaExceededException.
Documentation
Full guides, every method, and complete request/response details live in the docs:
📚 docs.lettr.com/quickstart/php
| Topic | Guide |
|---|---|
| Install & client setup | Installation |
| Sending — HTML, text, templates, attachments, tracking, errors | Sending Emails |
| Managing templates & merge tags | Templates |
| Add, verify, and manage sending domains | Domains |
| Webhook endpoints for delivery & engagement events | Webhooks |
| Lists, contacts, topics, properties, segments | Audience |
| List, send, and schedule campaigns | Campaigns |
| Endpoint reference (params & schemas) | API Reference |
Development
Install Dependencies
composer install
Code Style
This project uses Laravel Pint for code style:
composer lint
Static Analysis
This project uses PHPStan at level 8:
composer analyse
Testing
This project uses Pest for testing:
composer test
Contributing
Please see CONTRIBUTING for details.
License
MIT License. See LICENSE for details.