Skip to content

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 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 or 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=..."
}

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.

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.

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

Behaviourallow_partial_payments: true (default)allow_partial_payments: false
Bank-transfer amountAny amount up to the remaining balanceLocked to the exact remaining balance. Under and over payments are rejected at the bank.
ZevPay ID methodAvailableHidden. ZevPay ID is customer-initiated and cannot enforce an exact amount.
Status after a partial paymentMoves to partial. The customer can return via the same link to finish.Cannot reach partial. Every successful payment is a full payment.

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
ParameterDescription
statusCurrently always success on the post-payment redirect. Leaves room for future states.
invoiceThe invoice's public_id. Use it to look up the full state via the GET invoice endpoint.

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)
StatusDescription
draftCreated but not active. The payment link is not usable yet.
sentActive and awaiting payment.
partialThe customer has made a partial payment.
paidFully paid.
overduePast the due date and not fully paid. Set automatically.
cancelledCancelled 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." }'

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:

EventWhenIncludes
Invoice sentYou call the send endpointAmount due, due date, and a Pay Now link.
Partial payment receivedThe customer paid less than the totalAmount paid, balance remaining, and a link to pay the rest.
Payment received in fullPayments reach the totalA receipt link. The same invoice URL serves as a permanent receipt.

Webhook events

EventWhen
invoice.createdInvoice created via the API.
invoice.sentInvoice sent (draft to sent).
invoice.payment_receivedPayment received, partial or full.
invoice.paidInvoice fully paid.
invoice.overdueInvoice past its due date.
invoice.cancelledInvoice 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.

Invoice compared to other options

InvoiceStandardInline
Line itemsYesOptionalNo
Tax calculationBuilt inNoNo
Partial paymentsYesNoNo
ExpiryDue date30 minutes30 minutes
Session created byYour server, invoice APIYour serverThe browser SDK

ZevPay Checkout Developer Documentation