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#

Webhooks are configured per integration. Each integration has its own Webhook URL and Webhook Secret, allowing you to route payment notifications to different backends for different projects.

Step 1: Configure Your Endpoint#

  1. Go to Dashboard > Integrations
  2. Click Edit on your integration (or create a new one)
  3. In the Webhook Configuration section, enter your endpoint URL (must be HTTPS in production)
  4. Save the integration

Step 2: Copy Your Webhook Secret#

Each integration has a Webhook Secret automatically generated when the integration is created. In the edit modal:

  1. Click Show to reveal the secret
  2. Click Copy to copy it to your clipboard
  3. Store it securely -- you will need it in your verification code

You can rotate the secret at any time by clicking Rotate in the integration edit modal.

Multi-project setup: If you use one DirectCryptoPay account for multiple SaaS projects, create a separate integration for each project. Each integration has its own webhook URL and secret, so payment notifications are routed to the correct backend.
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.

HTTP Request Format#

When a payment is confirmed, DCP sends a POST request to your webhook URL. Here is the complete HTTP request format:

Headers#

Header Value Description
Content-Type application/json Request body is JSON
X-DCP-Signature t=1741392087,v1=a3f8b2... Timestamp + HMAC-SHA256 signature (see Verification)
User-Agent DirectCryptoPay-Webhooks/1.0 Identifies DCP as the sender
Important: DCP sends a single signature header (X-DCP-Signature) that contains both the timestamp and the HMAC signature. There are no separate X-Timestamp, X-Signature, or X-Event headers. The event type is in the JSON body (body.event), not in the headers.

Body#

{
  "event": "payment.succeeded",
  "intent_id": "cm1abc2def3ghi",
  "tx_hash": "0x7a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
  "chain_id": 11155111,
  "amount_wei": "49990000",
  "currency": "USDC",
  "confirmations": 3,
  "paid_at": "2025-01-15T11:35:00.000Z",
  "metadata": { "orderId": "order_123", "plan": "premium" }
}

Payload Fields#

Field Type Description
event string Event type (see Webhook Events for all types)
intent_id string Payment intent ID
tx_hash string On-chain transaction hash
chain_id number EVM chain ID (e.g., 137 for Polygon, 11155111 for Sepolia)
amount_wei string Amount paid in the token's smallest unit
currency string Token symbol (e.g., USDC, ETH, USDT)
confirmations number Number of block confirmations reached
paid_at string ISO 8601 timestamp when the payment was verified on-chain
metadata object Custom metadata you passed when creating the payment (e.g., order ID, plan name)

HMAC Signature Verification#

Every webhook request includes a single signature header:

Header Description
X-DCP-Signature Contains timestamp and HMAC-SHA256 signature in format t=<timestamp>,v1=<hmac_hex>

Verification Steps#

  1. Parse the header -- Extract the timestamp (t) and signature (v1) from the X-DCP-Signature header
  2. Check the timestamp -- Reject requests older than 5 minutes (replay protection)
  3. Construct the message -- Concatenate the timestamp, a dot, and the raw JSON body: <timestamp>.<rawBody>
  4. Compute the HMAC -- HMAC-SHA256 the constructed message with your webhook secret
  5. Compare signatures -- Use a timing-safe comparison to prevent timing attacks

Node.js / Express#

const crypto = require('crypto');

function verifyWebhook(req, webhookSecret) {
  const signatureHeader = req.headers['x-dcp-signature'];
  const rawBody = typeof req.body === 'string' ? req.body : req.body.toString();

  // Step 1: Parse the signature header
  const parts = signatureHeader.split(',');
  const timestampPart = parts.find(p => p.startsWith('t='));
  const signaturePart = parts.find(p => p.startsWith('v1='));

  if (!timestampPart || !signaturePart) {
    throw new Error('Invalid signature header format');
  }

  const timestamp = parseInt(timestampPart.split('=')[1]);
  const signature = signaturePart.split('=')[1];

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

  // Step 3: Construct the signed message
  const message = `${timestamp}.${rawBody}`;

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

  // Step 5: 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.succeeded') {
      const { intent_id, tx_hash } = event;
      console.log(`Payment ${intent_id} succeeded: ${tx_hash}`);

      // Fulfill the order
      // await fulfillOrder(intent_id);
    }

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

NestJS#

import { Controller, Post, Req, Res, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';
import * as crypto from 'crypto';

@Controller('webhooks')
export class WebhooksController {
  private readonly webhookSecret = process.env.DCP_WEBHOOK_SECRET;

  @Post('dcp')
  handleDCPWebhook(@Req() req: Request, @Res() res: Response) {
    try {
      // Get the raw body (requires rawBody: true in NestFactory.create options)
      const rawBody = (req as any).rawBody?.toString() || JSON.stringify(req.body);

      // Get the SINGLE signature header (contains both timestamp and HMAC)
      const signatureHeader = req.headers['x-dcp-signature'] as string;
      if (!signatureHeader) {
        return res.status(HttpStatus.BAD_REQUEST).json({ error: 'Missing X-DCP-Signature header' });
      }

      // Parse: "t=1741392087,v1=abc123..."
      const parts = signatureHeader.split(',');
      const timestamp = parseInt(parts.find(p => p.startsWith('t='))?.split('=')[1] || '0');
      const signature = parts.find(p => p.startsWith('v1='))?.split('=')[1];

      if (!timestamp || !signature) {
        return res.status(HttpStatus.BAD_REQUEST).json({ error: 'Invalid signature format' });
      }

      // Check timestamp (5 min tolerance)
      if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
        return res.status(HttpStatus.BAD_REQUEST).json({ error: 'Timestamp too old' });
      }

      // Verify HMAC
      const message = `${timestamp}.${rawBody}`;
      const expected = crypto.createHmac('sha256', this.webhookSecret).update(message).digest('hex');
      const isValid = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

      if (!isValid) {
        return res.status(HttpStatus.UNAUTHORIZED).json({ error: 'Invalid signature' });
      }

      // Event type is in the body, NOT in a header
      const event = req.body;
      console.log(`DCP webhook: ${event.event}`, event);

      if (event.event === 'payment.succeeded') {
        // Fulfill the order using event.intent_id, event.metadata, etc.
      }

      return res.status(HttpStatus.OK).json({ received: true });
    } catch (error) {
      return res.status(HttpStatus.BAD_REQUEST).json({ error: 'Webhook processing failed' });
    }
  }
}
NestJS rawBody: To access the raw request body for HMAC verification, create your app with rawBody: true:

const app = await NestFactory.create(AppModule, { rawBody: true });

Python / Flask#

import os
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_header, secret):
    # Step 1: Parse the signature header
    parts = signature_header.split(',')
    timestamp_part = next((p for p in parts if p.startswith('t=')), None)
    signature_part = next((p for p in parts if p.startswith('v1=')), None)

    if not timestamp_part or not signature_part:
        raise ValueError('Invalid signature header format')

    timestamp = int(timestamp_part.split('=')[1])
    signature = signature_part.split('=')[1]

    # Step 2: Check timestamp
    if abs(time.time() - timestamp) > 300:
        raise ValueError('Webhook timestamp too old')

    # Step 3: Construct the signed message
    message = f'{timestamp}.{raw_body.decode("utf-8")}'

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

    # Step 5: 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-DCP-Signature', ''),
            WEBHOOK_SECRET
        )

        if event['event'] == 'payment.succeeded':
            intent_id = event['intent_id']
            tx_hash = event['tx_hash']
            print(f'Payment {intent_id} succeeded: {tx_hash}')

            # Fulfill the order
            # fulfill_order(intent_id)

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

PHP#

<?php
function verifyWebhook($rawBody, $signatureHeader, $secret) {
    // Step 1: Parse the signature header
    $parts = explode(',', $signatureHeader);
    $timestamp = null;
    $signature = null;

    foreach ($parts as $part) {
        if (strpos($part, 't=') === 0) {
            $timestamp = intval(substr($part, 2));
        } elseif (strpos($part, 'v1=') === 0) {
            $signature = substr($part, 3);
        }
    }

    if ($timestamp === null || $signature === null) {
        throw new Exception('Invalid signature header format');
    }

    // Step 2: Check timestamp
    if (abs(time() - $timestamp) > 300) {
        throw new Exception('Webhook timestamp too old');
    }

    // Step 3: Construct the signed message
    $message = $timestamp . '.' . $rawBody;

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

    // Step 5: 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');
$signatureHeader = $_SERVER['HTTP_X_DCP_SIGNATURE'] ?? '';
$secret = getenv('DCP_WEBHOOK_SECRET');

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

    if ($event['event'] === 'payment.succeeded') {
        $paymentId = $event['intent_id'];
        $txHash = $event['tx_hash'];

        // Fulfill the order
        error_log("Payment $paymentId succeeded: $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"
	"strings"
	"time"
)

func verifyWebhook(body []byte, signatureHeader, secret string) (map[string]interface{}, error) {
	// Step 1: Parse the signature header
	var timestamp int64
	var signature string

	parts := strings.Split(signatureHeader, ",")
	for _, part := range parts {
		if strings.HasPrefix(part, "t=") {
			ts, err := strconv.ParseInt(strings.TrimPrefix(part, "t="), 10, 64)
			if err != nil {
				return nil, fmt.Errorf("invalid timestamp in signature header")
			}
			timestamp = ts
		} else if strings.HasPrefix(part, "v1=") {
			signature = strings.TrimPrefix(part, "v1=")
		}
	}

	if timestamp == 0 || signature == "" {
		return nil, fmt.Errorf("invalid signature header format")
	}

	// Step 2: Check timestamp
	if math.Abs(float64(time.Now().Unix()-timestamp)) > 300 {
		return nil, fmt.Errorf("webhook timestamp too old")
	}

	// Step 3: Construct the signed message
	message := fmt.Sprintf("%d.%s", timestamp, string(body))

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

	// Step 5: 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)
	signatureHeader := r.Header.Get("X-DCP-Signature")

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

	fmt.Printf("Payment succeeded: %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

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 3 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 intent ID (intent_id) to check if you have already processed this payment.

async function processPayment(event) {
  const paymentId = event.intent_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(paymentId, { 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.