Invoice email: send once, never twice
Updated
An invoice email is a financial record. A duplicate is a support ticket, a missing one is a chargeback risk. That makes the sending logic more important than the design.
When it fires
When an invoice is finalised or a payment succeeds. Drive it from your billing system's event, such as a payment-succeeded webhook, not from a page load.
Workflow
- Your billing event arrives and you load the invoice.
- Check
invoices.emailed_at. If set, stop. - Create a time-limited, authenticated download URL for the PDF.
- Call the Inboxili API.
- On HTTP 200, set
emailed_atand storemessage_id. - On a network error with no response, do not resend automatically. The first request may have succeeded. Queue it for review or accept a small risk of a duplicate.
Implementation (PHP 8)
<?php
function emailInvoice(PDO $db, array $invoice, array $customer): void {
if ($invoice['emailed_at'] !== null) {
return;
}
$payload = [
'to' => $customer['email'],
'from_email' => 'billing@yourdomain.com',
'from_name' => 'Acme Billing',
'subject' => 'Invoice {{number}} from Acme',
'html_body' => '<p>Hi {{name}},</p><p>Invoice {{number}} for {{amount}} is paid. <a href="{{pdf_url}}">Download the PDF</a> (link valid for 7 days).</p>',
'text_body' => "Invoice {$invoice['number']}: {$invoice['pdf_url']}",
'template_data' => [
'name' => $customer['first_name'],
'number' => $invoice['number'],
'amount' => number_format($invoice['total'] / 100, 2) . ' ' . $invoice['currency'],
'pdf_url' => $invoice['pdf_url'],
],
];
$ch = curl_init('https://api.inboxili.com/api/v1/transactional/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('INBOXILI_API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status === 200) {
$body = json_decode($raw, true);
$db->prepare('UPDATE invoices SET emailed_at = NOW(), email_message_id = ? WHERE id = ?')
->execute([$body['message_id'], $invoice['id']]);
} elseif ($status === 0) {
throw new RuntimeException('No response from Inboxili; not retrying automatically.');
} else {
throw new RuntimeException("Inboxili returned $status: $raw");
}
}
Sample email
Subject: Invoice INV-1042 from Acme Hi Ada, Invoice INV-1042 for 49.00 USD is paid. Download the PDF (link valid for 7 days).
Things that go wrong
- Duplicate sends from webhook redeliveries. Billing providers redeliver events, so the
emailed_atguard is mandatory. - PDF links that expire before the user reads the email. Seven days or more, and a way to re-request.
- Floats for money. Format from integer minor units.
- Wrong From address. The sender must be registered on a verified domain, or the API returns
sender_not_verified.
Delivery events
Subscribe to bounced and flag the customer's billing contact, so someone gets an invoice somewhere else. Subscribe to complained to stop sending to that address.
Deliverability notes
Finance emails get opened late and forwarded to accountants. Use a plain, stable subject pattern, keep the From address constant, and put the invoice number in the subject so it is searchable.
Frequently asked questions
- Can I attach the invoice PDF?
- Not through the Inboxili API today, which has no attachment field. Link to a secure, expiring download URL instead.
- How do I avoid duplicate invoice emails?
- The API has no idempotency key, so keep your own record. Mark the invoice as emailed in your database in the same transaction that creates the send job, and check it before any retry.
Send your first invoice email
Create a workspace, verify a domain, and make your first API call.