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:

  1. Go to SettingsWebhooks
  2. Click Add Endpoint
  3. Enter your URL: https://yoursite.com/webhooks/blaqpay
  4. Select events to receive
  5. Save and copy your webhook secret

Event Types

Transaction Events

  • transaction.created - Transaction was created
  • transaction.payment_received - Payment detected on blockchain
  • transaction.confirming - Payment detected, waiting for confirmations
  • transaction.completed - Payment confirmed and completed
  • transaction.failed - Payment failed
  • transaction.expired - Transaction expired without payment

Test Mode Events

When testing_mode is enabled, event names are prefixed with test.:

  • test.transaction.completed - Test transaction completed
  • test.transaction.failed - Test transaction failed

Refund Events

  • refund.initiated - Refund initiated
  • refund.completed - Refund completed
  • refund.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 sent
  • data.transaction_id (string) - Unique transaction UUID
  • data.transaction_number (string) - Human-readable transaction number
  • data.status (string) - Transaction status
  • data.amount_in_currency (number) - Amount in fiat currency
  • data.currency (string) - Currency code (e.g., “USD”)
  • data.created_at (string) - ISO 8601 timestamp when transaction was created
  • data.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”)
    • null until customer selects a token
  • data.token_amount (string | null) - Amount in token’s smallest unit
    • null until customer selects a token
  • data.blockchain_network (string | null) - Blockchain network (e.g., “ethereum”, “polygon”)
    • null until customer selects a token
  • data.transaction_hash (string | null) - Blockchain transaction hash
    • null until payment is detected on-chain
  • data.block_number (number | null) - Block number where transaction was mined
    • null until payment is confirmed
  • data.customer_ip_address (string | null) - Customer’s IP address
  • data.order_id (string | null) - Your order/reference ID
  • data.metadata (object | null) - Custom metadata you provided
  • data.confirmed_at (string | null) - ISO 8601 timestamp when transaction was confirmed
    • null until 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:

  1. Go to SettingsWebhooks
  2. Select your endpoint
  3. Click Send Test Event
  4. Choose event type
  5. 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

  1. Check your firewall allows incoming requests
  2. Verify webhook URL is publicly accessible
  3. Check Dashboard for delivery logs
  4. Ensure endpoint returns 200 status

Signature Verification Fails

  1. Use raw request body (don’t parse before verifying)
  2. Check you’re using the correct webhook secret
  3. Verify signature header name: x-blaqpay-signature

Duplicate Events

  1. Implement idempotency checks
  2. Store processed event IDs
  3. Check timestamp to detect replays

“Missing fields” Error

If you receive a 400 Bad Request: Missing fields error:

  1. Check for nullable fields: Fields like token_symbol, blockchain_network, and transaction_hash can be null

  2. Validate correctly: Only check for undefined, not null:

    // ❌ Wrong: Rejects null values
    if (!data.token_symbol) return error;
    
    // ✅ Correct: Allows null but catches undefined
    if (data.token_symbol === undefined) return error;
  3. Use correct field names:

    • Use token_symbol (not crypto_currency)
    • Use token_amount (not crypto_amount)
    • Use transaction_id (not id)
  4. Check payload structure: Root object has event, timestamp, and data (not transaction)

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?