Search by

capimail / capimail-php

lelianto

Official PHP SDK for CapiMail with Laravel integration: transactional email, a Laravel mail transport, suppressions, credits, Email Validation, OTP, Domain Health and webhook verification.

v0.1.1 2026-11-08 14:26 UTC

This package is auto-updated.

Last update: 2026-11-08 14:31:44 UTC


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