Webhooks
Receive real-time notifications about payment events
Webhooks
Webhooks allow your application to receive real-time notifications when events happen in your BLAQPAY account. This is the recommended way to handle payment confirmations.
Why Use Webhooks?
- Real-time updates: Get notified immediately when events occur
- Reliable: We retry failed deliveries automatically
- Secure: Cryptographically signed for verification
- Asynchronous: Don’t depend on customers returning to your site
Setting Up Webhooks
1. Create an Endpoint
Create an endpoint on your server to receive webhook events:
const express = require('express');
const app = express();
app.post('/webhooks/blaqpay', express.raw({ type: 'application/json' }), (req, res) => {
const payload = JSON.parse(req.body);
// Handle the event
console.log('Received event:', payload.event);
console.log('Transaction:', payload.data.transaction_number);
// Return 200 to acknowledge receipt
res.status(200).send('OK');
}); 2. Register Your Webhook URL
Register your endpoint in the Dashboard:
- Go to Settings → Webhooks
- Click Add Endpoint
- Enter your URL:
https://yoursite.com/webhooks/blaqpay - Select events to receive
- Save and copy your webhook secret
Event Types
Transaction Events
transaction.created- Transaction was createdtransaction.payment_received- Payment detected on blockchaintransaction.confirming- Payment detected, waiting for confirmationstransaction.completed- Payment confirmed and completedtransaction.failed- Payment failedtransaction.expired- Transaction expired without payment
Test Mode Events
When testing_mode is enabled, event names are prefixed with test.:
test.transaction.completed- Test transaction completedtest.transaction.failed- Test transaction failed
Refund Events
refund.initiated- Refund initiatedrefund.completed- Refund completedrefund.failed- Refund failed
Webhook Payload Structure
All webhook events follow this structure:
{
"event": "transaction.completed",
"timestamp": "2024-01-01T12:00:00.000Z",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"transaction_number": "TXN-12345",
"status": "completed",
"amount_in_currency": 100.0,
"currency": "USD",
"token_symbol": "USDC",
"token_amount": "100000000",
"blockchain_network": "ethereum",
"transaction_hash": "0xabc123...",
"block_number": 18234567,
"customer_ip_address": "192.168.1.1",
"order_id": "order_12345",
"metadata": {
"custom_field": "value"
},
"created_at": "2024-01-01T11:55:00.000Z",
"confirmed_at": "2024-01-01T12:00:00.000Z",
"testing_mode": false
}
} Field Reference
Always Present Fields
These fields are always included in webhook payloads:
event(string) - Event type (e.g., “transaction.completed”)timestamp(string) - ISO 8601 timestamp when webhook was sentdata.transaction_id(string) - Unique transaction UUIDdata.transaction_number(string) - Human-readable transaction numberdata.status(string) - Transaction statusdata.amount_in_currency(number) - Amount in fiat currencydata.currency(string) - Currency code (e.g., “USD”)data.created_at(string) - ISO 8601 timestamp when transaction was createddata.testing_mode(boolean) - Whether transaction is in test mode
Nullable Fields
These fields may be null depending on transaction state:
data.token_symbol(string | null) - Cryptocurrency symbol (e.g., “USDC”, “ETH”)nulluntil customer selects a token
data.token_amount(string | null) - Amount in token’s smallest unitnulluntil customer selects a token
data.blockchain_network(string | null) - Blockchain network (e.g., “ethereum”, “polygon”)nulluntil customer selects a token
data.transaction_hash(string | null) - Blockchain transaction hashnulluntil payment is detected on-chain
data.block_number(number | null) - Block number where transaction was minednulluntil payment is confirmed
data.customer_ip_address(string | null) - Customer’s IP addressdata.order_id(string | null) - Your order/reference IDdata.metadata(object | null) - Custom metadata you provideddata.confirmed_at(string | null) - ISO 8601 timestamp when transaction was confirmednulluntil transaction is confirmed
Example: transaction.completed Event
{
"event": "transaction.completed",
"timestamp": "2024-01-01T12:00:00.000Z",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"transaction_number": "TXN-12345",
"status": "completed",
"amount_in_currency": 100.0,
"currency": "USD",
"token_symbol": "USDC",
"token_amount": "100000000",
"blockchain_network": "ethereum",
"transaction_hash": "0xabc123def456...",
"block_number": 18234567,
"customer_ip_address": "192.168.1.1",
"order_id": "order_12345",
"metadata": {
"user_id": "user_789",
"order_reference": "ORD-2024-001"
},
"created_at": "2024-01-01T11:55:00.000Z",
"confirmed_at": "2024-01-01T12:00:00.000Z",
"testing_mode": false
}
} Example: transaction.payment_received Event
Note: Some fields may be null at this stage:
{
"event": "transaction.payment_received",
"timestamp": "2024-01-01T11:58:00.000Z",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"transaction_number": "TXN-12345",
"status": "processing",
"amount_in_currency": 100.0,
"currency": "USD",
"token_symbol": "USDC",
"token_amount": "100000000",
"blockchain_network": "ethereum",
"transaction_hash": "0xabc123def456...",
"block_number": 18234560,
"customer_ip_address": "192.168.1.1",
"order_id": "order_12345",
"metadata": null,
"created_at": "2024-01-01T11:55:00.000Z",
"confirmed_at": null,
"testing_mode": false
}
} Transaction Lifecycle & Field Availability
Understanding when fields become available helps you handle null values correctly:
Stage 1: transaction.created
Available: transaction_id, transaction_number, status, amount_in_currency, currency, created_at, testing_mode
Null: token_symbol, token_amount, blockchain_network, transaction_hash, block_number, confirmed_at
At this stage, customer hasn’t selected a payment token yet.
Stage 2: transaction.payment_received
Available: All Stage 1 fields + token_symbol, token_amount, blockchain_network, transaction_hash
Null: block_number (may still be null), confirmed_at
Payment detected on blockchain, waiting for confirmations.
Stage 3: transaction.confirming
Available: All Stage 2 fields + block_number
Null: confirmed_at
Transaction has enough confirmations, finalizing…
Stage 4: transaction.completed
Available: All fields populated
Null: None (all critical fields are now available)
Payment fully confirmed and successful.
Verifying Webhook Signatures
Always verify webhook signatures to ensure requests are from BLAQPAY:
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const expectedSignature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
}
app.post('/webhooks/blaqpay', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-blaqpay-signature'];
const rawPayload = req.body.toString();
if (!verifyWebhook(rawPayload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(rawPayload);
// Handle event...
res.status(200).send('OK');
}); Handling Events
Process different event types and handle nullable fields:
app.post('/webhooks/blaqpay', async (req, res) => {
const payload = JSON.parse(req.body);
try {
// Validate required fields
if (!payload.data.transaction_id || !payload.data.status) {
return res.status(400).send('Missing required fields');
}
switch (payload.event) {
case 'transaction.created':
await handleTransactionCreated(payload.data);
break;
case 'transaction.payment_received':
await handlePaymentReceived(payload.data);
break;
case 'transaction.confirming':
await handleTransactionConfirming(payload.data);
break;
case 'transaction.completed':
await handleTransactionCompleted(payload.data);
break;
case 'transaction.failed':
await handleTransactionFailed(payload.data);
break;
case 'refund.completed':
await handleRefundCompleted(payload.data);
break;
default:
console.log(`Unhandled event type: ${payload.event}`);
}
res.status(200).send('OK');
} catch (error) {
console.error('Webhook error:', error);
res.status(500).send('Error processing webhook');
}
});
async function handleTransactionCompleted(transaction) {
// Fulfill the order
console.log(`Transaction ${transaction.transaction_number} completed`);
// Handle nullable fields safely
const orderRef = transaction.order_id || transaction.metadata?.order_reference;
// Update database
await db.orders.update({
where: { id: orderRef },
data: {
status: 'paid',
payment_id: transaction.transaction_id,
transaction_hash: transaction.transaction_hash,
blockchain_network: transaction.blockchain_network || 'unknown',
token_symbol: transaction.token_symbol || 'unknown',
paid_at: transaction.confirmed_at
}
});
// Send confirmation email (if customer email is in metadata)
if (transaction.metadata?.customer_email) {
await sendEmail(transaction.metadata.customer_email, {
subject: 'Payment Received',
template: 'payment-confirmation',
data: {
transactionNumber: transaction.transaction_number,
amount: transaction.amount_in_currency,
currency: transaction.currency,
transactionHash: transaction.transaction_hash
}
});
}
}
async function handlePaymentReceived(transaction) {
// Payment detected on blockchain, but not yet confirmed
console.log(`Payment received for ${transaction.transaction_number}`);
await db.orders.update({
where: { id: transaction.order_id },
data: {
status: 'payment_pending',
transaction_hash: transaction.transaction_hash
}
});
} Best Practices
1. Handle Nullable Fields
Always check for null values before using optional fields:
async function handleTransactionCompleted(transaction) {
// ✅ Good: Check for null before using
const tokenSymbol = transaction.token_symbol || 'Unknown';
const blockchainNetwork = transaction.blockchain_network || 'Unknown';
const transactionHash = transaction.transaction_hash || 'N/A';
// ✅ Good: Use optional chaining for nested properties
const customerId = transaction.metadata?.customer_id;
const orderRef = transaction.metadata?.order_reference;
// ✅ Good: Validate required fields
if (!transaction.transaction_id || !transaction.status) {
throw new Error('Missing required fields');
}
// ❌ Bad: Assuming fields are always present
// const symbol = transaction.token_symbol.toUpperCase(); // May crash if null!
// Process transaction...
} 2. Return 200 Quickly
Return a 200 status immediately and process asynchronously:
app.post('/webhooks/blaqpay', async (req, res) => {
const event = JSON.parse(req.body);
// Acknowledge receipt immediately
res.status(200).send('OK');
// Process asynchronously
processWebhookAsync(event).catch(console.error);
}); 3. Idempotency
Handle duplicate events gracefully using the transaction ID:
async function handleTransactionCompleted(transaction) {
// Check if already processed
const existing = await db.payments.findUnique({
where: { transaction_id: transaction.transaction_id }
});
if (existing) {
console.log(`Transaction ${transaction.transaction_number} already processed`);
return;
}
// Process the payment
await fulfillOrder(transaction);
} 4. Error Handling
Handle errors properly to trigger retries:
app.post('/webhooks/blaqpay', async (req, res) => {
try {
const event = JSON.parse(req.body);
await processEvent(event);
res.status(200).send('OK');
} catch (error) {
console.error('Webhook processing failed:', error);
// Return 500 to trigger retry
res.status(500).send('Processing failed');
}
}); 5. Logging
Log all webhook events for debugging:
app.post('/webhooks/blaqpay', async (req, res) => {
const payload = JSON.parse(req.body);
// Log to database
await db.webhookLogs.create({
data: {
transaction_id: payload.data.transaction_id,
event_type: payload.event,
payload: payload,
processed_at: new Date(),
success: true
}
});
// Process event...
}); Retry Logic
BLAQPAY automatically retries failed webhook deliveries:
- Immediate retry: If first attempt fails
- Exponential backoff: 1min, 5min, 30min, 2hr, 6hr, 12hr, 24hr
- Maximum attempts: 10 attempts over 3 days
- Manual retry: Retry failed webhooks from Dashboard
Testing Webhooks
Local Development
Use tools like ngrok to expose your local server:
# Start ngrok
ngrok http 3000
# Use the ngrok URL in your webhook settings
https://abc123.ngrok.io/webhooks/blaqpay Test Events
Send test webhooks from the Dashboard:
- Go to Settings → Webhooks
- Select your endpoint
- Click Send Test Event
- Choose event type
- View response
CLI Testing
Use cURL to simulate webhooks:
curl -X POST http://localhost:3000/webhooks/blaqpay
-H "Content-Type: application/json"
-H "X-BLAQPay-Signature: test_signature"
-d '{
"event": "transaction.completed",
"timestamp": "2024-01-01T12:00:00.000Z",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"transaction_number": "TXN-12345",
"status": "completed",
"amount_in_currency": 100.0,
"currency": "USD",
"token_symbol": "USDC",
"token_amount": "100000000",
"blockchain_network": "ethereum",
"transaction_hash": "0xabc123def456...",
"block_number": 18234567,
"customer_ip_address": null,
"order_id": "order_12345",
"metadata": null,
"created_at": "2024-01-01T11:55:00.000Z",
"confirmed_at": "2024-01-01T12:00:00.000Z",
"testing_mode": false
}
}' Monitoring
Monitor webhook delivery in the Dashboard:
- Success rate: View delivery success rate
- Recent deliveries: See recent webhook attempts
- Failed deliveries: Review and retry failed webhooks
- Logs: View full request/response logs
Security Considerations
- ✅ Always verify signatures
- ✅ Use HTTPS endpoints only
- ✅ Validate event data
- ✅ Implement idempotency
- ✅ Rate limit your endpoint
- ❌ Don’t trust unverified webhooks
- ❌ Don’t process duplicate events
- ❌ Don’t expose sensitive data in logs
Troubleshooting
Webhooks Not Received
- Check your firewall allows incoming requests
- Verify webhook URL is publicly accessible
- Check Dashboard for delivery logs
- Ensure endpoint returns 200 status
Signature Verification Fails
- Use raw request body (don’t parse before verifying)
- Check you’re using the correct webhook secret
- Verify signature header name:
x-blaqpay-signature
Duplicate Events
- Implement idempotency checks
- Store processed event IDs
- Check timestamp to detect replays
“Missing fields” Error
If you receive a 400 Bad Request: Missing fields error:
Check for nullable fields: Fields like
token_symbol,blockchain_network, andtransaction_hashcan benullValidate correctly: Only check for
undefined, notnull:// ❌ Wrong: Rejects null values if (!data.token_symbol) return error; // ✅ Correct: Allows null but catches undefined if (data.token_symbol === undefined) return error;Use correct field names:
- Use
token_symbol(notcrypto_currency) - Use
token_amount(notcrypto_amount) - Use
transaction_id(notid)
- Use
Check payload structure: Root object has
event,timestamp, anddata(nottransaction)
Complete Working Example
Here’s a production-ready webhook handler with all best practices:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Webhook verification function
function verifyWebhook(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Webhook endpoint
app.post('/webhooks/blaqpay', express.raw({ type: 'application/json' }), async (req, res) => {
try {
// 1. Get signature and payload
const signature = req.headers['x-blaqpay-signature'];
const rawPayload = req.body.toString();
// 2. Verify signature
if (!signature || !verifyWebhook(rawPayload, signature, process.env.BLAQPAY_WEBHOOK_SECRET)) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
// 3. Parse payload
const payload = JSON.parse(rawPayload);
// 4. Validate required fields
if (!payload.data?.transaction_id || !payload.data?.status) {
console.error('Missing required fields');
return res.status(400).send('Missing required fields');
}
// 5. Acknowledge receipt immediately
res.status(200).send('OK');
// 6. Process asynchronously
processWebhookAsync(payload).catch(console.error);
} catch (error) {
console.error('Webhook error:', error);
res.status(500).send('Error processing webhook');
}
});
async function processWebhookAsync(payload) {
const { event, data } = payload;
// Log webhook
await logWebhook(event, data);
// Handle event
switch (event) {
case 'transaction.completed':
await handleTransactionCompleted(data);
break;
case 'transaction.failed':
await handleTransactionFailed(data);
break;
// Add more cases...
}
}
async function handleTransactionCompleted(transaction) {
// Check for duplicate processing
const existing = await db.payments.findUnique({
where: { transaction_id: transaction.transaction_id }
});
if (existing) {
console.log(`Transaction ${transaction.transaction_number} already processed`);
return;
}
// Handle nullable fields safely
const tokenSymbol = transaction.token_symbol || 'Unknown';
const blockchainNetwork = transaction.blockchain_network || 'Unknown';
const transactionHash = transaction.transaction_hash || 'N/A';
const orderRef = transaction.order_id || transaction.metadata?.order_reference;
// Update your database
await db.orders.update({
where: { id: orderRef },
data: {
status: 'paid',
payment_id: transaction.transaction_id,
transaction_number: transaction.transaction_number,
transaction_hash: transactionHash,
blockchain_network: blockchainNetwork,
token_symbol: tokenSymbol,
amount_paid: transaction.amount_in_currency,
currency: transaction.currency,
paid_at: transaction.confirmed_at || new Date().toISOString()
}
});
// Fulfill order
await fulfillOrder(orderRef);
// Send confirmation email
if (transaction.metadata?.customer_email) {
await sendConfirmationEmail(
transaction.metadata.customer_email,
transaction.transaction_number,
transaction.amount_in_currency,
transaction.currency
);
}
console.log(`✅ Transaction ${transaction.transaction_number} processed successfully`);
}
async function logWebhook(event, data) {
await db.webhookLogs.create({
data: {
transaction_id: data.transaction_id,
event_type: event,
payload: { event, data },
processed_at: new Date()
}
});
}
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
}); More Examples
See our GitHub repository for complete webhook examples in multiple languages:
- Node.js/Express
- Python/Flask
- PHP/Laravel
- Ruby/Rails
- Go
Need Help?
- Check the API Reference
- Review Authentication Guide
- Join our Discord
- Email support@blaqpay.io
