Sending Email in PHP and Laravel (2026): cURL, HTTP Client, and Webhook Verification
Send transactional email in PHP 8.2+ using cURL, then in Laravel with the HTTP client: idempotent retries, file attachments, concurrent batch sends with Http::pool, and HMAC-SHA256 webhook verification with hash_equals.

PHP runs a large share of the web's transactional email, and modern PHP (8.2+) has everything you need built in: cURL for HTTP, hash_hmac for HMAC signing, and hash_equals for timing-safe comparison. This guide covers sending email through the Rovela API in plain PHP, then again in Laravel using its HTTP client, with idempotent retries, file attachments, concurrent batch sends, and webhook signature verification.
Prerequisites
- PHP 8.2 or newer
- A Rovela API key from the dashboard
- For the Laravel sections: Laravel 10 or newer
- The
ext-curl,ext-json, andext-mbstringextensions (standard in most distributions)
Plain PHP: Sending a Single Email
Use cURL to POST JSON to https://api.rovela.dev/emails. Keep the API key in an environment variable and read it with getenv.
<?php
const ROVELA_URL = 'https://api.rovela.dev/emails';
class RovelaError extends Exception
{
public int $statusCode;
public string $name;
public function __construct(int $statusCode, string $name, string $message)
{
parent::__construct($message, $statusCode);
$this->statusCode = $statusCode;
$this->name = $name;
}
}
function sendEmail(string $apiKey, array $payload, string $idempotencyKey = ''): string
{
$body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$ch = curl_init(ROVELA_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => array_filter([
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
$idempotencyKey !== '' ? 'Idempotency-Key: ' . $idempotencyKey : null,
]),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$errno = curl_errno($ch);
curl_close($ch);
if ($errno !== 0) {
throw new Exception('HTTP request failed: ' . curl_strerror($errno));
}
$data = json_decode($response, true);
if ($status >= 400) {
throw new RovelaError(
$status,
$data['name'] ?? 'Unknown',
$data['message'] ?? 'Unknown error'
);
}
return $data['id'];
}
Usage:
$apiKey = getenv('ROVELA_API_KEY');
$id = sendEmail($apiKey, [
'from' => 'hello@yourcompany.com',
'to' => ['customer@example.com'],
'subject' => 'Welcome to the platform',
'html' => '<h1>You are in.</h1><p>Click below to get started.</p>',
]);
echo "sent: {$id}
";
Retries with Idempotency-Key
The Rovela API accepts an Idempotency-Key header on POST /emails. Within a 24-hour window, repeating the same key returns the cached response instead of sending a duplicate. Generate the key once before the loop so every retry uses the same value. Retry only on 429 Too Many Requests.
function sendWithRetry(string $apiKey, array $payload, int $maxAttempts = 3): string
{
// Generate once; reuse on every retry so the send is idempotent.
$idempotencyKey = ($payload['to'][0] ?? 'send') . '-' . hrtime(true);
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
return sendEmail($apiKey, $payload, $idempotencyKey);
} catch (RovelaError $e) {
if ($e->statusCode !== 429) {
throw $e;
}
// 429: back off and retry with the same idempotency key.
usleep((1 << $attempt) * 1_000_000);
}
}
throw new Exception("exceeded {$maxAttempts} attempts");
}
File Attachments
Add an attachments array to the payload. Each attachment needs a filename, base64-encoded content, and a content_type. Attachments are not supported on the batch endpoint.
function buildInvoiceEmail(string $recipient, string $invoicePath): array
{
$data = file_get_contents($invoicePath);
return [
'from' => 'billing@yourcompany.com',
'to' => [$recipient],
'subject' => 'Your invoice',
'html' => '<p>Your invoice is attached.</p>',
'attachments' => [
[
'filename' => 'invoice.pdf',
'content' => base64_encode($data),
'content_type' => 'application/pdf',
],
],
];
}
Laravel: Using the HTTP Client
Laravel's Http facade wraps Guzzle and gives you a fluent, testable API. Put the base URL and API key in config/services.php:
// config/services.php
'rovela' => [
'url' => env('ROVELA_URL', 'https://api.rovela.dev'),
'key' => env('ROVELA_API_KEY'),
'secret' => env('ROVELA_WEBHOOK_SECRET'),
],
A small service class keeps the sending logic in one place:
<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class RovelaService
{
public function __construct(
private string $baseUrl,
private string $apiKey,
) {}
public function send(array $payload, string $idempotencyKey = ''): string
{
$response = Http::withToken($this->apiKey)
->timeout(10)
->when($idempotencyKey !== '', fn ($r) => $r->withHeaders([
'Idempotency-Key' => $idempotencyKey,
]))
->post("{$this->baseUrl}/emails", $payload);
if ($response->failed()) {
throw new RovelaApiException(
$response->status(),
$response->json('name', 'Unknown'),
$response->json('message', 'Unknown error'),
);
}
return $response->json('id');
}
public function sendWithRetry(array $payload, int $maxAttempts = 3): string
{
$idempotencyKey = ($payload['to'][0] ?? 'send') . '-' . hrtime(true);
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
return $this->send($payload, $idempotencyKey);
} catch (RovelaApiException $e) {
if ($e->statusCode !== 429) {
throw $e;
}
usleep((1 << $attempt) * 1_000_000);
}
}
throw new \Exception("exceeded {$maxAttempts} attempts");
}
}
Register it in a service provider:
// AppServiceProvider::register()
$this->app->bind(RovelaService::class, function ($app) {
return new RovelaService(
baseUrl: config('services.rovela.url'),
apiKey: config('services.rovela.key'),
);
});
Laravel: Concurrent Batch Sends
When each recipient needs a personalised message, fan out with Http::pool, which runs requests concurrently. Cap the pool size to stay under the Rovela rate limit. Generate each idempotency key before the pool so retries inside sendWithRetry stay stable.
use Illuminate\Support\Facades\Http;
function sendBatchConcurrent(RovelaService $rovela, array $payloads): array
{
$responses = Http::pool(fn (Pool $pool) => array_map(
fn (int $i, array $payload) => $pool
->as("send-{$i}")
->withToken(config('services.rovela.key'))
->timeout(10)
->post(config('services.rovela.url') . '/emails', $payload),
array_keys($payloads),
$payloads,
));
$ids = [];
foreach ($payloads as $i => $payload) {
$response = $responses["send-{$i}"];
if ($response->failed()) {
// Surface the first failure; in production, collect all.
throw new \Exception("send {$i} failed: " . $response->body());
}
$ids[] = $response->json('id');
}
return $ids;
}
The batch endpoint (POST /emails/batch) accepts up to 100 recipients in a single call and is better when every recipient shares the same subject and body. Use the pool above only when payloads differ per recipient.
Webhook Signature Verification
Rovela signs webhook deliveries with HMAC-SHA256. The signature is in the x-resend-signature header in the format t=<timestamp>,v1=<hex-signature>. The signed string is the timestamp, a literal dot, and the raw request body: <timestamp>.<raw-body>.
PHP's hash_hmac computes the HMAC and hash_equals performs a timing-safe comparison. Reading the raw body matters: do not let a framework re-encode or trim it before verification, because the signature covers the exact bytes that were sent.
Plain PHP
function verifyWebhook(string $rawBody, string $signatureHeader, string $secret): array
{
if ($signatureHeader === '' || !str_contains($signatureHeader, ',')) {
throw new Exception('missing or malformed x-resend-signature');
}
$parts = [];
foreach (explode(',', $signatureHeader) as $segment) {
[$k, $v] = explode('=', $segment, 2);
$parts[$k] = $v;
}
$timestamp = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if ($timestamp === '' || $v1 === '') {
throw new Exception('malformed x-resend-signature');
}
// signed string = "<timestamp>.<raw-body>"
$signed = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signed, $secret);
if (!hash_equals($expected, $v1)) {
throw new Exception('signature mismatch');
}
return json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
}
In a plain PHP front controller, read php://input for the raw body:
$rawBody = file_get_contents('php://input');
$secret = getenv('ROVELA_WEBHOOK_SECRET');
try {
$event = verifyWebhook($rawBody, $_SERVER['HTTP_X_RESEND_SIGNATURE'] ?? '', $secret);
} catch (Exception $e) {
http_response_code(401);
echo 'Unauthorized';
exit;
}
http_response_code(204);
Laravel Controller
Laravel re-reads the request body after the framework parses it, so fetch the raw body explicitly with getContent(), which returns the exact bytes from the request stream.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function __invoke(Request $request)
{
$secret = config('services.rovela.secret');
$rawBody = $request->getContent();
$header = $request->header('x-resend-signature', '');
try {
$event = $this->verify($rawBody, $header, $secret);
} catch (\Exception $e) {
return response('Unauthorized', 401);
}
// Dispatch the event to your queue.
// event(new RovelaWebhookReceived($event));
return response()->noContent();
}
private function verify(string $rawBody, string $header, string $secret): array
{
if ($header === '' || !str_contains($header, ',')) {
throw new \Exception('missing or malformed signature header');
}
$parts = [];
foreach (explode(',', $header) as $segment) {
[$k, $v] = explode('=', $segment, 2);
$parts[$k] = $v;
}
$timestamp = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if ($timestamp === '' || $v1 === '') {
throw new \Exception('malformed signature header');
}
$signed = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signed, $secret);
if (!hash_equals($expected, $v1)) {
throw new \Exception('signature mismatch');
}
return json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
}
}
Register the route in routes/web.php or routes/api.php:
Route::post('/webhooks/rovela', WebhookController::class);
If you have middleware that re-reads or transforms the body (for example, JSON parsing or trimming), either disable it on this route or read the raw body before it runs. The signature covers the exact bytes Rovela sent.
Production Checklist
- API key in environment: read from
ROVELA_API_KEY(orconfig('services.rovela.key')in Laravel); never hard-code it or commit it to version control. - Idempotency keys before the loop: generate the key once before the retry loop, not inside it.
- Retry only on 429: the API returns
{"statusCode":429,"name":"Too Many Requests"}when you exceed the send limit; retrying on 4xx or 5xx is safe only because the idempotency key prevents duplicates. - Raw body for webhooks: read
php://inputor$request->getContent()before any framework transformation; the signature covers the exact sent bytes. - Timing-safe comparison: always use
hash_equals, never===or==on the signature hex string. - Pool size under the rate limit: keep
Http::poolconcurrency below your Rovela send limit divided by the expected per-send latency. - Webhook secret in environment: store it in
ROVELA_WEBHOOK_SECRETand rotate it in the dashboard and your deployment atomically.