DirectCryptoPay Docs

Webhooks

Webhooks are the backbone of any DirectCryptoPay integration. When a payment is confirmed on the blockchain, DCP sends an HMAC-signed HTTP POST request to your server with the payment details.

Critical: Never rely solely on client-side callbacks (onSuccess) to confirm payments. The widget's onSuccess callback fires when the transaction is submitted, not when it is confirmed. Always use webhooks for server-side verification before fulfilling orders.

## Why Webhooks?

DirectCryptoPay uses a zero-trust verification model:

  1. The customer signs a transaction in their wallet
  2. The transaction is sent to the blockchain
  3. DCP's backend independently monitors the blockchain
  4. When the transaction reaches the required confirmations, DCP sends a webhook
  5. Your server receives the webhook and fulfills the order

This flow prevents any client-side manipulation. Even if someone tampers with the frontend, your server only acts on verified webhook data.

Setting Up Webhooks

Step 1: Configure Your Endpoint

  1. Go to Dashboard > Webhooks
  2. Click Create Webhook
  3. Enter your endpoint URL (must be HTTPS in production)
  4. Save the webhook

Step 2: Copy Your Webhook Secret

Each webhook endpoint has a Webhook Secret used for signature verification. Copy this secret -- you will need it in your verification code.

Local Development: For local testing, use a tunneling tool like ngrok to expose your local server to the internet. Example: ngrok http 3000 gives you a public HTTPS URL.

## Webhook Payload

When a payment is confirmed, DCP sends a POST request with the following JSON body:

{
  "event": "payment.confirmed",
  "timestamp": "2025-01-15T11:35:00.000Z",
  "data": {
    "id": "pi_abc123def456",
    "status": "paid",
    "amount": "49.99",
    "currency": "USD",
    "token": "USDC",
    "chain": "polygon",
    "chainId": 137,
    "txHash": "0x7a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
    "recipientAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
    "senderAddress": "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01",
    "confirmedAt": "2025-01-15T11:35:00.000Z",
    "metadata": {
      "orderId": "ORD-12345",
      "customerEmail": "customer@example.com"
    }
  }
}

Payload Fields

Field Type Description
event string Event type (currently payment.confirmed)
timestamp string ISO 8601 timestamp of the event
data.id string Payment intent ID
data.status string Payment status (paid)
data.amount string Original amount in the pricing currency
data.currency string Pricing currency (e.g., USD)
data.token string Token used for payment (e.g., USDC, ETH)
data.chain string Chain name (e.g., polygon, ethereum, bsc)
data.chainId number EVM chain ID
data.txHash string On-chain transaction hash
data.recipientAddress string Your merchant wallet address
data.senderAddress string Customer's wallet address
data.confirmedAt string ISO 8601 confirmation timestamp
data.metadata object Custom metadata you attached to the payment

HMAC Signature Verification

Every webhook request includes two headers for security:

Header Description
X-Webhook-Signature HMAC-SHA256 signature of the request body
X-Webhook-Timestamp Unix timestamp of when the webhook was sent

Verification Steps

  1. Check the timestamp -- Reject requests older than 5 minutes (replay protection)
  2. Compute the HMAC -- Hash the raw request body with your webhook secret
  3. Compare signatures -- Use a timing-safe comparison to prevent timing attacks

Node.js / Express

const crypto = require('crypto');

function verifyWebhook(req, webhookSecret) {
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];
  const rawBody = req.rawBody; // Requires raw body middleware

  // Step 1: Check timestamp (reject if older than 5 minutes)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    throw new Error('Webhook timestamp too old - possible replay attack');
  }

  // Step 2: Compute HMAC-SHA256
  const expectedSignature = crypto
    .createHmac('sha256', webhookSecret)
    .update(rawBody)
    .digest('hex');

  // Step 3: Timing-safe comparison
  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );

  if (!isValid) {
    throw new Error('Invalid webhook signature');
  }

  return JSON.parse(rawBody);
}

// Express example
const express = require('express');
const app = express();

// Important: Use raw body for signature verification
app.post('/webhooks/dcp', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = verifyWebhook(req, process.env.DCP_WEBHOOK_SECRET);

    if (event.event === 'payment.confirmed') {
      const { id, txHash, metadata } = event.data;
      console.log(`Payment ${id} confirmed: ${txHash}`);

      // Fulfill the order
      // await fulfillOrder(metadata.orderId);
    }

    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook verification failed:', error.message);
    res.status(400).send('Invalid signature');
  }
});

Python / Flask

import hmac
import hashlib
import json
import time
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['DCP_WEBHOOK_SECRET']

def verify_webhook(raw_body, signature, timestamp, secret):
    # Step 1: Check timestamp
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError('Webhook timestamp too old')

    # Step 2: Compute HMAC-SHA256
    expected = hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    # Step 3: Timing-safe comparison
    if not hmac.compare_digest(signature, expected):
        raise ValueError('Invalid webhook signature')

    return json.loads(raw_body)

@app.route('/webhooks/dcp', methods=['POST'])
def handle_webhook():
    try:
        event = verify_webhook(
            request.data,
            request.headers.get('X-Webhook-Signature', ''),
            request.headers.get('X-Webhook-Timestamp', ''),
            WEBHOOK_SECRET
        )

        if event['event'] == 'payment.confirmed':
            order_id = event['data']['metadata']['orderId']
            tx_hash = event['data']['txHash']
            print(f'Payment confirmed for order {order_id}: {tx_hash}')

            # Fulfill the order
            # fulfill_order(order_id)

        return 'OK', 200
    except ValueError as e:
        return str(e), 400

PHP

<?php
function verifyWebhook($rawBody, $signature, $timestamp, $secret) {
    // Step 1: Check timestamp
    if (abs(time() - intval($timestamp)) > 300) {
        throw new Exception('Webhook timestamp too old');
    }

    // Step 2: Compute HMAC-SHA256
    $expected = hash_hmac('sha256', $rawBody, $secret);

    // Step 3: Timing-safe comparison
    if (!hash_equals($expected, $signature)) {
        throw new Exception('Invalid webhook signature');
    }

    return json_decode($rawBody, true);
}

// Usage
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$secret = getenv('DCP_WEBHOOK_SECRET');

try {
    $event = verifyWebhook($rawBody, $signature, $timestamp, $secret);

    if ($event['event'] === 'payment.confirmed') {
        $orderId = $event['data']['metadata']['orderId'];
        $txHash = $event['data']['txHash'];

        // Fulfill the order
        error_log("Payment confirmed for order $orderId: $txHash");
    }

    http_response_code(200);
    echo 'OK';
} catch (Exception $e) {
    http_response_code(400);
    echo $e->getMessage();
}

Go

package main

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

func verifyWebhook(body []byte, signature, timestamp, secret string) (map[string]interface{}, error) {
    // Step 1: Check timestamp
    ts, _ := strconv.ParseInt(timestamp, 10, 64)
    if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return nil, fmt.Errorf("webhook timestamp too old")
    }

    // Step 2: Compute HMAC-SHA256
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))

    // Step 3: Compare
    if !hmac.Equal([]byte(signature), []byte(expected)) {
        return nil, fmt.Errorf("invalid webhook signature")
    }

    var event map[string]interface{}
    json.Unmarshal(body, &event)
    return event, nil
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("X-Webhook-Signature")
    timestamp := r.Header.Get("X-Webhook-Timestamp")

    event, err := verifyWebhook(body, signature, timestamp, os.Getenv("DCP_WEBHOOK_SECRET"))
    if err != nil {
        http.Error(w, err.Error(), 400)
        return
    }

    fmt.Printf("Payment confirmed: %v\n", event)
    w.WriteHeader(200)
    w.Write([]byte("OK"))
}

Retry Policy

If your endpoint fails to respond with a 2xx status code, DCP retries the webhook with exponential backoff:

Attempt Delay Cumulative Time
1 Immediate 0
2 30 seconds 30s
3 2 minutes 2m 30s
4 10 minutes 12m 30s
5 1 hour 1h 12m 30s

A delivery is considered failed if:

  • Your server returns a non-2xx status code
  • No response is received within 10 seconds
  • The connection is refused or times out

After 5 failed attempts, the webhook is marked as failed. You can view failed webhooks in the dashboard and manually retry them.

Best Practices

1. Respond Quickly

Return a 200 OK response immediately, then process the webhook asynchronously. DCP considers a webhook failed if your server does not respond within 10 seconds.

app.post('/webhooks/dcp', (req, res) => {
  // Verify signature first
  const event = verifyWebhook(req);

  // Respond immediately
  res.status(200).send('OK');

  // Process asynchronously
  processPayment(event).catch(console.error);
});

2. Handle Duplicates

Webhooks may be retried, so your handler should be idempotent. Use the payment ID (data.id) to check if you have already processed this payment.

async function processPayment(event) {
  const paymentId = event.data.id;

  // Check if already processed
  const existing = await db.getPayment(paymentId);
  if (existing && existing.status === 'fulfilled') {
    return; // Already handled
  }

  // Process the payment
  await db.updateOrder(event.data.metadata.orderId, { status: 'paid' });
}

3. Always Verify Signatures

Never skip signature verification, even in development. This protects against:

  • Spoofed webhook requests from attackers
  • Man-in-the-middle modifications
  • Replay attacks (via timestamp checking)

4. Use HTTPS

Webhook endpoints must use HTTPS in production. For local development, use a tunneling tool like ngrok.

Next Step: Explore the Supported Chains and Tokens to understand what your customers can pay with.