Search by

avelto / avelto-php

Joshua03208

The official PHP SDK for Avelto, the email API for developers who want it to just work.

v0.2.0 2026-10-09 19:42 UTC

This package is auto-updated.

Last update: 2026-10-09 19:47:30 UTC


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-curl and ext-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> - delivered
  • bounced@<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