Integration
Send email from Laravel with SNDR.
Send transactional email from Laravel using the SNDR HTTP API directly. No native PHP SDK to ship; the recipes below use the built-in Http facade for sends and raw request bytes for signed webhook verification.
There's no official PHP SDK yet — and you don't need one. SNDR's API is a single POST /v1/send with a typed JSON body and a Bearer token, which Laravel's Http facade handles cleanly. The patterns below show how to wrap the API in a thin service class, dispatch sends from queued jobs (so failures retry on Laravel's queue, not yours), and verify incoming webhooks with constant-time HMAC comparison.
Setup
Install + configure
# No SDK install — Laravel's Http facade is built in.
composer install # nothing extra required# .env
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=...
SNDR_FROM_ADDRESS="hello@yourdomain.com"
SNDR_API_BASE=https://api.sndr.shRecipes
The patterns you actually need.
Register config keys
Don't reach into env() outside config files — Laravel caches config in production and env() returns null. Surface keys via config/services.php once.
// config/services.php return [ // ... 'sndr' => [ 'api_key' => env('SNDR_API_KEY'), 'webhook_secret' => env('SNDR_WEBHOOK_SECRET'), 'from' => env('SNDR_FROM_ADDRESS', 'hello@yourdomain.com'), 'base' => env('SNDR_API_BASE', 'https://api.sndr.sh'), ], ];Wrap the API in a service
A thin service keeps the call site in your controllers/jobs short and gives you one place to add retries, logging, or per-environment behavior.
<?php // app/Services/SndrClient.php namespace App\Services; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; class SndrClient { public function send(array $payload, ?string $idempotencyKey = null): array { $key = $idempotencyKey ?? (string) Str::uuid(); $response = Http::withToken(config('services.sndr.api_key')) ->withHeaders(['Idempotency-Key' => $key]) ->acceptJson() ->asJson() ->timeout(30) ->retry(3, function (int $attempt, \Exception $e) { $after = $e instanceof RequestException ? $e->response->header('Retry-After') : null; return is_numeric($after) ? max(250, (int) $after * 1000) : 250 * $attempt; }, function (\Exception $e) { return $e instanceof ConnectionException || ($e instanceof RequestException && ($e->response->status() === 429 || $e->response->serverError())); }) ->post(config('services.sndr.base') . '/v1/send', $payload); $response->throw(); return $response->json(); } }Send from a controller
Inject the service. Validate input. Call ->send() with recipient arrays. The HTTP API requires arrays for to, cc, and bcc; the JavaScript SDK also accepts single strings.
// app/Http/Controllers/ContactController.php use App\Services\SndrClient; use Illuminate\Http\Request; class ContactController extends Controller { public function store(Request $request, SndrClient $sndr) { $data = $request->validate([ 'name' => 'required|string|min:2|max:120', 'email' => 'required|email', 'message' => 'required|string|min:10|max:8000', ]); $sndr->send([ 'from' => config('services.sndr.from'), 'to' => ['team@yourdomain.com'], 'reply_to' => $data['email'], 'subject' => "New contact from {$data['name']}", 'text' => $data['message'], ]); return response()->json(['ok' => true]); } }Queue the send (recommended)
Wrapping in a queued job means transactional sends survive a request failure and benefit from Laravel's built-in retry/backoff. Pair with a per-job idempotency key (e.g. order ID) so retries don't double-deliver.
<?php // app/Jobs/SendTransactionalEmail.php namespace App\Jobs; use App\Services\SndrClient; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Http\Client\RequestException; class SendTransactionalEmail implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; public array $backoff = [10, 30, 60, 300, 900]; public function __construct( public array $payload, public string $idempotencyKey, ) {} public function handle(SndrClient $sndr): void { try { $sndr->send($this->payload, $this->idempotencyKey); } catch (RequestException $e) { if ($e->response->clientError() && $e->response->status() !== 429) { $this->fail($e); return; } throw $e; } } } // Dispatch from anywhere: SendTransactionalEmail::dispatch([ 'from' => config('services.sndr.from'), 'to' => [$user->email], 'subject' => 'Order confirmation', 'html' => view('emails.order-confirmation', ['order' => $order])->render(), ], 'order-' . $order->id);Attach a PDF
Send up to 10 files with a combined decoded size of 10 MiB. The complete JSON request must fit within 16 MiB. Use base64 bytes; remote URLs and data URIs are not supported.
// Read an application-owned file, not an arbitrary user-supplied path. $pdf = file_get_contents(storage_path('app/invoices/invoice.pdf')); if ($pdf === false) throw new \RuntimeException('Invoice could not be read'); $sndr->send([ 'from' => 'Your App <billing@yourdomain.com>', 'to' => [$customer->email], 'subject' => 'Your invoice', 'text' => 'Your invoice is attached.', 'attachments' => [[ 'filename' => 'invoice.pdf', 'content' => base64_encode($pdf), 'content_type' => 'application/pdf', ]], ], 'invoice-' . $invoice->id);Verify an incoming webhook
Webhook routes must be exempt from CSRF (they're not browser-initiated). Verify the HMAC over `{timestamp}.{rawBody}` with hash_equals. Accept primary and staged signatures so a new signing key can be deployed before promotion.
// routes/api.php: ensure this route is registered outside CSRF middleware. use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; function sndr_verify(string $raw, string $header, string $secret): bool { if ($secret === '') return false; $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { $pair = explode('=', $part, 2); if (count($pair) !== 2) continue; [$key, $value] = array_map('trim', $pair); if ($key === 't') { if ($timestamp !== null && $timestamp !== $value) return false; $timestamp = $value; } elseif (preg_match('/^v1(?:s[0-9]*)?$/', $key) && preg_match('/^[a-f0-9]{64}$/i', $value)) { $signatures[] = strtolower($value); } } if ($timestamp === null || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) return false; $expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) return true; } return false; } Route::post('/webhooks/sndr', function (Request $request) { $raw = $request->getContent(); if (!sndr_verify($raw, $request->header('X-Sndr-Signature', ''), (string) config('services.sndr.webhook_secret'))) { return response('bad signature', 400); } try { $event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { return response('invalid json', 400); } // Enqueue work and deduplicate by the envelope's id before side effects. // Payload fields are inside $event['data']. match ($event['type'] ?? '') { 'email.delivered' => null, 'email.bounced' => null, 'email.failed' => null, 'email.unsubscribed' => null, default => null, }; return response('ok'); });
Laravel FAQ
Is there an official PHP SDK?
Not yet. The HTTP API is small enough that wrapping it with the Http facade gives you nearly all the SDK ergonomics without an extra dependency. If a community PHP SDK appears we will link to it from the docs.
Why not use Laravel Mail with an SMTP transport?
Use the documented HTTP API; public SMTP credentials are not provided by this integration. Render a Blade view with view(...)->render() and pass the result as html. An existing Laravel Mail transport requires its own adapter; the Http facade example is ready to use without one.
Should I retry 4xx errors?
No. 4xx means the server has already decided the request is invalid (validation, auth, suppression). Retry connection failures, 429 (rate limited — honour the Retry-After header), and 5xx. The Http facade ->retry() closure above matches that pattern.
How do I idempotency-key from an Eloquent model?
Stable per-resource keys are best — e.g. 'order-confirmation-' . $order->id. The same key reused across retries makes the second send a no-op server-side. Auto-generate (UUID) only when there is no natural key.
Ship your first Laravel email in 5 minutes.
Free tier includes 3,000 emails / month. No card required.
Start building
Your next message starts here.
Connect your domain, create an API key, and send with SNDR.
Framework reference: Laravel HTTP client retries. Check the documentation for your Laravel version when adapting this service.