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

# Webhooks

> Receive real-time payout status notifications

Receive real-time notifications when payout events occur.

## How Webhooks Work

Configure a webhook URL in your Merchant Dashboard under **Developer API > Webhooks**. Quickei sends an HTTP POST request to your URL whenever a payout is completed.

## Payload Format

The webhook payload is a flat JSON object (not wrapped in a `data` envelope):

```json theme={null}
{
  "event": "payout.completed",
  "payout_id": "POAB1C2D3E",
  "reference": "PAYROLL-2026-03-001",
  "amount": 100.00,
  "currency": "EUR",
  "fee": 2.50,
  "total_deducted": 102.50,
  "exchange_rate": 655.957,
  "receiver_amount": 65595.70,
  "receiver_currency": "XAF",
  "status": "completed",
  "processed_at": "2026-03-29T14:01:15+00:00",
  "timestamp": "2026-03-29T14:01:16+00:00"
}
```

<ResponseField name="event" type="string">
  The event type. Currently always `payout.completed`.
</ResponseField>

<ResponseField name="payout_id" type="string">
  Unique payout identifier matching the one returned from the [Payout](/partner-api/05-payout) endpoint.
</ResponseField>

<ResponseField name="reference" type="string">
  Your internal reference for this payout.
</ResponseField>

<ResponseField name="amount" type="number">
  The amount sent (before fees).
</ResponseField>

<ResponseField name="currency" type="string">
  The sender currency code.
</ResponseField>

<ResponseField name="fee" type="number">
  Total fee charged.
</ResponseField>

<ResponseField name="total_deducted" type="number">
  Total debited from your merchant wallet (`amount + fee`).
</ResponseField>

<ResponseField name="exchange_rate" type="number">
  Applied exchange rate.
</ResponseField>

<ResponseField name="receiver_amount" type="number">
  Amount credited to the recipient.
</ResponseField>

<ResponseField name="receiver_currency" type="string">
  The recipient's currency code.
</ResponseField>

<ResponseField name="status" type="string">
  Payout status (e.g. `completed`).
</ResponseField>

<ResponseField name="processed_at" type="string">
  ISO 8601 timestamp when the payout was processed.
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO 8601 timestamp when the webhook was sent.
</ResponseField>

## Headers

Each webhook request includes these headers:

| Header                | Description                                   |
| --------------------- | --------------------------------------------- |
| `Content-Type`        | `application/json`                            |
| `X-Quickei-Signature` | HMAC-SHA256 signature of the raw request body |
| `X-Webhook-Event`     | `payout.completed`                            |

## Signature Verification

The `X-Quickei-Signature` header contains an HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret (configured in the Merchant Dashboard).

```
X-Quickei-Signature: <HMAC-SHA256 hex digest>
```

<Warning>
  Always verify the signature before processing webhook events. Reject any request with an invalid or missing signature.
</Warning>

## Verification Examples

<Accordion title="PHP">
  ```php theme={null}
  $payload = file_get_contents('php://input');
  $signature = $_SERVER['HTTP_X_QUICKEI_SIGNATURE'] ?? '';

  $expected = hash_hmac('sha256', $payload, $webhook_secret);

  if (!hash_equals($expected, $signature)) {
      http_response_code(403);
      exit('Invalid signature');
  }

  $event = json_decode($payload, true);

  if ($event['event'] === 'payout.completed') {
      // Mark payout as successful in your system
  }

  http_response_code(200);
  ```
</Accordion>

<Accordion title="Node.js">
  ```javascript theme={null}
  const crypto = require('crypto');

  app.post('/webhooks/quickei', (req, res) => {
    const payload = req.rawBody; // ensure raw body is available
    const signature = req.headers['x-quickei-signature'] || '';

    const expected = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(payload)
      .digest('hex');

    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature)
    )) {
      return res.status(403).send('Invalid signature');
    }

    const event = JSON.parse(payload);

    if (event.event === 'payout.completed') {
      // Mark payout as successful
    }

    res.status(200).send('OK');
  });
  ```
</Accordion>

<Accordion title="Python">
  ```python theme={null}
  import hmac
  import hashlib

  @app.route('/webhooks/quickei', methods=['POST'])
  def handle_webhook():
      payload = request.get_data(as_text=True)
      signature = request.headers.get('X-Quickei-Signature', '')

      expected = hmac.new(
          WEBHOOK_SECRET.encode(),
          payload.encode(),
          hashlib.sha256
      ).hexdigest()

      if not hmac.compare_digest(expected, signature):
          return 'Invalid signature', 403

      event = request.get_json()

      if event['event'] == 'payout.completed':
          # Mark payout as successful
          pass

      return 'OK', 200
  ```
</Accordion>

## Retry Policy

If your endpoint does not return a `2xx` status code, Quickei retries the webhook delivery:

| Attempt   | Delay      |
| --------- | ---------- |
| 1st retry | 10 seconds |
| 2nd retry | 1 minute   |
| 3rd retry | 5 minutes  |

After 3 failed attempts, the webhook is marked as failed. You can view failed deliveries and trigger manual retries from the Merchant Dashboard under **Developer API > Webhooks > Delivery Log**.

<Note>
  Return a `200` status code as quickly as possible. Perform heavy processing (database writes, notifications) asynchronously after acknowledging the webhook.
</Note>

## Best Practices

* **Verify signatures** on every webhook before processing
* **Respond quickly** with a `200` and process asynchronously
* **Handle duplicates** -- you may receive the same event more than once during retries. Use the `payout_id` to deduplicate.
* **Use the status endpoint** as a fallback. If you miss a webhook, poll [`GET /payout/{id}`](/partner-api/06-status) to get the current status.
