# Send Email with PHP: A Complete API Tutorial

> Send email with PHP using cURL and an email API instead of mail(): HTML, templates, attachments, batches, safe retries and a contact form, with working code.

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

PHP's `mail()` function hands email to the server's local mailer, and on most hosts that mail lands in spam or never leaves. To **send email with PHP** reliably, call an email API instead: one HTTPS request with the cURL extension, and Cresca delivers the email from your own verified domain with SPF and DKIM in place. This tutorial covers the basic request, a production-ready function with retries, templates, attachments, batches and a contact form.

**TL;DR**

- POST JSON to `https://api.cresca.xyz/api/v1/emails` with `Authorization: Bearer <key>`.
- Works with the cURL extension that ships with almost every PHP install. No Composer package needed.
- Add an `Idempotency-Key` header and retry only 429, 5xx and network errors.
- Escape user input with `htmlspecialchars()` before it goes into HTML.

## 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 PHP 8.0 or later with the cURL extension (check with `php -m | grep curl`). The full setup, with screenshots of each step, is in the [email API quickstart](https://www.cresca.xyz/docs/quickstart).

## Send email with PHP: the basic request

```php
<?php
$payload = [
    'from' => 'Acme <hello@acme.com>',
    'to' => 'ana@example.com',
    'subject' => 'Welcome to Acme',
    'html' => '<p>Hi Ana, your account is ready.</p>',
];

$ch = curl_init('https://api.cresca.xyz/api/v1/emails');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 10,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('CRESCA_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Network error: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$body = json_decode($raw, true);

if ($status !== 200) {
    throw new RuntimeException("{$body['name']}: {$body['message']}");
}
echo $body['id'], PHP_EOL;
```

A successful send returns `200` and the email id:

```json
{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }
```

`to` can be one address or an array of up to 50 (across `to`, `cc` and `bcc`). If you send only `html`, Cresca builds the plain-text part for you.

## A production-ready PHP function with retries

The basic request is fine for a script. In production you want three more things, and the `cresca()` function 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
// cresca.php: a small client for the Cresca email API.

function cresca(string $path, array $payload, ?string $idempotencyKey = null, int $attempts = 4): array
{
    $headers = [
        'Authorization: Bearer ' . getenv('CRESCA_API_KEY'),
        'Content-Type: application/json',
    ];
    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }
    $json = json_encode($payload, JSON_THROW_ON_ERROR);

    for ($attempt = 1; ; $attempt++) {
        $retryAfter = 0;
        $ch = curl_init('https://api.cresca.xyz/api/v1' . $path);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_POSTFIELDS => $json,
            CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$retryAfter): int {
                if (stripos($line, 'retry-after:') === 0) {
                    $retryAfter = (int) trim(substr($line, 12));
                }
                return strlen($line);
            },
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $body = is_string($raw) ? (json_decode($raw, true) ?? []) : [];

        if ($status >= 200 && $status < 300) {
            return $body;
        }

        // $raw === false means a network error or timeout: safe to retry.
        $name = $body['name'] ?? ($raw === false ? 'network_error' : 'unknown_error');
        $retryable = $raw === false
            || $status >= 500
            || ($status === 429 && $name !== 'monthly_quota_exceeded')
            || $name === 'concurrent_idempotent_requests';
        if (!$retryable || $attempt >= $attempts) {
            $message = $body['message'] ?? curl_error($ch);
            throw new RuntimeException("Cresca {$status} {$name}: {$message}", $status);
        }
        // Wait for retry-after, or 1s, 2s, 4s... plus jitter.
        $seconds = $retryAfter > 0 ? $retryAfter : 2 ** ($attempt - 1) + mt_rand(0, 250) / 1000;
        usleep((int) ($seconds * 1000000));
    }
}
```

Using it:

```php
<?php
require __DIR__ . '/cresca.php';

$name = htmlspecialchars($user['first_name'], ENT_QUOTES, 'UTF-8');

$sent = cresca('/emails', [
    'from' => 'Acme <hello@acme.com>',
    'to' => $user['email'],
    'subject' => 'Welcome to Acme',
    'html' => "<p>Hi {$name}, your account is ready.</p>",
], "welcome/{$user['id']}");
```

If you prefer Guzzle, the same rules apply: POST the JSON with `json =>`, send the same headers, and retry only the cases above.

## 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
<?php
cresca('/emails', [
    'to' => $user['email'],
    'template' => [
        'id' => 'password-reset', // the template's alias or id
        'variables' => ['NAME' => $user['first_name'], 'RESET_LINK' => $resetUrl],
    ],
], "password-reset/{$resetRequestId}");
```

## 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
<?php
cresca('/emails', [
    'from' => 'Acme Billing <billing@acme.com>',
    'to' => $customer['email'],
    'subject' => 'Your invoice INV-2026-0042',
    'html' => '<p>Thanks for your payment. Your invoice is attached.</p>',
    'attachments' => [[
        'filename' => 'inv-2026-0042.pdf',
        'content' => base64_encode(file_get_contents(__DIR__ . '/invoices/inv-2026-0042.pdf')),
        'content_type' => 'application/pdf',
    ]],
], 'invoice/inv-2026-0042');
```

## 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
<?php
$emails = array_map(fn (array $user) => [
    'from' => 'Acme <hello@acme.com>',
    'to' => $user['email'],
    'subject' => 'Your weekly summary',
    'html' => renderSummary($user),
], $users);

foreach (array_chunk($emails, 100) as $index => $chunk) {
    $result = cresca('/emails/batch', $chunk, "weekly-summary/2026-10-27/{$index}");
    echo "Batch {$index}: " . count($result['data']) . " emails accepted\n";
}
```

## Send a contact form email with PHP

A contact form is the classic reason to send email from PHP. Send the message to your own team from your verified sender, put the visitor's address in `reply_to` so you can answer with one click, and use `text` so nothing the visitor typed is ever rendered as HTML:

```php
<?php
require __DIR__ . '/cresca.php';

$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$message = trim((string) filter_input(INPUT_POST, 'message'));

if (!$email || $message === '' || strlen($message) > 5000) {
    http_response_code(422);
    exit('Please enter a valid email address and a message.');
}

cresca('/emails', [
    'from' => 'Acme Website <website@acme.com>',
    'to' => 'team@acme.com',
    'reply_to' => $email,
    'subject' => 'New contact form message',
    'text' => "From: {$email}\n\n{$message}",
]);

echo 'Thanks, we will get back to you soon.';
```

Never use the visitor's address as the from address: you can only send from your own verified domain, and spoofing their address would fail authentication anyway. Add a CAPTCHA or a rate limit to the form so bots cannot use it to burn through your allowance.

## PHPMailer, mail() or an email API?

`mail()` depends on the server's local mail setup, gives you no error details and rarely passes SPF, DKIM and DMARC. PHPMailer and Symfony Mailer are good SMTP clients if you have an SMTP server. An HTTP API is simpler on shared hosting and in containers: no SMTP ports, errors as JSON with a stable `name`, and idempotency keys for safe retries. Cresca does not offer an SMTP relay yet, so with Cresca you use the API as shown. Using Laravel? Read [send email with Laravel](https://www.cresca.xyz/blog/send-email-laravel).

## Errors to handle when you send email with PHP

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

### Why does PHP mail() go to spam?

mail() passes the message to the server's local mailer, which usually sends from a shared IP address and a domain without SPF, DKIM and DMARC alignment. Gmail and Yahoo now require that authentication from bulk senders and filter mail without it. Sending through an email API from your own verified domain fixes this.

### How do I send email in PHP without SMTP?

Use an email API with PHP's cURL extension. POST a JSON body with from, to, subject and html to https://api.cresca.xyz/api/v1/emails with your API key in the Authorization header. No SMTP server, port or password is involved.

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

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

### Does this work in WordPress?

Yes, the cresca() function is plain PHP, so a plugin or theme can call it. Store the API key in wp-config.php or an environment variable, never in the database or theme files, and call it from server-side hooks only.

### 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) · [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) · [Laravel](https://www.cresca.xyz/blog/send-email-laravel) · [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 PHP 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).
