Skip to content

How to send email from Laravel with an API: the Http facade, a queued job and a webhook

Published Updated 10 min readBy the Rasket team

Layered flat cards fanned open, releasing a single envelope from between them, drawn as white and violet outlines on black.

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.

Sending from Laravel with an API and with Laravel Mail over SMTP
An HTTP APILaravel Mail over SMTP
InstallationNothing: the Http facade is built inBuilt in
ConfigurationOne key in config/services.phpMAIL_MAILER plus host, port, credentials, TLS
Round tripsOneSeveral, by design
What you get backA message id to storeNothing you can look up later
MIME assemblyNot yoursMailables and Blade views
Domain authenticationGenerated and checked for youYours to publish and watch
SuppressionsKept and enforced at send timeNone
BouncesTyped events on a webhookMessages you parse
TestingHttp::fake()Mail::fake()
Best forProduct mail you have to account forA 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.

  1. Add and verify a sending domainAdd the domain the mail will come from, publish the DNS records Rasket generates for it, and wait for verification to pass. A send from an unverified domain is refused.
  2. Declare the key in config/services.phpAdd a rasket entry whose key reads env('RASKET_API_KEY'), and a webhook_secret beside it. This is the only place env() is called; everywhere else reads config().
  3. Put the value in .envCreate an API key in the dashboard and set RASKET_API_KEY in .env locally and in the host's environment in production. Run php artisan config:cache after a deploy so the cached config carries it.
  4. Make the Http facade callChain withToken, withHeaders with a User-Agent and an Idempotency-Key derived from the order, and post to /emails; call throw() and read json('id') to get the id to store.
  5. Move the send into a queued jobImplement ShouldQueue with $tries and backoff(), send the same idempotency key on every attempt, release() on a 429 and fail() on any other 4xx, then dispatch it with afterCommit().
  6. Receive delivery events in a controllerExclude the route from CSRF validation, verify $request->getContent() with svix/svix before anything parses it, answer 200 at once, and record the event from a job keyed on svix-id.

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 run php 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 returns null, 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 is RASKET_API_KEY everywhere, 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 boot method, 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 $except array of the VerifyCsrfToken middleware. A route in routes/api.php has 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, answer 200, 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.

Frequently asked questions

Can I keep Mail::to()->send() and only change the mailer?

Not to the API directly: a mailer is a transport, and this guide replaces the transport with one HTTPS request from a job. You can keep the Blade view of a mailable for the HTML, because view()->render() produces the string the request carries. 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.

What happens if the job retries after the API already accepted the message?

Nothing is sent twice, provided every attempt carries the same idempotency key. The API remembers a key for 24 hours and answers a repeat with the original response, so the retry gets the first email's id back. That is why the key is derived from the order rather than generated inside the job.

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.

Sources

  1. HTTP ClientLaravel, read 2026-09-16
  2. MailLaravel, read 2026-09-16
  3. QueuesLaravel, read 2026-09-16
  • Send email from Laravel with an APISend email from Laravel with the Http facade instead of an SMTP mailer: one POST, an id back, and every delivery event on a webhook.
  • EmailsSend, batch, retrieve, list, reschedule, cancel, attachments.
  • IdempotencyRetry a send without sending it twice.
  • Email API vs SMTP: which should you use?SMTP is a conversation; an email API is one request. What each gives you on retries, idempotency, events and firewalls, and how to move from one to the other.
  • WebhooksEvery delivery, bounce, complaint, open and click posted to your endpoint, signed with a timestamp, retried on failure and replayable from the dashboard.

Start sending this morning

Sign up, verify a domain and send your first email in minutes.