sendaramail / sendara
Sendara — email & marketing API (transactional email, broadcasts, contacts, templates, webhooks) for PHP. Zero runtime dependencies.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The official PHP SDK for the Sendara email API. Send transactional email, run broadcasts, page through your message history, and verify webhooks — with zero runtime dependencies (just ext-curl and ext-json).
Requires PHP 8.1+.
Installation
composer require sendaramail/sendara
Quickstart
Create a client with your API key, then send an email:
<?php require 'vendor/autoload.php'; use Sendara\Client; $client = new Client('sk_live_your_api_key'); $message = $client->emails()->send([ 'from' => 'hello@yourdomain.com', 'to' => 'customer@example.com', 'subject' => 'Welcome to Acme', 'html' => '<h1>Welcome aboard</h1><p>Glad to have you.</p>', 'text' => 'Welcome aboard. Glad to have you.', ]); echo $message['id']; // e.g. "msg_..."
The resource accessors are also exposed as lazy, memoized properties, so $client->emails is equivalent to $client->emails().
Client options
The second constructor argument is an options array. All fields are optional:
$client = new Client('sk_live_your_api_key', [ 'baseUrl' => 'https://api.sendara.dev', // default 'timeout' => 30, // seconds, default 30 'maxRetries' => 2, // default 2 ]);
The client automatically:
- Sends
Authorization: Bearer <apiKey>. - JSON-encodes request bodies.
- Adds an
Idempotency-Keyto every write (POST/PUT/PATCH) request. - Retries idempotent requests on
429and5xxresponses (and network failures) with exponential backoff, honoringRetry-After.
Send options
emails()->send() accepts these keys:
| Key | Type | Description |
|---|---|---|
to |
string (required) | Recipient address. |
from |
string | Sender address. Required after you verify your own domain; sandbox onboarding uses Sendara's shared sender. |
subject |
string | Email subject. |
html |
string | HTML body. |
text |
string | Plain-text body. |
templateId |
string | Render a stored template instead of inline body. |
templateVars |
array | Variables for the template. |
messageType |
string | Message type tag. |
metadata |
array | Arbitrary metadata stored with the message. |
storePayload |
bool | Store the content payload for later inspection. |
testSend |
bool | Use the verified test-recipient path without billing. |
scheduledAt |
string | ISO 8601 time for a send at least one minute in the future. |
validateRecipient |
bool | Validate the address before provider dispatch. |
idempotencyKey |
string | Supply your own key; one is generated if omitted. |
For full control over the request body, use the escape hatch:
$client->emails()->sendRaw([ 'channel' => 'email', 'destination' => ['email' => 'customer@example.com'], 'payload' => ['subject' => 'Hi', 'body_html' => '<p>Hi</p>'], ]);
Broadcasts
Create, send, and manage broadcasts to a list or an explicit recipient set:
$broadcast = $client->broadcasts()->create([ 'name' => 'June newsletter', 'fromEmail' => 'news@yourdomain.com', 'subject' => 'What shipped in June', 'bodyHtml' => '<h1>June</h1><p>Here is what is new.</p>', 'audienceListId' => 'list_123', ]); $client->broadcasts()->send($broadcast['id']);
List, fetch, cancel, and delete:
$broadcasts = $client->broadcasts()->list(['limit' => 20, 'offset' => 0]); $one = $client->broadcasts()->get('bcast_123'); $client->broadcasts()->cancel('bcast_123'); $client->broadcasts()->delete('bcast_123');
Create and fan out in a single call with bulkSend():
$client->broadcasts()->bulkSend([ 'fromEmail' => 'news@yourdomain.com', 'subject' => 'Launch day', 'bodyHtml' => '<p>We are live.</p>', 'recipients' => [ ['email' => 'a@example.com'], ['email' => 'b@example.com'], ], ]);
create() accepts name, fromEmail, subject, bodyHtml, bodyText,
templateId, messageType, audienceListId, listIds, excludeListIds,
recipients, scheduledAt, and sendNow. bulkSend() accepts the same content
and audience fields but always fans out immediately, so it does not accept
scheduledAt or sendNow.
Pass listIds to send to several contact lists at once, and excludeListIds to
subtract a list from that audience.
Billing checkout
Checkout accepts starter, pro, growth, or scale, with a month or
year billing period. It returns a CheckoutResult for both successful
outcomes:
$checkout = $client->billing()->checkout('growth', 'year'); if ($checkout->updated) { echo "Subscription changed to {$checkout->plan}"; } else { header("Location: {$checkout->url}"); // hosted checkout for a new subscription }
Messages and pagination
Fetch a single page, including the cursor for the next one:
$page = $client->messages()->page([ 'status' => 'delivered', 'limit' => 50, ]); foreach ($page->messages as $message) { echo $message['id'], PHP_EOL; } if ($page->hasMore()) { $next = $client->messages()->page(['cursor' => $page->nextCursor]); }
Or let the SDK walk every page for you. list() returns a Generator that auto-paginates through next_cursor:
foreach ($client->messages()->list(['status' => 'delivered']) as $message) { echo $message['id'], PHP_EOL; }
page() and list() accept the same filters: status, search, from, to, limit, cursor.
Fetch a single message with its full event timeline, by id or by the idempotency key from the original send:
$message = $client->messages()->get(['id' => 'msg_123']); $message = $client->messages()->get(['idempotency_key' => 'order-42']);
Webhook verification
Verify the authenticity of an inbound webhook with Sendara\Webhooks::verify(). Pass the raw request body (the exact bytes received — never a re-encoded object), the request headers, and your signing secret. On success it returns the decoded JSON payload; on failure it throws WebhookVerificationException.
<?php use Sendara\Webhooks; use Sendara\Exception\WebhookVerificationException; $rawBody = file_get_contents('php://input'); $secret = getenv('SENDARA_WEBHOOK_SECRET'); try { $event = Webhooks::verify($rawBody, getallheaders(), $secret); } catch (WebhookVerificationException $e) { http_response_code(400); exit('Invalid signature'); } // $event is the decoded payload switch ($event['event_type'] ?? null) { case 'delivered': // ... break; case 'bounced': // ... break; } http_response_code(200);
Verification recomputes HMAC-SHA256(secret, "<timestamp>.<rawBody>") and compares it in constant time against the Sendara-Signature header. It also rejects requests whose Sendara-Timestamp falls outside a tolerance window (default 300 seconds). Pass a fourth argument to change the tolerance, or 0 to disable the timestamp check:
$event = Webhooks::verify($rawBody, $headers, $secret, 600);
Headers are matched case-insensitively, so framework-normalized header maps work as-is.
Error handling
Every non-2xx response throws Sendara\Exception\ApiException, which extends Sendara\Exception\SendaraException. The API's { "error": { "code", "message" } } envelope is decoded onto the exception:
<?php use Sendara\Exception\ApiException; use Sendara\Exception\SendaraException; try { $client->emails()->send([ 'to' => 'customer@example.com', 'subject' => 'Hi', 'html' => '<p>Hi</p>', ]); } catch (ApiException $e) { $e->getStatus(); // HTTP status, e.g. 422 $e->getErrorCode(); // machine code, e.g. "invalid_request" $e->getMessage(); // human-readable message $e->getRequestId(); // X-Request-Id, or null $e->getRetryAfter(); // seconds from Retry-After, or null } catch (SendaraException $e) { // Configuration, encoding, or network errors (no HTTP response) }
Catch ApiException for failed API calls, and SendaraException to also cover client-side problems such as a missing API key, JSON encoding failures, or network errors that exhausted retries.
Laravel
The package ships a service provider and facade and is auto-discovered — no manual registration needed.
Set your credentials in .env:
SENDARA_API_KEY=sk_live_your_api_key # optional overrides: SENDARA_BASE_URL=https://api.sendara.dev SENDARA_TIMEOUT=30 SENDARA_MAX_RETRIES=2
Optionally publish the config file to config/sendara.php:
php artisan vendor:publish --tag=sendara-config
Use the Sendara facade anywhere:
<?php use Sendara\Laravel\SendaraFacade as Sendara; Sendara::emails()->send([ 'from' => 'hello@yourdomain.com', 'to' => 'customer@example.com', 'subject' => 'Welcome', 'html' => '<h1>Welcome</h1>', ]); $page = Sendara::messages()->page(['limit' => 25]);
The underlying Sendara\Client is bound as a singleton, so you can also resolve it via dependency injection:
use Sendara\Client; class WelcomeMailer { public function __construct(private readonly Client $sendara) { } public function send(string $to): void { $this->sendara->emails()->send([ 'to' => $to, 'subject' => 'Welcome', 'html' => '<h1>Welcome</h1>', ]); } }
The facade and client expose the same resources: emails, broadcasts, messages, contacts, lists, domains, templates, suppressions, usage, apiKeys, and billing.
License
MIT