PHP & Laravel
The Transactional client API is a single POST. The built-in cURL extension is enough — no Composer package required. If you're on Laravel, the framework's HTTP client is even nicer.
Plain PHP (cURL extension)
<?php
function transactionalGenerate(string $documentId, array $variables): array
{
$ch = curl_init('https://api.transactional.dev/v1/generate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'x-api-token: ' . getenv('TRANSACTIONAL_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'documentId' => $documentId,
'variables' => $variables,
]),
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($body, true);
if ($status < 200 || $status >= 300) {
throw new RuntimeException(
"Transactional {$status}: " . ($data['error'] ?? 'unknown') . ' — ' . ($data['message'] ?? '')
);
}
return $data; // ['url' => '...', 'documentId' => '...']
}
$result = transactionalGenerate(
'1d8e5d56-1a4f-4b62-8c33-2d34a64b2f00',
[
'customer' => ['name' => 'Acme Corp'],
'invoice' => ['number' => 'INV-2026-0142', 'total' => 1280.50],
]
);
echo $result['url'];
Laravel HTTP client
Laravel's HTTP client (built on Guzzle) gives you retries and timeouts in one line.
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders([
'x-api-token' => config('services.transactional.token'),
])
->timeout(30)
->retry(2, 200, throw: false) // 2 retries, 200ms baseline
->post('https://api.transactional.dev/v1/generate', [
'documentId' => $documentId,
'variables' => $variables,
]);
if ($response->failed()) {
$body = $response->json();
throw new \RuntimeException(
"Transactional {$response->status()}: "
. ($body['error'] ?? 'unknown') . ' — '
. ($body['message'] ?? '')
);
}
return $response->json('url');
Add the token to config/services.php and pull it from .env:
// config/services.php
'transactional' => [
'token' => env('TRANSACTIONAL_API_TOKEN'),
],
Branching on error codes
$body = $response->json();
$code = $body['error'] ?? null;
match (true) {
$response->successful() => $body['url'],
$code === 'quota_exceeded' => throw new OutOfCreditsException(),
in_array($code, ['NOT_FOUND', 'invalid_document_id']) => throw new BadTemplateException($documentId),
$code === 'UNAUTHORIZED' => throw new ApiTokenInvalidException(),
default => throw new RuntimeException('Transactional unknown error'),
};
Queueing as a job (Laravel)
Generating from a synchronous request blocks the HTTP cycle for a few hundred ms. Push it to a queue:
// app/Jobs/GenerateInvoicePdf.php
class GenerateInvoicePdf implements ShouldQueue
{
use Queueable;
public function __construct(
public int $invoiceId,
public string $documentUuid,
) {}
public function handle(): void
{
$invoice = Invoice::findOrFail($this->invoiceId);
$response = Http::withHeaders([
'x-api-token' => config('services.transactional.token'),
])->retry(2, 200, throw: false)
->post('https://api.transactional.dev/v1/generate', [
'documentId' => $this->documentUuid,
'variables' => $invoice->toTemplateVariables(),
]);
if ($response->failed()) {
// Let the queue retry policy handle it.
$this->fail("Transactional failed: " . $response->status());
return;
}
// Stream the PDF to S3.
$pdf = Http::get($response->json('url'))->body();
Storage::disk('s3')->put("invoices/{$invoice->id}.pdf", $pdf);
$invoice->update(['pdf_path' => "invoices/{$invoice->id}.pdf"]);
}
}
// Dispatch it
GenerateInvoicePdf::dispatch($invoice->id, $templateUuid);
Attaching the PDF to a Mailable
// app/Mail/InvoiceMail.php
public function attachments(): array
{
$response = Http::withHeaders([
'x-api-token' => config('services.transactional.token'),
])->post('https://api.transactional.dev/v1/generate', [
'documentId' => config('invoices.template_uuid'),
'variables' => $this->invoice->toTemplateVariables(),
]);
$pdf = Http::get($response->json('url'))->body();
return [
Attachment::fromData(fn () => $pdf, "invoice-{$this->invoice->number}.pdf")
->withMime('application/pdf'),
];
}