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.9.0
- 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-chore/bump-version-2.9.0
- dev-vojta/tpl-2132-lettr-php-add-chunking-rate-limit-pacing-and-retry-with
- 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-11-05 16:22:21 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. The SDK retries only rate-limit 429s (which the API rejects before doing any work); a timeout, a 5xx or an in-progress conflict is not retried, so that 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 } // The SDK already retried this 429 (see "Rate Limits" below) before // throwing, so hand it to your queue rather than retrying in a loop. echo $e->retryAfter; // Seconds the API asked to wait, if sent } 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) |
The SDK handles the rate limit for you. A rate-limit 429 is retried up to 3
times, waiting the Retry-After the API sends (at most 5 seconds per wait);
RateLimitException is thrown only once retries run out, or straight away if
the API asks for a longer wait. When a response reports no requests left in the
window, the next request waits for the reset. Quota 429s, validation errors,
5xx responses and timeouts are never retried. Pacing works within one PHP
process — several workers sharing a team still rely on the retry.
Tune or turn it off with RetryConfig:
use Lettr\Lettr; use Lettr\RetryConfig; $lettr = Lettr::client('your-api-key', retry: new RetryConfig( maxRetries: 3, // retries after the first attempt maxWaitSeconds: 5, // longest single wait; longer Retry-After throws at once pace: true, // wait for the window to reset when it is used up )); $lettr = Lettr::client('your-api-key', retry: RetryConfig::disabled()); // fail fast, as before
Bulk contact calls (bulkCreate(), bulkAttachLists(), bulkDetachLists(),
bulkSubscribeTopics(), bulkUnsubscribeTopics()) take any number of contacts:
over 1000 they are split into requests of 1000 and the results merged. (A
bulkCreate() that sets both emails and contacts is sent as-is, unsplit.) Chunked
calls are not atomic — if a later chunk fails, PartialBulkFailureException
carries the merged result of what was saved and how many items were
processed, so you can resubmit array_slice($items, $e->processed).
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.