capimail / capimail-php
Official PHP SDK for CapiMail with Laravel integration: transactional email, a Laravel mail transport, suppressions, credits, Email Validation, OTP, Domain Health and webhook verification.
Requires
- php: ^8.1
- ext-json: *
- guzzlehttp/guzzle: ^7.5
- guzzlehttp/psr7: ^2.4.5
Requires (Dev)
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- laravel/framework: Laravel 10, 11 or 12: auto-discovered service provider, facade, 'capimail' mail transport and webhook middleware.
Provides
None
Conflicts
None
Replaces
None
README
Official PHP client for the CapiMail API, with first-class Laravel integration: a capimail mail transport so every Mailable goes through CapiMail, a facade, webhook signature middleware and a webhook event. Also covers suppressions, credits, the Email Validation, OTP and Domain Health add-ons, and webhook verification for plain PHP.
Send the email your product depends on - verification links, OTP codes, password resets, receipts - from your own verified domain.
Requires PHP 8.1+ and Guzzle 7. Laravel 10, 11 and 12 are supported (optional).
What's inside
| Resource | Methods | Scope |
|---|---|---|
emails() |
send, get |
email:send / email:read |
account() |
credits |
email:read |
suppressions() |
list, get, add, remove |
email:read / email:send |
validation() |
check, jobs()->create, jobs()->get, jobs()->list, jobs()->results, jobs()->iterateResults |
email:send / email:read |
otp() |
send, verify |
email:send |
domains() |
check, monitors()->create, monitors()->list, monitors()->delete |
email:send / email:read |
Webhook |
verify, constructEvent |
none (uses your endpoint secret) |
| Laravel | capimail mail transport, CapiMail facade, VerifyCapiMailSignature middleware, Route::capimailWebhook(), CapiMailWebhookReceived event |
Full API reference: https://capimail.com/docs (add-ons: https://capimail.com/docs#addons). Release notes: CHANGELOG.md.
Install
composer require capimail/capimail-php
Quickstart (plain PHP)
use CapiMail\CapiMail; // Start with a cm_test_ key: it runs the full simulated lifecycle without // sending, reserving credits, or touching live data. Swap to cm_live_ later. $capimail = new CapiMail(getenv('CAPIMAIL_API_KEY')); $result = $capimail->emails()->send([ 'from' => 'hello@your-verified-domain.com', 'to' => 'customer@example.com', 'subject' => 'Welcome to Acme', 'html' => '<h1>Welcome</h1><p>Your account is ready.</p>', 'idempotency_key' => 'welcome-user-123', // required; safe to retry ]); echo $result['message_id'], ' ', $result['status']; // e.g. "…uuid…" "queued"
Keep your key server-side. Never commit it or expose it to the browser.
Return values
Every method returns the decoded JSON response as an associative array with the API's snake_case keys, exactly as documented at https://capimail.com/docs, including request_id. Parameters use the same snake_case names as the API. There are no DTOs to keep in sync, so new API fields reach you without an SDK upgrade. The only exceptions are suppressions()->remove() and domains()->monitors()->delete(), which return a bool (false when the item did not exist).
Laravel
The service provider and the CapiMail facade are auto-discovered.
Setup in 3 steps
1. Add your key to .env:
CAPIMAIL_API_KEY=cm_test_xxxxxxxx MAIL_MAILER=capimail MAIL_FROM_ADDRESS=hello@your-verified-domain.com MAIL_FROM_NAME="Acme"
2. Add the mailer to config/mail.php:
'mailers' => [ // ... 'capimail' => [ 'transport' => 'capimail', ], ],
3. (Optional) publish the config to change the base URL, timeout or webhook settings:
php artisan vendor:publish --tag=capimail-config
That's it: Mail::to(...)->send(...), notifications and queued mail now go through POST /api/v1/emails.
Sending a Mailable
use App\Mail\OrderShipped; use Illuminate\Support\Facades\Mail; Mail::to($order->customer_email)->send(new OrderShipped($order));
namespace App\Mail; use Illuminate\Mail\Attachment; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Mail\Mailables\Headers; class OrderShipped extends Mailable { public function __construct(public \App\Models\Order $order) {} public function envelope(): Envelope { return new Envelope( subject: "Pesanan {$this->order->number} dikirim", metadata: ['order_id' => (string) $this->order->id], // becomes CapiMail tags ); } public function content(): Content { return new Content(view: 'mail.order-shipped', text: 'mail.order-shipped-text'); } public function headers(): Headers { return new Headers(text: [ // Makes retries safe: the same key never sends twice. 'X-CapiMail-Idempotency-Key' => "order-shipped-{$this->order->id}", ]); } public function attachments(): array { return [Attachment::fromStorage("invoices/{$this->order->id}.pdf")->as('invoice.pdf')]; } }
How the transport maps a message:
| Laravel / Symfony message | CapiMail request |
|---|---|
| From (with name) | from ("Acme" <hello@acme.com>) |
| To + Cc | to (max 50 unique; more throws before sending) |
| Bcc | each Bcc address gets its own copy (CapiMail has no Bcc field, and adding them to to would expose them); at most capimail.mail.max_bcc (default 50), more throws before sending |
| first Reply-To | reply_to |
| Subject, HTML, text | subject, html, text |
| attachments | attachments (base64); inline images from $message->embed() keep their cid: reference |
metadata() |
tags |
List-Unsubscribe, List-Unsubscribe-Post, X-Campaign-Id, X-Entity-Ref-Id |
headers (other custom headers are dropped; the API does not accept them) |
X-CapiMail-Idempotency-Key header |
idempotency_key; when absent, one is derived from the message content (see below) |
Idempotency and retries. Set X-CapiMail-Idempotency-Key from your own record id (as above): it is the recommended protection against duplicate sends and charges. Without it, the transport derives auto-<sha256> from the stable content of the message: From, the sorted To/Cc addresses, Subject, HTML, text and each attachment's filename and SHA-256 of its bytes. The Message-ID, Date and the random Content-IDs of embedded images are not part of it, so a retried queued Mailable reuses the same key and is not sent (or charged) twice. The derived key also includes a time bucket of idempotency_window seconds (CAPIMAIL_MAIL_IDEMPOTENCY_WINDOW, default 3600): an identical message inside the same window counts as one send, and one sent in a later window is delivered again. A retry that crosses a window boundary can send twice, which is another reason to set the header.
Each Bcc copy is its own API send with the key <key>:bcc:<first 16 hex of sha256(lower-cased address)>, tied to the address rather than its position in the list.
Partial delivery. A message with Bcc recipients is several API requests (one for To/Cc, one per Bcc address). If a later request fails, the copies already accepted stay sent and charged, and the transport throws a TransportException. Retrying the same message (for example by the queue) resends only the copies that were not accepted, because the earlier ones reuse their idempotency keys. This holds for the derived key only as long as the message renders identically; a key from your own record id makes it hold regardless.
To cap the fan-out, set CAPIMAIL_MAIL_MAX_BCC (config capimail.mail.max_bcc, default 50) or 'max_bcc' on the mailer.
After sending, $sentMessage->getMessageId() is CapiMail's message_id. API errors are thrown as Symfony TransportException with the CapiMailException as the previous exception.
To use a different key for one mailer (for example a separate workspace), add 'key' => env('CAPIMAIL_OTHER_KEY') (and optionally 'base_url', 'timeout', 'max_bcc') to that mailer's config.
Using the API directly
use CapiMail\Laravel\Facades\CapiMail; $credits = CapiMail::account()->credits(); $otp = CapiMail::otp()->send(['to' => $user->email, 'purpose' => 'login']); // or inject it public function __construct(private \CapiMail\CapiMail $capimail) {}
Webhooks in Laravel
CAPIMAIL_WEBHOOK_SECRET=whsec_xxxxxxxx # while rotating: CAPIMAIL_WEBHOOK_SECRET=whsec_new,whsec_old
// routes/web.php or routes/api.php Route::capimailWebhook('webhooks/capimail'); // POST, signature-verified, CSRF-exempt, named capimail.webhook
Then listen for the event:
use CapiMail\Laravel\Events\CapiMailWebhookReceived; use Illuminate\Support\Facades\Event; Event::listen(function (CapiMailWebhookReceived $event) { // $event->eventId the signed payload's `id`; dedupe on this, deliveries are retried // $event->type "email.bounced", "validation.completed", "domain.health_changed", … // $event->data the event's data object // $event->payload the full decoded body if ($event->type === 'email.bounced') { // $event->data['message_id'] } });
The event id is taken only from the signed payload's id. The X-CapiMail-Event-Id header is not covered by the signature, so a request whose header differs from the payload id is rejected with 400 and no event is dispatched.
Prefer your own controller? Use the middleware on any route: ->middleware('capimail.webhook') (or \CapiMail\Laravel\Http\Middleware\VerifyCapiMailSignature::class). Invalid signatures get 400; a missing secret gets 500 (fail closed). If the route is in routes/web.php, exclude it from CSRF verification (Route::capimailWebhook() already does).
Sending
emails()->send($params) accepts the API fields:
| Field | Type | Notes |
|---|---|---|
from |
string |
Verified sender. Bare address or "Acme <hello@acme.com>". |
to |
string|string[] |
1-50 unique recipients. |
subject |
string |
Required unless provided by a template. |
html / text |
string |
At least one is required (unless templated). |
reply_to |
string |
Optional Reply-To address. |
headers |
array<string,string> |
Only List-Unsubscribe, List-Unsubscribe-Post, X-Campaign-Id, X-Entity-Ref-Id are kept. |
tags |
array<string,string> |
Optional metadata. |
attachments |
array[] |
['filename', 'content_type', 'content' (base64), 'content_id'?, 'disposition'?]. Max 10, 10 MB total. |
idempotency_key |
string |
Required. 8-128 of A-Z a-z 0-9 . _ : -. Reuse returns the original message. |
template |
['key', 'locale'?, 'variables'?] |
Shorthand for template_key / locale / variables. |
Template send
$capimail->emails()->send([ 'from' => 'accounts@your-verified-domain.com', 'to' => $user->email, 'template' => [ 'key' => 'email-verification', 'locale' => 'id', 'variables' => ['name' => $user->name, 'verify_url' => $link], ], 'idempotency_key' => "verify-{$user->id}", ]);
Checking status
A 202 means the send is durably queued - not delivered. Poll for the real state:
$response = $capimail->emails()->get($result['message_id']); echo $response['email']['status']; // queued | processing | provider_accepted | delivered | hard_bounced | …
Credits and suppressions
$available = $capimail->account()->credits()['available']; $page = $capimail->suppressions()->list(['limit' => 50]); // ['suppressions' => [...], 'next_cursor' => ...] $next = $capimail->suppressions()->list(['cursor' => $page['next_cursor']]); $suppressed = $capimail->suppressions()->get('user@example.com')['suppressed']; $capimail->suppressions()->add('user@example.com', 'unsubscribe'); $capimail->suppressions()->remove('user@example.com'); // false if it was not suppressed
Add-ons
Add-ons are paid from the same credit balance and need a live key (cm_live_), except OTP which also works with test keys. Prices: https://capimail.com/docs#addons.
Email Validation
// Up to 100 addresses, answered immediately. 2 credits per address with a verdict. $check = $capimail->validation()->check(['emails' => ['budi@perusahaan.co.id']]); $check['results'][0]['verdict']; // "valid" | "risky" | "invalid" | "unknown" $check['results'][0]['reasons']; // e.g. ["role_address"] // Up to 10,000 addresses in the background. Subscribe your webhook to // validation.completed, or poll jobs()->get($job['id']). $job = $capimail->validation()->jobs()->create([ 'emails' => $list, 'suppress_invalid' => true, // invalid addresses join your suppression list 'metadata' => ['list_id' => 'march'], // echoed in the webhook 'idempotency_key' => 'list-march', // sent as the Idempotency-Key header; a retry returns the same job ]); // Every result across all pages, fetched lazily: foreach ($capimail->validation()->jobs()->iterateResults($job['id'], 'invalid') as $result) { markInvalid($result['email']); } $capimail->validation()->jobs()->list(['limit' => 20]); $capimail->validation()->jobs()->results($job['id'], ['verdict' => 'risky', 'limit' => 500]);
OTP
$otp = $capimail->otp()->send(['to' => $user->email, 'purpose' => 'login']); // store $otp['id'] with the login attempt; never store the code $check = $capimail->otp()->verify($otp['id'], $codeTypedByUser); if (! $check['valid']) { // $check['status']: "invalid_code" | "locked" | "expired" | "already_verified" | "not_found" }
Sender, template, language, code length, validity and attempts come from Dashboard > Add-ons > OTP (per sending domain) and can be overridden per call (template_key, locale, code_length, ttl_seconds, max_attempts, domain, variables). Your template receives {{code}}, {{expires_minutes}} and {{purpose}}. With a cm_test_ key nothing is delivered and the response includes test_code.
Domain Health
$health = $capimail->domains()->check(['domain' => 'client.co.id', 'dkim_selectors' => ['google']]); $health['status']; // "pass" | "warn" | "fail" $health['issues']; // [['check' => 'dmarc', 'code' => 'dmarc_policy_none'], ...] // Daily monitoring; domain.health_changed fires when the status changes. $monitor = $capimail->domains()->monitors()->create(['domain' => 'client.co.id']); $capimail->domains()->monitors()->list(); // ['data' => [...]] $capimail->domains()->monitors()->delete($monitor['id']); // false if it did not exist
validation()->check(), validation()->jobs()->create() and domains()->check() send idempotency_key as the Idempotency-Key header; emails()->send() sends it in the body, as the API requires.
Webhooks (plain PHP)
CapiMail signs every webhook with X-CapiMail-Signature: t=<unix>,v1=<hex>. Verify it against the raw request body:
use CapiMail\Webhook; use CapiMail\Exceptions\SignatureVerificationException; $payload = file_get_contents('php://input'); try { $event = Webhook::constructEvent( $payload, $_SERVER['HTTP_X_CAPIMAIL_SIGNATURE'] ?? null, getenv('CAPIMAIL_WEBHOOK_SECRET'), // or [$current, $previous] while rotating ); } catch (SignatureVerificationException) { http_response_code(400); exit; } switch ($event['type']) { case 'email.bounced': /* $event['data']['message_id'] */ break; case 'validation.completed': /* $event['data']['job_id'], ['summary'], ['metadata'] */ break; case 'domain.health_changed': /* $event['data']['domain'], ['status'], ['issues'] */ break; } http_response_code(200); // any 2xx; dedupe on $event['id'] (signed), not on the unsigned X-CapiMail-Event-Id header
Webhook::verify($payload, $signature, $secret, $tolerance = 300) returns a bool if you prefer to decode the body yourself. Webhook::sign($payload, $secret) builds a valid header for your own tests.
Errors
Non-2xx responses throw CapiMail\Exceptions\CapiMailException with a stable errorCode:
use CapiMail\Exceptions\CapiMailException; use CapiMail\Exceptions\ConnectionException; try { $capimail->emails()->send([/* … */]); } catch (CapiMailException $e) { error_log("{$e->status} {$e->errorCode} {$e->getMessage()} {$e->requestId}"); // 402 insufficient_credits … // $e->details: field errors, e.g. [['path' => 'to.0', 'message' => '…']] // Add-on codes include live_key_required, otp_rate_limited, domain_required, // invalid_domain and monitor_limit_reached; see https://capimail.com/docs#addons-errors if ($e->isRetryable()) { // 429 / 503 - back off and retry; $e->retryAfterSeconds may be set. } } catch (ConnectionException $e) { // DNS, TLS, connection failure or timeout. Retry with the same idempotency key. }
All SDK exceptions implement CapiMail\Exceptions\ExceptionInterface.
Test mode
A cm_test_ key simulates the full lifecycle (real message_id, signed webhooks) without sending. The simulated outcome is driven by the first recipient's local-part:
| Recipient | Simulated outcome |
|---|---|
bounce@… |
hard bounce |
complaint@… |
complaint |
fail@… |
failure |
| anything else | delivered |
$capimail = new CapiMail('cm_test_…'); $capimail->isTestMode(); // true $r = $capimail->emails()->send([ 'from' => 'x@acme.com', 'to' => 'bounce@example.com', 'subject' => 't', 'text' => 't', 'idempotency_key' => 'test-bounce-1', ]); $r['test']; // true $r['outcome']; // the simulated bounce outcome
In Laravel, put a cm_test_ key in .env for local and staging environments.
Options
new CapiMail($apiKey, [ 'base_url' => 'https://capimail.com', // default 'timeout' => 30, // seconds, default; must be greater than 0 'http_client' => $guzzleClient, // optional GuzzleHttp\ClientInterface 'handler' => $handlerStack, // optional Guzzle handler (e.g. MockHandler in tests) ]);
Requests send User-Agent: capimail-php/<version>. Redirects are never followed (that would replay the Authorization header to another location): a 3xx response throws CapiMailException with errorCode unexpected_redirect, which usually means base_url is wrong (for example http:// instead of https://).
Ids passed to methods such as emails()->get(), otp()->verify(), validation()->jobs()->get() and domains()->monitors()->delete() are URL-encoded, and ids that are empty, ., .. or contain / throw InvalidArgumentException before any request is sent.
The API key is marked #[\SensitiveParameter] and var_dump() / print_r() of the client show only its prefix (cm_live_…). Keep it in the environment, never in logs or source control.
Development
composer install vendor/bin/phpunit
Tests use Guzzle's MockHandler (no network) and Orchestra Testbench for the Laravel parts.
License
MIT