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.
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:
- 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#
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#
- Go to Dashboard > Integrations
- Click Edit on your integration (or create a new one)
- In the Webhook Configuration section, enter your endpoint URL (must be HTTPS in production)
- 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:
- Click Show to reveal the secret
- Click Copy to copy it to your clipboard
- 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.
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 |
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#
- Parse the header -- Extract the timestamp (
t) and signature (v1) from theX-DCP-Signatureheader - Check the timestamp -- Reject requests older than 5 minutes (replay protection)
- Construct the message -- Concatenate the timestamp, a dot, and the raw JSON body:
<timestamp>.<rawBody> - Compute the HMAC -- HMAC-SHA256 the constructed message 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 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' });
}
}
}
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.