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.
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.
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. |
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:
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 |
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 | 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:
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}` },
});
});