letmesendemail/letmesendemail-php

letmesend.email PHP SDK.

Maintainers

Package info

github.com/letmesendemail/letmesendemail-php

Homepage

pkg:composer/letmesendemail/letmesendemail-php

Transparency log

Statistics

Installs: 159

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.1 2026-07-11 20:39 UTC

This package is auto-updated.

Last update: 2026-07-11 20:40:40 UTC


README

Tests Packagist Downloads Packagist Version License

The official PHP SDK for the letmesend.email API.

Full Documentation

See the comprehensive user manual for complete documentation of every resource, configuration option, retry behavior, error handling, webhook verification, and detailed examples.

Installation

composer require letmesendemail/letmesendemail-php

Quick Start

use LetMeSendEmail\LetMeSendEmail;

$apiKey = getenv('LETMESENDEMAIL_API_KEY');
if ($apiKey === false || $apiKey === '') {
    throw new \RuntimeException('LETMESENDEMAIL_API_KEY is not set.');
}

$client = new LetMeSendEmail(apiKey: $apiKey);

$email = $client->emails()->send(
    from: 'Acme <hello@acme.com>',
    to: ['person@example.com'],
    subject: 'Welcome',
    html: '<p>Hello from letmesend.email</p>',
);

echo $email->getId(); // "01kvv5a6xk9qd6y2egeae8w76e"

Configuration

use LetMeSendEmail\LetMeSendEmail;
use LetMeSendEmail\Configuration;

// Simple setup with just an API key:
$client = new LetMeSendEmail(apiKey: 'lms_live_...');

// With custom configuration:
$config = new Configuration(
    apiKey: 'lms_live_...',
    baseUrl: 'https://letmesend.email/api/v1',  // default
    timeout: 60,                                  // default: 30 seconds
);

$client = new LetMeSendEmail(configuration: $config);

Options

Option Default Description
apiKey Your letmesend.email API key
baseUrl https://letmesend.email/api/v1 API base URL override
timeout 30 Request timeout in seconds
retries 0 Maximum number of retry attempts

API Coverage

This SDK is built from the API fixtures in data/api-data.

Implemented

Resource Fixture operations SDK usage
Emails send, send-with-template, verify, list, show $client->emails()
Domains list, show, verify $client->domains()
Contacts store, list, show, update, delete $client->contacts()
Contact Categories store, list, show, update, delete $client->contactCategories()
Email Topics store, list, show, update, delete $client->emailTopics()
Error responses daily-quote-exceed, monthly-quote-exceed, domain-not-found, domain-unverified, email-size-exceeded Typed exceptions
Webhooks Signature verification standard WebhookSignature::verify(...)

Emails

Send an email

$email = $client->emails()->send(
    from: 'Acme <hello@acme.com>',
    to: ['person@example.com', 'Jane <jane@example.com>'],
    subject: 'Welcome to letmesend.email',
    html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
    text: 'Welcome! Thanks for signing up.',
    type: 'transactional', // or 'broadcast'
    eventName: 'user.created',
    replyTo: ['support@acme.com'],
    cc: ['manager@acme.com'],
    bcc: ['archive@acme.com'],
    headers: ['X-Custom-Header' => 'value'],
    attachments: [
        [
            'name' => 'report.pdf',
            'mime' => 'application/pdf',
            'path' => 'https://storage.example.com/report.pdf',
        ],
    ],
);

echo $email->getId();         // "01kvv5a6xk9qd6y2egeae8w76e"
echo $email->getStatus();     // "pending_scan" or "accepted"
print_r($email->getEmails()); // list of all recipient addresses

Attachments

You can provide attachments as raw arrays or as Attachment objects:

use LetMeSendEmail\Requests\Attachment;

// From a URL (external file)
$attachment = Attachment::fromPath(
    name: 'report.pdf',
    path: 'https://example.com/report.pdf',
    contentId: 'optional-cid',
    contentDisposition: 'attachment', // or 'inline'
);

// From base64 content
$attachment = Attachment::fromContent(
    name: 'data.txt',
    content: base64_encode('Hello World'),
    contentId: 'optional-cid',
    contentDisposition: 'inline',
);

$email = $client->emails()->send(
    from: 'Acme <hello@acme.com>',
    to: ['person@example.com'],
    subject: 'With attachment',
    html: '<p>See attached</p>',
    attachments: [$attachment],
);

Send with a template

// Raw arrays:
$email = $client->emails()->sendWithTemplate(
    from: 'Acme <hello@acme.com>',
    to: ['person@example.com'],
    templateId: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
    subject: 'Your order confirmation',
    templateVariables: [
        ['key' => 'USER_NAME', 'type' => 'string', 'value' => 'John'],
        ['key' => 'ORDER_NUMBER', 'type' => 'number', 'value' => 12345],
    ],
);

Or use TemplateVariable objects:

use LetMeSendEmail\Requests\TemplateVariable;

$email = $client->emails()->sendWithTemplate(
    from: 'Acme <hello@acme.com>',
    to: ['person@example.com'],
    templateId: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
    templateVariables: [
        new TemplateVariable('USER_NAME', 'string', 'John'),
        new TemplateVariable('ORDER_NUMBER', 'number', 12345),
    ],
);

Idempotency

Pass an idempotencyKey to prevent duplicate sends on retry:

$email = $client->emails()->send(
    from: 'Acme <hello@acme.com>',
    to: ['person@example.com'],
    subject: 'Your invoice',
    html: '<p>Invoice attached</p>',
    idempotencyKey: 'my-unique-key-abc123',
);

if ($email->isDuplicate()) {
    // This send was a duplicate — the original send was not re-attempted.
}

When the API returns a duplicate response (same idempotencyKey used within the expiry window), the response includes "duplicate": true and the isDuplicate() method returns true.

Verify an email address

$result = $client->emails()->verify('person@example.com');

echo $result->getStatus();     // "valid", "invalid", or "risky"
echo $result->getScore();      // 0-100
echo $result->hasMailbox();    // true
echo $result->isDisposable();  // false
echo $result->hasValidSyntax(); // true

List emails

// First page
$list = $client->emails()->list(perPage: 20);

foreach ($list->items() as $email) {
    echo $email->getId() . ' - ' . ($email->getSubject() ?? '(no subject)') . PHP_EOL;
}

// Pagination info
$pagination = $list->pagination();
echo $pagination->hasMore(); // true
echo $pagination->getTotal(); // 100

// Next page using the last item's ID
if ($list->pagination()->hasMore() && count($list->items()) > 0) {
    $items = $list->items();
    $lastId = $items[count($items) - 1]->getId();
    $nextPage = $client->emails()->list(perPage: 20, after: $lastId);
}

// Previous page using the first item's ID (from a page other than the first)
if (count($list->items()) > 0) {
    $items = $list->items();
    $firstId = $items[0]->getId();
    $prevPage = $client->emails()->list(perPage: 20, before: $firstId);
}

Get a single email

$email = $client->emails()->get('01kvv5dv472evp42a60sy4p7zx');

echo $email->getStatus();           // "sent", "queued", etc.
echo $email->getSubject();          // email subject
echo $email->getRecipientsCount();  // 1
echo $email->getAttachmentsCount(); // 0

// Typed recipient objects
foreach ($email->getRecipients() as $recipient) {
    echo $recipient->getEmailAddress(); // "koelpin.burdette@example.org"
    echo $recipient->getStatus();       // "queued", "delivered", "bounced"
    echo $recipient->getOpenCount();    // opens count
    echo $recipient->getClickCount();   // clicks count
}

// Typed attachment objects
foreach ($email->getAttachments() as $attachment) {
    echo $attachment->getName();          // "I9wJnL1QeUOKnsgE.png"
    echo $attachment->getMime();          // "image/png"
    echo $attachment->getSize();          // 16174079
    echo $attachment->getDownloadUrl();   // signed download URL
}

Domains

List domains

$list = $client->domains()->list(perPage: 20);

foreach ($list->items() as $domain) {
    echo $domain->getId() . ' - ' . $domain->getDomainName() . ' (' . $domain->getStatus() . ')' . PHP_EOL;
}

$pagination = $list->pagination();
echo $pagination->hasMore(); // true/false

Get a domain

$domain = $client->domains()->get('01kvv5th65xzkwxe5avmdqbn4a');

echo $domain->getDomainName(); // "mpxlubowitz.com"
echo $domain->getStatus();     // "verified" or "pending"

Verify a domain

$result = $client->domains()->verify('mpxlubowitz.com');

echo $result->getStatus(); // "verified"

Contacts

Create a contact

$contact = $client->contacts()->create(
    email: 'john@example.com',
    firstName: 'John',
    lastName: 'Doe',
    phone: '11231231234',
    categories: ['01kvtsch6f6e7hz543mjyjnqsp'],
);

echo $contact->getId(); // "01kvtsch6t80qpx3ncfea2r5a3"
echo $contact->getFirstName(); // "John"

List contacts

$list = $client->contacts()->list(perPage: 10);

foreach ($list->items() as $contact) {
    echo $contact->getEmail() . ' - ' . $contact->getFirstName() . PHP_EOL;
}

Get a contact

$contact = $client->contacts()->get('01kvtsch80sxnzw8cggwhh22x2');

echo $contact->getEmail();     // "mayer.elmira@example.com"
echo $contact->getFirstName(); // "Jalen"

Update a contact

$result = $client->contacts()->update(
    id: '01kvtsch98rxyxwaxwx2fbpsbp',
    firstName: 'John',
    lastName: 'Doe',
    syncCategories: false,
);

echo $result->getId(); // "01kvtsch98rxyxwaxwx2fbpsbp"

The update() method returns a ContactUpdateResponse which contains only the contact ID.

Delete a contact

$result = $client->contacts()->delete('01kvtscham9bjdwftnxxa8at1k');

echo $result->getStatus(); // "success"

Contact Categories

Create a category

$category = $client->contactCategories()->create(name: 'New Name');

echo $category->getId();   // "01kvtkm3x5tpyhyw7xcnqf32rj"
echo $category->getName(); // "New Name"
echo $category->getSlug(); // "new-name"

List categories

$list = $client->contactCategories()->list(perPage: 20);

foreach ($list->items() as $category) {
    echo $category->getName() . ' (' . $category->getSlug() . ')' . PHP_EOL;
}

Get a category

$category = $client->contactCategories()->get('01kvtr2b9ztdvggdbrcjmm45nj');

echo $category->getName(); // "Category Name"

Update a category

$category = $client->contactCategories()->update(
    id: '01kvtkq34gc5zqbxpyw90q4sk6',
    name: 'New Name',
    slug: 'new-name',
);

echo $category->getSlug(); // "new-name"

Delete a category

$result = $client->contactCategories()->delete('01kvtmr3evcs2brxp2vcztd102');

echo $result->getStatus(); // "success"

Email Topics

Create a topic

$topic = $client->emailTopics()->create(
    name: 'Product Updates',
    slug: 'product-updates',
    description: 'Emails for product updates',
    autoSubscribe: true,
    public: true,
    domainId: '01kvtsgfavkx5609jd3j2t6jr1', // optional
);

echo $topic->getId();   // "01kvtsgfb2rp5g1y3xdsrnk6n4"
echo $topic->getName(); // "Product Updates"

List topics

$list = $client->emailTopics()->list(perPage: 20);

foreach ($list->items() as $topic) {
    echo $topic->getName() . ' - ' . ($topic->getDescription() ?? '(no description)') . PHP_EOL;
}

Get a topic

$topic = $client->emailTopics()->get('01kvtsgf9e96nnj98nxsac751r');

echo $topic->getName(); // "Billing Notifications"
echo $topic->isAutoSubscribe(); // false

Update a topic

$topic = $client->emailTopics()->update(
    id: '01kvtsgfcbte0npnqkj8kep642',
    name: 'New Name',
    description: 'Updated description',
    public: true,
);

echo $topic->getName(); // "New Name"

Delete a topic

$result = $client->emailTopics()->delete('01kvtsgfdq4xw54vcvqw0ae68n');

echo $result->getStatus();  // "success"
echo $result->getMessage(); // "Email category deleted"

Error Handling

All API errors throw exceptions that extend LetMeSendEmail\Exceptions\ApiException:

HTTP Status Exception Description
400, 413, 422 ValidationError Request validation failed
401 AuthenticationError Invalid or missing API key
403 AuthorizationError Insufficient permissions
404 NotFoundError Resource not found
409 ConflictError Resource conflict
429 RateLimitError Rate limit exceeded
500+ ApiError Server error
NetworkError Connection failed
TimeoutError Request timed out
WebhookVerificationException Webhook verification failed
WebhookSigningException Webhook signing configuration error
use LetMeSendEmail\Exceptions\ValidationError;
use LetMeSendEmail\Exceptions\AuthenticationError;
use LetMeSendEmail\Exceptions\RateLimitError;
use LetMeSendEmail\Exceptions\ApiException;

try {
    $client->emails()->send(/* ... */);
} catch (ValidationError $e) {
    echo $e->getMessage();
    print_r($e->getValidationErrors()); // field-level errors
} catch (AuthenticationError $e) {
    echo 'Check your API key.';
} catch (RateLimitError $e) {
    echo 'Retry after ' . $e->getRetryAfter() . ' seconds.';
} catch (ApiException $e) {
    echo 'HTTP ' . $e->getHttpStatus() . ': ' . $e->getMessage();
}

Error exceptions provide:

  • getMessage() — human-readable error description.
  • getHttpStatus() — HTTP status code.
  • getApiCode() — API error code (e.g., domain_not_found, daily_quota_exceeded).
  • getValidationErrors() — field-level validation errors, if any.
  • getHeaders() — response headers.
  • getRequestId() — request ID for debugging, if present in headers.
  • getRawBody() — raw response body.

Pagination

List endpoints return a response with a PaginationInfo object:

$list = $client->emails()->list(perPage: 10);

// Items on this page
$emails = $list->items();

// Pagination metadata
$pag = $list->pagination();
$pag->hasMore();   // bool
$pag->getTotal();  // int
$pag->getPerPage(); // int
$pag->getFetched(); // int

Use cursor-based pagination:

// Next page: use the last item's ID
if ($list->pagination()->hasMore() && count($list->items()) > 0) {
    $items = $list->items();
    $nextList = $client->emails()->list(after: $items[count($items) - 1]->getId());
}

// Previous page: use the first item's ID (from a page other than the first)
if (count($list->items()) > 0) {
    $items = $list->items();
    $prevList = $client->emails()->list(before: $items[0]->getId());
}

Retries

The SDK can automatically retry idempotent requests on transient failures:

$config = new Configuration(
    apiKey: 'lms_live_...',
    retries: 3,
);

$client = new LetMeSendEmail(configuration: $config);

When retries happen

  • GET, HEAD, OPTIONS, DELETE requests are always retryable.
  • POST, PUT, PATCH requests are retryable only when an Idempotency-Key header is present.

What is retried

  • NetworkError and TimeoutError — connection or timeout failures.
  • HTTP 408, 429, 500, 502, 503, 504 — retryable server / rate-limit errors.

Backoff strategy

  • Rate-limit (429): Uses the Retry-After header (delta-seconds or HTTP-date), capped at 300 seconds.
  • Other retryable errors: Bounded exponential backoff with ±25% jitter starting at 100ms base delay. Each delay is capped at 300 seconds. Large attempt numbers are clamped to prevent overflow.

All delays are testable via the SleeperInterface (LetMeSendEmail\Http\SleeperInterface).

Webhooks

Webhook signature verification is built in:

use LetMeSendEmail\Support\WebhookSignature;
use LetMeSendEmail\Exceptions\WebhookVerificationException;
use LetMeSendEmail\Exceptions\WebhookSigningException;

$payload = file_get_contents('php://input');
if ($payload === false) {
    http_response_code(400);
    echo 'Failed to read request body.';
    exit;
}

$headers = getallheaders();
if (!is_array($headers)) {
    $headers = [];
}

$secret = getenv('LETMESENDEMAIL_WEBHOOK_SECRET');
if ($secret === false || $secret === '') {
    http_response_code(500);
    echo 'Webhook secret is not configured.';
    exit;
}

try {
    $event = WebhookSignature::verify(
        payload: $payload,
        headers: $headers,
        secret: $secret,
        tolerance: 300,
    );

    // $event contains the verified payload as an associative array
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    echo 'Webhook verification failed: ' . $e->getMessage();
} catch (WebhookSigningException $e) {
    http_response_code(500);
    echo 'Webhook configuration error: ' . $e->getMessage();
}

The verifier reads four headers (webhook-id, webhook-log-id, webhook-timestamp, webhook-signature), validates the timestamp against a configurable tolerance (default 300 seconds), and checks the HMAC-SHA256 signature against the decoded secret.

The signing secret may be prefixed with whsec_. The prefix is stripped automatically before decoding.

The webhook-signature header supports multiple space-separated versioned signatures. Only v1 signatures are accepted; unknown versions are ignored.

Testing

composer install
vendor/bin/pest

Version Support

PHP Version Supported
8.1 Yes
8.2 Yes
8.3 Yes
8.4 Yes
8.5 Yes

Changelog

See CHANGELOG.md.