# Send Email with Laravel Using an Email API

> Send email with Laravel through an email API with the Http client: a service class with retries, Mailables rendered to HTML, queued jobs, templates and batches.

Canonical: https://www.cresca.xyz/blog/send-email-laravel
Author: Lalit Kumar Jangid
Published: 2026-10-27
Last updated: 2026-10-27

To **send email with Laravel** through an email API, you do not need a mail driver package. Laravel's `Http` client already does everything: bearer token, JSON, timeouts and retries. With Cresca that is one request to `https://api.cresca.xyz/api/v1/emails`, an email id back, and delivery from your own verified domain. This tutorial builds a small service class, sends your existing Mailables through it, queues the send, and covers templates, attachments and batches.

**TL;DR**

- Store the key in `.env` as `CRESCA_API_KEY` and expose it through `config/services.php`.
- A 40-line `App\Services\Cresca` class wraps `Http::withToken()->retry()`.
- Render a Mailable with `->render()` and send the HTML through Cresca.
- Send from a queued job with an `Idempotency-Key`, so a job retry never sends twice.

## Before you start

1. **Verify a sending domain.** In Cresca, open [Sending Domains](https://app.cresca.xyz/sending-domains), add your domain and publish the DNS records it shows (DKIM, a custom MAIL FROM domain and an ownership record). If your DNS is on Cloudflare, Cresca can publish them for you.
2. **Add a sender** on that domain, for example `hello@acme.com`. Every `from` address you send from must be an active sender on a verified domain.
3. **Create an API key** on the [API keys](https://app.cresca.xyz/api-keys) page. Choose *Sending access* for production: it can send email but cannot read logs or change templates. The key starts with `cr_live_` and is shown once, so store it as the environment variable `CRESCA_API_KEY`.

The examples need Laravel 10 or later. The full setup, with screenshots of each step, is in the [email API quickstart](https://www.cresca.xyz/docs/quickstart).

## Configure the API key

```bash
# .env
CRESCA_API_KEY=cr_live_xxxxxxxxx
```

```php
// config/services.php
'cresca' => [
    'key' => env('CRESCA_API_KEY'),
],
```

Read the key with `config("services.cresca.key")`, never with `env()` outside config files: after `php artisan config:cache`, `env()` returns null.

## Send email with Laravel: the service class

The basic request is fine for a script. In production you want three more things, and the `Cresca` service below adds all of them:

- **An `Idempotency-Key` header.** If the connection drops after Cresca accepted the email, a retry with the same key returns the first result instead of sending a second email. Keys last 24 hours. Build them from the event, such as `welcome/user-123` or `invoice/inv-2026-0042`, never from the time or a random value. See [idempotency](https://www.cresca.xyz/docs/idempotency).
- **Retries only where they help.** Retry network errors, timeouts, `429 rate_limit_exceeded` and `5xx` with exponential backoff, and wait for the `retry-after` header when Cresca sends it. Do not retry other `4xx` errors: the same request fails the same way.
- **Errors you can branch on.** Every error body has a stable `name` and a readable `message`, for example `{"statusCode": 422, "name": "validation_error", "message": "..."}`. Branch on `name`, because messages can be reworded.

```php
<?php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;

class Cresca
{
    public static function post(string $path, array $payload, ?string $idempotencyKey = null): array
    {
        return Http::baseUrl('https://api.cresca.xyz/api/v1')
            ->withToken(config('services.cresca.key'))
            ->withHeaders(array_filter(['Idempotency-Key' => $idempotencyKey]))
            ->acceptJson()
            ->timeout(10)
            ->retry(4, fn (int $attempt, Throwable $e) => self::delay($attempt, $e), fn (Throwable $e) => self::shouldRetry($e))
            ->post($path, $payload)
            ->json();
    }

    private static function shouldRetry(Throwable $e): bool
    {
        if ($e instanceof ConnectionException) {
            return true; // network error or timeout: the idempotency key makes a retry safe
        }
        if (! $e instanceof RequestException) {
            return false;
        }
        $status = $e->response->status();
        $name = $e->response->json('name');

        return $status >= 500
            || ($status === 429 && $name !== 'monthly_quota_exceeded')
            || $name === 'concurrent_idempotent_requests';
    }

    private static function delay(int $attempt, Throwable $e): int
    {
        $retryAfter = $e instanceof RequestException ? (int) $e->response->header('Retry-After') : 0;

        // Milliseconds: retry-after if Cresca sent it, else 1s, 2s, 4s... plus jitter.
        return $retryAfter > 0 ? $retryAfter * 1000 : 2 ** ($attempt - 1) * 1000 + random_int(0, 250);
    }
}
```

When retries run out, or the error is not worth retrying, Laravel throws `RequestException`; `$e->response->json("name")` gives you the error name to branch on.

## Send your existing Mailables through Cresca

A Mailable's `render()` method returns its HTML, Blade or Markdown layout included. Keep building emails the Laravel way and send the result:

```php
use App\Mail\WelcomeMail;
use App\Services\Cresca;

Cresca::post('/emails', [
    'from' => 'Acme <hello@acme.com>',
    'to' => $user->email,
    'subject' => 'Welcome to Acme',
    'html' => (new WelcomeMail($user))->render(),
], "welcome/{$user->id}");
```

Blade's `{{ }}` escapes values, so user input in the view is safe. The response is `["id" => "4ef9a417-..."]`.

## Queue the send

Sending inside the request makes users wait on the network. Dispatch a job instead. Laravel retries failed jobs, and the idempotency key makes sure a retry after a timeout cannot send a second email:

```php
<?php

namespace App\Jobs;

use App\Mail\WelcomeMail;
use App\Models\User;
use App\Services\Cresca;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SendWelcomeEmail implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    public function __construct(public User $user) {}

    public function handle(): void
    {
        Cresca::post('/emails', [
            'from' => 'Acme <hello@acme.com>',
            'to' => $this->user->email,
            'subject' => 'Welcome to Acme',
            'html' => (new WelcomeMail($this->user))->render(),
        ], "welcome/{$this->user->id}");
    }
}

// In your controller, after creating the user:
SendWelcomeEmail::dispatch($user);
```

The single `Queueable` trait is Laravel 11's job stub; on Laravel 10, use the four traits (`Dispatchable`, `InteractsWithQueue`, `Queueable`, `SerializesModels`) your generated job already has.

## Send a template with variables

Instead of building HTML in your code, you can design the email in Cresca's template editor, mark the parts your app fills in with triple braces such as `{{{RESET_LINK}}}`, and publish it. Your app then sends only the template alias and the values. Sends always use the *published* version, so you can edit a draft without touching live email. Values are HTML-escaped, so a user's name can never change your markup. A missing variable with no fallback returns `422 validation_error` naming it. More in [templates and variables](https://www.cresca.xyz/docs/templates).

```php
Cresca::post('/emails', [
    'to' => $user->email,
    'template' => [
        'id' => 'password-reset', // the template's alias or id
        'variables' => ['NAME' => $user->first_name, 'RESET_LINK' => $resetUrl],
    ],
], "password-reset/{$resetRequest->id}");
```

## Add attachments

Attachments are sent inline as base64: up to 20 files and 7 MB in total per email. Each item has `filename`, `content` and optionally `content_type`. Set `content_id` to show an inline image with `<img src="cid:logo">`. Executable types such as .exe and .js are refused, and attaching by URL is not supported yet.

```php
use Illuminate\Support\Facades\Storage;

Cresca::post('/emails', [
    'from' => 'Acme Billing <billing@acme.com>',
    'to' => $invoice->customer->email,
    'subject' => "Your invoice {$invoice->number}",
    'html' => view('emails.invoice', ['invoice' => $invoice])->render(),
    'attachments' => [[
        'filename' => "{$invoice->number}.pdf",
        'content' => base64_encode(Storage::get($invoice->pdf_path)),
        'content_type' => 'application/pdf',
    ]],
], "invoice/{$invoice->number}");
```

## Send a batch of up to 100 emails

`POST /emails/batch` takes a JSON array of up to 100 emails, each with the same fields as a single send except `attachments` and `scheduled_at`. It is all or nothing: if one email is invalid, nothing is sent and the error message names the failing email by its zero-based index. The response is `{"data": [{"id": "..."}, ...]}` in the same order. One request counts as one request against your rate limit, so batching is how you send thousands of emails per minute.

```php
User::query()->where('wants_summary', true)->chunkById(100, function ($users, $page) {
    $emails = $users->map(fn (User $user) => [
        'from' => 'Acme <hello@acme.com>',
        'to' => $user->email,
        'subject' => 'Your weekly summary',
        'html' => (new WeeklySummary($user))->render(),
    ])->values()->all();

    Cresca::post('/emails/batch', $emails, "weekly-summary/2026-10-27/{$page}");
});
```

`chunkById` passes the page number as the second argument, which makes a stable idempotency key for each chunk.

## Why not a custom mail transport?

You can register a custom Symfony Mailer transport so that `Mail::to()->send()` goes through Cresca. It is more code, and the mail layer hides the two things that make an API worth using: the idempotency key and the error `name`. Calling the API from a small service class keeps both in your hands. Cresca does not offer an SMTP relay yet, so Laravel's `smtp` mailer cannot point at Cresca.

## Errors to handle when you send email with Laravel

These are the errors a sending app actually meets. The full list is on the [errors page](https://www.cresca.xyz/docs/errors).

| Status | name | What it means | Retry? |
| --- | --- | --- | --- |
| 403 | `invalid_from_address` | The from address is not an active sender on a verified domain. | No, fix the sender |
| 422 | `validation_error` | A field is invalid, or a template variable is missing. | No, fix the request |
| 422 | `missing_required_field` | No to, from or subject (and no template that supplies them). | No |
| 429 | `rate_limit_exceeded` | More requests per second than your plan allows. | Yes, after retry-after |
| 429 | `monthly_quota_exceeded` | The plan's email allowance is used up. | No |
| 409 | `concurrent_idempotent_requests` | The same Idempotency-Key is still being processed. | Yes, shortly |
| 5xx | `application_error` | A problem on Cresca's side. | Yes, with backoff |

Recipients on your suppression list (earlier hard bounces, complaints and unsubscribes) are skipped, but the request still succeeds. See [suppression](https://www.cresca.xyz/docs/suppression).

## Check that the email was delivered

A `200` response means Cresca accepted the email, not that it reached the inbox. Open [Transactional](https://app.cresca.xyz/transactional) in the app to see each email's timeline, or look it up with a full-access key:

```bash
curl https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \
  -H "Authorization: Bearer $CRESCA_API_KEY"
```

The `last_event` field moves from `queued` to `sent` and then `delivered` or `bounced`. See [email lifecycle](https://www.cresca.xyz/docs/email-lifecycle) for every status.

## Frequently asked questions

### How do I send email with Laravel using an API instead of SMTP?

Use Laravel's Http client. Call Http::withToken(config('services.cresca.key'))->post('https://api.cresca.xyz/api/v1/emails', \[...\]) with from, to, subject and html. Render existing Mailables to HTML with ->render() and send the result, ideally from a queued job.

### Is there an official Cresca SDK for Laravel?

No, and you do not need one. The API is plain HTTPS and JSON, so the HTTP client you already use in Laravel works. The helper in this tutorial is about 40 lines you can copy into your project and own.

### Can I keep using Mailables and Blade templates?

Yes. A Mailable's render() method returns its final HTML, so you keep writing emails in Blade or Markdown and only change how they are sent. You can also design emails in Cresca's template editor and send just the alias and variables.

### How do I avoid duplicate emails when a queued job retries?

Send an Idempotency-Key header built from the event, such as welcome/123. If the job retries within 24 hours with the same key and the same body, Cresca returns the first result instead of sending again.

### How many emails can I send per request?

One email can have up to 50 recipients across to, cc and bcc, and a batch request carries up to 100 emails. Requests per second depend on your plan: 2 on Free, 10 on Professional, 20 on Premium, 50 on Ultra and 100 on Enterprise. Each recipient counts as one email against the monthly allowance.

### Is Cresca free to try?

Yes. The Free plan gives you 30 days, 50 contacts and 50 emails with no credit card, which is enough to build and test your integration. API email shares your plan's email allowance with campaigns; paid plans are on the pricing page.

## The same tutorial in other languages

[Node.js](https://www.cresca.xyz/blog/send-email-nodejs) · [Python](https://www.cresca.xyz/blog/send-email-python) · [PHP](https://www.cresca.xyz/blog/send-email-php) · [Go](https://www.cresca.xyz/blog/send-email-golang) · [Ruby](https://www.cresca.xyz/blog/send-email-ruby) · [Java](https://www.cresca.xyz/blog/send-email-java) · [C# and .NET](https://www.cresca.xyz/blog/send-email-csharp) · [Next.js](https://www.cresca.xyz/blog/send-email-nextjs) · [Django](https://www.cresca.xyz/blog/send-email-django) · [Ruby on Rails](https://www.cresca.xyz/blog/send-email-rails)

For every field of the Emails API with cURL examples, read the [send email API guide](https://www.cresca.xyz/blog/send-email-api). For the big picture, start with the [transactional email API overview](https://www.cresca.xyz/blog/transactional-email-api). Still deciding how to send? Read [SMTP vs API](https://www.cresca.xyz/blog/smtp-vs-api-sending).

## Start sending

Verify a domain, create a sending-access key and send your first email with Laravel in a few minutes. **[Try Cresca free for 30 days](https://app.cresca.xyz/signup)**, no credit card required, then follow the [quickstart](https://www.cresca.xyz/docs/quickstart) or browse the [API docs](https://www.cresca.xyz/docs).
