---
title: Invoice Checkout
description: Create programmable invoices with line items, tax, and due dates. Share a hosted, tokenised payment link that supports partial payments and doubles as a receipt.
---

# Invoice Checkout

Create an invoice on your server, share the returned payment link, and let us host the payment page. Invoices add line items, tax, due dates, and optional partial payments on top of the standard checkout. The payment page becomes a receipt once the invoice is paid.

For a single one-off payment without line items, use [Standard Checkout](/checkout/standard) instead.

## How it works

1. **Create** an invoice with customer details, line items, and a due date.
2. **Send** it to move it from draft to active.
3. **Share** the `payment_url` with your customer by email, SMS, or in your app.
4. The customer opens the link and pays by [bank transfer](/payment-methods/bank-transfer) or [ZevPay ID](/payment-methods/zevpay-id).
5. The status updates automatically. Partial payments are supported.
6. Your server receives a webhook for each status change.

Invoices are created with your secret key, so this is a server-side flow.

## Create 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. It carries an access token, so treat it as the private pay link for that customer.

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

::: tip Amounts are in kobo
Every monetary field in the API is in kobo. 100 kobo is 1 Naira, so `35000000` kobo is ₦350,000.00.
:::

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

## Tax

There are two ways to add tax. Use one or the other, never both in the same request.

### Percentage rate

Pass `tax_rate` as a percentage from `0` to `100` and we compute the tax:

```json
{ "tax_rate": 7.5 }
```

`tax_amount` is derived as `subtotal * rate`, and `total = subtotal + tax_amount`.

### Exact amount

If your own system computes the tax and you need the total to land on an exact figure, pass `tax_amount` in kobo:

```json
{ "tax_amount": 117054 }
```

We use it verbatim: `total = subtotal + tax_amount`, to the kobo. The invoice page and customer emails show it as a dedicated Tax line, separate from your line items.

::: warning One or the other
Sending both `tax_rate` and `tax_amount` in the same request returns a `400` error. Requests with neither are untaxed.
:::

## Partial payments

By default, an invoice accepts partial payments. The customer can pay any amount up to the remaining balance and return later to pay the rest. Each partial payment fires its own `invoice.payment_received` webhook with `is_partial` and `is_fully_paid` flags.

To require the full balance in one payment, 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

| Behaviour | `allow_partial_payments: true` (default) | `allow_partial_payments: false` |
|-----------|------------------------------------------|----------------------------------|
| Bank-transfer amount | Any amount up to the remaining balance | Locked to the exact remaining balance. Under and over payments are rejected at the bank. |
| ZevPay ID method | Available | Hidden. ZevPay ID is customer-initiated and cannot enforce an exact amount. |
| Status after a partial payment | Moves to `partial`. The customer can return via the same link to finish. | Cannot reach `partial`. Every successful payment is a full payment. |

::: danger Always check `is_fully_paid`
Even with `allow_partial_payments: false`, check `is_fully_paid` on `invoice.payment_received` before you fulfil. It is a cheap safeguard against future changes such as an admin partial-crediting a customer for a service issue.
:::

## Post-payment return URL

Set a `return_url` on the invoice and the payment page shows a "Return to {your business}" button after payment and redirects there 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"
  }'
```

On return, we append two query parameters so you can match the payment without a database lookup:

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

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

::: tip Falls back to your checkout config
If you do not pass `return_url`, we fall back to the redirect URL in your checkout config (per key, with a merchant default). Set the default in your dashboard under Checkout, then Settings, and pass `return_url` per invoice only when you need to override it.
:::

## Invoice lifecycle

```
draft -> sent -> partial -> paid
  |        |        |
  +--------+--------+--> cancelled

sent or partial -> overdue (automatic, past due date)
```

| Status | Description |
|--------|-------------|
| `draft` | Created but not active. The payment link is not usable yet. |
| `sent` | Active and awaiting payment. |
| `partial` | The customer has made a partial payment. |
| `paid` | Fully paid. |
| `overdue` | Past the due date and not fully paid. Set automatically. |
| `cancelled` | Cancelled by you via the API or dashboard. |

### Send an invoice

A draft is not payable. Send it to make the payment link work:

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

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

### Overdue detection

Invoices past their `due_date` and not fully paid are marked `overdue` automatically. The check runs hourly. Overdue invoices can still be paid, and become `paid` when the balance is cleared.

### Update a draft

While an invoice is in `draft`, update any field with the PATCH endpoint. Send only 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 '{ "note": "Updated scope and revised pricing." }'
```

::: warning Update rules
- Only draft invoices can be updated. Updating a sent, paid, or cancelled invoice returns a `400` error.
- If you send `line_items`, the whole list is replaced, not merged. Send the complete list every time.
- Totals are recalculated when `line_items`, `tax_rate`, or `tax_amount` change.
:::

## Payment flow

When the customer opens the `payment_url`:

1. The invoice details, total, and remaining balance are shown.
2. A checkout session is created for the remaining balance.
3. The customer picks a method and pays.
4. `amount_paid` is updated.
5. If the balance is not cleared, the status stays `partial` and the same link works again later.
6. When fully paid, the status becomes `paid` and the page becomes a receipt.

## Customer emails

We email the invoice `customer_email` on three events:

| Event | When | Includes |
|-------|------|----------|
| Invoice sent | You call the send endpoint | Amount due, due date, and a Pay Now link. |
| Partial payment received | The customer paid less than the total | Amount paid, balance remaining, and a link to pay the rest. |
| Payment received in full | Payments reach the total | A receipt link. The same invoice URL serves as a permanent receipt. |

## Webhook events

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

```js
app.post('/webhooks/zevpay', (req, res) => {
  res.status(200).send('OK'); // respond fast, process asynchronously

  const { event, data } = req.body;
  switch (event) {
    case 'invoice.paid':
      fulfillOrder(data.public_id, data.amount_paid);
      break;
    case 'invoice.payment_received':
      updatePaymentProgress(data.public_id, data.amount_paid, data.total);
      break;
    case 'invoice.overdue':
      sendPaymentReminder(data.customer_email, data.payment_url);
      break;
  }
});
```

Always verify the webhook signature. See [Verifying Signatures](/webhooks/signatures).

## Invoice compared to other options

| | Invoice | Standard | Inline |
|---|---------|----------|--------|
| Line items | Yes | Optional | No |
| Tax calculation | Built in | No | No |
| Partial payments | Yes | No | No |
| Expiry | Due date | 30 minutes | 30 minutes |
| Session created by | Your server, invoice API | Your server | The browser SDK |

## Related

- [Invoice API reference](/api/invoice)
- [Standard Checkout](/checkout/standard)
- [Payment Methods](/payment-methods/overview)
- [Webhook Events](/webhooks/events)
