Official PHP SDK for the Lettr email API.
- PHP 8.4+
- Guzzle HTTP client 7.5+
composer require lettr/lettr-phpuse 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
}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')
);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);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 must have a subject defined,
// otherwise the API will return an error)
$response = $lettr->emails()->sendTemplate(
from: 'sender@example.com',
to: 'recipient@example.com',
subject: null,
templateSlug: 'welcome-email',
templateVersion: 2,
projectId: 123,
substitutionData: ['name' => 'John'],
);
// Override the template subject
$response = $lettr->emails()->sendTemplate(
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Welcome!',
templateSlug: 'welcome-email',
);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);$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,
])
);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.
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);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.
$domains = $lettr->domains()->list();
foreach ($domains as $domain) {
echo $domain->domain; // example.com
echo $domain->status->value; // 'pending', 'approved'
echo $domain->canSend; // true/false
echo $domain->dkimStatus->value; // 'valid', 'invalid', etc.
echo $domain->returnPathStatus->value; // 'valid', 'invalid', etc.
}use Lettr\ValueObjects\DomainName;
$result = $lettr->domains()->create('example.com');
// or
$result = $lettr->domains()->create(new DomainName('example.com'));
echo $result->domain; // example.com
echo $result->status; // DomainStatus::Pending
echo $result->statusLabel; // "Pending Review"
// DKIM configuration
if ($result->dkim !== null) {
echo $result->dkim->selector; // DKIM selector (e.g. "scph0226")
echo $result->dkim->publicKey; // DKIM public key
echo $result->dkim->headers; // Signed headers (e.g. "from:to:subject:date")
echo $result->dkim->signingDomain; // Signing domain
}$domain = $lettr->domains()->get('example.com');
echo $domain->domain;
echo $domain->status;
echo $domain->canSend;
echo $domain->dkimStatus->label(); // DnsStatus enum
echo $domain->cnameStatus->label(); // DnsStatus enum
echo $domain->dmarcStatus->label(); // DnsStatus enum
echo $domain->trackingDomain;
echo $domain->createdAt;
echo $domain->verifiedAt;
// DKIM configuration (if available)
if ($domain->dkim !== null) {
echo $domain->dkim->selector;
echo $domain->dkim->publicKey;
echo $domain->dkim->headers;
echo $domain->dkim->recordName('example.com'); // Full DNS record name
echo $domain->dkim->recordValue(); // Full DNS record value
}$verification = $lettr->domains()->verify('example.com');
if ($verification->isFullyVerified()) {
echo "Domain is ready to send!";
} else {
// Check individual record statuses
echo $verification->dkimStatus->label(); // "Valid", "Invalid", "Missing", etc.
echo $verification->cnameStatus->label();
echo $verification->dmarcStatus->label();
echo $verification->spfStatus->label();
// DNS record errors
if ($verification->hasErrors()) {
foreach ($verification->errors() as $type => $error) {
echo "$type: $error";
}
}
// DMARC details
if ($verification->dmarc !== null) {
echo $verification->dmarc->status->label();
echo $verification->dmarc->policy;
echo $verification->dmarc->coveredByParentPolicy ? 'Yes' : 'No';
}
// SPF details
if ($verification->spf !== null) {
echo $verification->spf->status->label();
echo $verification->spf->record;
echo $verification->spf->includesSparkpost ? 'Yes' : 'No';
}
}$lettr->domains()->delete('example.com');$webhooks = $lettr->webhooks()->list();
foreach ($webhooks as $webhook) {
echo $webhook->id;
echo $webhook->name;
echo $webhook->url;
echo $webhook->enabled;
echo $webhook->authType->value; // 'none', 'basic', 'oauth2'
// Event types this webhook listens to
foreach ($webhook->eventTypes as $eventType) {
echo $eventType->value; // 'delivery', 'bounce', 'open', etc.
}
// Health check
if ($webhook->isFailing()) {
echo "Last error: " . $webhook->lastError;
}
}$webhook = $lettr->webhooks()->get('webhook-id');
echo $webhook->name;
echo $webhook->url;
echo $webhook->lastStatus?->value;
echo $webhook->lastTriggeredAt;
// Check if webhook listens to specific events
if ($webhook->listensTo(EventType::Bounce)) {
echo "Webhook receives bounce notifications";
}use Lettr\Dto\Template\ListTemplatesFilter;
// List all templates
$response = $lettr->templates()->list();
foreach ($response->templates as $template) {
echo $template->id;
echo $template->name;
echo $template->slug;
echo $template->projectId;
}
// With pagination
$filter = ListTemplatesFilter::create()
->projectId(123)
->perPage(20)
->page(2);
$response = $lettr->templates()->list($filter);$template = $lettr->templates()->get('welcome-email');
echo $template->id;
echo $template->name;
echo $template->slug;
echo $template->html;
echo $template->json;
echo $template->activeVersion;
echo $template->versionsCount;
// With specific project
$template = $lettr->templates()->get('welcome-email', projectId: 123);use Lettr\Dto\Template\CreateTemplateData;
// With HTML content
$template = $lettr->templates()->create(new CreateTemplateData(
name: 'My Template',
slug: 'my-template', // optional, auto-generated if not provided
projectId: 123, // optional
folderId: 5, // optional
html: '<html>...</html>', // provide html OR json, not both
));
// Or with TOPOL.io JSON format
$template = $lettr->templates()->create(new CreateTemplateData(
name: 'My Template',
json: '{"blocks":[]}', // TOPOL.io editor JSON
));
echo $template->id;
echo $template->name;
echo $template->slug;
echo $template->projectId;
echo $template->folderId;
echo $template->activeVersion;
// Merge tags extracted from the template
foreach ($template->mergeTags as $tag) {
echo $tag->key;
echo $tag->required;
}$lettr->templates()->delete('my-template');
// With specific project
$lettr->templates()->delete('my-template', projectId: 123);Retrieve merge tags (template variables) from a template:
$response = $lettr->templates()->getMergeTags('welcome-email');
echo $response->projectId;
echo $response->templateSlug;
echo $response->version;
foreach ($response->mergeTags as $tag) {
echo $tag->key; // e.g., 'user_name'
echo $tag->required; // true/false
echo $tag->type; // e.g., 'string', 'object'
// Nested tags (for objects)
if ($tag->children !== null) {
foreach ($tag->children as $child) {
echo $child->key; // e.g., 'first_name'
echo $child->type; // e.g., 'string'
}
}
}
// With specific project and version
$response = $lettr->templates()->getMergeTags(
'welcome-email',
projectId: 123,
version: 2,
);// 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 objectThe 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
echo $timestamp->value; // DateTimeImmutable instance
echo $timestamp->format('Y-m-d'); // Custom formatuse 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();
}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
}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.
composer installThis project uses Laravel Pint for code style:
composer lintThis project uses PHPStan at level 8:
composer analyseThis project uses Pest for testing:
composer testPlease see CONTRIBUTING for details.
MIT License. See LICENSE for details.