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

# Webhook Integration

> Receive real-time webhook notifications when payment events occur. Learn webhook setup, ED25519 signature verification, and best practices.

## Overview

Webhooks enable you to receive automatic notifications when:

* A payment is initiated
* A customer completes payment
* A payment is under user review
* A payment expires
* A payment fails

## Webhook Events

### payment.initiated

Triggered when a payment is created.

```json theme={null}
{
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "payId": "bybit_pay_abc123",
  "merchantTradeNo": "ORDER-2025-001",
  "status": "INITIATED",
  "amount": 149.99,
  "currency": "USDT",
  "timestamp": "2025-01-15T10:30:00.000Z"
}
```

### payment.user\_review

Triggered when a customer loads the payment QR code in their wallet app (currently supported for Bybit Pay only). This indicates the customer is actively viewing the payment.

```json theme={null}
{
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "payId": "bybit_pay_abc123",
  "merchantTradeNo": "ORDER-2025-001",
  "status": "USER_REVIEW",
  "amount": 149.99,
  "currency": "USDT",
  "timestamp": "2025-01-15T10:32:00.000Z"
}
```

### payment.paid

Triggered when a customer successfully completes payment.

```json theme={null}
{
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "payId": "bybit_pay_abc123",
  "merchantTradeNo": "ORDER-2025-001",
  "status": "PAID",
  "amount": 149.99,
  "currency": "USDT",
  "paidAt": "2025-01-15T10:35:22.000Z",
  "timestamp": "2025-01-15T10:35:22.000Z"
}
```

### payment.expired

Triggered when a payment link expires without payment.

```json theme={null}
{
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "payId": "bybit_pay_abc123",
  "merchantTradeNo": "ORDER-2025-001",
  "status": "EXPIRED",
  "amount": 149.99,
  "currency": "USDT",
  "expiredAt": "2025-01-15T11:30:00.000Z",
  "timestamp": "2025-01-15T11:30:00.000Z"
}
```

### payment.failed

Triggered when a payment fails.

```json theme={null}
{
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "payId": "bybit_pay_abc123",
  "merchantTradeNo": "ORDER-2025-001",
  "status": "FAILED",
  "amount": 149.99,
  "currency": "USDT",
  "failedAt": "2025-01-15T10:32:00.000Z",
  "timestamp": "2025-01-15T10:32:00.000Z"
}
```

## Webhook Headers

Every webhook request includes these headers:

```
X-Webhook-Signature: abc123def456...
X-Webhook-Timestamp: 1705315800000
X-Webhook-Attempt: 1
Content-Type: application/json
```

* **X-Webhook-Signature**: Base64-encoded ED25519 signature for verification
* **X-Webhook-Timestamp**: Unix timestamp in milliseconds
* **X-Webhook-Attempt**: Current delivery attempt (1-5)

## Signature Verification

**IMPORTANT**: Always verify webhook signatures to prevent unauthorized requests. CeyPay uses **ED25519** (an EdDSA signature scheme) for signing webhooks.

### CeyPay Public Keys

Use these public keys to verify the signatures sent by CeyPay:

**Production**

```
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAs+hotF0u5tdoATwAIezK58lzfMZIoKDTlRgoUU+6Qr4=
-----END PUBLIC KEY-----
```

**Sandbox**

```
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXD9k6rwT30WwEN9nwvEuPRsfZCJ9nTLIOeQZQxludAk=
-----END PUBLIC KEY-----
```

### Signature Calculation

The signature is generated by signing the concatenation of the timestamp and the raw JSON payload:

```
message = X-Webhook-Timestamp + raw_request_body
signature = ED25519_SIGN(message, private_key)
```

The `X-Webhook-Signature` header contains the **Base64-encoded** signature.

### Implementation Examples

#### Node.js / Express

```javascript theme={null}
const crypto = require('crypto');
const express = require('express');
const app = express();

const CEYPAY_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAs+hotF0u5tdoATwAIezK58lzfMZIoKDTlRgoUU+6Qr4=
-----END PUBLIC KEY-----
`;

// Important: Use raw body for signature verification
app.use('/webhooks/ceypay', express.raw({ type: 'application/json' }));

app.post('/webhooks/ceypay', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];
  const rawBody = req.body.toString('utf8');

  // Verify signature using ED25519
  const message = timestamp + rawBody;
  
  try {
    const isValid = crypto.verify(
      null,
      Buffer.from(message),
      CEYPAY_PUBLIC_KEY,
      Buffer.from(signature, 'base64')
    );

    if (!isValid) {
      console.error('Invalid webhook signature');
      return res.status(401).json({ error: 'Invalid signature' });
    }
  } catch (err) {
    console.error('Signature verification error:', err);
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Verify timestamp (prevent replay attacks)
  const now = Date.now();
  const requestTime = parseInt(timestamp);
  const fiveMinutes = 5 * 60 * 1000;

  if (Math.abs(now - requestTime) > fiveMinutes) {
    console.error('Webhook timestamp outside valid window');
    return res.status(401).json({ error: 'Invalid timestamp' });
  }

  // Parse and process webhook
  const payload = JSON.parse(rawBody);
  console.log('Webhook received:', payload);

  switch(payload.status) {
    case 'PAID':
      // Update order status, fulfill order
      console.log(`Payment ${payload.paymentId} completed!`);
      break;
    case 'EXPIRED':
      // Cancel order, release inventory
      console.log(`Payment ${payload.paymentId} expired`);
      break;
    case 'FAILED':
      // Notify customer, offer retry
      console.log(`Payment ${payload.paymentId} failed`);
      break;
  }

  // Always respond with 200 to acknowledge receipt
  res.status(200).json({ received: true });
});

app.listen(3000);
```

#### Python / Flask (using cryptography)

```python theme={null}
import base64
import time
import json
from flask import Flask, request, jsonify
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization

app = Flask(__name__)

CEYPAY_PUBLIC_KEY_PEM = b"""-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAs+hotF0u5tdoATwAIezK58lzfMZIoKDTlRgoUU+6Qr4=
-----END PUBLIC KEY-----"""

public_key = serialization.load_pem_public_key(CEYPAY_PUBLIC_KEY_PEM)

@app.route('/webhooks/ceypay', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')
    timestamp = request.headers.get('X-Webhook-Timestamp')
    raw_body = request.get_data(as_text=True)

    # Verify signature
    message = (timestamp + raw_body).encode()
    signature_bytes = base64.b64decode(signature)

    try:
        public_key.verify(signature_bytes, message)
    except Exception:
        return jsonify({'error': 'Invalid signature'}), 401

    # Verify timestamp
    now = int(time.time() * 1000)
    request_time = int(timestamp)
    five_minutes = 5 * 60 * 1000

    if abs(now - request_time) > five_minutes:
        return jsonify({'error': 'Invalid timestamp'}), 401

    # Parse and process webhook
    payload = json.loads(raw_body)
    print(f"Webhook received: {payload}")

    if payload['status'] == 'PAID':
        # Update order status
        print(f"Payment {payload['paymentId']} completed!")
    elif payload['status'] == 'EXPIRED':
        # Cancel order
        print(f"Payment {payload['paymentId']} expired")
    elif payload['status'] == 'FAILED':
        # Notify customer
        print(f"Payment {payload['paymentId']} failed")

    return jsonify({'received': True}), 200

if __name__ == '__main__':
    app.run(port=3000)
```

#### PHP

```php theme={null}
<?php

// Get raw POST data
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'];

// Verify signature using sodium
$message = $timestamp . $rawBody;
$signatureBytes = base64_decode($signature);

// Sodium requires the raw 32-byte public key
// You can extract it from the SPKI PEM or use the hex/bin version
// Raw key for CeyPay: s+hotF0u5tdoATwAIezK58lzfMZIoKDTlRgoUU+6Qr4=
$rawPublicKey = base64_decode("s+hotF0u5tdoATwAIezK58lzfMZIoKDTlRgoUU+6Qr4=");

if (!sodium_crypto_sign_verify_detached($signatureBytes, $message, $rawPublicKey)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// Verify timestamp
$now = time() * 1000;
$requestTime = intval($timestamp);
$fiveMinutes = 5 * 60 * 1000;

if (abs($now - $requestTime) > $fiveMinutes) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid timestamp']);
    exit;
}

// Parse and process webhook
$payload = json_decode($rawBody, true);

switch ($payload['status']) {
    case 'PAID':
        // Update order status
        error_log("Payment {$payload['paymentId']} completed!");
        break;
    case 'EXPIRED':
        // Cancel order
        error_log("Payment {$payload['paymentId']} expired");
        break;
    case 'FAILED':
        // Notify customer
        error_log("Payment {$payload['paymentId']} failed");
        break;
}

// Always respond with 200
http_response_code(200);
echo json_encode(['received' => true]);
?>
```

## Retry Policy

CeyPay implements exponential backoff for failed webhook deliveries:

| Attempt | Delay After Previous |
| ------- | -------------------- |
| 1       | Immediate            |
| 2       | 1 minute             |
| 3       | 2 minutes            |
| 4       | 4 minutes            |
| 5       | 8 minutes            |

**Total retry window**: \~15 minutes

### When Are Webhooks Retried?

Webhooks are retried when:

* Connection timeout (10 seconds)
* HTTP status code is not 2xx (200-299)
* Network error occurs

### When Do Retries Stop?

Retries stop when:

* Webhook responds with 2xx status code
* Maximum attempts (5) reached
* 15 minutes elapsed since first attempt

## Best Practices

### 1. Respond Quickly

Return a 200 status code immediately, then process asynchronously:

```javascript theme={null}
app.post('/webhooks/ceypay', async (req, res) => {
  // Verify signature first
  if (!verifySignature(req)) {
    return res.status(401).send('Invalid signature');
  }

  // Immediately acknowledge receipt
  res.status(200).json({ received: true });

  // Process webhook asynchronously
  processWebhookAsync(req.body).catch(err => {
    console.error('Error processing webhook:', err);
  });
});
```

### 2. Handle Idempotency

You may receive the same webhook multiple times. Use `paymentId` to track processed webhooks:

```javascript theme={null}
const processedWebhooks = new Set();

function processWebhook(payload) {
  const paymentId = payload.paymentId;

  if (processedWebhooks.has(paymentId)) {
    console.log(`Already processed webhook for payment ${paymentId}`);
    return;
  }

  // Process the webhook
  updateOrderStatus(paymentId, payload.status);

  // Mark as processed
  processedWebhooks.add(paymentId);
}
```

### 3. Use HTTPS

Always use HTTPS for webhook endpoints. CeyPay will reject insecure HTTP URLs in production.

### 4. Validate Timestamp

Reject webhooks with old timestamps to prevent replay attacks:

```javascript theme={null}
const FIVE_MINUTES = 5 * 60 * 1000;
const now = Date.now();
const requestTime = parseInt(req.headers['x-webhook-timestamp']);

if (Math.abs(now - requestTime) > FIVE_MINUTES) {
  return res.status(401).send('Timestamp outside valid window');
}
```

### 5. Log Everything

Maintain detailed logs for debugging:

```javascript theme={null}
console.log({
  timestamp: new Date().toISOString(),
  paymentId: payload.paymentId,
  status: payload.status,
  attempt: req.headers['x-webhook-attempt'],
  signature: req.headers['x-webhook-signature']
});
```

## Testing Webhooks

### Local Development with ngrok

1. Install ngrok: `npm install -g ngrok`
2. Start your local server: `node server.js`
3. Expose via ngrok: `ngrok http 3000`
4. Use the ngrok URL for testing: `https://abc123.ngrok.io/webhooks/ceypay`

### Test Endpoint

Use the webhook test endpoint to verify your integration:

```bash theme={null}
curl -X POST https://api.ceypay.io/v1/webhooks/test \
  -H "Content-Type: application/json" \
  -H "x-api-key: your_api_key" \
  -H "x-timestamp: $(date +%s)000" \
  -H "x-signature: your_signature" \
  -d '{"webhookUrl": "https://your-app.com/webhooks/ceypay"}'
```

Response:

```json theme={null}
{
  "success": true,
  "message": "Test webhook delivered successfully",
  "deliveryTime": 245
}
```

## Troubleshooting

### Webhook Not Received

**Check:**

1. Webhook URL is publicly accessible (not localhost)
2. Endpoint returns 200 status code
3. No firewall blocking CeyPay's IP addresses
4. Endpoint timeout is > 10 seconds

### Signature Verification Failed

**Common causes:**

1. Wrong public key (ensure you use CeyPay's public key)
2. Body parsing issue (use raw body for verification)
3. Timestamp not included in message
4. Character encoding mismatch

**Debug:**

```javascript theme={null}
console.log('Received signature:', signature);
console.log('Timestamp:', timestamp);
console.log('Raw body:', rawBody);
console.log('Computed message:', timestamp + rawBody);
```

### Multiple Webhook Deliveries

This is normal! Implement idempotency handling using `paymentId`.

### Webhook Timeout

Ensure your endpoint responds within 10 seconds:

* Return 200 immediately
* Process webhook asynchronously
* Optimize database queries

## Example Implementations

### E-commerce Order Fulfillment

```javascript theme={null}
async function handlePaymentWebhook(payload) {
  const order = await db.orders.findOne({ paymentId: payload.paymentId });

  if (!order) {
    console.error('Order not found for payment:', payload.paymentId);
    return;
  }

  switch (payload.status) {
    case 'PAID':
      await db.orders.update(order.id, { status: 'paid' });
      await fulfillOrder(order.id);
      await sendConfirmationEmail(order.customerEmail);
      break;

    case 'EXPIRED':
      await db.orders.update(order.id, { status: 'expired' });
      await releaseInventory(order.items);
      break;

    case 'FAILED':
      await db.orders.update(order.id, { status: 'failed' });
      await sendPaymentFailureEmail(order.customerEmail);
      break;
  }
}
```

### Subscription Activation

```javascript theme={null}
async function handleSubscriptionWebhook(payload) {
  if (payload.status === 'PAID') {
    const subscription = await db.subscriptions.findOne({
      paymentId: payload.paymentId
    });

    await db.subscriptions.update(subscription.id, {
      status: 'active',
      activatedAt: new Date(),
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
    });

    await grantUserAccess(subscription.userId);
    await sendWelcomeEmail(subscription.userEmail);
  }
}
```

## Support

Need help with webhook integration?

* Email: [support@ceypay.io](mailto:support@ceypay.io)
* Documentation: [https://docs.ceypay.io](https://docs.ceypay.io)
* Status page: [https://status.ceypay.io](https://status.ceypay.io)
