Search by

lettr / lettr-php

topol_gause

Lettr PHP SDK - Send emails via Lettr API

Package info

github.com/lettr-com/lettr-php

Homepage

pkg:composer/lettr/lettr-php

Statistics

Installs: 25 464

Dependents: 1

Suggesters: 0

Stars: 2

Open Issues: 1


README

CI Latest Version on Packagist Total Downloads PHP Version License

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-Unsubscribe for non-transactional emails) may be overwritten by the email delivery provider. Use custom headers for application-specific headers like X-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:

  1. Add an unsubscribe link in your HTML using the data-msys-unsubscribe attribute:
<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.

  1. Listen for unsubscribe events via webhooks — subscribe to link_unsubscribe and list_unsubscribe event 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.