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

# Webhooks

> Get notified in real time, with signature verification and replay protection

When a payment lands in a virtual account (or a Collect Payments link is paid), we send a `POST` request to your configured webhook URL instead of making you poll for it.

Set your webhook URL and grab your webhook secret from **Webhook Settings** in the merchant dashboard.

## Payload

```json theme={null}
{
    "event": "payment.received",
    "nonce": "9f2a7c1e4b0d8a3f6c5e9b1d2a4f7c0e",
    "sent_at": "2026-05-10T15:25:13Z",
    "data": {
        "reference": "Transfer from JOHN DOE",
        "amount": 100,
        "currency": "NGN",
        "virtual_account": "6678550369",
        "payer_name": "John Doe",
        "payer_bank": "ACCESS BANK",
        "payer_account": "1234567890",
        "transaction_id": "MI2053495351264129024",
        "status": "successful",
        "timestamp": "2026-05-10T15:25:13Z"
    }
}
```

<Warning>
  For this event, `data.reference` is the bank transfer narration the payer's own bank attached to the transfer — it is **not** something you sent, and it is **not** unique. Two completely different payments can carry identical narration text (a generic "Personal Transfer", or the same payer transferring twice). Never use it as a lookup key or a duplicate-detection key — use `transaction_id` instead, below.
</Warning>

<ResponseField name="data.reference" type="string">
  The payer's bank transfer narration/description, exactly as their bank sent it to us. Free text, not guaranteed unique, and not something you provided. Useful only as a human-readable label to show alongside a transaction — never for matching or deduplication.
</ResponseField>

<ResponseField name="data.transaction_id" type="string">
  **Ours, and unique.** ABCMPay's own internal ID for the transaction (shown as "Order Number" in the dashboard). This is the value to store and to key your duplicate-detection off.
</ResponseField>

<ResponseField name="nonce" type="string">
  Unique per delivery attempt — even a retry of the same transaction gets a new one. Covered by the signature. See [Replay protection](#replay-protection).
</ResponseField>

<ResponseField name="sent_at" type="string">
  ISO 8601 timestamp of this specific delivery attempt. Also covered by the signature.
</ResponseField>

### Request headers

```text theme={null}
Content-Type: application/json
X-Webhook-Signature: a1f7307efafe5b292fab7989d9fc5970191994194b9763010e62e256d7c67281
X-Webhook-Event: payment.received
```

## Verifying the signature

Every payload is signed with HMAC-SHA256 using your webhook secret, over the *entire raw request body* (not a re-serialized version of it — hash the bytes exactly as received).

<Warning>
  Always verify the signature before trusting a webhook. Anyone who finds your webhook URL can otherwise send you a fake `payment.received` event.
</Warning>

```php PHP theme={null}
<?php
// Get the raw POST body
$payload = file_get_contents('php://input');

// Get the signature from the X-Webhook-Signature header
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = 'your-webhook-secret';

// Verify the signature using HMAC-SHA256
$expected = hash_hmac('sha256', $payload, $secret);

if (hash_equals($expected, $signature)) {
    // Signature is valid - process webhook
    $data = json_decode($payload, true);

    $event = $data['event']; // 'payment.received'
    $txnData = $data['data'];

    // transaction_id is the unique ID - use it (not reference) as your
    // duplicate-detection key before crediting anything.
    $transactionId = $txnData['transaction_id'];
    $reference = $txnData['reference']; // display-only narration, not unique
    $amount = $txnData['amount'];
    $virtualAccount = $txnData['virtual_account'];
    $payerName = $txnData['payer_name'];
    $payerBank = $txnData['payer_bank'];

    if (already_processed($transactionId)) {
        // Seen this transaction_id before (first delivery, or a retry) -
        // acknowledge and stop, don't credit twice.
        http_response_code(200);
        echo 'success';
        exit;
    }

    // Credit user wallet, update order status, etc.
    // Record $transactionId as processed before returning.

    http_response_code(200);
    echo 'success';
} else {
    // Invalid signature - reject request
    http_response_code(401);
    echo 'Invalid signature';
}
```

<Tip>
  Use `hash_equals()` (or your language's constant-time comparison) rather than `==` or `===` — a plain string comparison leaks timing information an attacker can use to guess a valid signature byte by byte.
</Tip>

## Replay protection

A valid signature only proves the request came from us — it doesn't stop someone from capturing and resending an old, still-valid request later. Every delivery carries a unique `nonce` and a `sent_at` timestamp, both covered by the signature above, so you can reject a resend even if its signature checks out.

```php theme={null}
// Reject anything older than 5 minutes
if (strtotime($data['sent_at']) < time() - 300) {
    http_response_code(401);
    echo 'Stale request';
    exit;
}

// Reject a nonce you've already seen (store seen nonces for at
// least as long as your freshness window above, e.g. in a cache
// or a small database table)
if (nonce_already_seen($data['nonce'])) {
    http_response_code(401);
    echo 'Duplicate request';
    exit;
}
remember_nonce($data['nonce']);
```

## Retries

If your endpoint doesn't respond with a `2xx` status, the delivery is treated as failed and may be retried or manually resent from your dashboard. A retry (or a manual resend from **Webhook Events**) carries the **same** `transaction_id` as the original delivery, with a fresh `nonce`/`sent_at` — this is expected and is exactly why you should key duplicate-detection off `transaction_id`, not `reference`: it's the one field guaranteed to stay identical across every delivery attempt of the same payment, and guaranteed distinct between different payments.

You can review delivery history (including HTTP status codes returned) from the **Webhook Events** page in your dashboard, which also shows both `reference` and `transaction_id` for each event so you can trace exactly what was sent.

<Note>
  Respond quickly (under a few seconds) and with a `2xx` status as soon as you've accepted the payload — do slow work (emails, downstream API calls) after responding, not before.
</Note>
