sendpulse/rest-api
Official PHP client for the SendPulse REST API, supporting API key and OAuth auth. Manage bulk email campaigns, transactional SMTP, SMS, CRM contacts and chatbots through a typed, ergonomic service layer with optional PSR-18 and PSR-16 adapters. Requires PHP 8.3+ and ext-curl/ext-json.
Install and check PHP first. The SDK requires PHP ≥ 8.3 and has zero runtime dependencies beyond ext-curl/ext-json. If the app is still on 8.2 (common on Laravel 10 / older Forge boxes), Composer will refuse to install — verify php -v on the web and queue hosts before you plan anything.
composer require sendpulse/rest-api
Pin ≥ 3.0.1. 3.0.0 shipped with broken URL prefixes: every CRM and chatbot call returned 404. If you copy a composer.json snippet from a blog post or an old lockfile, this is the first thing to fix.
Read these in order:
docs/laravel.md — the maintainers ship Laravel guidance; start there, not with the README.docs/authentication.md — decides your token storage strategy, which is the single most consequential config choice.README.md service table — it tells you which methods return typed models and which return raw arrays (they are not consistent).Pick an auth mode. The constructor takes apiKey: or clientId: + clientSecret: as named arguments. Use API key for a single-account integration; use OAuth client credentials if you're building something that may later be installed against a customer's own SendPulse account.
First use case: transactional SMTP as a smoke test, not a Mail replacement. The fastest way to prove the wiring works end-to-end:
$smtp = $client->smtpService();
$smtp->emails()->sendSmtpEmail([
'email' => [
'html' => view('mail.welcome', $data)->render(),
'text' => 'Welcome',
'subject' => 'Welcome aboard',
'from' => ['name' => config('mail.from.name'), 'email' => config('mail.from.address')],
'to' => [['name' => $user->name, 'email' => $user->email]],
],
]);
Then hit a read endpoint ($client->emailService()->campaigns()->getCampaigns(limit: 5)) to confirm tokens persist between requests.
Register it once. There is no package auto-discovery and no service provider — you write ~20 lines. Bind Client as a singleton in AppServiceProvider (or a dedicated SendPulseServiceProvider) so the token cache and curl handle aren't rebuilt per injection, and keep env() calls in config/ only:
// config/sendpulse.php
return [
'api_key' => env('SENDPULSE_API_KEY'),
'client_id' => env('SENDPULSE_CLIENT_ID'),
'client_secret' => env('SENDPULSE_CLIENT_SECRET'),
'cache_dir' => storage_path('app/sendpulse'),
'connect_timeout' => 10,
'request_timeout' => 30,
];
$this->app->singleton(Client::class, fn () => config('sendpulse.api_key')
? new Client(
apiKey: config('sendpulse.api_key'),
connectTimeout: config('sendpulse.connect_timeout'),
requestTimeout: config('sendpulse.request_timeout'),
cacheDir: config('sendpulse.cache_dir'),
)
: new Client(
clientId: config('sendpulse.client_id'),
clientSecret: config('sendpulse.client_secret'),
cacheDir: config('sendpulse.cache_dir'),
));
Wrap the client behind one app-level gateway. The fluent chain ($client->emailService()->campaigns()->getCampaigns()) reads nicely but is miserable to fake in tests and impossible to swap for a fake without touching every call site. A thin SendPulseGateway class with your five or six real operations gives you a single fakeable seam and a place to hang logging, metrics, and retry policy:
final class SendPulseGateway
{
public function __construct(private Client $client) {}
/** @return CampaignSummary[] */
public function recentCampaigns(int $limit = 50): array
{
return $this->client->emailService()->campaigns()->getCampaigns(limit: $limit);
}
}
Queue everything that writes. Campaign creation, list imports, SMS sends — none of these belong in a request/response cycle behind a 30s timeout. Dispatch a job, inject the gateway, and let Horizon own the retry semantics.
Retry by exception type, not blanket $tries. Laravel's default retry loop will happily hammer a 4xx validation error three times. Use type-directed releases:
public function handle(SendPulseGateway $sp): void
{
try {
$sp->sendTransactional($this->message);
} catch (RateLimitException $e) {
$this->release(30); // 429 — back off
} catch (NetworkException $e) {
$this->release(10); // transport — safe to retry
}
// AuthException / ForbiddenException / ApiException bubble up: never auto-retry
}
public function backoff(): array { return [10, 60, 300]; }
Never retry a send blindly. There is no idempotency key in the API. A NetworkException on sendSmtpEmail may mean "delivered, response lost." Either accept at-most-once (catch and alert, don't retry) or reconcile afterwards via getSmtpEmailInfo('message-id') / getSmtpEmails() before re-sending. The 1.x line added "re-try for SMTP send method" internally — assume the same failure mode exists at your layer.
Chunk address-book imports. Bulk contact import is your main 429 source. Batch 500–1000 contacts per call, one job per batch, and put a RateLimiter::attempt("sendpulse:{$account}", 9, 60) guard in front so parallel workers don't trip the API limit together.
Token storage is the config that bites at scale. Default FileTokenStorage writes to the system temp dir (or your cacheDir) using flock. On a single box that's fine. On ephemeral containers, Lambda, or a Horizon fleet spread across pods, every instance fetches its own OAuth token — multiplying token-endpoint traffic and risking throttling. Move to shared storage:
use Sendpulse\RestApi\Token\Psr16TokenStorage;
$client = new Client(
clientId: config('sendpulse.client_id'),
clientSecret: config('sendpulse.client_secret'),
tokenStorage: new Psr16TokenStorage($psr16Cache), // Redis / database store
);
Laravel's cache Repository isn't PSR-16, so you'll need a small adapter or symfony/cache's Psr16Adapter over a Laravel store. Twenty lines, worth it.
Use the PSR-18 adapter for observability. Psr18Adapter lets you inject a decorated PSR-18 client that logs latency and status codes — the SDK has no middleware/event hooks of its own:
$client = new Client(
apiKey: 'key',
httpClient: new Psr18Adapter(
client: new LoggingPsr18Client($guzzle), // your decorator
requestFactory: $psr17,
streamFactory: $psr17,
),
);
Store SendPulse IDs in your own tables. Campaign IDs, message-ids, contact IDs from CRM v1 — persist them. SendPulse's API is your transport and contact list; your DB should remain the system of record for who was messaged, when, and what the result was. Decide up front whether contact attributes and consent live in your DB, in SendPulse mailing lists, or both with a sync job.
Track usage data while you're already calling. Campaign and address-book endpoints give you volume and per-channel numbers — pull them into a scheduled command if you need pricing/capacity modeling rather than doing a separate reporting integration.
Testing: use the PSR-18 seam (as above) with a mock client, or fake your own SendPulseGateway binding. Don't try to mock $client->emailService()->campaigns() — the generated chain is deep and brittle. See docs/testing.md.
Mixed return types — don't assume typed models. getCampaigns() returns CampaignSummary[], getSmtpEmails() returns EmailRecord[] — but getMailingLists(), getDealsList(), getSmsCampaigns(), and the chatbot calls all return raw array. The same service facade mixes both. Check the README table per method; type-hint accordingly.
Path parameters are rawurlencoded by the SDK (since 3.0.1). Pass raw values. If you pre-encode an email address or an ID containing #, ?, /, +, you'll double-encode and get 404s that look like auth or routing problems.
ForbiddenException is new in 3.0.1. 403 (tariff/permission) used to surface as AuthException. Any code written against 3.0.0 that catches AuthException to mean "bad credentials" will now silently miss quota errors. Catch both, or catch SendPulseExceptionInterface for the generic case:
use Sendpulse\RestApi\Exception\SendPulseExceptionInterface;
try {
$campaigns = $client->emailService()->campaigns()->getCampaigns();
} catch (SendPulseExceptionInterface $e) {
Log::error('sendpulse', ['class' => $e::class, 'msg' => $e->getMessage()]);
throw $e;
}
Always log $e->rawBody on ApiException. The message alone is usually a generic status string; the body carries SendPulse's actual field-level complaint. $e->httpStatus plus $e->rawBody is the only thing that makes support tickets debuggable.
ProtocolException now fires on non-array JSON. As of 3.0.1, a response like true or a bare number throws instead of returning []. Delete/status endpoints that return primitives are the likely offenders — if a deleteMailingList() call suddenly fails after upgrade, this is why.
The 401 auto-retry doubles your failure cost. The client invalidates the cached token and retries once. With genuinely wrong credentials that's two network round trips per call before AuthException surfaces. In a health-check or high-traffic path, cache the failure yourself or fail fast on boot.
Set cacheDir explicitly. The default temp directory is user-scoped. If your web process (www-data) and queue worker (forge) run as different users, the flock-based file storage will throw permission errors that only appear under queue load. Put it under storage_path('app/sendpulse') and ensure one owner.
Raise requestTimeout for bulk operations. The 30s default is fine for SMTP sends; campaign creation with large audiences or address-book imports can exceed it and produce a NetworkException for an operation that actually succeeded server-side.
Rolling your own TokenStorage? Implement the interface with atomic writes and a lock. Naive Cache::put() gets you a thundering herd of token requests when a token expires across a fleet — SendPulse will throttle you.
No webhook helpers. SendPulse events (bounces, unsubscribes, opens) are configured in the dashboard and hit your own routes. There's nothing in the SDK for signature verification — build that yourself and treat the payload as untrusted.
Small ecosystem. 117 stars, zero dependents — you will not find a Stack Overflow answer for your specific error. Budget for reading the generated source directly; it's OpenAPI-generated and readable, and docs/exceptions.md is genuinely good.
Pin the major version. Services and models are generated from OpenAPI specs; regenerated code can rename properties or change nullability in a minor release. Read changelog/ before bumping, and treat model classes as vendor DTOs — map them into your own data objects at the boundary rather than passing them through your domain.
How can I help you explore Laravel packages today?