Send email from Laravel with an email API
Send email from Laravel with the Http facade instead of an SMTP mailer: one POST, an id back, and every delivery event on a webhook.
<?php// config/services.php:// 'rasket' => ['key' => env('RASKET_API_KEY')]// Read it through config(), never env() directly:// a cached config makes env() return null.
use Illuminate\Support\Facades\Http;
$key = config('services.rasket.key');
$response = Http::withToken($key) ->withHeaders([ // Required on every request. 'User-Agent' => 'acme-billing/1.0', 'Idempotency-Key' => 'order-1042', ]) ->post('https://api.rasket.com/emails', [ 'from' => 'Acme <orders@send.acme.example>', 'to' => ['ronald.williams@example.com'], 'subject' => 'Your order has shipped', 'html' => '<p>Order 1042 shipped today.</p>', ]);
$response->throw();
echo $response->json('id');What sending from Laravel actually involves
To send email from Laravel you would normally add a mailer to config/mail.php and give it SMTP credentials. Calling an email API instead is one Http::post from a service, a job or a listener, and it hands back an id you can store. There is no PHP SDK, so the sample below uses the Http facade, which ships with the framework.
Last updated 2026-10-13. Every field, header and limit below is the one the emails reference carries.
What you get with the key
Five things that arrive with the first request rather than with a later plan.
Idempotent sends
Put an Idempotency-Key on a send and repeat it as often as you like. For 24 hours the same key and payload return the first response instead of a second email, and a different payload under that key is refused rather than sent.
Signed webhooks you can replay
Delivery, bounce, complaint, open and click posted to your endpoint with a timestamp and a signature, retried on failure and replayable from the dashboard.
Inbound mail on your own domain
Receive at your domain on every plan. Each message arrives on a signed webhook with its headers, text and HTML, and its attachments behind signed links.
Templates with typed variables
Publish a template, then send it by ID or alias with the values it declares. Every email records the exact version it was rendered from.
Campaigns and automations
Contacts with typed properties, topics people subscribe to, and per-contact workflows that wait, branch and send — through the same pipeline as the rest of your mail.
How to send email from Laravel
Six steps, in this order. Everything before the fifth is done once, and only the fifth is about the language you write in.
- 1
Create an API key
Open API keys in the dashboard and create one. It is shown once and stored hashed, so copy it then. Put it in your environment as RASKET_API_KEY and read it from there; a key committed to a repository is a key you have to rotate.
- 2
Add a sending domain
POST /domains with a domain you control. A subdomain such as send.acme.example is the usual choice, because it keeps this mail's reputation separate from the address your people write from. The response carries a records array.
- 3
Publish the DNS records
Every row of records goes into your DNS: the DKIM TXT record at rasket._domainkey, and the CNAME that gives the domain its own return path. Nothing sends from the domain until they resolve.
- 4
Verify the domain
POST /domains/{domain_id}/verify. The domain comes back with a status on each record, and once it reads verified you can send from any address on it. An unverified sender is refused rather than quietly dropped.
- 5
Send your first email from Laravel
Post the message with the sample above. Carry an Idempotency-Key — yours or your queue's retry cannot then turn into a second email — and a User-Agent, which every request has to have. A 200 with an id means we have taken responsibility for the message.
- 6
Subscribe a webhook and check its signature
POST /webhooks with an https endpoint and the events you care about. Delivery, bounce, complaint, open and click arrive there with svix-id, svix-timestamp and svix-signature; verify against the raw body before anything parses it.
Verify webhooks in Laravel
$request->getContent() is the raw body. Read it before $request->all() or $request->json(), exclude the route from CSRF verification, and pass the string with the headers to the svix/svix package's Webhook::verify(). A body Laravel has already decoded and re-encoded is a different string from the one that was signed.
The five steps a verifier performs, in seven languages, are on the webhooks reference.
Laravel Mail vs an API
To send email from Laravel you either write a mailable and let the Mail facade deliver it through a transport you configure — SMTP, or a driver for a provider — or you make one HTTPS request to an email API with the Http facade. Both put a message in front of a mail server. The difference is everything around that handoff, and it is what decides which one an application should use.
| An HTTP API | Laravel Mail over SMTP | |
|---|---|---|
| Installation | Nothing: the Http facade is built in | Built in |
| Configuration | One key in config/services.php | MAIL_MAILER plus host, port, credentials, TLS |
| Round trips | One | Several, by design |
| What you get back | A message id to store | Nothing you can look up later |
| MIME assembly | Not yours | Mailables and Blade views |
| Domain authentication | Generated and checked for you | Yours to publish and watch |
| Suppressions | Kept and enforced at send time | None |
| Bounces | Typed events on a webhook | Messages you parse |
| Testing | Http::fake() | Mail::fake() |
| Best for | Product mail you have to account for | A relay you already run, or the log driver in development |
Laravel Mail is well made. Markdown mailables, Mail::fake() and the log driver are things you will miss elsewhere. What it cannot do is remember that an address bounced last week, refuse a send from a domain that fails authentication, or tell you a message was delivered, because SMTP carries none of that back. The API versus SMTP comparison goes through the trade in full, and the Laravel stack page is the short version of this guide. The rest of it is the Laravel email API integration end to end: the request, the config, the job and the webhook.
The Http facade call
This is the sample the stack page shows, unchanged, so the two pages describe one request. There is no package to install: the Http facade is part of the framework.
<?php// config/services.php:// 'rasket' => ['key' => env('RASKET_API_KEY')]// Read it through config(), never env() directly:// a cached config makes env() return null.
use Illuminate\Support\Facades\Http;
$key = config('services.rasket.key');
$response = Http::withToken($key) ->withHeaders([ // Required on every request. 'User-Agent' => 'acme-billing/1.0', 'Idempotency-Key' => 'order-1042', ]) ->post('https://api.rasket.com/emails', [ 'from' => 'Acme <orders@send.acme.example>', 'to' => ['ronald.williams@example.com'], 'subject' => 'Your order has shipped', 'html' => '<p>Order 1042 shipped today.</p>', ]);
$response->throw();
echo $response->json('id');Three things carry the weight. withToken sets the bearer header from a value read through config(), for the reason the next section gives. The User-Agent is required — a request without one is refused — and what you send is what lets support tell your billing worker from your web process. And the Idempotency-Key is derived from the order, which is the thing that caused the send, rather than generated fresh. A repeat of a keyed request inside 24 hours returns the original response and sends nothing; the idempotency guide has the exact rules.
throw() turns any 4xx or 5xx into a RequestException, and json('id') reads one key out of the body. The id is what every later event is about, so store it against the order. The HTTP client documentation covers both methods, and the timeouts the job below adds.
The from address has to be on a verified domain
A send from a domain that has not passed verification is refused before anything leaves. Publishing the records takes a few minutes and the verification walkthrough covers the panel behaviour that trips most people up.
Config and .env
The comment at the top of the first sample is the most common Laravel production bug in one sentence, so it is worth the whole section.
<?php// config/services.php
return [ // ...
'rasket' => [ 'key' => env('RASKET_API_KEY'), 'webhook_secret' => env('RASKET_WEBHOOK_SECRET'), ],];# .env — listed in .gitignore, never committedRASKET_API_KEY=RASKET_WEBHOOK_SECRET=env()belongs in config files only. When you runphp artisan config:cache— which every deployment should — Laravel evaluates the config files once, writes the result to disk and stops loading.env. After that,env('RASKET_API_KEY')anywhere else returnsnull, and your first request after deploying sends an empty bearer token.config('services.rasket.key')works in both cases.- The value is never a literal. Locally it comes from
.env; in production from the host’s own environment or its secret store. The name isRASKET_API_KEYeverywhere, which is what lets one config file serve every environment. - Fail loudly. A key that is missing should stop the process, not send nothing. A check in a service provider’s
bootmethod, or a validation of the config on start-up, costs one line and saves a quiet night of refused requests.
Queued sends with the same key
A send is a network call to another service. Doing it inside a controller makes your response time depend on theirs, and your request fail when theirs does. Laravel’s queue already has attempts and backoff; the only thing to get right is which failures to retry, and with what key.
<?php// app/Jobs/SendShippingEmail.php
namespace App\Jobs;
use App\Models\Order;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Queue\Queueable;use Illuminate\Http\Client\RequestException;use Illuminate\Support\Facades\Http;
class SendShippingEmail implements ShouldQueue{ use Queueable;
public int $tries = 5;
public function __construct(public Order $order) {}
/** Seconds to wait before attempts two to five. */ public function backoff(): array { return [10, 30, 90, 300]; }
public function handle(): void { $response = Http::withToken(config('services.rasket.key')) ->withHeaders([ 'User-Agent' => 'acme-billing/1.0', // The SAME key on every attempt of this order. 'Idempotency-Key' => "order-{$this->order->id}", ]) ->connectTimeout(5) ->timeout(10) ->post('https://api.rasket.com/emails', [ 'from' => 'Acme <orders@send.acme.example>', 'to' => [$this->order->email], 'subject' => 'Your order has shipped', 'html' => view('emails.shipped', ['order' => $this->order])->render(), ]);
if ($response->status() === 429) { // Wait what the API asked for, then try again with the same key. $this->release((int) $response->header('Retry-After') ?: 1);
return; }
if ($response->clientError()) { // Any other 4xx fails the same way next time: stop retrying. $this->fail(new RequestException($response));
return; }
// A 5xx throws; the queue retries with the backoff above. $response->throw();
$this->order->update(['shipping_email_id' => $response->json('id')]); }}The three branches are the whole retry policy. A connection timeout throws a ConnectionException before any of them run, so the attempt fails and the queue tries again after the next entry in backoff() — nothing answered, and you do not know whether the message went. A 429 means wait what the Retry-After header says, in whole seconds, and release() puts the job back with that delay. Any other 4xx means the API answered and said no; a validation error will say no identically forever, so fail() stops the attempts and lets the failed-jobs table hear about it. A 5xx throws and is retried. The limit is ten requests a second per team, shared by every key; the rate limits guide has the headers and the error body.
The comment inside withHeaders is the reason the retries are safe. Every attempt sends order-1042 as its key, so an attempt that timed out after the API had in fact accepted the message gets the original response back rather than a second email. Without that, backoff is a machine for sending duplicates politely. The queue documentation covers $tries, backoff(), release() and fail().
Dispatching it
// In the controller or service that marks the order shipped.// afterCommit() holds the job until the transaction is committed.SendShippingEmail::dispatch($order)->afterCommit();afterCommit() is the small thing that avoids a large confusion. Without it, a fast worker can pick the job up before the transaction that marked the order shipped has committed, and find the old state of the row.
Webhooks in a controller
A send returns 200 when the message is accepted and recorded, not when it is delivered. Delivery, bounces, complaints and opens arrive later as signed HTTPS requests to a URL you register. In Laravel that URL is a controller with three properties the framework will fight you on.
<?php// app/Http/Controllers/RasketWebhookController.php// composer require svix/svix
namespace App\Http\Controllers;
use App\Jobs\RecordEmailEvent;use Illuminate\Http\Request;use Illuminate\Http\Response;use Svix\Exception\WebhookVerificationException;use Svix\Webhook;
class RasketWebhookController extends Controller{ public function __invoke(Request $request): Response { // getContent() is the raw body. Never $request->json() first: // the signature is over what arrived, not what you re-serialise. $payload = $request->getContent();
try { $webhook = new Webhook(config('services.rasket.webhook_secret')); $event = $webhook->verify($payload, [ 'svix-id' => $request->header('svix-id'), 'svix-timestamp' => $request->header('svix-timestamp'), 'svix-signature' => $request->header('svix-signature'), ]); } catch (WebhookVerificationException) { return response('invalid signature', 400); }
// Answer first, work later. The job dedupes on svix-id. RecordEmailEvent::dispatch($request->header('svix-id'), $event);
return response('', 200); }}<?php// routes/web.phpuse App\Http\Controllers\RasketWebhookController;
Route::post('hooks/rasket', RasketWebhookController::class);
// bootstrap/app.php (Laravel 11 and later; the use statements// for Application and Middleware are already in the file)return Application::configure(basePath: dirname(__DIR__)) ->withRouting(web: __DIR__.'/../routes/web.php') ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: ['hooks/rasket']); }) ->create();- It is excluded from CSRF validation. The web middleware group rejects any POST without a token it issued, and a webhook sender has none. On Laravel 11 and later the exclusion lives in
bootstrap/app.php, as above; on older versions it is the$exceptarray of theVerifyCsrfTokenmiddleware. A route inroutes/api.phphas no CSRF check to begin with. The signature is the authentication instead. - It reads
getContent()first. The signature is an HMAC over the raw bytes. Reading$request->json()or$request->all()parses the body, and re-serialising an array produces a different string with a different signature. Verify the bytes, then use the parsed event the verifier returns. - It answers fast. A handler that does its work inline and times out will be sent the same event again. Hand the event to a job keyed on
svix-id, answer200, and let the job dedupe.
Register the same URL in the dashboard and put the secret it gives you in RASKET_WEBHOOK_SECRET. The verifier checks the timestamp as well as the digest, so a request replayed more than five minutes later is refused too. The webhooks article covers retries, ordering and replay, and the events reference lists every event a send can produce.
Sources
- HTTP Client — Laravel, read 2026-09-16
- Mail — Laravel, read 2026-09-16
- Queues — Laravel, read 2026-09-16
Questions about Laravel
Do I need an SMTP server to send email from Laravel?
No. An HTTPS API replaces the mailer with one request and one key. If you need Mail::to(...)->send(...) to keep working, point the smtp mailer in config/mail.php at smtp.rasket.com on port 465 with the username rasket and your key instead.
Is there a PHP SDK?
Not today. The Http facade covers it: withToken for the bearer, withHeaders for the User-Agent and the idempotency key, and post with an array that becomes the JSON body. ->throw() turns a non-2xx into an exception your handler already knows about.
Where do I keep the API key in a Laravel app?
Put RASKET_API_KEY in .env, surface it in config/services.php, and read it with config('services.rasket.key'). Calling env() outside a config file breaks the moment somebody runs config:cache, because env() then returns null and the send fails with no key.
How do I stop a retry from sending the same email twice?
Send an Idempotency-Key header built from the model the mail is about. A queued job that fails and is retried then repeats the same key, and for 24 hours that returns the first response instead of sending again; a different payload under that key is refused.
How do I verify a Rasket webhook in Laravel?
composer require svix/svix, read $request->getContent() first, and pass it with the svix-id, svix-timestamp and svix-signature headers to Webhook::verify(). Exclude the route from CSRF protection, store the event, and answer 400 when verification throws.
Can I keep Mail::to()->send() and only change the mailer?
Yes, through the SMTP relay: point the smtp mailer at smtp.rasket.com on port 465 with the username rasket and your key. This guide replaces the transport with one HTTPS request from a job instead, which returns the message id. You can keep the Blade view of a mailable for the HTML, because view()->render() produces the string the request carries, and the Mail facade and the Http call coexist in one application without conflict.
Why does config('services.rasket.key') return null in production?
Three usual causes. The variable was never added to the server's environment, only to a local .env file; the config was cached before the variable existed, so run php artisan config:cache again; or the code calls env() somewhere other than a config file, which returns null once the config is cached.
Which failures should the queued job retry?
Retry a ConnectionException, because nothing answered and you do not know whether the message went; the queue does that on its own with backoff(). Release the job on a 429 after the seconds in the Retry-After header. Fail any other 4xx, since a validation error will fail identically on every attempt.
Why does my webhook route answer 419?
A 419 is Laravel's CSRF failure: the web middleware group rejects a POST without a token it issued, and a webhook sender has none. Exclude the route with validateCsrfTokens(except: [...]) in bootstrap/app.php on Laravel 11 and later, or in the VerifyCsrfToken middleware's $except array before that. The signature verifies the request instead.
How do I test the job without sending?
Call Http::fake() in the test, dispatch the job synchronously, and assert with Http::assertSent on the URL, the bearer header, the User-Agent and the Idempotency-Key. The key is the part that goes wrong quietly, so a test that checks it is worth more than one that checks the subject line.
Keep reading
Make your first send
Create a key, verify a domain, and post your first message from Laravel. The free plan does not expire.