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
- Create an invoice via the API with customer details, line items, and a due date
- Send the invoice to transition it from draft to active
- Share the
payment_urlwith your customer (email, SMS, in-app, etc.) - Customer opens the link and pays via bank transfer or PayID
- Invoice status updates automatically — supports partial payments
- You receive webhook events for each status change
Creating an Invoice
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:
{
"public_id": "abc123def456ghi78",
"invoice_number": "INV-202603-001",
"status": "draft",
"total": 44075000,
"payment_url": "https://secure.zevpaycheckout.com/inv/abc123def456ghi78"
}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.
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:
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:
- The invoice details (line items, total, amount remaining) are displayed
- A checkout session is automatically created for the remaining balance
- The customer selects a payment method (bank transfer or PayID) and pays
- On successful payment, the invoice's
amount_paidis updated - If partially paid, the status becomes
partial— the customer can pay the rest later - 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_paidis 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 |
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:
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
400error. - 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 whenline_itemsortax_ratechange.
Once you're happy with the invoice, send it to make it active.
Filtering by Customer
Exact email filter
Use the customer_email query parameter to retrieve all invoices for a specific customer:
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):
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:
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 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.
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:
// 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).
- Respond with
200quickly — do heavy processing (email, fulfillment) asynchronously. - Handle
invoice.paidfor order fulfillment. Useinvoice.payment_receivedto 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.overdueto trigger payment reminders to your customers.
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}` },
});
});