Skip to main content

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.
{
  "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.
{
  "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.
{
  "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.
{
  "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.
{
  "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

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)

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

// 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:
AttemptDelay After Previous
1Immediate
21 minute
32 minutes
44 minutes
58 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:
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:
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:
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:
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:
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:
{
  "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:
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

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

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?