# Programmable Invoices

Programmable invoices let you create and manage invoices via API. Your customers pay through the standard ZevPay checkout gateway — no need to build your own invoicing or payment collection system.

## How It Works

1. **Create** an invoice via the [API](/api/invoice) with customer details, line items, and a due date
2. **Send** the invoice to transition it from draft to active
3. **Share** the `payment_url` with your customer (email, SMS, in-app, etc.)
4. Customer opens the link and pays via **bank transfer** or **PayID**
5. Invoice status updates automatically — supports **partial payments**
6. You receive **webhook events** for each status change

## Creating an Invoice

```bash
curl -X POST https://api.zevpaycheckout.com/v1/checkout/invoice \
  -H "Content-Type: application/json" \
  -H "x-api-key: sk_test_your_secret_key" \
  -d '{
    "customer_name": "Chidi Okonkwo",
    "customer_email": "chidi@example.com",
    "line_items": [
      {
        "description": "Website Development",
        "quantity": 1,
        "unit_price": 35000000
      },
      {
        "description": "Monthly Hosting (12 months)",
        "quantity": 12,
        "unit_price": 500000
      }
    ],
    "tax_rate": 7.5,
    "due_date": "2026-03-31T00:00:00Z",
    "note": "Thank you for your business!"
  }'
```

The response includes a `payment_url` that you share with your customer:

```json
{
  "public_id": "abc123def456ghi78",
  "invoice_number": "INV-202603-001",
  "status": "draft",
  "total": 44075000,
  "payment_url": "https://secure.zevpaycheckout.com/inv/abc123def456ghi78"
}
```

::: tip Amounts
All monetary amounts in the API are in **kobo** (1 NGN = 100 kobo). For example, `35000000` kobo = ₦350,000.00.
:::

See the full [Invoice API reference](/api/invoice).

## Post-payment Return URL

Set a `return_url` on the invoice (or rely on the merchant's checkout-config redirect URL) and the invoice page will show a **"Return to {your business}"** button after payment + auto-redirect after 5 seconds.

```bash
curl -X POST https://api.zevpaycheckout.com/v1/checkout/invoice \
  -H "x-api-key: sk_test_your_secret_key" \
  -d '{
    "customer_name": "Chidi Okonkwo",
    "customer_email": "chidi@example.com",
    "due_date": "2026-03-31T00:00:00Z",
    "line_items": [{ "description": "Subscription", "quantity": 1, "unit_price": 1000000 }],
    "return_url": "https://yourstore.com/orders/12345/thank-you"
  }'
```

When the customer returns, we append two query params so you can match them back to the invoice without a database lookup:

```
https://yourstore.com/orders/12345/thank-you?status=success&invoice=abc123def456ghi78
```

| Param | Description |
|-------|-------------|
| `status` | Currently always `success` on the post-payment redirect — leaves room for future `cancelled` / `expired` states. |
| `invoice` | The invoice's `public_id`. Use to look up the full state via the [GET invoice endpoint](/api/invoice). |

::: tip Cascade with checkout config
If you don't pass `return_url` on the invoice, we fall back to the merchant's checkout-config redirect URL (per-API-key, with merchant-default fallback). Set the global default in your dashboard under **Checkout → Settings** to apply to every invoice automatically; pass `return_url` per-invoice only when you need to override.
:::

## Partial Payments

By default, invoices accept **partial payments** — your customer can pay any amount up to the remaining balance and come back later to pay the rest. Each partial payment fires a separate `invoice.payment_received` webhook with explicit `is_partial` / `is_fully_paid` flags so you can't mistake one for the other.

To require the customer to pay the full balance at once, set `allow_partial_payments: false`:

```bash
curl -X POST https://api.zevpaycheckout.com/v1/checkout/invoice \
  -H "x-api-key: sk_test_your_secret_key" \
  -d '{
    "customer_name": "Chidi Okonkwo",
    "customer_email": "chidi@example.com",
    "due_date": "2026-03-31T00:00:00Z",
    "line_items": [{ "description": "One-off service", "quantity": 1, "unit_price": 5000000 }],
    "allow_partial_payments": false
  }'
```

### What this changes

| Behavior | `allow_partial_payments: true` (default) | `allow_partial_payments: false` |
|----------|------------------------------------------|----------------------------------|
| Bank-transfer virtual account | Accepts any amount **up to** the remaining balance | Locked to the **exact** remaining balance — under/overpayments rejected at the bank |
| PayID payment method | Available | **Hidden** (PayID is user-initiated and can't enforce an exact amount) |
| Invoice status after partial pay | Flips to `partial`; customer can return via the same link to complete | Cannot reach `partial` state — every successful payment is full |
| Customer email on partial | Dedicated partial-payment email with "Pay remaining balance" CTA | Not applicable |

::: danger Always check `is_fully_paid` in webhooks
Even with `allow_partial_payments: false`, your code should still verify `is_fully_paid` on `invoice.payment_received` events before fulfilling — it's a cheap belt-and-braces against future contract changes (e.g. an admin tooling partial-credit a customer for a service issue).
:::

## Customer Emails

We send context-aware emails to the invoice's `customer_email` on three events:

| Event | When | Includes |
|-------|------|----------|
| **Invoice sent** | You call the [send endpoint](#sending-an-invoice) | Amount due, due date, "Pay Now" link |
| **Partial payment received** | Customer paid less than the total | Amount paid, balance remaining, "Pay Remaining Balance" link back to the same invoice URL |
| **Payment received in full** | Cumulative payments reach the total | "View Receipt" link — the same invoice URL doubles as a permanent receipt (line items + amount paid + paid date) |

The invoice URL is permanent and tokenized. After full payment it stays accessible as a receipt; customers can save it, forward it to their accounting team, or revisit any time without re-authenticating.

## Invoice Lifecycle

Invoices follow a clear status progression:

```
draft → sent → partial → paid
  ↓       ↓       ↓
  └───────┴───────┴──→ cancelled

sent/partial → overdue (automatic, past due date)
```

| Status | Description |
|--------|-------------|
| `draft` | Created but not yet active — payment URL is not usable |
| `sent` | Active and awaiting payment |
| `partial` | Customer has made a partial payment |
| `paid` | Fully paid |
| `overdue` | Past due date, not fully paid (set automatically) |
| `cancelled` | Cancelled by you via API or dashboard |

### Sending an Invoice

After creating an invoice, you must **send** it to make it payable:

```bash
curl -X POST https://api.zevpaycheckout.com/v1/checkout/invoice/abc123def456ghi78/send \
  -H "x-api-key: sk_test_your_secret_key"
```

This transitions the invoice from `draft` to `sent` and sets the `issued_at` timestamp.

### Overdue Detection

Invoices that are past their `due_date` and not fully paid are automatically marked as `overdue`. This check runs hourly. Overdue invoices can still receive payments — when fully paid, they transition to `paid`.

## Payment Flow

When your customer opens the `payment_url`:

1. The invoice details (line items, total, amount remaining) are displayed
2. A **checkout session** is automatically created for the remaining balance
3. The customer selects a payment method (bank transfer or PayID) and pays
4. On successful payment, the invoice's `amount_paid` is updated
5. If partially paid, the status becomes `partial` — the customer can pay the rest later
6. When fully paid, the status becomes `paid`

### Partial Payments

Invoices support partial payments out of the box. Each time a customer pays:

- A new payment record is attached to the invoice
- `amount_paid` is updated with the cumulative total
- If the full total is not yet covered, the status stays `partial`
- On the next payment URL visit, a new checkout session is created for the remaining balance

## Webhook Events

| Event | When |
|-------|------|
| `invoice.created` | Invoice created via API |
| `invoice.sent` | Invoice sent (draft → sent) |
| `invoice.payment_received` | Payment received (partial or full) |
| `invoice.paid` | Invoice fully paid |
| `invoice.overdue` | Invoice past due date |
| `invoice.cancelled` | Invoice cancelled |

```js
app.post('/webhooks/zevpay', (req, res) => {
  const { event, data } = req.body;

  switch (event) {
    case 'invoice.paid':
      // Invoice fully paid — fulfill the order
      fulfillOrder(data.public_id, data.amount_paid);
      break;

    case 'invoice.payment_received':
      // Partial payment — update records
      updatePaymentProgress(data.public_id, data.amount_paid, data.total);
      break;

    case 'invoice.overdue':
      // Send reminder to customer
      sendPaymentReminder(data.customer_email, data.payment_url);
      break;
  }

  res.status(200).send('OK');
});
```

## Invoices vs Other Products

| | Invoice | Checkout Session | Virtual Account |
|---|---|---|---|
| **Use case** | Recurring billing, freelance invoicing, B2B payments | One-time checkout | Backend payment collection |
| **UI** | Full checkout gateway | Full checkout gateway | No UI |
| **Partial payments** | Yes | No | No |
| **Line items** | Yes | No | No |
| **Tax calculation** | Built-in | No | No |
| **Expiry** | Due date (days/weeks) | 30 minutes | Up to 72 hours |
| **Status tracking** | Full lifecycle | Active → Completed | Pending → Completed |

Use **invoices** when you need itemized billing with due dates and partial payment support. Use **checkout sessions** for simple one-time payments. Use **virtual accounts** for headless/API-only payment collection.

## Updating Draft Invoices

While an invoice is still in `draft` status, you can update any of its fields using the PATCH endpoint. Only send the fields you want to change:

```bash
curl -X PATCH https://api.zevpaycheckout.com/v1/checkout/invoice/abc123def456ghi78 \
  -H "Content-Type: application/json" \
  -H "x-api-key: sk_test_your_secret_key" \
  -d '{
    "customer_name": "Chidi O. Okonkwo",
    "line_items": [
      {
        "description": "Website Development (revised)",
        "quantity": 1,
        "unit_price": 40000000
      },
      {
        "description": "Monthly Hosting (12 months)",
        "quantity": 12,
        "unit_price": 500000
      }
    ],
    "note": "Updated scope — revised pricing."
  }'
```

::: warning
- Only **draft** invoices can be updated. Attempting to update a sent, paid, or cancelled invoice returns a `400` error.
- If you send `line_items`, the entire line items list is **replaced** — it is not a merge. Always send the complete list of items.
- Totals (`subtotal`, `tax_amount`, `total`) are automatically recalculated when `line_items` or `tax_rate` change.
:::

Once you're happy with the invoice, [send it](#sending-an-invoice) to make it active.

## Filtering by Customer

### Exact email filter

Use the `customer_email` query parameter to retrieve all invoices for a specific customer:

```bash
curl "https://api.zevpaycheckout.com/v1/checkout/invoice?customer_email=chidi@example.com" \
  -H "x-api-key: sk_test_your_secret_key"
```

This performs an **exact match** on the email address.

### Search

Use the `search` parameter for partial matching across multiple fields (customer name, email, invoice number):

```bash
curl "https://api.zevpaycheckout.com/v1/checkout/invoice?search=chidi" \
  -H "x-api-key: sk_test_your_secret_key"
```

### Combining filters

You can combine `customer_email`, `status`, and `search` in a single query:

```bash
curl "https://api.zevpaycheckout.com/v1/checkout/invoice?customer_email=chidi@example.com&status=sent" \
  -H "x-api-key: sk_test_your_secret_key"
```

## Dashboard

Invoices can be viewed and managed from the [Invoices](https://business.zevpay.ng/invoices) section of your dashboard. From the dashboard you can:

- View all invoices with status filtering and search
- See invoice details including line items, payment history, and settlement info
- Send draft invoices
- Cancel invoices

## Best Practices

### Store invoice identifiers

When you create an invoice, save the `public_id` and/or `invoice_number` to your database. Use these to look up invoices later — don't rely on customer email as a primary key, since email addresses can change.

```js
const response = await fetch('https://api.zevpaycheckout.com/v1/checkout/invoice', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': 'sk_live_your_secret_key',
  },
  body: JSON.stringify(invoiceData),
});

const { data } = await response.json();

// Save these to your database
await db.orders.update({
  where: { id: orderId },
  data: {
    zevpayInvoiceId: data.public_id,
    zevpayInvoiceNumber: data.invoice_number,
    paymentUrl: data.payment_url,
  },
});
```

### Payment URL lifecycle

The `payment_url` remains valid as long as the invoice is payable (`sent`, `partial`, or `overdue`). Share it via email, SMS, or embed it in your app. When the invoice is fully paid, the same URL shows a receipt. Draft and cancelled invoices show an appropriate status message.

### Idempotency

Use the `reference` field to prevent duplicate invoices. Before retrying a create call, check if an invoice with that reference already exists:

```js
// Check for existing invoice before creating
const existing = await fetch(
  `https://api.zevpaycheckout.com/v1/checkout/invoice?search=${reference}`,
  { headers: { 'x-api-key': secretKey } }
);

const { data } = await existing.json();
if (data.items.length === 0) {
  // Safe to create
  await createInvoice({ ...invoiceData, reference });
}
```

### Webhook handling

- **Always verify** the webhook signature using your webhook secret (see [Verifying Signatures](/webhooks/signatures)).
- **Respond with `200` quickly** — do heavy processing (email, fulfillment) asynchronously.
- **Handle `invoice.paid`** for order fulfillment. Use `invoice.payment_received` to track partial payment progress.
- **Implement idempotency** — check if you've already processed a webhook before acting on it. The same event may be delivered more than once.
- **Use `invoice.overdue`** to trigger payment reminders to your customers.

```js
app.post('/webhooks/zevpay', async (req, res) => {
  // Respond immediately
  res.status(200).send('OK');

  const { event, data } = req.body;

  // Idempotency check
  const processed = await db.webhookLog.findUnique({
    where: { eventId: `${event}:${data.public_id}:${data.amount_paid}` },
  });
  if (processed) return;

  switch (event) {
    case 'invoice.paid':
      await fulfillOrder(data.public_id);
      break;
    case 'invoice.payment_received':
      await updatePaymentProgress(data.public_id, data.amount_paid);
      break;
    case 'invoice.overdue':
      await sendPaymentReminder(data.customer_email, data.payment_url);
      break;
  }

  await db.webhookLog.create({
    data: { eventId: `${event}:${data.public_id}:${data.amount_paid}` },
  });
});
```
