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

# Webhooks Guide

> Receive real-time notifications about payment events

# Webhooks Guide

Webhooks allow you to receive real-time HTTP notifications when events occur in your LuxCore account, such as when a payment completes or fails.

## How Webhooks Work

<Steps>
  <Step title="Event Occurs">
    A payment status changes (e.g., completed, failed)
  </Step>

  <Step title="LuxCore Sends Notification">
    We send an HTTP POST request to your configured endpoint
  </Step>

  <Step title="You Process the Event">
    Your server processes the event and returns a 2xx response
  </Step>

  <Step title="Retry if Needed">
    If delivery fails, we retry with exponential backoff
  </Step>
</Steps>

## Setting Up Webhooks

### Create a Webhook Endpoint

```bash theme={null}
curl -X POST "https://api.lux-core.io/api/v1/webhooks" \
  -H "X-API-Key: qp_test_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/luxcore",
    "events": ["payment.completed", "payment.failed"],
    "signature_algorithm": "hmac_sha256"
  }'
```

### Response

```json theme={null}
{
  "success": true,
  "data": {
    "id": "wh_abc123",
    "url": "https://your-server.com/webhooks/luxcore",
    "events": ["payment.completed", "payment.failed"],
    "status": "active",
    "signature_algorithm": "hmac_sha256",
    "secret": "whsec_a1b2c3d4e5f6g7h8i9j0...",
    "created_at": "2025-01-21T10:30:00Z"
  }
}
```

<Warning>
  The webhook secret is only shown once when creating the webhook. Store it securely!
</Warning>

## Webhook Events

| Event                  | Description                                    |
| ---------------------- | ---------------------------------------------- |
| `payment.created`      | Payment was initialized                        |
| `payment.completed`    | Payment completed successfully                 |
| `payment.failed`       | Payment failed                                 |
| `payment.cancelled`    | Payment was cancelled                          |
| `payment.refunded`     | Payment was refunded                           |
| `balance.updated`      | Merchant balance was updated                   |
| `merchant.updated`     | Merchant configuration was updated             |
| `ticket.created`       | A new support ticket was created               |
| `ticket.updated`       | Support ticket status changed (e.g., reopened) |
| `ticket.comment_added` | Support team replied to a ticket               |
| `ticket.resolved`      | Support ticket was resolved (reserved)         |
| `ticket.closed`        | Support ticket was closed                      |

<Note>
  Withdrawal-type payments (`withdrawal`, `withdrawal_pp`) use the same `payment.*` events as deposits. The `payout.*` events from the initial release have been merged into the `payment.*` event namespace.
</Note>

## Webhook Payload

All webhook payloads follow this envelope structure:

```json theme={null}
{
  "id": "evt_abc123def456",
  "created_at": "2025-01-21T10:35:00Z",
  "merchant_id": 123,
  "data": {
    "payment": {
      "id": "pay_1234567890_abcdefgh",
      "status": "completed",
      "amount": 100050,
      "currency": "ARS",
      "type": "deposit",
      "merchant_id": 123,
      "merchant_reference": "order_123456",
      "method": "bank_transfer",
      "fee": 4500,
      "net_amount": 95550,
      "created_at": "2025-01-21T10:30:00Z",
      "confirmed_at": null,
      "completed_at": "2025-01-21T10:35:00Z",
      "failed_at": null,
      "failure_reason": null,
      "customer_data": {
        "note": "Customer order note",
        "expires_at": "2025-01-21T10:45:00Z",
        "requested_payment_type": "deposit",
        "commission": {
          "currency": "ARS",
          "total_minor": 4500
        }
      },
      "processing_time_ms": 300000
    }
  }
}
```

<Warning>
  **Field name differences:** The webhook payment object uses different field names than the payment creation API response. Key differences:

  * Webhook: `id` → API response: `transaction_id`
  * Webhook: `fee` → API response: `fee_amount`
  * Webhook: `net_amount` → present in both (same name)

  Make sure your webhook handler uses the correct field names from the webhook payload.
</Warning>

### Payment Fields Reference

| Field                | Type           | Description                                                                                           |
| -------------------- | -------------- | ----------------------------------------------------------------------------------------------------- |
| `id`                 | string         | Unique payment identifier                                                                             |
| `status`             | string         | Current payment status (`completed`, `failed`, `cancelled`, `expired`, `refunded`, `partial_refund`)  |
| `amount`             | integer        | Total payment amount in **minor units** (centavos). Example: `100050` = 1000.50 ARS                   |
| `currency`           | string         | ISO 4217 currency code (`ARS`, `AUD`, `LKR`, `MXN`, `TRY`, `UYU`)                                     |
| `type`               | string         | Payment type: `deposit`, `withdrawal`, `deposit_pp`, or `withdrawal_pp`                               |
| `merchant_id`        | integer        | Your merchant ID                                                                                      |
| `merchant_reference` | string         | Your unique reference passed when creating the payment                                                |
| `method`             | string \| null | Payment method code (`bank_transfer`, `spei`, `crypto`, `payid`). Nullable if method not yet assigned |
| `fee`                | integer        | Commission amount in **minor units**. Already calculated based on your merchant fee rate              |
| `net_amount`         | integer        | Amount you receive (deposit) or total debited (withdrawal), in **minor units**                        |
| `created_at`         | string         | Payment creation timestamp (ISO 8601)                                                                 |
| `confirmed_at`       | string \| null | When payment was confirmed by the customer                                                            |
| `completed_at`       | string \| null | When payment reached terminal `completed` status                                                      |
| `failed_at`          | string \| null | When payment failed (if applicable)                                                                   |
| `failure_reason`     | string \| null | Human-readable failure description                                                                    |
| `customer_data`      | object         | Whitelisted metadata (see below)                                                                      |
| `processing_time_ms` | integer        | Time from creation to completion in milliseconds. Only present for `completed` events                 |
| `refund`             | object \| null | Refund details. Only present for `payment.refunded` events                                            |

### Amount, Fee, and Net Amount

All monetary values are integers in **minor units** (centavos). The fee is automatically calculated based on your merchant commission rate.

<Info>
  **Formula:** `net_amount = amount - fee`

  **Deposit example** (customer pays 1000.50 ARS, merchant fee 4.5%):

  * `amount`: `100050` — the amount the customer paid
  * `fee`: `4502` — commission charged (100050 × 4.5%)
  * `net_amount`: `95548` — amount credited to your balance

  **Withdrawal example** (payout of 500.00 ARS, merchant fee 5%):

  * `amount`: `50000` — the payout amount sent to the recipient
  * `fee`: `2500` — commission charged (50000 × 5%)
  * `net_amount`: `47500` — total debited from your balance: payout amount minus fee
</Info>

### Withdrawal Payload Example

For withdrawals, the payload includes `payout` details inside `customer_data`:

```json theme={null}
{
  "id": "evt_def456ghi789",
  "created_at": "2025-01-21T11:05:00Z",
  "merchant_id": 123,
  "data": {
    "payment": {
      "id": "pay_1234567890_abcdefgh",
      "status": "completed",
      "amount": 50000,
      "currency": "ARS",
      "type": "withdrawal",
      "merchant_id": 123,
      "merchant_reference": "payout_789",
      "method": "bank_transfer",
      "fee": 2500,
      "net_amount": 47500,
      "created_at": "2025-01-21T11:00:00Z",
      "confirmed_at": null,
      "completed_at": "2025-01-21T11:05:00Z",
      "failed_at": null,
      "failure_reason": null,
      "customer_data": {
        "note": "Withdrawal request",
        "requested_payment_type": "withdrawal",
        "payout": {
          "bank_code": "BBVA",
          "bank_account": "0000003100010000000001",
          "recipient_name": "Juan Perez",
          "bank_name": "BBVA Argentina"
        },
        "commission": {
          "currency": "ARS",
          "total_minor": 2500
        }
      },
      "processing_time_ms": 300000
    }
  }
}
```

### customer\_data Fields

The `customer_data` object uses a **whitelist approach** — only specific fields are included:

| Field                    | Type   | Description                                                                                                        |
| ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------ |
| `note`                   | string | Customer or merchant note                                                                                          |
| `customer_email`         | string | Customer email (if provided)                                                                                       |
| `customer_phone`         | string | Customer phone (if provided)                                                                                       |
| `customer_name`          | string | Customer name (if provided)                                                                                        |
| `external_id`            | string | External reference ID                                                                                              |
| `reference`              | string | Payment reference                                                                                                  |
| `description`            | string | Payment description                                                                                                |
| `expires_at`             | string | Payment expiration time (ISO 8601)                                                                                 |
| `requested_payment_type` | string | Original requested type (`deposit` or `withdrawal`)                                                                |
| `commission`             | object | Commission details: `currency`, `total_minor`                                                                      |
| `payout`                 | object | **Only for withdrawals**: `bank_code`, `bank_account`, `recipient_name`, `bank_name`, `bsb`, `payid`, `payid_type` |

<Warning>
  Internal processing data (IP addresses, internal IDs, processing metadata) is **never** included in webhook payloads for security reasons.
</Warning>

## Webhook Headers

Each webhook request includes these headers:

| Header                     | Description                                 |
| -------------------------- | ------------------------------------------- |
| `X-Webhook-Timestamp`      | Unix timestamp when signature was created   |
| `X-Webhook-Signature`      | HMAC-SHA256 signature for verification      |
| `X-Webhook-Id`             | Unique identifier for this webhook delivery |
| `X-Quadpay-Secret-Version` | Secret version used for signing             |
| `Content-Type`             | Always `application/json`                   |

<Info>
  The `X-Webhook-Event` and `X-Webhook-Retry` headers are only sent for **in-request webhooks** (created via the `webhook_url` field in payment creation). They are not included in standard admin-configured webhook deliveries.
</Info>

## Signature Verification

**Always verify webhook signatures** to ensure requests are from LuxCore.

### Signature Format

```
X-Webhook-Signature: hmac_sha256=<hmac_signature>
```

### Verification Algorithm

```
payload_to_sign = `${timestamp}.${raw_request_body}`
expected_signature = HMAC_SHA256(webhook_secret, payload_to_sign)
```

<Warning>
  **Important:** Always compute the HMAC on the **raw request body bytes** exactly as received, not on re-serialized JSON. Re-serializing (e.g., `JSON.stringify(parsedObject)`) can change key ordering, whitespace, or Unicode escaping, causing signature mismatches.
</Warning>

### Implementation Examples

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

  function verifyWebhookSignature(rawBody, signature, timestamp, secret) {
    // Reject old timestamps (> 5 minutes)
    const currentTime = Math.floor(Date.now() / 1000);
    if (Math.abs(currentTime - timestamp) > 300) {
      throw new Error('Timestamp too old');
    }

    // Calculate expected signature using raw body (not re-serialized JSON)
    const payloadToSign = `${timestamp}.${rawBody}`;
    const expectedSignature = 'hmac_sha256=' + crypto
      .createHmac('sha256', secret)
      .update(payloadToSign)
      .digest('hex');

    // Compare signatures
    if (!crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    )) {
      throw new Error('Invalid signature');
    }

    return true;
  }

  // Capture raw body for signature verification
  app.use(express.json({
    verify: (req, res, buf) => { req.rawBody = buf.toString(); }
  }));

  app.post('/webhooks/luxcore', (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = parseInt(req.headers['x-webhook-timestamp']);
    const webhookId = req.headers['x-webhook-id'];

    try {
      // Verify using raw body (not re-serialized JSON)
      verifyWebhookSignature(req.rawBody, signature, timestamp, WEBHOOK_SECRET);

      // Process the event — use envelope structure
      const event = req.headers['x-webhook-event'];
      const payment = req.body.data.payment;
      console.log(`Received ${event} for payment ${payment.id} (webhook: ${webhookId})`);

      // Handle specific events
      switch (event) {
        case 'payment.completed':
          // Update order status, send confirmation email, etc.
          break;
        case 'payment.failed':
          // Handle failed payment
          break;
      }

      res.status(200).json({ received: true });
    } catch (error) {
      console.error('Webhook error:', error.message);
      res.status(400).json({ error: error.message });
    }
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import os
  import time
  from flask import Flask, request, jsonify

  app = Flask(__name__)
  WEBHOOK_SECRET = os.environ.get('LUXCORE_WEBHOOK_SECRET')

  def verify_webhook_signature(raw_body, signature, timestamp, secret):
      # Reject old timestamps (> 5 minutes)
      current_time = int(time.time())
      if abs(current_time - timestamp) > 300:
          raise ValueError('Timestamp too old')

      # Calculate expected signature using raw body (not re-serialized JSON)
      payload_to_sign = f"{timestamp}.{raw_body}"
      expected_signature = 'hmac_sha256=' + hmac.new(
          secret.encode(),
          payload_to_sign.encode(),
          hashlib.sha256
      ).hexdigest()

      # Compare signatures
      if not hmac.compare_digest(signature, expected_signature):
          raise ValueError('Invalid signature')

      return True

  @app.route('/webhooks/luxcore', methods=['POST'])
  def webhook_handler():
      signature = request.headers.get('X-Webhook-Signature')
      timestamp = int(request.headers.get('X-Webhook-Timestamp'))
      raw_body = request.get_data(as_text=True)
      payload = request.json  # for business logic

      try:
          verify_webhook_signature(raw_body, signature, timestamp, WEBHOOK_SECRET)

          # Process the event — use envelope structure
          event = request.headers.get('X-Webhook-Event')
          webhook_id = request.headers.get('X-Webhook-Id')
          payment = payload['data']['payment']
          print(f"Received {event} for payment {payment['id']} (webhook: {webhook_id})")

          if event == 'payment.completed':
              # Handle completed payment
              pass
          elif event == 'payment.failed':
              # Handle failed payment
              pass

          return jsonify({'received': True}), 200
      except ValueError as e:
          return jsonify({'error': str(e)}), 400
  ```

  ```php PHP theme={null}
  <?php
  function verifyWebhookSignature(string $rawBody, string $signature, int $timestamp, string $secret): bool {
      // Reject old timestamps (> 5 minutes)
      $currentTime = time();
      if (abs($currentTime - $timestamp) > 300) {
          throw new InvalidArgumentException('Webhook timestamp too old');
      }

      // Calculate expected signature using raw body (not re-serialized JSON)
      $payloadToSign = "{$timestamp}.{$rawBody}";
      $expectedSignature = 'hmac_sha256=' . hash_hmac('sha256', $payloadToSign, $secret);

      // Compare signatures (timing-safe)
      if (!hash_equals($signature, $expectedSignature)) {
          throw new InvalidArgumentException('Invalid webhook signature');
      }

      return true;
  }

  // Handle incoming webhook
  $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
  $timestamp = (int)($_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? 0);
  $rawBody = file_get_contents('php://input');
  $payload = json_decode($rawBody, true);

  try {
      verifyWebhookSignature($rawBody, $signature, $timestamp, WEBHOOK_SECRET);
      
      // Process event — use envelope structure
      $event = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
      $webhookId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';
      $payment = $payload['data']['payment'];

      switch ($event) {
          case 'payment.completed':
              // Handle completed payment
              break;
          case 'payment.failed':
              // Handle failed payment
              break;
      }

      http_response_code(200);
      echo json_encode(['received' => true]);
  } catch (Exception $e) {
      http_response_code(400);
      echo json_encode(['error' => $e->getMessage()]);
  }
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "fmt"
      "io"
      "math"
      "net/http"
      "strconv"
      "time"
  )

  func verifyWebhookSignature(payload []byte, signature string, timestamp int64, secret string) error {
      // Reject old timestamps (> 5 minutes)
      currentTime := time.Now().Unix()
      if math.Abs(float64(currentTime-timestamp)) > 300 {
          return fmt.Errorf("timestamp too old")
      }

      // Calculate expected signature using raw body bytes
      payloadToSign := fmt.Sprintf("%d.%s", timestamp, string(payload))
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write([]byte(payloadToSign))
      expectedSignature := "hmac_sha256=" + hex.EncodeToString(mac.Sum(nil))

      // Compare signatures
      if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
          return fmt.Errorf("invalid signature")
      }

      return nil
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      // Read raw body first for signature verification
      body, err := io.ReadAll(r.Body)
      if err != nil {
          http.Error(w, "Failed to read body", http.StatusBadRequest)
          return
      }

      signature := r.Header.Get("X-Webhook-Signature")
      timestampStr := r.Header.Get("X-Webhook-Timestamp")
      timestamp, _ := strconv.ParseInt(timestampStr, 10, 64)

      // Verify using raw body (not re-serialized JSON)
      if err := verifyWebhookSignature(body, signature, timestamp, webhookSecret); err != nil {
          http.Error(w, err.Error(), http.StatusBadRequest)
          return
      }

      // Parse for business logic
      var payload map[string]interface{}
      json.Unmarshal(body, &payload)

      // Process the event — use envelope structure
      event := r.Header.Get("X-Webhook-Event")
      webhookId := r.Header.Get("X-Webhook-Id")
      data := payload["data"].(map[string]interface{})
      payment := data["payment"].(map[string]interface{})
      fmt.Printf("Received %s for payment %s (webhook: %s)\n", event, payment["id"], webhookId)

      w.WriteHeader(http.StatusOK)
      json.NewEncoder(w).Encode(map[string]bool{"received": true})
  }
  ```
</CodeGroup>

## Replay Protection

In addition to signature verification, implement these measures to prevent replay attacks:

1. **Check timestamp:** Reject webhooks with timestamps older than 5 minutes (already shown in verification examples above)
2. **Store webhook IDs:** Save the `X-Webhook-Id` header value and reject duplicates. This prevents replayed webhooks within the timestamp window
3. **Distinguish retries from replays:** Legitimate retries from LuxCore will have the same `X-Webhook-Id`. Only process each unique webhook ID once

<Note>
  The combination of timestamp validation and webhook ID deduplication provides strong replay protection.
</Note>

## Retry Policy

If webhook delivery fails (non-2xx response or timeout), we retry with exponential backoff and full jitter:

* **Default retries:** 3 attempts after the initial delivery (configurable per webhook)
* **Backoff formula:** Random delay between 0 and `2^attempt x base_delay`, with a maximum cap of 24 hours
* **Timeout:** Each delivery attempt times out after 30 seconds

| Attempt | Max Delay   |
| ------- | ----------- |
| Initial | Immediate   |
| Retry 1 | \~2 minutes |
| Retry 2 | \~4 minutes |
| Retry 3 | \~8 minutes |

<Note>
  Actual delays are randomized (full jitter) to prevent thundering herd. The values above are maximums.
</Note>

After all retry attempts are exhausted, the webhook event is marked as failed. You can manually retry failed events via the API.

## Testing Webhooks

Send a test event to verify your endpoint:

```bash theme={null}
curl -X POST "https://api.lux-core.io/api/v1/webhooks/wh_abc123/test" \
  -H "X-API-Key: qp_test_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "payment.completed"
  }'
```

## Managing Webhooks

### List Webhooks

```bash theme={null}
curl -X GET "https://api.lux-core.io/api/v1/webhooks" \
  -H "X-API-Key: qp_test_sk_your_key"
```

### Update Webhook

```bash theme={null}
curl -X PUT "https://api.lux-core.io/api/v1/webhooks/wh_abc123" \
  -H "X-API-Key: qp_test_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["payment.completed", "payment.failed", "payment.cancelled"]
  }'
```

### Delete Webhook

```bash theme={null}
curl -X DELETE "https://api.lux-core.io/api/v1/webhooks/wh_abc123" \
  -H "X-API-Key: qp_test_sk_your_key"
```

## In-Request Webhooks

Instead of pre-configuring webhooks via the API, you can pass a `webhook_url` directly in the payment creation request. This is useful when you want per-payment notification routing or a simpler integration without managing webhook endpoints.

### How It Works

1. Include `webhook_url` in your `POST /payments` request
2. LuxCore automatically creates (or reuses) a webhook endpoint for your merchant
3. Events are delivered to this URL **in addition to** any admin-configured webhooks
4. The webhook is signed using your merchant's **default webhook secret**

### Example

```bash theme={null}
curl -X POST "https://api.lux-core.io/api/v1/payments" \
  -H "X-API-Key: qp_test_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 150000,
    "currency": "ARS",
    "method": "bank_transfer",
    "type": "deposit",
    "merchant_reference": "order_abc123",
    "customer": {
      "name": "Maria Garcia",
      "email": "maria@example.com"
    },
    "webhook_url": "https://your-server.com/webhooks/order_abc123",
    "webhook_events": ["payment.completed", "payment.failed"]
  }'
```

### Default Events

If `webhook_events` is not provided, the following events are subscribed by default:

* `payment.created`
* `payment.completed`
* `payment.failed`
* `payment.refunded`

### Webhook Secret

In-request webhooks are signed using your merchant's **default webhook secret**. This secret is:

* Automatically generated the first time you use `webhook_url`
* Shared across all in-request webhooks for your merchant
* Visible in the merchant settings panel of the backoffice
* Used for HMAC-SHA256 signature verification (same algorithm as admin webhooks)

<Info>
  Use the same signature verification logic for in-request webhooks as for admin webhooks. The only difference is the signing secret — in-request webhooks use your merchant's default secret instead of the per-webhook secret.
</Info>

### Deduplication

If you send the same `webhook_url` across multiple payments, the system automatically reuses the existing webhook configuration. This means:

* No duplicate webhook endpoints are created
* Event subscriptions from the first request are preserved
* The same secret key is used for all deliveries to that URL

### Differences from Admin Webhooks

| Feature                   | Admin Webhooks                    | In-Request Webhooks                     |
| ------------------------- | --------------------------------- | --------------------------------------- |
| Created via               | `POST /webhooks` API              | `webhook_url` field in `POST /payments` |
| Secret key                | Per-webhook (custom or generated) | Merchant default secret (shared)        |
| Listed in `GET /webhooks` | Yes (by default)                  | No (use `?source=in_request` to view)   |
| Events                    | Configurable per-webhook          | Configurable per-request (or defaults)  |
| Deduplication             | Manual (create once, reuse)       | Automatic (same URL = same webhook)     |

## Best Practices

<CardGroup cols={2}>
  <Card title="Always Verify Signatures" icon="shield-check">
    Never process webhooks without verifying the signature first
  </Card>

  <Card title="Respond Quickly" icon="bolt">
    Return 200 immediately, process events asynchronously
  </Card>

  <Card title="Handle Duplicates" icon="copy">
    Use payment ID + `status_version` for deduplication and ordering
  </Card>

  <Card title="Log Everything" icon="clipboard-list">
    Log webhook payloads for debugging and audit trails
  </Card>
</CardGroup>

## Ordering and Deduplication

Webhooks are delivered **at-least-once** and may arrive out of order. Each webhook payload includes a `status_version` field that increments with every payment status change.

**Recommended approach:**

1. Store the last processed `status_version` per payment ID
2. Ignore webhooks where `status_version <= stored_version` (stale or duplicate)
3. Process only webhooks with a higher `status_version`

```json theme={null}
{
  "id": "evt_abc123",
  "data": {
    "payment": {
      "id": "pay_xyz789",
      "status": "completed",
      "status_version": 3,
      "amount": 500.00,
      "currency": "MXN"
    }
  }
}
```

<Note>
  Webhook endpoints must be publicly accessible HTTPS URLs. Self-signed certificates are not supported.
</Note>
