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

# Real-Time Webhook Events for Paylink Integration

> Paylink pushes signed event notifications to your server in real time. Verify signatures, handle all event types, and build idempotent handlers.

Polling the API for payment status is unreliable — network timeouts, user drop-offs, and asynchronous bank processing all mean that a transaction may settle seconds or minutes after the customer leaves your checkout page. Webhooks solve this by having Paylink push a signed notification to your server the moment a transaction status changes. Your backend receives the event, verifies its authenticity, and updates your records without ever needing to poll.

## Configuring a Webhook URL

Pass a `webhook_url` in the request body when you initialise a payment, create a virtual account, set up a direct debit, or initiate a transfer. Paylink will `POST` event notifications to that URL as the transaction progresses.

```json theme={null}
{
  "reference": "order_12345",
  "amount": 2500,
  "currency": "NGN",
  "webhook_url": "https://yourapp.example.com/webhooks/lyseis"
}
```

Your webhook endpoint must be publicly reachable over HTTPS and must respond with a `2xx` status code to acknowledge receipt. If your endpoint returns a non-`2xx` response or does not reply in time, Paylink retries the delivery with exponential back-off.

## Delivery Headers

Every webhook request includes the following HTTP headers.

| Header                | Description                                                                        |
| --------------------- | ---------------------------------------------------------------------------------- |
| `Content-Type`        | Always `application/json`                                                          |
| `X-Payment-Timestamp` | Unix timestamp (seconds) of when the event was dispatched                          |
| `X-Payment-Signature` | HMAC-SHA256 signature you use to verify the payload                                |
| `X-Idempotency-Key`   | Unique identifier for this event delivery — identical to `event_id` in the payload |

## Verifying Signatures

Paylink signs every webhook using HMAC-SHA256. The signature is computed as:

```text theme={null}
HMAC-SHA256(secret, "{timestamp}:{compact_json_payload}")
```

Where `secret` is the webhook secret associated with your transaction, `timestamp` is the value from the `X-Payment-Timestamp` header, and `compact_json_payload` is the raw request body exactly as received — **not re-serialised**.

<Warning>
  Always verify the signature against the **raw request body bytes** delivered by Paylink. Re-serialising the parsed JSON before hashing will produce a different string and cause every verification to fail. In most frameworks, you can access the raw body through a dedicated buffer or by reading the request stream before your JSON parser processes it.
</Warning>

The following Python snippet shows a complete verification helper.

```python theme={null}
import hashlib, hmac

def verify_webhook(secret: str, timestamp: str, raw_body: bytes, received_sig: str) -> bool:
    message = f"{timestamp}:".encode() + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received_sig)
```

Reject any request where verification fails or where the timestamp is significantly older than the current time (for example, more than five minutes) to guard against replay attacks.

## Payload Envelope

Every event shares the same top-level envelope structure. The `data` object contains event-specific fields described in the [Event Payloads](#event-payloads) section below.

```json theme={null}
{
  "event_type": "charge_success",
  "status": "success",
  "message": "Payment has been processed",
  "data": {},
  "event_id": "8c5f6c0e-0e9f-4c84-8df7-123456789abc",
  "timestamp": "2026-09-09T12:00:00+00:00"
}
```

| Field        | Type   | Description                                                                    |
| ------------ | ------ | ------------------------------------------------------------------------------ |
| `event_type` | string | Identifies the event — see the full list in [Event Types](#event-types)        |
| `status`     | string | High-level outcome such as `success` or `failure`                              |
| `message`    | string | Human-readable summary of the event                                            |
| `data`       | object | Event-specific payload — varies by `event_type`                                |
| `event_id`   | string | UUID that uniquely identifies this event delivery; matches `X-Idempotency-Key` |
| `timestamp`  | string | ISO 8601 datetime when the event occurred                                      |

## Event Types

| Event Type              | Trigger                                                       |
| ----------------------- | ------------------------------------------------------------- |
| `charge_success`        | A card or checkout payment completed successfully             |
| `charge_failure`        | A card or checkout payment failed                             |
| `va_charge_success`     | A bank transfer to a virtual account was received and matched |
| `va_charge_failure`     | A virtual account payment attempt failed                      |
| `direct_debit_success`  | A mandate debit was collected successfully                    |
| `direct_debit_failure`  | A mandate debit attempt failed                                |
| `mandate_activated`     | A direct debit mandate transitioned to active status          |
| `disbursement_success`  | A transfer to a bank account completed successfully           |
| `disbursement_failure`  | A transfer attempt failed                                     |
| `disbursement_reversal` | A previously successful transfer was reversed                 |

## Event Payloads

The `data` object varies by event type. Expand each section below to see the full payload.

<AccordionGroup>
  <Accordion title="charge_success / charge_failure">
    Sent when a card or hosted-checkout payment is processed. `merchant_bears_fees` indicates whether the fee was absorbed by you or passed to the payer.

    ```json theme={null}
    {
      "fee": 75.00,
      "amount": 2425.00,
      "amount_paid": 2500.00,
      "amount_settled": 2425.00,
      "status": "success",
      "currency": "NGN",
      "reference": "order_12345",
      "payment_method": "card",
      "payment_reference": "order_12345",
      "merchant_bears_fees": false,
      "meta": {
        "order_id": "12345"
      }
    }
    ```

    | Field                 | Description                                                                                    |
    | --------------------- | ---------------------------------------------------------------------------------------------- |
    | `amount_paid`         | Total amount the customer was charged                                                          |
    | `amount`              | Amount after fees were deducted                                                                |
    | `amount_settled`      | Amount that will be settled to your account                                                    |
    | `fee`                 | Processing fee for this transaction                                                            |
    | `merchant_bears_fees` | `true` if the fee was deducted from your settlement rather than added to the customer's charge |
    | `meta`                | Arbitrary metadata you passed at initialisation                                                |
  </Accordion>

  <Accordion title="va_charge_success / va_charge_failure">
    Sent when a customer transfers funds to a virtual account. The `account_reference` in `meta` identifies the virtual account that received the transfer.

    ```json theme={null}
    {
      "fee": 25.00,
      "amount": 4975.00,
      "status": "success",
      "currency": "NGN",
      "reference": "va_txn_12345",
      "payment_method": "bank_transfer",
      "payment_reference": "va_txn_12345",
      "meta": {
        "customer_id": "123",
        "account_reference": "customer_123"
      }
    }
    ```

    | Field                    | Description                                                 |
    | ------------------------ | ----------------------------------------------------------- |
    | `amount`                 | Amount received after fees                                  |
    | `fee`                    | Fee charged on this bank transfer                           |
    | `meta.account_reference` | Reference of the virtual account that received the payment  |
    | `meta.customer_id`       | Your customer identifier passed at virtual account creation |
  </Accordion>

  <Accordion title="direct_debit_success / direct_debit_failure">
    Sent when Lyseis Pay attempts to collect funds under an active mandate. The `mandate_code` ties the debit back to the mandate created during setup.

    ```json theme={null}
    {
      "mandate_code": "mandate-code-123",
      "transaction_reference": "dd_txn_12345",
      "payment_reference": "subscription_2026_09",
      "amount": "1500.00",
      "currency": "NGN",
      "status": "PAID",
      "narration": "Monthly subscription",
      "payment_method": "direct_debit"
    }
    ```

    | Field                   | Description                                                    |
    | ----------------------- | -------------------------------------------------------------- |
    | `mandate_code`          | Identifier of the mandate under which this debit was initiated |
    | `transaction_reference` | Lyseis Pay's internal reference for this individual debit      |
    | `payment_reference`     | Your reference supplied when you triggered the debit           |
    | `narration`             | Description that appears on the customer's bank statement      |
  </Accordion>

  <Accordion title="mandate_activated">
    Sent once when a direct debit mandate transitions to `ACTIVE` status, confirming that you can begin initiating debits against it.

    ```json theme={null}
    {
      "mandate_code": "mandate-code-123",
      "mandate_reference": "mandate_123",
      "mandate_status": "ACTIVE",
      "mandate_amount": "10000.00",
      "start_date": "2026-09-10T00:00:00+00:00",
      "end_date": "2027-09-10T00:00:00+00:00",
      "activation_date": "2026-09-09T12:00:00+00:00"
    }
    ```

    | Field                     | Description                                        |
    | ------------------------- | -------------------------------------------------- |
    | `mandate_code`            | Unique code assigned to this mandate by Lyseis Pay |
    | `mandate_reference`       | Your reference for the mandate                     |
    | `mandate_amount`          | Maximum debit amount authorised per collection     |
    | `start_date` / `end_date` | Active period during which collections can be made |
    | `activation_date`         | Exact datetime when the mandate became active      |
  </Accordion>

  <Accordion title="disbursement_success / disbursement_failure / disbursement_reversal">
    Sent when a bank transfer (payout) to a recipient account changes status. A `disbursement_reversal` means a previously settled transfer was reversed by the receiving bank.

    ```json theme={null}
    {
      "fee": 200.00,
      "amount": 10000.00,
      "status": "success",
      "currency": "NGN",
      "reference": "payout_12345",
      "meta": {
        "payout_id": "12345"
      }
    }
    ```

    | Field            | Description                                                 |
    | ---------------- | ----------------------------------------------------------- |
    | `amount`         | Amount disbursed to the recipient                           |
    | `fee`            | Transfer fee charged for this disbursement                  |
    | `meta.payout_id` | Your internal payout identifier passed at transfer creation |
  </Accordion>
</AccordionGroup>

## Best Practices

<CardGroup cols={2}>
  <Card title="Ensure idempotency" icon="fingerprint">
    Record the `event_id` from every incoming webhook **before** processing it. If a delivery is retried and you receive the same `event_id` again, skip processing and return `200 OK` immediately. This prevents double-crediting, duplicate fulfilment, or other unintended side-effects.
  </Card>

  <Card title="Return 2xx quickly" icon="bolt">
    Acknowledge the webhook with a `200 OK` (or any `2xx`) as fast as possible — ideally before you do any heavy processing. Enqueue the event for background processing if needed. Slow or hanging handlers will be treated as failed deliveries and retried.
  </Card>

  <Card title="Expect retries with back-off" icon="arrow-rotate-right">
    Paylink retries failed deliveries with exponential back-off. Your handler must tolerate receiving the same event more than once. Idempotency keying on `event_id` is the correct way to handle this safely.
  </Card>

  <Card title="Reject stale or invalid signatures" icon="shield-halved">
    Always verify the `X-Payment-Signature` header and reject events with signatures that do not match. Also reject events where `X-Payment-Timestamp` is more than a few minutes in the past to mitigate replay attacks.
  </Card>
</CardGroup>
