Skip to content

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 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"
}

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)
StatusDescription
draftCreated but not yet active — payment URL is not usable
sentActive and awaiting payment
partialCustomer has made a partial payment
paidFully paid
overduePast due date, not fully paid (set automatically)
cancelledCancelled 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

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

InvoiceCheckout SessionVirtual Account
Use caseRecurring billing, freelance invoicing, B2B paymentsOne-time checkoutBackend payment collection
UIFull checkout gatewayFull checkout gatewayNo UI
Partial paymentsYesNoNo
Line itemsYesNoNo
Tax calculationBuilt-inNoNo
ExpiryDue date (days/weeks)30 minutesUp to 72 hours
Status trackingFull lifecycleActive → CompletedPending → 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 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.

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

ZevPay Checkout Developer Documentation