Skip to main content

Webhooks

Set up webhooks to receive real-time notifications for payment and settlement events.

Setup

  1. Log in to Volr Dashboard
  2. Select project → SettingsPayments
  3. Enter Webhook URL (e.g., https://api.yourapp.com/webhooks/volr)
  4. Save
tip

Webhook Secret is automatically generated. Use it for signature verification.

Events

EventDescription
checkout.paidPayment completed (normal payment)
checkout.expiredCheckout expired without payment
checkout.cancelledCheckout cancelled by merchant
checkout.late_paidPayment received after expiry
checkout.settledSettlement completed

Payload Format

{
"event": "checkout.paid",
"data": {
"checkoutId": "cm5xyz123...",
"projectId": "project_id",
"status": "PAID",
"referenceId": "ORDER-12345",

"chainId": 8453,
"tokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"amount": "3703703",
"depositAddress": "0xabc123...",

"fiatAmount": "5000",
"fiatCurrency": "KRW",
"exchangeRateUsed": "1350.50",
"exchangeRateAt": "2026-01-30T10:30:00.000Z",

"itemName": "Americano",
"itemDescription": null,

"customerEmail": null,
"customerName": null,

"paymentTxHash": "0xdef456...",
"paidAmount": "3703703",
"paidAt": "2026-01-30T10:35:00.000Z",

"createdAt": "2026-01-30T10:30:00.000Z",
"expiresAt": "2026-01-30T11:00:00.000Z",

"metadata": {
"customField": "value"
}
},
"timestamp": "2026-01-30T10:35:01.000Z"
}

Signature Verification

Webhook requests are signed with the X-Volr-Signature header. Always verify signatures.

Headers

HeaderDescription
X-Volr-SignatureHMAC-SHA256 signature
X-Volr-EventEvent type
Content-Typeapplication/json

Node.js Example

Important

You must verify against the raw request body (before JSON parsing). Re-serializing a parsed object with JSON.stringify may produce different output than the original, causing signature mismatch.

const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(rawBody) // use raw body string, NOT JSON.stringify(parsedObj)
.digest('hex');

return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}

// Use express.raw() to get the raw body, then parse manually
app.post('/webhooks/volr', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-volr-signature'];
const webhookSecret = process.env.VOLR_WEBHOOK_SECRET;
const rawBody = req.body.toString();

if (!verifyWebhookSignature(rawBody, signature, webhookSecret)) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}

const { event, data } = JSON.parse(rawBody);

switch (event) {
case 'checkout.paid':
handlePaymentSuccess(data);
break;
case 'checkout.late_paid':
handleLatePayment(data);
break;
case 'checkout.settled':
handleSettlementComplete(data);
break;
}

res.status(200).send('OK');
});

Python Example

import hmac
import hashlib
import json
import os
from flask import Flask, request

app = Flask(__name__)

def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
raw_body, # use raw bytes, NOT json.dumps(parsed_obj)
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)

@app.route('/webhooks/volr', methods=['POST'])
def webhook():
signature = request.headers.get('X-Volr-Signature')
webhook_secret = os.environ['VOLR_WEBHOOK_SECRET']
raw_body = request.get_data()

if not verify_signature(raw_body, signature, webhook_secret):
return 'Invalid signature', 401

payload = json.loads(raw_body)
event = payload['event']
data = payload['data']

if event == 'checkout.paid':
handle_payment_success(data)

return 'OK', 200

Retry Policy

Volr automatically retries webhook delivery on failure:

AttemptDelayTotal Time
1stImmediate0s
2nd1 minute1m
3rd5 minutes6m
4th15 minutes21m
  • Timeout: Each request has a 5-second timeout
  • Max Retries: 4 attempts total
  • Retry Triggers: Network errors, non-2xx responses, timeouts
warning

Webhook handlers must respond within 5 seconds. Process heavy tasks asynchronously.

Best Practices

1. Idempotency

The same event may be sent multiple times. Use checkoutId to prevent duplicate processing.

const processedCheckouts = new Set();

function handlePaymentSuccess(data) {
if (processedCheckouts.has(data.checkoutId)) {
console.log('Already processed:', data.checkoutId);
return;
}

processedCheckouts.add(data.checkoutId);
// ... process order
}

2. Quick Response

Webhook handlers must return a 200 response within 5 seconds. Process heavy tasks in the background.

app.post('/webhooks/volr', (req, res) => {
// Respond immediately
res.status(200).send('OK');

// Background processing
processWebhookAsync(req.body);
});

3. Fallback: Polling

Webhooks can fail, so use polling as a backup.

// Check PENDING checkouts every 30 seconds
setInterval(async () => {
const pendingOrders = await getPendingOrders();

for (const order of pendingOrders) {
const checkout = await fetchCheckoutStatus(order.checkoutId);

if (checkout.status === 'PAID') {
handlePaymentSuccess(checkout);
}
}
}, 30000);

Testing Webhooks

Use ngrok to test webhooks in development.

# Local server: http://localhost:3000
ngrok http 3000

# Set ngrok URL as Dashboard Webhook URL
# https://abc123.ngrok.io/webhooks/volr