Webhooks
Set up webhooks to receive real-time notifications for payment and settlement events.
Setup
- Log in to Volr Dashboard
- Select project → Settings → Payments
- Enter Webhook URL (e.g.,
https://api.yourapp.com/webhooks/volr) - Save
Webhook Secret is automatically generated. Use it for signature verification.
Events
| Event | Description |
|---|---|
checkout.paid | Payment completed (normal payment) |
checkout.expired | Checkout expired without payment |
checkout.cancelled | Checkout cancelled by merchant |
checkout.late_paid | Payment received after expiry |
checkout.settled | Settlement 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
| Header | Description |
|---|---|
X-Volr-Signature | HMAC-SHA256 signature |
X-Volr-Event | Event type |
Content-Type | application/json |
Node.js Example
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:
| Attempt | Delay | Total Time |
|---|---|---|
| 1st | Immediate | 0s |
| 2nd | 1 minute | 1m |
| 3rd | 5 minutes | 6m |
| 4th | 15 minutes | 21m |
- Timeout: Each request has a 5-second timeout
- Max Retries: 4 attempts total
- Retry Triggers: Network errors, non-2xx responses, timeouts
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