> ## Documentation Index
> Fetch the complete documentation index at: https://dev-docs.pagoramp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Virtual Account Webhooks

We send events to the webhook URL set in your dashboard. Every request is signed with your webhook secret, so you can check it came from us.

You must have a webhook secret before you can create virtual accounts. Creating one without a secret returns `400` with the code `webhook_secret_required`.

## Verifying a signature

Each request carries two headers:

| Header | Value |
| - | - |
| `X-Pagoramp-Timestamp` | Unix time in seconds when we signed the request |
| `X-Pagoramp-Signature` | Hex HMAC-SHA256 of `{timestamp}.{raw body}` with your webhook secret |

Compute the signature over the raw request body, before any JSON parsing, and compare it in constant time. Reject requests whose timestamp is more than five minutes old.

```js theme={null}
import crypto from 'node:crypto';

export function isValid(rawBody, headers, secret) {
  const timestamp = headers['x-pagoramp-timestamp'];
  const received = headers['x-pagoramp-signature'];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return (
    expected.length === received.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))
  );
}
```

## Delivery and retries

Answer with `200` once you have stored the event. Any other response, or a timeout, is retried up to five times with exponentially growing delays. The same event can arrive more than once, so make your handler idempotent: use `data.transaction.reference` as the key.

## Virtual account events

| Event | When |
| - | - |
| `virtual_account.payment.received` | A payment was credited |
| `virtual_account.payment.manual_review` | A payment arrived and is held for review |
| `virtual_account.payment.rejected` | A held payment was declined |
| `virtual_account.created` | An account that was pending or waiting for verification is now active |
| `virtual_account.failed` | The account was declined while it was pending or waiting for verification |
| `virtual_account.expired` | A dynamic account passed its expiry |

All amounts are decimal strings in the account currency, except `destinationAmount`, which is in USD. `sender.accountNumber` is masked to its last four digits.

### `virtual_account.payment.received`

```json theme={null}
{
  "event": "virtual_account.payment.received",
  "data": {
    "transaction": {
      "reference": "va-17912345678901234",
      "status": "Success",
      "sourceAmount": "50000.00",
      "sourceCurrency": "NGN",
      "destinationAmount": "32.05",
      "destinationCurrency": "USD",
      "fees": { "sourceTotalFee": "375.00" }
    },
    "virtualAccount": {
      "uuid": "9a1c1c52-7b8e-4a52-9f0e-3e6b0a8d6c11",
      "reference": "va_1001",
      "currency": "NGN",
      "type": "dedicated"
    },
    "customer": {
      "uuid": "224ca5c5-f2a6-419b-bf36-73997434d945",
      "email": "johndoe@example.com",
      "externalReference": "cust_1001"
    },
    "sender": { "name": "JOHN DOE", "bank": "GTBank", "accountNumber": "******6789" },
    "occurredAt": "2026-10-16T10:02:00.000Z"
  }
}
```

### `virtual_account.payment.manual_review`

Same shape, with `transaction.status` set to `Manual Review` and a `review` object:

```json theme={null}
{
  "review": {
    "reason": "amount_variance",
    "expectedAmount": "5000.00",
    "receivedAmount": "4000.00",
    "variance": "underpayment",
    "flaggedAt": "2026-10-16T10:05:00.000Z"
  }
}
```

| `reason` | Meaning |
| - | - |
| `amount_variance` | A dynamic account received a different amount than expected. `variance` is `underpayment` or `overpayment`, and `expectedAmount` is set |
| `third_party_deposit` | The sender's name does not match the customer, and Pagoramp holds third-party payments for your account's risk tier in this currency |
| `amount_above_limit` | The payment is above the review limit for this currency |
| `duplicate_payment` | A second payment arrived on a dynamic account that was already paid |
| `account_inactive` | The account was deactivated or expired before the payment arrived |
| `provider_review` | The bank is holding the payment |

For reasons other than `amount_variance`, `expectedAmount` and `variance` are `null`.

After the review you receive either `virtual_account.payment.received` (approved) or `virtual_account.payment.rejected` (declined) for the same `transaction.reference`.

### `virtual_account.payment.rejected`

Same shape as `received`, with `transaction.status` set to `Rejected`.

## Account status events

Creating an account returns one of these statuses right away. Some change later, and you get a webhook when they do, so you never need to poll.

| Status | Meaning | What to do |
| - | - | - |
| `active` | Bank details are ready in `bankDetails` | Show them to the customer |
| `requires_action` | The customer must verify first. `nextStep.url` holds the link | Send the customer to the link. You get `virtual_account.created` when it passes, or `virtual_account.failed` if it does not |
| `pending` | The account is being issued or approved | Wait for `virtual_account.created` or `virtual_account.failed` |
| `failed` | The account was declined. `statusReason` says why | Fix the cause (see `statusReason.message`) and create a new account |
| `expired` | A dynamic account passed its expiry | Create a new one. A payment that still arrives is held for review |
| `deactivated` | You or we closed the account | None |

An account that is rejected while you are creating it returns `422` straight away with the reason in `errors[0].reason`, and no account is created:

```json theme={null}
{
  "status": "error",
  "message": "The provider could not create the account",
  "errors": [{ "field": "currency", "code": "provider_declined", "reason": "Document unreadable" }]
}
```

### `virtual_account.created`

```json theme={null}
{
  "event": "virtual_account.created",
  "data": {
    "virtualAccount": {
      "uuid": "9a1c1c52-7b8e-4a52-9f0e-3e6b0a8d6c11",
      "reference": "va_1001",
      "status": "active",
      "currency": "CAD",
      "type": "dedicated",
      "bankDetails": { "rail": "interac_e_transfer", "accountName": "ACME PAY / JOHN DOE", "interacEmail": "johndoe@interac.example.ca" },
      "expiresAt": null
    },
    "customer": { "uuid": "224ca5c5-f2a6-419b-bf36-73997434d945", "email": "johndoe@example.com", "externalReference": "cust_1001" },
    "occurredAt": "2026-10-16T10:12:00.000Z"
  }
}
```

### `virtual_account.failed`

Same shape with `status: "failed"`, `bankDetails: null` and a `reason`:

```json theme={null}
{ "reason": { "code": "verification_failed", "message": "Document is expired" } }
```

| `reason.code` | Meaning |
| - | - |
| `verification_failed` | The customer's identity or business verification was not approved. `message` carries the reasons |
| `declined` | The account was declined for another reason |

The same `statusReason` is on the account in `GET /virtual-accounts/{uuid}`.

### `virtual_account.expired`

Same shape with `status: "expired"`, no `bankDetails` and no `reason`.

## Checkout events

Checkout payments send `success`, `failed`, `pending`, `partially_paid` and the chargeback events with the existing payload, unchanged.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.