# 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

- Customer completes a bank transfer to the virtual account
- Customer sends payment via ZevPay ID (PayID)

### 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

| Field | Type | Description |
|-------|------|-------------|
| `reference` | string | ZevPay transaction reference |
| `merchant_reference` | string \| null | Your custom reference (if provided during initialization) |
| `amount` | integer | Amount in kobo |
| `currency` | string | Currency code (e.g., `"NGN"`) |
| `status` | string | Always `"completed"` for this event |
| `channel` | string | Payment channel: `"bank_transfer"` or `"payid"` |
| `customer.email` | string | Customer's email address |
| `customer.name` | string \| null | Customer's name |
| `metadata` | object \| null | Custom metadata from session initialization |
| `paid_at` | string | ISO 8601 timestamp of payment confirmation |
| `fees` | integer | Transaction fees in kobo (bank transfer only) |
| `payer` | object | Payer 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
}
```

| Field | Type | Description |
|-------|------|-------------|
| `payer.name` | string | Sender's name |
| `payer.bank` | string | Sender's bank name |
| `payer.account` | string | Sender's account number |
| `fees` | integer | Transaction fees in kobo |

#### PayID

When `channel` is `"payid"`:

```json
{
  "payer": {
    "name": "John Doe"
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `payer.name` | string | Sender's ZevPay name |

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

## Invoice events — base fields

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

| Field | Type | Description |
|-------|------|-------------|
| `public_id` | string | Unique public identifier for the invoice |
| `invoice_number` | string | Invoice number (auto-generated or custom) |
| `status` | string | Current status: `draft`, `sent`, `partial`, `paid`, `overdue`, `cancelled` |
| `customer_name` | string | Customer name |
| `customer_email` | string | Customer email |
| `subtotal` | integer | Subtotal in kobo (before tax) |
| `tax_rate` | number | Tax rate percentage (e.g., `7.5`) |
| `tax_amount` | integer | Tax amount in kobo |
| `total` | integer | Total in kobo (subtotal + tax) |
| `amount_paid` | integer | Cumulative amount paid so far in kobo |
| `currency` | string | Currency code (always `"NGN"`) |
| `due_date` | string | Due date (ISO 8601) |
| `issued_at` | string \| null | Timestamp when invoice was sent (null for drafts) |
| `paid_at` | string \| null | Timestamp when invoice was fully paid |
| `merchant_reference` | string \| null | Your custom reference (if provided at creation) |
| `metadata` | object \| null | Your custom key-value metadata |
| `payment_url` | string | URL 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](#invoice-events-—-base-fields), this event includes:

| Field | Type | Description |
|-------|------|-------------|
| `payment_amount` | integer | This specific payment's amount in kobo |
| `amount_paid` | integer | **Cumulative** amount paid on the invoice so far in kobo (includes this payment) |
| `amount_remaining` | integer | Remaining balance after this payment in kobo |
| `total` | integer | Invoice total in kobo (mirrors the base `total`, included here for convenience in payment handlers) |
| `is_partial` | boolean | `true` if this payment brought `amount_paid` to less than `total` (partial settlement). `false` if this payment fully paid the invoice. |
| `is_fully_paid` | boolean | `true` only when this payment brought `amount_paid` to `>= total`. Always the negation of `is_partial`. |
| `invoice_status` | string | The 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_payments` | boolean | Whether the invoice itself accepts partial payments. Useful for logic that wants to special-case strict-payment invoices. |
| `payer_name` | string | Name of the person who made the payment |
| `payment_channel` | string | Payment 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"
  }
}
```

::: danger 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` — leading 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

| Field | Type | Description |
|-------|------|-------------|
| `reference` | string | ZevPay transaction reference |
| `public_id` | string | Public transaction identifier |
| `type` | string | `"bank_transfer"` or `"payid"` |
| `status` | string | Always `"completed"` for this event |
| `amount` | integer | Transfer amount in kobo |
| `fees` | integer | Transfer fees in kobo (0 for PayID) |
| `currency` | string | Currency code (`"NGN"`) |
| `narration` | string \| null | Transfer description |
| `recipient.name` | string | Recipient name |
| `recipient.bank` | string \| null | Recipient bank (bank transfers only) |
| `recipient.account_number` | string \| null | Recipient account (bank transfers only) |
| `merchant_reference` | string \| null | Your custom reference |
| `metadata` | object \| null | Your custom metadata |
| `created_at` | string | ISO 8601 creation timestamp |
| `completed_at` | string | ISO 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
