Skip to content

Events Reference

This page documents all webhook event types, their payloads, and when they are triggered.

charge.success

Sent when a checkout payment is completed successfully. This is the primary event you should handle to fulfill orders.

When it fires

Any successful checkout payment, whichever method the customer used:

  • Customer completes a bank transfer to the virtual account (channel: "bank_transfer")
  • Customer sends payment via ZevPay ID (channel: "payid")
  • Customer pays with a card (channel: "card")
  • Customer pays with crypto (channel: "crypto")
  • Customer pays with PalmPay (channel: "palmpay")
  • A transfer lands in one of your Static NGN Accounts (channel: "static_account")

The payload is the same shape for every channel; only channel and the payer object differ.

Payload

json
{
  "event": "charge.success",
  "data": {
    "reference": "ZVP-CKO-S-abc123",
    "merchant_reference": "order_12345",
    "amount": 500000,
    "currency": "NGN",
    "status": "completed",
    "channel": "bank_transfer",
    "customer": {
      "email": "customer@example.com",
      "name": "John Doe"
    },
    "metadata": {
      "orderId": "12345"
    },
    "paid_at": "2026-03-07T19:42:00.000Z",
    "fees": 7500,
    "payer": {
      "name": "John Doe",
      "bank": "GTBank",
      "account": "0123456789"
    }
  }
}

Fields

FieldTypeDescription
referencestringZevPay transaction reference
merchant_referencestring | nullYour custom reference (if provided during initialization)
amountintegerAmount in kobo
currencystringCurrency code (e.g., "NGN")
statusstringAlways "completed" for this event
channelstringPayment channel: "bank_transfer", "payid", "card", "crypto", "palmpay", or "static_account"
customer.emailstringCustomer's email address
customer.namestring | nullCustomer's name
metadataobject | nullCustom metadata from session initialization
line_itemsarray | nullThe session's line items, if any were set at initialization
paid_atstringISO 8601 timestamp of payment confirmation
feesintegerTransaction fees in kobo
payerobjectPayer information (varies by channel)

Payer object by channel

Bank transfer

When channel is "bank_transfer":

json
{
  "payer": {
    "name": "John Doe",
    "bank": "GTBank",
    "account": "0123456789"
  },
  "fees": 7500
}
FieldTypeDescription
payer.namestringSender's name
payer.bankstringSender's bank name
payer.accountstringSender's account number
feesintegerTransaction fees in kobo

PayID

When channel is "payid":

json
{
  "payer": {
    "name": "John Doe"
  }
}
FieldTypeDescription
payer.namestringSender's ZevPay name

INFO

PayID payments do not include fees, payer.bank, or payer.account fields.

Card

When channel is "card", the event carries a card object (no payer.bank/payer.account):

json
{
  "payer": {
    "name": "John Doe"
  },
  "card": {
    "masked_pan": "506066******7699",
    "card_type": "verve"
  },
  "fees": 5000
}
FieldTypeDescription
card.masked_panstringMasked card number (first 6 + last 4). Never the full PAN.
card.card_typestringCard scheme, e.g. verve, mastercard, visa.
feesintegerTransaction fees in kobo

Crypto

When channel is "crypto", the event carries a crypto object:

json
{
  "payer": {
    "name": "John Doe"
  },
  "crypto": {
    "asset": "USDT",
    "network": "TRON",
    "amount": "10.500000"
  },
  "fees": 5000
}
FieldTypeDescription
crypto.assetstringAsset the customer paid with, e.g. USDT.
crypto.networkstringNetwork the payment arrived on, e.g. TRON. Uses our network identifiers, see Assets and networks. The same asset on another network is a different pair; reconcile on asset and network together.
crypto.amountstringAmount in the crypto asset's own units, as a decimal string.
feesintegerTransaction fees in kobo

Static NGN Account

When channel is "static_account", the payment came into a permanent account you issued to a customer. There is no checkout session: reference is the payment's transaction reference, merchant_reference is the account's reference, and customer and metadata come from the account. A static_account object identifies the account:

json
{
  "event": "charge.success",
  "data": {
    "reference": "ZVP-CKOTX-1789...",
    "merchant_reference": "customer-1042",
    "amount": 2500000,
    "currency": "NGN",
    "status": "completed",
    "channel": "static_account",
    "customer": { "email": "adaeze@example.com", "name": "Adaeze Okafor" },
    "metadata": { "plan": "pro" },
    "line_items": null,
    "paid_at": "2026-09-08T09:12:45.000Z",
    "fees": 37500,
    "net_amount": 2462500,
    "static_account": {
      "id": "V1StGXR8_Z5jdHi6B-myT",
      "account_number": "6641234567",
      "account_name": "Adaeze Okafor",
      "bank_name": "PalmPay",
      "reference": "customer-1042",
      "customer_name": "Adaeze Okafor"
    },
    "payer": { "name": "ADAEZE OKAFOR", "bank": "GTBank", "account": "0123456789" },
    "narration": "Top up",
    "session_id": "000013250908091200000123456789"
  }
}
FieldTypeDescription
static_account.idstringThe account that was paid. Use it to look the customer up on your side
static_account.account_numberstringThe NUBAN that received the transfer
static_account.referencestring | nullYour reference on the account (also in merchant_reference)
net_amountintegerAmount after fees, in kobo
payerobjectSender's name, bank and account number as reported by the bank
narrationstring | nullTransfer narration, when the sender's bank supplied one
session_idstring | nullNIP session id, when the channel returned one

The same transfer never fires twice; retries of a delivery carry the same reference, so deduplicate on it.

charge.refunded

Sent when a payment has been returned to the payer after a refund request. The reference is the same one you received on charge.success; the transaction now shows as refunded and is excluded from settlement.

channel tells you which rail was refunded: "bank_transfer", "card", "palmpay" or "static_account". The static_account object below is present only on that rail. A declined refund fires no event: the payment simply settles to you as normal.

Payload

json
{
  "event": "charge.refunded",
  "data": {
    "reference": "ZVP-CKOTX-1789...",
    "merchant_reference": "customer-1042",
    "amount": 2500000,
    "refunded_amount": 2500000,
    "currency": "NGN",
    "status": "refunded",
    "channel": "static_account",
    "refund": {
      "id": "V1StGXR8_Z5jdHi6B-myT",
      "reason": "Duplicate payment",
      "requested_at": "2026-09-08T10:02:11.000Z",
      "completed_at": "2026-09-08T10:41:57.000Z"
    },
    "static_account": {
      "id": "V1StGXR8_Z5jdHi6B-myT",
      "account_number": "6641234567",
      "account_name": "Adaeze Okafor",
      "bank_name": "PalmPay",
      "reference": "customer-1042",
      "customer_name": "Adaeze Okafor"
    },
    "payer": { "name": "ADAEZE OKAFOR", "bank": "GTBank", "account": "0123456789" },
    "refunded_at": "2026-09-08T10:41:57.000Z"
  }
}
FieldTypeDescription
referencestringThe original payment's reference
refunded_amountintegerAmount returned to the payer, in kobo
refund.idstringThe refund id from the refund API

The example above is a Static NGN Account refund. On other rails the payload is identical minus static_account, with channel set to the rail the customer paid on.

Invoice events: base fields

All invoice webhook events share the same base payload structure. These fields are always present:

FieldTypeDescription
public_idstringUnique public identifier for the invoice
invoice_numberstringInvoice number (auto-generated or custom)
statusstringCurrent status: draft, sent, partial, paid, overdue, cancelled
customer_namestringCustomer name
customer_emailstringCustomer email
subtotalintegerSubtotal in kobo (before tax)
tax_ratenumberTax rate percentage (e.g., 7.5)
tax_amountintegerTax amount in kobo
totalintegerTotal in kobo (subtotal + tax)
amount_paidintegerCumulative amount paid so far in kobo
currencystringCurrency code (always "NGN")
due_datestringDue date (ISO 8601)
issued_atstring | nullTimestamp when invoice was sent (null for drafts)
paid_atstring | nullTimestamp when invoice was fully paid
merchant_referencestring | nullYour custom reference (if provided at creation)
metadataobject | nullYour custom key-value metadata
payment_urlstringURL for customer to pay the invoice

invoice.created

Sent when a new invoice is created via the API.

When it fires

  • A new invoice is created via POST /v1/checkout/invoice

Payload

json
{
  "event": "invoice.created",
  "data": {
    "public_id": "abc123def456ghi78",
    "invoice_number": "INV-202603-001",
    "status": "draft",
    "customer_name": "Chidi Okonkwo",
    "customer_email": "chidi@example.com",
    "subtotal": 41000000,
    "tax_rate": 7.5,
    "tax_amount": 3075000,
    "total": 44075000,
    "amount_paid": 0,
    "currency": "NGN",
    "due_date": "2026-03-31T00:00:00.000Z",
    "issued_at": null,
    "paid_at": null,
    "merchant_reference": "order_12345",
    "metadata": { "orderId": "12345" },
    "payment_url": "https://secure.zevpaycheckout.com/inv/abc123def456ghi78"
  }
}

invoice.sent

Sent when an invoice is transitioned from draft to sent, making it payable.

When it fires

  • Invoice is sent via POST /v1/checkout/invoice/:publicId/send

Payload

json
{
  "event": "invoice.sent",
  "data": {
    "public_id": "abc123def456ghi78",
    "invoice_number": "INV-202603-001",
    "status": "sent",
    "customer_name": "Chidi Okonkwo",
    "customer_email": "chidi@example.com",
    "subtotal": 41000000,
    "tax_rate": 7.5,
    "tax_amount": 3075000,
    "total": 44075000,
    "amount_paid": 0,
    "currency": "NGN",
    "due_date": "2026-03-31T00:00:00.000Z",
    "issued_at": "2026-03-08T10:30:00.000Z",
    "paid_at": null,
    "merchant_reference": "order_12345",
    "metadata": { "orderId": "12345" },
    "payment_url": "https://secure.zevpaycheckout.com/inv/abc123def456ghi78"
  }
}

TIP

The issued_at timestamp is set when the invoice is sent. Use this to track when an invoice became active.

invoice.payment_received

Sent when a payment is received on an invoice (partial or full). This is the most important event for tracking payment progress. It fires for every payment, including the final one that fully pays the invoice.

When it fires

  • Customer completes a bank transfer for an invoice payment
  • Customer sends payment via PayID for an invoice payment

Additional fields

In addition to the base fields, this event includes:

FieldTypeDescription
payment_amountintegerThis specific payment's amount in kobo
amount_paidintegerCumulative amount paid on the invoice so far in kobo (includes this payment)
amount_remainingintegerRemaining balance after this payment in kobo
totalintegerInvoice total in kobo (mirrors the base total, included here for convenience in payment handlers)
is_partialbooleantrue if this payment brought amount_paid to less than total (partial settlement). false if this payment fully paid the invoice.
is_fully_paidbooleantrue only when this payment brought amount_paid to >= total. Always the negation of is_partial.
invoice_statusstringThe invoice's status AFTER this payment. "partial" for a partial payment, "paid" for a full payment, "overdue" if past due + still partial after the payment.
allow_partial_paymentsbooleanWhether the invoice itself accepts partial payments. Useful for logic that wants to special-case strict-payment invoices.
payer_namestringName of the person who made the payment
payment_channelstringPayment channel: "bank_transfer" or "payid"

Payload (partial payment)

json
{
  "event": "invoice.payment_received",
  "data": {
    "public_id": "abc123def456ghi78",
    "invoice_number": "INV-202603-001",
    "status": "partial",
    "customer_name": "Chidi Okonkwo",
    "customer_email": "chidi@example.com",
    "subtotal": 41000000,
    "tax_rate": 7.5,
    "tax_amount": 3075000,
    "total": 44075000,
    "amount_paid": 20000000,
    "currency": "NGN",
    "due_date": "2026-03-31T00:00:00.000Z",
    "issued_at": "2026-03-08T10:30:00.000Z",
    "paid_at": null,
    "merchant_reference": "order_12345",
    "metadata": { "orderId": "12345" },
    "payment_url": "https://secure.zevpaycheckout.com/inv/abc123def456ghi78",
    "payment_amount": 20000000,
    "amount_remaining": 24075000,
    "is_partial": true,
    "is_fully_paid": false,
    "invoice_status": "partial",
    "allow_partial_payments": true,
    "payer_name": "CHIDI OKONKWO",
    "payment_channel": "bank_transfer"
  }
}

Don't mistake a partial for a full payment

invoice.payment_received fires on every payment, including partial ones. Always check is_fully_paid (or equivalently is_partial) before fulfilling an order or marking the customer's account as paid. If you only need the "fully paid" signal, listen for invoice.paid instead.

A common bug is dispatching fulfillment off invoice.payment_received without checking is_fully_paid, which leads to orders shipped against partial payments.

INFO

When the payment fully pays the invoice, status will be "paid", amount_remaining will be 0, is_fully_paid will be true, and paid_at will be set. You will also receive a separate invoice.paid event immediately after.

invoice.paid

Sent when an invoice is fully paid (cumulative payments reach or exceed the total). This event always follows an invoice.payment_received event for the final payment.

When it fires

  • A payment completes that brings amount_paid to equal or exceed total

Payload

json
{
  "event": "invoice.paid",
  "data": {
    "public_id": "abc123def456ghi78",
    "invoice_number": "INV-202603-001",
    "status": "paid",
    "customer_name": "Chidi Okonkwo",
    "customer_email": "chidi@example.com",
    "subtotal": 41000000,
    "tax_rate": 7.5,
    "tax_amount": 3075000,
    "total": 44075000,
    "amount_paid": 44075000,
    "currency": "NGN",
    "due_date": "2026-03-31T00:00:00.000Z",
    "issued_at": "2026-03-08T10:30:00.000Z",
    "paid_at": "2026-03-10T14:22:00.000Z",
    "merchant_reference": "order_12345",
    "metadata": { "orderId": "12345" },
    "payment_url": "https://secure.zevpaycheckout.com/inv/abc123def456ghi78"
  }
}

TIP

Use invoice.paid to trigger order fulfillment. The paid_at timestamp tells you exactly when the invoice was fully settled. The payment_url now resolves to a receipt page.

invoice.overdue

Sent when an invoice is automatically marked as overdue.

When it fires

  • Invoice due_date has passed and invoice is not fully paid (checked hourly by the system)

Payload

json
{
  "event": "invoice.overdue",
  "data": {
    "public_id": "abc123def456ghi78",
    "invoice_number": "INV-202603-001",
    "status": "overdue",
    "customer_name": "Chidi Okonkwo",
    "customer_email": "chidi@example.com",
    "subtotal": 41000000,
    "tax_rate": 7.5,
    "tax_amount": 3075000,
    "total": 44075000,
    "amount_paid": 20000000,
    "currency": "NGN",
    "due_date": "2026-03-31T00:00:00.000Z",
    "issued_at": "2026-03-08T10:30:00.000Z",
    "paid_at": null,
    "merchant_reference": "order_12345",
    "metadata": { "orderId": "12345" },
    "payment_url": "https://secure.zevpaycheckout.com/inv/abc123def456ghi78"
  }
}

INFO

Overdue invoices can still receive payments. If the customer pays the full remaining balance, the invoice transitions to paid and you'll receive invoice.payment_received and invoice.paid events. Use this event to send payment reminders to your customers.

invoice.cancelled

Sent when an invoice is cancelled by the merchant.

When it fires

  • Invoice is cancelled via POST /v1/checkout/invoice/:publicId/cancel

Payload

json
{
  "event": "invoice.cancelled",
  "data": {
    "public_id": "abc123def456ghi78",
    "invoice_number": "INV-202603-001",
    "status": "cancelled",
    "customer_name": "Chidi Okonkwo",
    "customer_email": "chidi@example.com",
    "subtotal": 41000000,
    "tax_rate": 7.5,
    "tax_amount": 3075000,
    "total": 44075000,
    "amount_paid": 0,
    "currency": "NGN",
    "due_date": "2026-03-31T00:00:00.000Z",
    "issued_at": "2026-03-08T10:30:00.000Z",
    "paid_at": null,
    "merchant_reference": "order_12345",
    "metadata": { "orderId": "12345" },
    "payment_url": "https://secure.zevpaycheckout.com/inv/abc123def456ghi78"
  }
}

transfer.success

Sent when a transfer is completed successfully. Funds have been delivered to the recipient.

When it fires

  • Bank transfer is confirmed by the banking provider
  • PayID transfer completes instantly

Payload

json
{
  "event": "transfer.success",
  "data": {
    "reference": "ZVP-TRF-abc123def456",
    "public_id": "xK9mQ2pL4vR7nW3jY",
    "type": "bank_transfer",
    "status": "completed",
    "amount": 500000,
    "fees": 2625,
    "currency": "NGN",
    "narration": "Vendor payment",
    "recipient": {
      "name": "JOHN DOE",
      "bank": "Guaranty Trust Bank",
      "account_number": "0123456789"
    },
    "merchant_reference": "payout_001",
    "metadata": {
      "invoice_id": "INV-2026-001"
    },
    "created_at": "2026-03-08T14:30:00.000Z",
    "completed_at": "2026-03-08T14:30:02.000Z"
  }
}

Fields

FieldTypeDescription
referencestringZevPay transaction reference
public_idstringPublic transaction identifier
typestring"bank_transfer" or "payid"
statusstringAlways "completed" for this event
amountintegerTransfer amount in kobo
feesintegerTransfer fees in kobo (0 for PayID)
currencystringCurrency code ("NGN")
narrationstring | nullTransfer description
recipient.namestringRecipient name
recipient.bankstring | nullRecipient bank (bank transfers only)
recipient.account_numberstring | nullRecipient account (bank transfers only)
merchant_referencestring | nullYour custom reference
metadataobject | nullYour custom metadata
created_atstringISO 8601 creation timestamp
completed_atstringISO 8601 completion timestamp

transfer.failed

Sent when a bank transfer fails. The debited amount is automatically reversed to the source wallet.

When it fires

  • Bank rejects the transfer (invalid account, bank downtime, etc.)

Payload

json
{
  "event": "transfer.failed",
  "data": {
    "reference": "ZVP-TRF-xyz789ghi012",
    "public_id": "aB3cD4eF5gH6iJ7kL",
    "type": "bank_transfer",
    "status": "failed",
    "amount": 500000,
    "fees": 2625,
    "currency": "NGN",
    "narration": "Vendor payment",
    "recipient": {
      "name": "JOHN DOE",
      "bank": "Guaranty Trust Bank",
      "account_number": "0123456789"
    },
    "merchant_reference": "payout_002",
    "metadata": null,
    "created_at": "2026-03-08T15:00:00.000Z",
    "completed_at": null
  }
}

INFO

When a transfer fails, the full amount (including fees) is reversed to your wallet. You do not need to handle the reversal manually. PayID transfers cannot fail. They either succeed instantly or are rejected before debiting.

transfer.reversed

Sent when a previously completed transfer is reversed (e.g., by admin action or bank recall).

When it fires

  • Admin initiates a transfer reversal
  • Bank recalls a completed transfer

Payload

json
{
  "event": "transfer.reversed",
  "data": {
    "reference": "ZVP-TRF-abc123def456",
    "public_id": "xK9mQ2pL4vR7nW3jY",
    "type": "bank_transfer",
    "status": "reversed",
    "amount": 500000,
    "fees": 2625,
    "currency": "NGN",
    "narration": "Vendor payment",
    "recipient": {
      "name": "JOHN DOE",
      "bank": "Guaranty Trust Bank",
      "account_number": "0123456789"
    },
    "merchant_reference": "payout_001",
    "metadata": null,
    "created_at": "2026-03-08T14:30:00.000Z",
    "completed_at": null
  }
}

Handling events

js
app.post('/webhooks/zevpay', (req, res) => {
  // Verify signature first (see Verifying Signatures)

  const { event, data } = req.body;

  switch (event) {
    case 'charge.success':
      handleSuccessfulPayment(data);
      break;
    case 'invoice.paid':
      handleInvoicePaid(data);
      break;
    case 'invoice.payment_received':
      handleInvoicePayment(data);
      break;
    case 'transfer.success':
      handleTransferSuccess(data);
      break;
    case 'transfer.failed':
      handleTransferFailed(data);
      break;
    default:
      console.log('Unhandled event:', event);
  }

  res.status(200).send('OK');
});

async function handleSuccessfulPayment(data) {
  // Check if already processed (idempotency)
  const existing = await db.findOrder({ reference: data.reference });
  if (existing?.fulfilled) return;

  // Fulfill the order
  await db.updateOrder({
    reference: data.reference,
    status: 'paid',
    amount: data.amount,
    paidAt: data.paid_at,
    channel: data.channel,
    payerName: data.payer.name,
    fulfilled: true,
  });

  // Send confirmation email
  await sendConfirmationEmail(data.customer.email, data);
}

Testing webhooks

Test mode

Use your test mode API keys to trigger test webhooks. Test transactions behave the same as live transactions but use test credentials.

Local development

To receive webhooks locally during development, use a tunneling tool:

bash
# Using ngrok
ngrok http 3000

# Set webhook URL in dashboard to:
# https://your-id.ngrok.io/webhooks/zevpay

Webhook logs

View delivery logs in the ZevPay Dashboard under Transactions → Webhook Logs. Each log entry shows:

  • Event type
  • Delivery status (delivered / failed)
  • HTTP status code
  • Response time
  • Request and response bodies

ZevPay Checkout Developer Documentation