avelto / avelto-php
The official PHP SDK for Avelto, the email API for developers who want it to just work.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The official PHP SDK for Avelto, the email API for developers who want it to just work.
- PHP 8.1+
- Typed: every response is a plain associative array, with PHPStan array shapes describing its keys
- No dependencies beyond
ext-curlandext-json
Install
composer require avelto/avelto-php
Quickstart
<?php require __DIR__ . '/vendor/autoload.php'; $avelto = new Avelto\Client(getenv('AVELTO_API_KEY')); $email = $avelto->emails->send([ 'from' => 'Acme <hello@mail.acme.com>', 'to' => 'jane@example.com', 'subject' => 'Welcome to Acme', 'html' => '<p>Thanks for signing up.</p>', 'text' => 'Thanks for signing up.', ]); echo $email['id']; // "5b3d…"
Requests are arrays with the API's own snake_case keys. to, cc and bcc take one address or a list. Addresses can be a@b.com or Name <a@b.com>. Provide html, text, or both. You can also pass reply_to, headers, tags (up to 10), attachments (arrays with filename and base64 content or an http(s) url, up to 10 files and 7 MB in total), scheduled_at and unsubscribe_url. To send a stored template instead of a body, pass template_id or template_slug with variables and leave subject, html and text unset.
Every method returns the API's JSON as an associative array, exactly as the reference documents it, so $email['id']. A method whose route answers 204 (every delete) returns nothing.
Options
$avelto = new Avelto\Client($apiKey, [ 'base_url' => 'https://api.avelto.dev', // also read from AVELTO_BASE_URL 'timeout' => 30.0, // per request, seconds 'max_attempts' => 3, // the retry defaults 'base_delay' => 0.3, 'max_delay' => 5.0, ]);
An unknown option is an InvalidArgumentException, so a misspelt key does not go unnoticed. The client keeps one connection open and reuses it between calls, so create it once and share it.
To send requests through something other than curl, pass 'transport' => $yours, any object implementing Avelto\Http\Transport: one method, send(Request $request): Response, which throws Avelto\Http\TransportException when no response arrives.
Idempotency
Every emails->send carries an Idempotency-Key header: a fresh random UUID per call by default, so the SDK's own retries can never create two emails. Pass your own key (an order id, say) to make retries at your level safe too. A replay returns the id of the email that was already created and does not send again.
$email = $avelto->emails->send( ['from' => 'hello@mail.acme.com', 'to' => 'jane@example.com', 'subject' => 'Receipt #1042', 'text' => '…'], ['idempotency_key' => 'receipt-1042'], );
Retries
The SDK makes up to three attempts at a failed request (the call plus two retries) with exponential backoff and jitter; a 429 waits for the server's Retry-After instead. 429, 502 and 503 are retried for every request. Network errors, timeouts and 504 (where the request may have been processed) are retried only for GET, DELETE, emails->send, emails->sendBatch and emails->validate, never for the other POSTs or for PATCH. Pass 'max_attempts' => 1 to turn retries off. After the last attempt the AveltoException is thrown; on 429 it carries getRetryAfterSeconds().
The waits happen inside the call, so a request that is being retried blocks for up to a few seconds (longer when the server asks for it with Retry-After). Inside a web request that must answer quickly, lower max_attempts or max_delay.
Scheduled send and cancel
scheduled_at is an ISO 8601 timestamp, in the future and at most 30 days ahead. A scheduled email can be cancelled until it is sent.
$email = $avelto->emails->send([ 'from' => 'hello@mail.acme.com', 'to' => 'jane@example.com', 'subject' => 'Your trial ends tomorrow', 'text' => '…', 'scheduled_at' => (new DateTimeImmutable('+1 day'))->format(DATE_ATOM), ]); $cancelled = $avelto->emails->cancel($email['id']); // $cancelled['status'] === 'cancelled'
Cancelling an email that is no longer scheduled throws an AveltoException with code not_scheduled.
Validate an address
Ask whether an address is worth a send before you spend one on it, for example on every submit of a signup form. Nothing is sent or stored.
$check = $avelto->emails->validate('jane@gmial.com'); $check['result']; // "deliverable" | "risky" | "undeliverable" $check['reason']; // null | "invalid_syntax" | "suppressed" | "no_mail_server" | "disposable" | "possible_typo" | "role_address" $check['suggestion']; // "jane@gmail.com" $check['normalized']; // the lower-cased address to store, or null when the syntax is invalid $check['checks']; // ['syntax', 'mail_server', 'disposable', 'role', 'free_provider', 'suppressed']
undeliverable means a send would fail or be refused: bad syntax, a domain with no mail server, or an address on your own suppression list. risky means it may well deliver but deserves a look: a throwaway domain, a role address such as info@, or a domain within a typo of a well-known one, in which case suggestion is the corrected address to offer the user. It cannot tell you whether the mailbox exists; nobody can without sending.
Batch send
emails->sendBatch sends up to 100 messages in one call. Each message is an array with the same keys as send. Each is accepted or refused on its own, so read results rather than assuming the call either worked or did not; results[$i] lines up with the $ith message.
$batch = $avelto->emails->sendBatch([ ['from' => 'billing@mail.acme.com', 'to' => 'jane@example.com', 'subject' => 'Receipt #1042', 'text' => '…'], ['from' => 'billing@mail.acme.com', 'to' => 'sam@example.com', 'subject' => 'Receipt #1043', 'text' => '…'], ], ['idempotency_key' => 'receipts-2026-09-24']); foreach ($batch['results'] as $r) { if ($r['ok']) { echo $r['index'], ' ', $r['id'], "\n"; } else { echo $r['index'], ' ', $r['error']['code'], ' ', $r['error']['message'], "\n"; } }
The request body form, ['messages' => [...]], is accepted too.
Fetch and list emails
$email = $avelto->emails->get($emailId); echo $email['status']; // "queued" | "scheduled" | "sent" | "delivered" | "bounced" | "complained" | "failed" | "cancelled" print_r(array_column($email['events'], 'type')); // ["email.queued", "email.sent", "email.delivered"]
Lists are newest first and cursor-paginated. Filter by status, tag, mode and q, a substring search over recipient, subject and message id. A live key defaults to live and may ask for test; a test key only ever sees test emails, whatever mode says, and a live email is a 404 to it.
$cursor = null; do { $page = $avelto->emails->list(['status' => 'bounced', 'tag' => 'onboarding', 'limit' => 100, 'cursor' => $cursor]); foreach ($page['data'] as $e) { echo $e['id'], ' ', implode(', ', $e['to']), ' ', $e['subject'], "\n"; } $cursor = $page['next_cursor']; } while ($cursor !== null);
A query value of null is left out of the request, so the first page can pass 'cursor' => null.
Domains
Add a domain, publish the DNS records it returns, then poll get until it is verified. domains->get re-checks verification on every call.
$domain = $avelto->domains->create(['name' => 'mail.acme.com']); foreach ($domain['dns_records'] as $r) { echo "{$r['type']}\t{$r['name']}\t{$r['value']}\t({$r['purpose']})\n"; } // Publish the records, then: $status = $domain['status']; while ($status === 'pending') { sleep(30); $status = $avelto->domains->get($domain['id'])['status']; } echo $status; // "verified" (or "failed") $avelto->domains->list(); $avelto->domains->delete($domain['id']);
Templates
$tpl = $avelto->templates->create(['name' => 'Welcome', 'subject' => 'Hi {{name}}', 'html' => '<p>Welcome, {{name}}.</p>']); $avelto->emails->send([ 'from' => 'hello@mail.acme.com', 'to' => 'jane@example.com', 'template_slug' => $tpl['slug'], 'variables' => ['name' => 'Jane'], ]); $avelto->templates->update($tpl['id'], ['subject' => 'Hello {{name}}']); // saves a new version $avelto->templates->update($tpl['id'], ['text' => null]); // null clears a body part; leaving the key out keeps it $versions = $avelto->templates->versions($tpl['id'])['data']; $avelto->templates->restore($tpl['id'], end($versions)['version']);
Only the keys present in the array are sent, so update changes exactly what you name.
Webhooks
Create an endpoint. The signing secret is returned once, on creation. Store it.
$endpoint = $avelto->webhooks->create([ 'url' => 'https://acme.com/hooks/avelto', 'events' => ['email.delivered', 'email.bounced', 'email.complained'], // optional; defaults to all eight ]); echo $endpoint['secret'];
Every delivery is a JSON POST with an Avelto-Signature header of the form t=<unix seconds>,v1=<hex>. Verify it against the raw body before you trust the payload. Use the event id to de-duplicate.
Plain PHP
<?php use Avelto\Webhook; require __DIR__ . '/vendor/autoload.php'; $body = file_get_contents('php://input'); // the raw body, before any parsing $header = $_SERVER['HTTP_AVELTO_SIGNATURE'] ?? null; if (!Webhook::verifySignature(getenv('AVELTO_WEBHOOK_SECRET'), $body, $header)) { http_response_code(400); exit('invalid signature'); } $event = json_decode($body, true); if ($event['type'] === 'email.bounced') { error_log('bounced ' . $event['data']['email_id'] . ' ' . json_encode($event['data']['details'])); } http_response_code(200);
Laravel
In routes/api.php, which has no CSRF middleware:
use Avelto\Webhook; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; Route::post('/hooks/avelto', function (Request $request) { $body = $request->getContent(); // the raw body, before any parsing if (!Webhook::verifySignature(config('services.avelto.webhook_secret'), $body, $request->header(Webhook::SIGNATURE_HEADER))) { abort(400, 'invalid signature'); } $event = json_decode($body, true); logger()->info('avelto webhook', ['type' => $event['type'], 'email_id' => $event['data']['email_id']]); return response('', 200); });
Signatures older than five minutes are rejected; pass a fourth argument, $toleranceSeconds, to change that.
Deliveries
$endpoints = $avelto->webhooks->list()['data']; $endpoint = $avelto->webhooks->get($endpointId); $page = $avelto->webhooks->listDeliveries($endpointId, ['limit' => 50]); foreach ($page['data'] as $d) { if ($d['status'] === 'failed') { $avelto->webhooks->retryDelivery($endpointId, $d['id']); break; } } // Change which events it receives, or switch it off and on. The URL cannot change. $avelto->webhooks->update($endpointId, ['events' => ['email.bounced', 'email.complained'], 'enabled' => true]); $avelto->webhooks->delete($endpointId);
Suppressions
Addresses that hard-bounce or complain are suppressed automatically and sends to them are rejected. You can manage the list yourself.
$page = $avelto->suppressions->list(['limit' => 100]); foreach ($page['data'] as $s) { echo $s['email_address'], ' ', $s['reason'], "\n"; // "bounce" | "complaint" | "manual" } $avelto->suppressions->create(['email_address' => 'unsubscribed@example.com']); $avelto->suppressions->delete('unsubscribed@example.com');
Account
account->get() returns the key's mode and scopes, the sandbox domain with every recipient a sandbox send may reach, and the domains, webhook endpoints and templates that exist. Read it first when wiring up a new project.
With the account:settings scope, read and change the account's name and its alert settings. Changing them needs a live key. A new notify_email only receives alerts once it confirms by the link sent to it; 'notify_email' => null sends them back to the owner.
$settings = $avelto->account->getSettings(); $avelto->account->updateSettings(['name' => 'Acme', 'alerts' => ['notify_email' => 'ops@acme.com', 'bounce_rate' => true]]);
API keys
With the keys:manage scope (part of a "Key for an AI agent", which only the account owner can create), a key can list keys, create narrower ones and revoke the ones it made. A created key's scopes must be a subset of the caller's and never include account:settings or keys:manage; a test key can only create test keys.
$created = $avelto->apiKeys->create(['name' => 'Production server', 'mode' => 'live', 'scopes' => ['emails:send']]); // $created['key'] is shown only here: put it in the application's environment. $keys = $avelto->apiKeys->list()['data']; $avelto->apiKeys->revoke($created['id']);
Error handling
Every failed request throws Avelto\AveltoException, a RuntimeException, with the HTTP status (getStatus()), the API code (getErrorCode()), the message (getMessage()), optional details (getDetails()), the request id the API answered with (getRequestId()), and on a 429 the wait it asked for (getRetryAfterSeconds()). A request that got no response at all has status 0 and code network_error, with the transport's exception as getPrevious(); isNetworkError() is true for exactly those. getCode() returns the HTTP status too, since PHP requires it to be an integer.
use Avelto\AveltoException; try { $avelto->emails->send(['from' => 'hello@mail.acme.com', 'to' => 'jane@example.com', 'subject' => 'Hi', 'text' => 'Hi']); } catch (AveltoException $err) { switch (true) { case $err->getErrorCode() === 'domain_not_verified': // 403: verify mail.acme.com first break; case $err->getErrorCode() === 'sandbox_recipient_not_allowed': // 403: the sandbox sender only delivers to your account's owner email break; case $err->getErrorCode() === 'recipient_suppressed': // 422: address is on your suppression list break; case $err->getErrorCode() === 'validation_error': // 400: getDetails() lists the failing fields break; case $err->isNetworkError(): // status 0: no response (DNS, TLS, timeout) break; default: error_log(sprintf('%d %s %s', $err->getStatus(), $err->getErrorCode(), $err->getMessage())); } }
| Code | Status | When |
|---|---|---|
validation_error |
400 | Request body or query failed validation. getDetails() lists the issues. |
unauthorized |
401 | Missing or invalid API key. |
forbidden |
403 | Key is not allowed to do this. |
live_key_required |
403 | A test key on a call that needs a live one, such as creating a webhook endpoint or changing a template. |
not_found |
404 | No such email, domain, webhook or suppression. |
conflict |
409 | Resource already exists (for example the domain). |
domain_unavailable |
409 | The domain cannot be added: it is part of a domain on another account, is reserved, or is already set up elsewhere. |
not_scheduled |
409 | emails->cancel on an email that is not scheduled. |
sandbox_recipient_not_allowed |
403 | Sending from the sandbox domain to anyone but your account's owner email. |
domain_not_verified |
403 | from uses a domain that is not added and verified on your account. |
recipient_suppressed |
422 | A recipient is on your suppression list. |
plan_limit |
429 / 403 | Monthly or first-week sending cap reached (429), or the plan does not allow another domain (403). |
account_paused |
403 | Sending is paused on the account. Check the dashboard. |
account_suspended |
403 | The account is suspended; the message says why. Nothing sends until it is lifted. |
rate_limited |
429 | Too many requests for this key; honour getRetryAfterSeconds(). |
internal_error |
5xx | Something went wrong on our side. |
network_error |
0 | The request never got a response. getPrevious() holds the underlying exception. |
Test mode
Keys starting with av_test_ never send real email. They run the full pipeline and emit the same events and webhooks, so you can build against them without touching an inbox.
Use these recipients on your sandbox domain to simulate outcomes, in both live and test mode:
delivered@<your sandbox domain>- deliveredbounced@<your sandbox domain>- hard bounce (never added to suppressions)complained@<your sandbox domain>- complaint (never added to suppressions)
Your sandbox domain is shown in the dashboard. Sending from it to any other address only works for your account's owner email; verify a domain to send to anyone.
Types
Every request and response shape is a PHPStan array shape declared on the resource classes in Avelto\Resources (Emails, Domains, Webhooks, Templates, Suppressions, Account), so PHPStan, Psalm and editors that read them know the keys.
License
MIT