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

  1. Your billing event arrives and you load the invoice.
  2. Check invoices.emailed_at. If set, stop.
  3. Create a time-limited, authenticated download URL for the PDF.
  4. Call the Inboxili API.
  5. On HTTP 200, set emailed_at and store message_id.
  6. 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_at guard 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.

Related