hampel/sparkpost

A PHP client for the SparkPost API - transmissions, message events and suppression - over any PSR-18 HTTP client

Maintainers

Package info

github.com/hampel/sparkpost

pkg:composer/hampel/sparkpost

Transparency log

Statistics

Installs: 65

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.0 2026-08-22 09:25 UTC

This package is auto-updated.

Last update: 2026-08-22 09:27:49 UTC


README

Tests Latest Version on Packagist Total Downloads Open Issues License

By Simon Hampel

A PHP client for the SparkPost API, built on PSR-18 so it works with whatever HTTP client your application already has.

Installation

composer require hampel/sparkpost

You also need a PSR-18 client and a PSR-17 factory. Guzzle provides both, 7 or 8:

composer require guzzlehttp/guzzle

Usage

use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Hampel\SparkPost\Config;
use Hampel\SparkPost\SparkPost;
use Hampel\SparkPost\Transmission\Transmission;

$guzzle  = new Client();
$factory = new HttpFactory();   // PSR-17, fills both the request and stream roles

$sparkpost = new SparkPost(new Config('MY-API-KEY'), $guzzle, $factory, $factory);

$result = $sparkpost->transmissions()->send(
    Transmission::make()
        ->from('webmaster@example.com', 'Webmaster')
        ->subject('Hello')
        ->text('Hello from SparkPost.')
        ->to('me@example.com', 'Me')
);

For the EU tenancy, or any other region:

$sparkpost = new SparkPost(Config::forRegion('MY-API-KEY', 'eu'), $guzzle, $factory, $factory);

A PSR-3 logger is optional and takes a fifth argument. Requests are logged at debug, failures at error; attachment payloads are truncated before they reach the log.

Building a transmission

send() takes a Transmission, or a plain array if you would rather build the payload yourself.

use Hampel\SparkPost\Transmission\Attachment;
use Hampel\SparkPost\Transmission\Transmission;

$transmission = Transmission::make()
    ->from('webmaster@example.com', 'Webmaster')
    ->subject('Your invoice')
    ->html('<p>Attached. Also see <img src="cid:logo"></p>')
    ->text('Attached.')
    ->to('alice@example.com', 'Alice')
    ->cc('accounts@example.com', 'Accounts')
    ->bcc('archive@example.com')
    ->replyTo('billing@example.com')
    ->header('X-Campaign', 'invoices')
    ->attach(Attachment::fromPath('/tmp/invoice.pdf', 'invoice.pdf', 'application/pdf'))
    ->attach(Attachment::inline('logo', 'image/png', $logoBytes))
    ->transactional()
    ->openTracking(false)
    ->campaignId('invoices')
    ->metadata(['user_id' => 7])
    ->substitutionData(['first_name' => 'Alice']);

Most of that is obvious. These parts are not, and are the reason the builder exists:

  • SparkPost sends one message per recipient, so without a header_to every recipient sees a To: line containing only themselves. The builder sets it on every recipient from your to() list, which is what reproduces ordinary mail.
  • Cc is made visible by a CC header, not by the recipient list — the recipients are how the mail is addressed, the header is how it is displayed. Bcc gets no header, which is what makes it blind.
  • A dozen headers are rejected if you pass them in content.headers, because SparkPost derives them from the transmission itself. header() drops those rather than letting the API reject the whole send.
  • false survives. An option set to false is sent as false, not dropped as empty — openTracking(false) means "do not track opens", not "use the account default".
  • Mail from @sparkpostbox.com switches the sandbox option on by itself, because that domain silently fails without it. sandbox(false) overrides.

Stored templates and A/B tests replace the content entirely:

Transmission::make()->to('alice@example.com')->template('welcome');
Transmission::make()->to('alice@example.com')->abTest('subject-line');

Message events

use Hampel\SparkPost\MessageEvent\EventQuery;
use Hampel\SparkPost\MessageEvent\EventType;

$query = EventQuery::make()
    ->events(EventType::Bounce, EventType::SpamComplaint, EventType::ListUnsubscribe)
    ->from(new DateTimeImmutable('-1 day'))
    ->to(new DateTimeImmutable())
    ->perPage(100);

foreach ($sparkpost->messageEvents()->each($query) as $event) {
    // one event at a time, across as many pages as it takes
}

each() is lazy — a page is only fetched once the previous one has been consumed, so stopping early stops making requests. from and to are converted to UTC, which is what the API assumes when no timezone is given.

Picking the work up again later

There are usually more events than one request has time to collect, so the position in a search is a cursor that casts to a string. Store it wherever a string can go, and resume in the next run:

$page = $sparkpost->messageEvents()->search($query);

process($page->results);

if ($page->hasMore()) {
    $job->data['cursor'] = (string) $page->next();   // and stop here
}
// ... a request, a job, or an hour later
$page = $sparkpost->messageEvents()->next(EventCursor::fromString($job->data['cursor']));

A page reports $page->totalCount, counts, and iterates. Events themselves are plain arrays: their shape varies a great deal by event type, and pinning it down would mean either a type that hides most of the payload or twenty of them.

Bounce classes

SparkPost reports why a message bounced as a numeric class. What to do about each one is your policy, but the codes and what they mean are SparkPost's:

use Hampel\SparkPost\MessageEvent\BounceClass;

// note the cast: SparkPost sends bounce_class as a string, and may add codes later
$class = BounceClass::tryFrom((int) ($event['bounce_class'] ?? 0));

$class?->classification();                 // Hard, Soft, Block, Admin, Undetermined
$class?->classification()->isPermanent();  // whether to stop sending to this address
$class?->slug();                           // 'invalid_recipient'

Suppression

Two questions, and the API answers both awkwardly enough to be worth wrapping.

$suppression = $sparkpost->suppression();

if ($suppression->isSuppressed('someone@example.com')) {
    // SparkPost is dropping mail to this address
}

$entry = $suppression->find('someone@example.com');

$entry?->source;        // 'Bounce Rule', 'Spam Complaint', 'Manually Added', ...
$entry?->description;   // the remote server's own words: "550-5.1.1 The email account ..."
$entry?->created;       // DateTimeImmutable
$entry?->transactional;

find() returns null for an address that is not suppressed, which is worth stating because the API does not: SparkPost answers 404, so "this address is fine" arrives as an error. Only the 404 is translated — a 401 from a bad key is still thrown, rather than quietly reporting every address as clear.

Removing an entry lets SparkPost attempt delivery again:

$suppression->delete('someone@example.com');   // false if it was not on the list

That is the operation worth having. SparkPost suppresses on a hard bounce by itself, and its list and your own idea of who may be emailed then drift apart — so an address you have re-enabled at your end can still be silently dropped at SparkPost's. Nothing in the sending path reports it, because it is not an error.

Reading the whole list, a page at a time:

foreach ($suppression->each() as $entry) {
    // lazy - stop early and it stops making requests
}

$page = $suppression->search(page: 1, perPage: 100);
$page->totalCount;
$page->hasMore;

The list you see is the one your API key can see. Suppression is scoped per subaccount, and a subaccount key is bound to its own automatically — so an address suppressed under a different subaccount simply is not there as far as that key is concerned. To work with a particular subaccount's list, use a key belonging to it. sendingDomains()->forAddress() above is how to find out which subaccount an address you send from belongs to.

Adding one is a PUT, and therefore an upsert — an address already listed has its entry replaced rather than the call being rejected:

$suppression->add('someone@example.com', 'asked us to stop');
$suppression->add('someone@example.com', transactional: false);

The list is eventually consistent, which is worth knowing before you write anything around it. Measured against the live API: an added address took about six seconds to become readable, and a deleted one stayed readable about as long after the delete succeeded. add() returning true means SparkPost accepted the write, not that isSuppressed() agrees yet. Nothing here retries for you — poll if you need to see the change.

Whether an unsubscribe in your application should also go on SparkPost's list is a policy question, and it is yours rather than this package's.

Sending domains, and finding the subaccount

Read-only. Creating and verifying a sending domain is an administration task done once in SparkPost's own UI, and a key that can write here can change where an account's mail appears to come from — so this only reads, and the key only needs the read grant.

foreach ($sparkpost->sendingDomains()->all() as $domain) {
    $domain->domain;
    $domain->subaccountId;
    $domain->isDefaultBounceDomain;
    $domain->status['ownership_verified'] ?? null;
}

The reason it is here is subaccountId. Suppression is per-subaccount, and a subaccount API key cannot read the subaccounts endpoint at all — SparkPost offers no such permission for one. The sending domain carries the number, so the address you send as is the way to find out which subaccount you are operating in:

$domain = $sparkpost->sendingDomains()->forAddress('Support <noreply@mail.example.com>');

$domain?->subaccountId;      // 3629
$domain?->hasSubaccount();   // false for the primary account, which is 0 or absent

forAddress() reads the display-name form as well as a bare address, and returns null without making a request when it is handed something that is not an address at all. find($domain) returns null for a domain the account does not have — which is also the reason a send from it would be refused.

HTTP 200 does not mean the mail was sent

SparkPost returns 200 having accepted zero recipients, so the status code alone will tell you a send succeeded when nothing left the building. That is what TransmissionResult is for:

if (! $result->wasAccepted()) {
    // nothing was sent, whatever the status code said
}

$result->id;                        // the transmission id
$result->totalAcceptedRecipients;
$result->totalRejectedRecipients;
$result->hasRejections();

Rejected recipients are reported rather than thrown, because what counts as a failure is your policy: a mail transport should treat wasAccepted() === false as a failed send, a bulk job may not.

Errors

Everything this package throws implements Hampel\SparkPost\Exception\ExceptionInterface, so one clause catches the lot. Below that, the distinctions are the ones you would actually branch on:

Exception When Retry?
RequestException The request never reached SparkPost — DNS, TLS, connect timeout Yes, and it cannot have duplicated anything
ClientException A 4xx. The request or the key was wrong Not unchanged
RateLimitException A 429, with $retryAfter when the header was sent Yes, after waiting
ServerException A 5xx. SparkPost's problem, probably temporary Yes
InvalidArgumentException Caught before the network — empty key, unencodable payload No

ClientException, RateLimitException and ServerException all carry $statusCode, the decoded $errors array, the raw $body, and $retryAfter.

use Hampel\SparkPost\Exception\RateLimitException;
use Hampel\SparkPost\Exception\ExceptionInterface;

try {
    $sparkpost->transmissions()->send($transmission);
} catch (RateLimitException $e) {
    $queue->release($e->retryAfter ?? 60);
} catch (ExceptionInterface $e) {
    $log->error($e->getMessage());
}

Bringing your own HTTP client

Any PSR-18 client works, which matters when the host application has its own HTTP stack that you are not free to bypass — one routing every outbound request through a configurable proxy, with SSRF protections applied on the way out. An adapter implementing sendRequest() over that keeps all of it and still shares this package, rather than forcing a second API client to be written alongside.

The same seam is what makes the test suite network-free — see tests/StubClient.php.

Endpoints not yet wrapped

$sparkpost->connection() exposes get() and post() directly, so an endpoint this package has not covered yet is a call away rather than a release away:

$body = $sparkpost->connection()->get('webhooks');

Paths may be given bare (transmissions), with a leading slash, or exactly as SparkPost returns them in pagination links (/api/v1/events/message?page=2) — the version prefix is handled either way.

Licence

MIT. See LICENSE.md.