Search by

tratto / email

Tratto.Email

Official PHP SDK for the Tratto email API

v1.1.0 2026-10-21 11:03 UTC

This package is auto-updated.

Last update: 2026-10-21 11:03:38 UTC


README

Official PHP SDK for Tratto, an email API hosted in the EU: transactional email, campaigns and automation flows from one REST API. Every provider is listed on the sub-processors page.

License: MIT

Links: Documentation · API reference · Node.js SDK · Python SDK · Changelog

Installation

composer require tratto/email

Requires PHP 8.1 or later with the curl extension. There are no other runtime dependencies.

Quick start

use Tratto\Client;

$tratto = new Client((string) getenv('TRATTO_API_KEY'));

$email = $tratto->emails->send([
    'from' => 'Acme <hello@mail.acme.com>',
    'to' => 'user@example.com',
    'subject' => 'Welcome!',
    'html' => '<p>Thanks for signing up.</p>',
]);

echo $email['id'];

Or write the email in emailmd markdown: the API renders it into responsive, email-safe HTML (plus a text part). markdown and html are mutually exclusive.

$tratto->emails->send([
    'from' => 'Acme <hello@mail.acme.com>',
    'to' => 'user@example.com',
    'subject' => 'Welcome!',
    'markdown' => "# Welcome, {{firstName}}\n\nGlad to have you on board.",
    'variables' => ['firstName' => 'Alice'],
]);

Keep the key out of your code: read it from the environment or your secret store. API keys are created in the dashboard, under API Keys.

Setup

$tratto = new Tratto\Client($apiKey, [
    'baseUrl' => 'https://api.tratto.email', // default
    'timeout' => 30,                         // seconds, default 30 (connection: at most 10)
]);
Option Type Description
baseUrl string http:// or https:// URL, without /v1 (the SDK adds it). With http:// the key travels in clear text: use it only for a local server.
timeout int|float Total time per request, in seconds.
transport Tratto\Http\Transport Replaces the built-in curl transport, for example with a fake in your tests. See Custom transport.

An empty key, an unknown option or an invalid value throws \InvalidArgumentException. There is no option to pass curl settings: TLS verification is always on and redirects are never followed.

Responses

Methods return the decoded JSON as associative arrays, so a field added by the API is available without updating the SDK.

  • Single objects come without the data envelope: $tratto->emails->get($id)['status'].
  • Paginated lists return the whole page, ['data' => [...], 'pagination' => ['hasMore' => bool, 'nextCursor' => ?string]]: pass nextCursor as after to read the next page.
  • Methods that answer 204 (webhooks->delete, templates->delete) return nothing.
  • Dates you pass (\DateTimeInterface, in parameters or query strings) are sent in UTC with milliseconds; your object is not modified.
  • Ids are URL-encoded, so an id can never change the route.

Test mode

Every workspace can create test keys (tratto_test_...) next to live ones. A test key runs the same pipeline (statuses, timeline, webhooks) but nothing is delivered: no domain verification needed, no monthly quota used. Test sends have their own limit of 100 emails per day.

Recipient Outcome
any address delivered
bounced@simulator.tratto.email permanent bounce, the email ends bounced
soft-bounced@simulator.tratto.email transient bounce
complained@simulator.tratto.email spam complaint

Email responses and webhook payloads carry livemode: false. A test key sees only test data. Calls that reach real recipients are rejected for test keys with 403 TEST_MODE_NOT_SUPPORTED: campaign send, campaign test send, template test send and flow activation.

Resources

Emails

// Idempotency: retrying with the same key never sends twice.
$email = $tratto->emails->send([
    'from' => 'hello@mail.acme.com',
    'to' => ['alice@example.com', 'bob@example.com'],
    'subject' => 'Your receipt',
    'templateId' => 'tmpl_abc123',
    'variables' => ['name' => 'Alice'],
    'scheduledAt' => new DateTimeImmutable('+1 hour'),
], 'receipt-order-1234');

$page = $tratto->emails->list(['status' => 'delivered', 'limit' => 20]);
$detail = $tratto->emails->get('email_abc123');
$events = $tratto->emails->listEvents('email_abc123');

emails->send and emails->sendBatch take an idempotency key. A key containing a line break is rejected before sending.

Batch

emails->sendBatch() sends 1 to 100 emails in one request (requires API 0.22.0). Each email takes the fields of send() except scheduledAt and attachments: the API rejects the batch with a 422 whose details name the email, for example /emails/3/to. One idempotency key covers the whole batch.

$emails = [
    ['from' => 'hello@mail.acme.com', 'to' => ['alice@example.com'], 'subject' => 'Hi Alice', 'html' => '<p>Hi</p>'],
    ['from' => 'hello@mail.acme.com', 'to' => ['bob@example.com'], 'subject' => 'Hi Bob', 'html' => '<p>Hi</p>'],
];
$batch = $tratto->emails->sendBatch($emails, 'newsletter-2026-10-20');

// $batch: ['batchId' => 'batch_...', 'livemode' => true, 'accepted' => 2, 'failed' => 0,
//          'emails' => [['id' => 'email_...', 'status' => 'queued', 'to' => [...]], ...]]

The response always has status 202, even when some emails failed: check failed. Results are in the order of the request, and a failed email has no id but an error with code and message. Resend only the failed ones, in a new batch with a new key (the same key would replay the same response):

$failed = [];
foreach ($batch['emails'] as $i => $result) {
    if ($result['status'] === 'failed') {
        $failed[] = $emails[$i];
    }
}
if ($failed !== []) {
    $tratto->emails->sendBatch($failed, 'newsletter-2026-10-20-retry-1');
}

After a 503 or a 500 nothing was sent and the key is released: retry the same batch with the same key.

Contacts

$contact = $tratto->contacts->create(['email' => 'alice@example.com', 'firstName' => 'Alice', 'tags' => ['vip']]);
$tratto->contacts->update($contact['id'], ['status' => 'unsubscribed']);

// q is a case-insensitive prefix search on the email: compare the address on the results.
// It combines with status only.
$page = $tratto->contacts->list(['q' => 'ann@example.com']);

// CSV import: pass the content, not a path. It is sent as text/csv and validated by the API.
$job = $tratto->contacts->importCsv(file_get_contents('contacts.csv'));
$status = $tratto->contacts->getImportJob($job['jobId']);

Audiences

$audience = $tratto->audiences->create([
    'name' => 'Power users',
    'rules' => [['field' => 'tags', 'operator' => 'array_contains', 'value' => 'vip']],
]);
$tratto->audiences->list(['limit' => 20]);
$tratto->audiences->get($audience['id']);
$result = $tratto->audiences->addContacts($audience['id'], ['cont_1', 'cont_2']); // 1 to 500 ids

Campaigns

$campaign = $tratto->campaigns->create([
    'name' => 'June newsletter',
    'templateId' => 'tmpl_abc123',
    'audienceId' => 'aud_abc123',
    'fromName' => 'Acme',
    'fromEmail' => 'news@mail.acme.com',
    'subjectA' => 'Our June update',
]);

$tratto->campaigns->testSend($campaign['id'], 'me@example.com');
$tratto->campaigns->send($campaign['id']);                                                   // now
$tratto->campaigns->send($campaign['id'], ['scheduledAt' => new DateTimeImmutable('2027-03-01 09:00', new DateTimeZone('Europe/Rome'))]);

$tratto->campaigns->unschedule($campaign['id']); // back to draft; 409 once the send has started
$tratto->campaigns->pause($campaign['id']);      // stops a send in progress
$stats = $tratto->campaigns->getStats($campaign['id']);
$tratto->campaigns->list(['status' => 'scheduled']);
$tratto->campaigns->get($campaign['id']);

Templates

// With "markdown" the SDK adds format "emailmd" for you.
$template = $tratto->templates->create(['name' => 'Welcome', 'markdown' => '# Hi {{name}}']);
$tratto->templates->update($template['id'], ['markdown' => '# Hello {{name}}']); // a template keeps its format: markdown stays markdown

$versions = $tratto->templates->listVersions($template['id']);
$v2 = $tratto->templates->getVersion($template['id'], 2);

$tratto->templates->testSend($template['id'], 'me@example.com', ['name' => 'Alice']);
$tratto->templates->list(['status' => 'published']);
$tratto->templates->get($template['id']);
$tratto->templates->delete($template['id']);

Webhooks

$webhook = $tratto->webhooks->create([
    'url' => 'https://my-app.example.com/webhooks/tratto',
    'events' => ['delivered', 'bounced', 'complained'],
]);
// $webhook['secret'] is returned only now: store it in your secret store, never in code or logs.

$tratto->webhooks->list();
$tratto->webhooks->listDeliveries($webhook['id'], ['limit' => 20]);
$tratto->webhooks->test($webhook['id']);
$rotated = $tratto->webhooks->rotateSecret($webhook['id']);
$tratto->webhooks->delete($webhook['id']);

Domains

$domain = $tratto->domains->add('mail.acme.com');   // includes the DNS records to publish
$tratto->domains->verify($domain['id']);
$tratto->domains->list();
$tratto->domains->get($domain['id']);
$deleted = $tratto->domains->delete($domain['id']); // ['id' => ..., 'deletedAt' => ...]

Analytics

$summary = $tratto->analytics->getSummary('30d');
$points = $tratto->analytics->getTimeseries('7d');

Periods: 7d, 30d (default), 90d, 180d, 1y. 180d and 1y hold live data only and are rejected for test keys.

Flows

$flow = $tratto->flows->create(['name' => 'Welcome series']);
$tratto->flows->update($flow['id'], [
    'trigger' => ['type' => 'contact_tag_added', 'config' => ['tagName' => 'signed-up']],
    'steps' => [
        ['id' => 'step_1', 'type' => 'send_email', 'config' => ['templateId' => 'tmpl_welcome']],
        ['id' => 'step_2', 'type' => 'wait', 'config' => ['delay' => '2', 'unit' => 'days']],
    ],
]);
$tratto->flows->activate($flow['id']);
$tratto->flows->deactivate($flow['id']);
$tratto->flows->list();
$tratto->flows->get($flow['id']);
$tratto->flows->delete($flow['id']); // ['id' => ...]

Config values are strings, and an unknown config key is ignored without an error: a trigger without tagName enrolls contacts on any tag change, a wait without delay and unit waits 5 minutes. The accepted keys for each trigger and step type are declared on Flows::update(), so PHPStan checks them.

Flows need an API key with the * permission. There are no flows:read or flows:write scopes. Create a key for the job and revoke it when you are done. activate is rejected for test keys.

Workspace

$workspace = $tratto->workspace->get();
$tratto->workspace->update(['name' => 'Acme Corp', 'timezone' => 'Europe/Rome']);
$tratto->workspace->updatePreferences(['locale' => 'en']);

Members are invited, changed and removed from the dashboard: this SDK has no member methods.

Errors

Every failed API call throws Tratto\TrattoException: HTTP errors, responses that are not JSON, and network errors (errorCode connection_error, statusCode 0). Configuration mistakes throw \InvalidArgumentException.

use Tratto\TrattoException;

try {
    $tratto->emails->send($params);
} catch (TrattoException $e) {
    error_log(sprintf('[%d] %s: %s', $e->statusCode, $e->errorCode, $e->getMessage()));
    // $e->suggestion: what to do about it, when the API says
    // $e->docs: a link to the documentation of this error, when available
}
Property Type
getMessage() string The API message, or HTTP <status> when the response has none
errorCode string The API error code, unknown_error, invalid_response, invalid_header or connection_error
statusCode int The HTTP status (also getCode()), 0 when no response was received
suggestion ?string
docs ?string
details ?array The details of the API error as they are, for example a 422's list of ['path' => ..., 'message' => ...]; null when absent

The SDK does not retry. To retry a send safely, pass the same idempotency key. A sendBatch where only some emails failed is not an exception: it returns the 202 response with failed greater than 0 (see Batch).

PHP 8.1: do not log full exception traces

On PHP 8.2 and later the key and the webhook secret are marked #[\SensitiveParameter] and never appear in the SDK's own traces. PHP 8.1 ignores that attribute: the SDK moves them out of its parameters before doing anything else, but an argument of the wrong type (for example an array where a string is expected) raises a TypeError before that, and the trace of that error holds the arguments. On PHP 8.1, log the message, errorCode and statusCode of SDK exceptions, not getTraceAsString() or a dump of the exception, or set zend.exception_ignore_args=On.

Webhook verification

Each delivery carries X-Tratto-Signature: t=<unix ms>,v1=<hex>. Tratto\Webhook::verify() checks the HMAC-SHA256 of {t}.{raw body} with your secret (whsec_..., used as it is), accepts any of several v1 values (secret rotation), and rejects deliveries older or newer than the tolerance (default 5 minutes). It returns bool and never throws for a bad signature.

Always pass the raw body: a decoded and re-encoded JSON will not match.

use Tratto\Webhook;

$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_TRATTO_SIGNATURE'] ?? null;

if (!Webhook::verify($payload, $header, (string) getenv('TRATTO_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

$event = json_decode($payload, true);
// Deliveries can repeat: use $event['id'] to process each event once.

With Laravel (route in routes/api.php, which has no CSRF middleware; on Laravel 11 or later create it with php artisan install:api):

public function __invoke(Illuminate\Http\Request $request)
{
    $signature = $request->header('X-Tratto-Signature');
    if (!Webhook::verify($request->getContent(), is_string($signature) ? $signature : null, (string) config('services.tratto.webhook_secret'))) {
        return response('', 400);
    }
    // ...
}

The tolerance is the fourth argument, in seconds: Webhook::verify($payload, $header, $secret, 600).

Notes

  • Empty objects. In PHP [] is both an empty list and an empty object. The SDK sends the bodies it builds correctly (templates->testSend always sends variables as an object, {} when empty), but in the arrays you pass, an empty nested array goes out as a JSON array. For fields that are objects, such as variables or headers in emails->send, omit the field or pass new \stdClass().
  • Custom transport. A transport must implement Tratto\Http\Transport and throw only Tratto\TrattoException (with errorCode connection_error when no response was received). The SDK re-throws those without their trace. Any other exception leaves your transport as it is, and its trace includes the request headers, API key included.
  • Dates. A \DateTimeInterface is sent in UTC; a string is sent as it is.

Examples

See examples/: one file per resource, a plain PHP webhook endpoint, and Laravel and Symfony integrations. Every example is analysed by PHPStan and PHP-CS-Fixer in CI, so a renamed method breaks the build instead of your integration. The key always comes from the environment:

TRATTO_API_KEY=tratto_test_... php examples/send-email.php
File
send-email.php HTML, markdown, template, idempotency key, timeline
contacts.php Create, update, search, CSV import
audiences.php Rule-based audiences, adding contacts
campaign.php Create, test, schedule, unschedule, pause
templates.php Markdown templates, versions, test send
domains.php Add a domain, DNS records, verify
webhooks.php Register, test, delivery history, rotate the secret
webhook-endpoint.php Receive and verify webhooks in plain PHP
flows.php Automation flows (key with the * permission)
analytics.php Summary and timeseries
workspace.php Workspace settings and preferences
laravel/ Service provider binding Tratto\Client, webhook controller
symfony/ Service definition, a service using the client, webhook controller

Development

composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse
vendor/bin/php-cs-fixer check

scripts/staging-smoke.php runs one real round trip against the staging API with a test key; it refuses live keys and the production URL. See the comment at the top of the script.

License

MIT