mailblastr / mailblastr
Official MailBlastr PHP SDK — send transactional and marketing email from your own verified domain.
Requires
- php: ^8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Official PHP SDK for the MailBlastr email API — send transactional and marketing email from your own verified domain.
Requirements
PHP 8.1+ with the curl and json extensions. No other runtime dependencies.
Install
composer require mailblastr/mailblastr
Usage
use Mailblastr\Mailblastr; $mailblastr = Mailblastr::client('mb_xxxxxxxxx'); $sent = $mailblastr->emails->send([ 'from' => 'Acme <hello@yourdomain.com>', 'to' => ['delivered@mailblastr.dev'], 'subject' => 'Hello from MailBlastr', 'html' => '<p>Your first email 🎉</p>', ]); echo 'sent ' . $sent['id'];
Every method returns the decoded JSON response as an associative array. Any non-2xx
response throws Mailblastr\Exceptions\MailblastrException carrying the API error
shape:
use Mailblastr\Exceptions\MailblastrException; try { $mailblastr->emails->send($payload); } catch (MailblastrException $e) { echo $e->getStatusCode(); // 422 echo $e->getName(); // 'validation_error' echo $e->getMessage(); // 'domain is required' }
Branch on getName(), and read getStatusCode() from the response rather than
inferring it from the name — a handler may return a name with a non-default
status (a missing User-Agent, for example, is validation_error with a 403,
and max_active_keys is a 429). Messages are sanitized human text and change
freely, so never match on them.
Some errors carry more than that envelope. The extras are accessors on the
exception and return null on an ordinary error:
} catch (MailblastrException $e) { // WHICH quota ran out, and what would clear it. if ($limit = $e->getLimit()) { echo $limit['kind']; // 'emails_daily' echo "{$limit['used']}/{$limit['limit']} used over {$limit['period']}"; echo $limit['next_plan']['name'] ?? ''; // 'Pro' } // Reputation gates: whether waiting helps, and until when. if ($rep = $e->getReputation()) { echo $rep['scope'], $rep['retryable'] ? " until {$rep['retry_at']}" : ' (not retryable)'; } // A batch that failed part way through — do NOT resend these. if ($sent = $e->getSent()) { echo "{$e->getSentCount()} already went out: " . implode(', ', array_column($sent, 'id')); } }
$e->getBody() is the whole parsed error body, so a field newer than this SDK
version is still reachable.
Attachments
Attach files by hosted URL (path, fetched at send time) or inline base64 (content):
$mailblastr->emails->send([ 'from' => 'Acme <hello@yourdomain.com>', 'to' => ['delivered@mailblastr.dev'], 'subject' => 'Your invoice', 'html' => '<p>Invoice attached.</p>', 'attachments' => [ ['filename' => 'invoice.pdf', 'path' => 'https://yourdomain.com/invoices/invoice.pdf'], ['filename' => 'report.csv', 'content' => $base64Content, 'content_type' => 'text/csv'], ], ]);
Options
$mailblastr = Mailblastr::client('mb_xxxxxxxxx', [ 'baseUrl' => 'https://www.mailblastr.com/api', // override your API host 'timeout' => 30, // per-request timeout in seconds (0 = no timeout) 'maxRetries' => 2, // automatic retries on 429/503 (0 disables) // 'transport' => $fake, // any TransportInterface — see Testing ]);
timeout and maxRetries configure the default curl transport only; a
transport you supply is used as-is.
Resources
The client exposes one property per resource, each following a consistent
(create / get / list / update / remove, plus resource-specific verbs) shape:
emails (with nested emails->receiving), batch,
domains, audiences, contacts, contactProperties, campaigns, segments,
topics, templates, automations, webhooks, logs, events,
apiKeys (list only — see below), polls.
// Emails $mailblastr->emails->send(['from' => …, 'to' => …, 'subject' => …, 'html' => …]); $mailblastr->emails->list(['limit' => 20, 'after' => $cursor]); // cursor pagination $mailblastr->emails->list(['status' => 'bounced', 'search' => 'ada@']); // server-side filters $mailblastr->emails->list(['folder' => 'scheduled']); // outbox | sent | scheduled | failed — any other value is rejected (422) $mailblastr->emails->get($id); $mailblastr->emails->sources(); // per-campaign/automation send metrics $mailblastr->emails->listAttachments(id: $id); $mailblastr->emails->getAttachment(id: $id, attachmentId: $attachmentId); $mailblastr->emails->update($id, ['scheduled_at' => $ts]); // reschedule $mailblastr->emails->cancel($id); // Inbound email $mailblastr->emails->receiving->list(); $mailblastr->emails->receiving->listAddresses(); // per-address inbound stats $mailblastr->emails->receiving->get($id); $mailblastr->emails->receiving->forward($id, ['from' => 'me@yourdomain.com', 'to' => 'team@you.com']); $mailblastr->emails->receiving->reply($id, ['from' => 'me@yourdomain.com', 'text' => 'Thanks!']); $bytes = $mailblastr->emails->receiving->getAttachment($id, $attachmentId); // raw bytes $mime = $mailblastr->emails->receiving->getRaw($id); // raw RFC822 // Batch send (alias of $mailblastr->emails->batch()) $res = $mailblastr->batch->send([ /* up to 100 email payloads */ ]); // 1-40 are sent inline (HTTP 200, no 'queued' key). 41-100 are QUEUED (HTTP 202): // $res['queued'] === true and $res['queued_count'] === count($res['data']), and // the mail has NOT gone out yet — the worker sends it on its next tick. if ($res['queued'] ?? false) { /* not transmitted yet — poll $mailblastr->emails->get($id) */ } // Domains (incl. claiming a domain verified elsewhere + one-click DNS) $mailblastr->domains->create(['name' => 'example.com']); $mailblastr->domains->verify($id); $mailblastr->domains->claim(['name' => 'example.com']); $mailblastr->domains->verifyClaim($id); $mailblastr->domains->detectDns($id); $mailblastr->domains->applyCloudflareDns($id, ['token' => $cfToken]); $mailblastr->domains->mxCheck('example.com'); // live MX lookup $csv = $mailblastr->domains->recordsCsv($id); // DNS records as CSV text // Contacts are DOMAIN-FIRST: each sending domain has its own contact pool // (the same address on two domains is two records with separate consent). $mailblastr->contacts->create(['domain' => 'example.com', 'email' => $email, 'first_name' => 'Ada']); $mailblastr->contacts->list(['domain' => 'example.com']); $mailblastr->contacts->get(['id' => $contactId]); // by contact id (exact) … $mailblastr->contacts->get(['id' => $email, 'domain' => 'example.com']); // … or by email + domain $mailblastr->contacts->update(['id' => $contactId, 'unsubscribed' => true]); $mailblastr->contacts->remove(['id' => $contactId]); $mailblastr->contacts->batch(['audienceId' => $audienceId, 'contacts' => [ … ]]); $mailblastr->contacts->batch(['domain' => 'example.com', 'contacts' => [ … ]]); // domain-first $mailblastr->contacts->import(['audienceId' => $audienceId, 'csv' => $csvText]); // Large files: mint a presigned slot, PUT the file to $slot['upload_url'] // yourself (it is a bearer credential — do not log it), then import by key. $slot = $mailblastr->contacts->createImportUpload(['audienceId' => $audienceId, 'filename' => 'leads.csv', 'size' => $bytes]); $mailblastr->contacts->import(['audienceId' => $audienceId, 'storage_key' => $slot['storage_key']]); $mailblastr->contacts->addToSegment($contactId, $segmentId); $mailblastr->contacts->updateTopics($contactId, ['topics' => [['id' => 'top_1', 'subscription' => 'opt_in']]]); // Contact properties (custom fields) $mailblastr->contactProperties->create(['key' => 'plan', 'type' => 'string']); // Campaigns, Segments — also domain-first: 'domain' picks the contact pool the // campaign/segment targets. Segment names are unique per domain (reusable // across domains), and every domain carries an auto-created "General" segment. $mailblastr->campaigns->create(['domain' => 'example.com', 'from' => …, 'subject' => …, 'html' => …, 'segment_id' => $segmentId]); $mailblastr->campaigns->send($id, ['scheduled_at' => $ts]); $mailblastr->campaigns->stats($id); // counts, rates, top links $mailblastr->campaigns->engagement($id); // who opened / clicked / replied $mailblastr->segments->create(['domain' => 'yourdomain.com', 'name' => 'VIP']); $mailblastr->segments->list(['domain' => 'yourdomain.com']); $mailblastr->segments->contacts($id); // preview who matches // Templates $mailblastr->templates->create(['name' => $name, 'subject' => $subject, 'html' => $html]); $mailblastr->templates->duplicate($id); $mailblastr->templates->publish($id); $mailblastr->emails->send(['from' => …, 'to' => …, 'template_id' => $templateId, 'variables' => ['first_name' => 'Ada']]); // Audiences (incl. Google Sheet import) $mailblastr->audiences->importSheet($audienceId, ['url' => $sheetUrl]); // API keys (listing only — creating, re-scoping and revoking is dashboard-only) $mailblastr->apiKeys->list(); // Custom events (the triggers for automations) $mailblastr->events->create(['name' => 'signup.completed', 'schema' => ['plan' => 'string']]); $mailblastr->events->update($eventId, ['schema' => ['plan' => 'string', 'seats' => 'number']]); // Polls (read-only results of the in-email poll widget) $mailblastr->polls->list(); $mailblastr->polls->get($emailId);
API keys are managed in the dashboard
apiKeys->list() is the whole API-key surface: the SDK deliberately exposes no
method to create, re-scope or revoke a key. Key lifecycle belongs to a signed-in
dashboard session, and the API enforces it — POST /api-keys,
PATCH /api-keys/:id and DELETE /api-keys/:id answer 403 dashboard_only to
any API-key caller, whatever its permission. That is the point: a key that leaks
cannot mint itself a replacement, widen its own access, or revoke the keys you
would use to shut it off. Create and revoke keys at
mailblastr.com.
Topics
Topics let contacts manage granular subscriptions (e.g. "Product updates").
$topic = $mailblastr->topics->create([ 'domain' => 'example.com', // topics belong to a sending domain 'name' => 'Product updates', 'description' => 'New features and releases', 'default_subscription' => 'opt_in', ]); $mailblastr->topics->list(['domain' => 'example.com', 'limit' => 50]); $mailblastr->topics->update($topic['id'], ['visibility' => 'private']); $mailblastr->topics->remove($topic['id']);
Automations
Build multi-step automations triggered by events, then inspect their runs.
Every automation belongs to one of your sending domains — domain is REQUIRED
on create, and events->send() names the domain it targets, so the same event
name across several products can never trigger the wrong automation.
$automation = $mailblastr->automations->create([ 'name' => 'Welcome series', 'domain' => 'yourdomain.com', 'trigger' => 'contact.created', ]); // Steps are only editable while the automation is disabled — new ones start // that way, so build first and enable last (call stop() to edit a live one). $mailblastr->automations->addStep($automation['id'], [ 'type' => 'send_email', 'config' => ['template_id' => 'tmpl_welcome'], ]); // A step update REPLACES the step rather than merging into it: send 'type' plus // the complete 'config' every time — omitting 'type' is a 422, not "leave the // type unchanged", and anything left out of 'config' is dropped, not preserved. $mailblastr->automations->updateStep($automation['id'], $stepId, ['type' => 'delay', 'config' => ['duration' => '3 days']]); // Or let AI draft the steps instead — also requires a stopped automation, and // without 'attach' the automation must have no steps yet $mailblastr->automations->createWithAi($automation['id'], ['prompt' => 'Welcome new signups over 3 days']); // Enable it once the steps are in place $mailblastr->automations->update($automation['id'], ['status' => 'enabled']); // Fire a custom event — only yourdomain.com's automations are triggered $mailblastr->events->send([ 'event' => 'signup.completed', 'domain' => 'yourdomain.com', 'email' => 'delivered@mailblastr.dev', 'payload' => ['plan' => 'pro'], ]); // Inspect execution ('status' takes a comma-separated list) $runs = $mailblastr->automations->runs($automation['id'], ['limit' => 25, 'status' => 'failed,running']); $mailblastr->automations->getRun($automation['id'], $runs['data'][0]['id']);
Webhooks
$hook = $mailblastr->webhooks->create([ // The endpoint must be https:// and must not resolve to a private address. 'endpoint' => 'https://yourapp.com/hooks/mailblastr', 'events' => ['email.delivered', 'email.bounced', 'email.unsubscribed'], ]); // $hook['signing_secret'] is shown ONCE — store it now. $mailblastr->webhooks->list(); $mailblastr->webhooks->update($hook['id'], ['status' => 'disabled']); $mailblastr->webhooks->rotate($hook['id']); // new signing_secret, revealed once // A failed delivery still returns HTTP 200 and does NOT throw — the outcome is // $result['ok'], with $result['status'] (your endpoint's HTTP status, when it // responded) and $result['error'] (e.g. 'lookup_failed'). $result = $mailblastr->webhooks->test($hook['id']); if (!$result['ok']) { error_log("test delivery failed: {$result['error']}"); }
Subscribable events: email.sent, email.delivered, email.delivery_delayed,
email.bounced, email.complained, email.opened, email.clicked,
email.failed, email.scheduled, email.suppressed, email.received,
email.replied, email.unsubscribed, contact.created, contact.updated,
contact.deleted, domain.created, domain.updated, domain.deleted.
Anything else is rejected with a 422.
Verify a delivery's Svix-style signature in your endpoint (a pure local HMAC-SHA256 computation — no HTTP request). Pass the EXACT raw request body:
$payload = file_get_contents('php://input'); // do NOT re-serialize parsed JSON $result = $mailblastr->webhooks->verify($payload, [ 'svix-id' => $_SERVER['HTTP_SVIX_ID'] ?? '', 'svix-timestamp' => $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? '', 'svix-signature' => $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '', ], $signingSecret); if (!$result['valid']) { http_response_code(401); // $result['reason'] says why }
Also available without a client: Mailblastr\WebhookSignature::verify(...).
Logs
$mailblastr->logs->list(['limit' => 100, 'method' => 'POST', 'status' => 429]); $mailblastr->logs->get($logId);
Pagination
list() methods accept optional cursor pagination — ['limit' => …, 'after' => …, 'before' => …] —
appended as a query string:
$mailblastr->campaigns->list(['limit' => 25, 'after' => 'cursor_abc']);
limit must be an integer between 1 and 100 (default 20). after and before
are item ids and are mutually exclusive — passing both is a 422. Every list
response is ['object' => 'list', 'has_more' => bool, 'data' => [...]]; there is
no total and no next cursor, so page forward with the last data item's id as
after. An unknown cursor returns an empty page, not an error.
Note that some list endpoints skip the 20-row default when you pass no
pagination params at all, returning everything up to a hard ceiling of 1,000
rows instead — domains, apiKeys, topics, campaigns, contacts,
contactProperties, segments, polls, segments->contacts(),
contacts->listSegments(), contacts->getTopics() and
emails->receiving->listAttachments(). Do not read that as "the whole
collection": past 1,000 rows the response is truncated and has_more is
true, so page with limit + after rather than relying on one unpaginated
call. The other direction is just as uneven: templates, webhooks,
audiences, automations, automations->runs() and events always cap an
unpaginated call at 20.
Idempotency
Pass an idempotency key to safely retry a send:
$mailblastr->emails->send($payload, ['idempotencyKey' => 'order-123']); $mailblastr->batch->send($payloads, ['idempotencyKey' => 'batch-2026-08-08']);
The key must be 1–255 characters, measured after the server trims it — 255,
not 256. Mailblastr\Client::IDEMPOTENCY_KEY_MAX_LENGTH carries that number.
The SDK sends the key verbatim and lets the server be the authority: an
out-of-range key comes back as a 400 invalid_idempotency_key.
Replaying a key returns the original response; reusing it with a different
payload is a 409. emails->send(), batch->send(), and received-email reply/forward honour it — every
other endpoint, including events->send(), still accepts an idempotencyKey
option and forwards it, but the API ignores it there, so a retry creates a
second resource. De-duplicate on your side instead.
Rate limits
Only the /emails SEND routes are rate limited: 30 requests per minute per IP.
Reads (GET /emails, GET /emails/:id, the receiving subtree and attachment
listings) are NOT subject to that cap — paging a large list no longer risks a
429. Capped responses carry
RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers on success
too. automations->createWithAi() is separately limited to 20 requests per minute per
account. The default transport retries a 429 or 503 automatically (honouring
Retry-After) up to maxRetries times.
Testing
The HTTP transport is swappable — pass any Mailblastr\Transport\TransportInterface
implementation as 'transport' to fake responses in your tests. A real custom
transport must send the headers it is handed verbatim: the API rejects any
request without a non-empty User-Agent with a 403 before it even authenticates.
The SDK's own test suite (no framework needed) runs with:
php tests/run.php
Documentation
Full docs: https://www.mailblastr.com/docs
License
MIT
Recovery and tracking contracts
Use a stable, unique operation key for each intended send, batch, reply, or forward. Keep the same key and payload when recovering that operation. These are the supported idempotent send endpoints; events do not implement this header. Existing calls without options still work.
$mailblastr->emails->receiving->reply($id, $reply, ['idempotencyKey' => 'reply-operation-1']); $mailblastr->emails->receiving->forward($id, $forward, ['idempotencyKey' => 'forward-operation-1']); $health = $mailblastr->domains->trackingHealth($domainId);
Automatic retries consider only 429/503. They stop on an original email id,
positive sent_count, nonempty sent or reserved, or batch_incomplete.
An ordinary rate limit can retry; a generic 503 can retry a read or a send with
the same supported key. Other writes retry only documented pre-processing
rejections (service_unavailable, sending_service_unavailable,
sending_configuration_unavailable, contacts_busy, contacts_timeout).
No network/body-read failure, 409, 422, or other 5xx is retried automatically.
The default transport refuses redirects; a custom transport/client must enforce
its own policy.
On a failed or unconfirmed send, inspect id with the email retrieval method
before creating another send. A 422 with an ID can identify an uncertain
provider handoff; 422 does not always mean nothing happened. For interrupted
batches, sent contains confirmed sends, reserved contains the original
attempted prefix (including uncertain handoffs), and unsent_count counts the
never-attempted tail. Do not resend the full batch or the reserved prefix under
a new key. Reconcile original IDs first, then submit only known unattempted
items as a new operation. Recovery fields remain available in the full error
body as well as language-specific fields/accessors.
Tracking health returns custom_host, status (shared, ready, or
unavailable), and checked_at. Configure custom tracking through the domain
API and check health before relying on it. A healthy endpoint cannot guarantee
an open event: recipients may block images, and coupon redemption alone is not
proof that the tracking pixel loaded. SDKs preserve supplied HTML/text and do
not infer opens or rewrite editor spacing.
Campaign cancellation also stops pending follow-ups for an already-sent campaign while retaining its sent history. Permanent received-email deletion acknowledges a durable cleanup request; attachment/object cleanup can finish asynchronously. Retrying that deletion is safe; it cannot be undone after the purge request is accepted.