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.
DirectCryptoPay uses a zero-trust verification model:
- The customer signs a transaction in their wallet
- The transaction is sent to the blockchain
- DCP's backend independently monitors the blockchain
- When the transaction reaches the required confirmations, DCP sends a webhook
- 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
- Go to Dashboard > Webhooks
- Click Create Webhook
- Enter your endpoint URL (must be HTTPS in production)
- 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.
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
- Check the timestamp -- Reject requests older than 5 minutes (replay protection)
- Compute the HMAC -- Hash the raw request body with your webhook secret
- 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.