What You’ll Learn
This guide shows you how to configure and handle Lava webhooks for real-time event notifications. You’ll learn to:
Configure webhook endpoints in the Lava dashboard
Verify webhook signatures for security
Handle different event types (checkout, usage, balance, connections)
Implement retry logic for failed webhooks
Test webhooks locally during development
Webhooks provide reliable backend processing. While frontend callbacks offer instant UX feedback, webhooks ensure events are processed even when users close tabs or lose network connection.
Webhook Endpoint Configuration
Step 1: Create Webhook Endpoint
Create an API route to receive webhook events from Lava.
Next.js App Router Example:
// app/api/webhooks/lava/route.ts
import { NextRequest , NextResponse } from 'next/server' ;
import { Lava } from '@lavapayments/nodejs' ;
import crypto from 'crypto' ;
const lava = new Lava ({ secretKey: process . env . LAVA_SECRET_KEY ! });
export async function POST ( req : NextRequest ) {
// 1. Get raw body and signature
const body = await req . text ();
const signature = req . headers . get ( 'x-lava-signature' );
if ( ! signature ) {
return NextResponse . json (
{ error: 'Missing signature' },
{ status: 401 }
);
}
// 2. Verify signature (CRITICAL for security)
const expectedSignature = crypto
. createHmac ( 'sha256' , process . env . LAVA_WEBHOOK_SECRET ! )
. update ( body )
. digest ( 'hex' );
if ( signature !== expectedSignature ) {
return NextResponse . json (
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// 3. Parse event
const event = JSON . parse ( body );
// 4. Handle event
try {
await handleWebhookEvent ( event );
return NextResponse . json ({ received: true });
} catch ( error ) {
console . error ( 'Webhook handler error:' , error );
return NextResponse . json (
{ error: 'Handler failed' },
{ status: 500 }
);
}
}
async function handleWebhookEvent ( event : any ) {
switch ( event . type ) {
case 'checkout.completed' :
await handleCheckoutCompleted ( event . data );
break ;
case 'usage.recorded' :
await handleUsageRecorded ( event . data );
break ;
case 'balance.low' :
await handleBalanceLow ( event . data );
break ;
case 'connection.deleted' :
await handleConnectionDeleted ( event . data );
break ;
default :
console . log ( 'Unhandled event type:' , event . type );
}
}
Express.js Example:
import express from 'express' ;
import { Lava } from '@lavapayments/nodejs' ;
import crypto from 'crypto' ;
const app = express ();
const lava = new Lava ({ secretKey: process . env . LAVA_SECRET_KEY ! });
// IMPORTANT: Use express.raw() for webhooks (not express.json())
app . post ( '/api/webhooks/lava' , express . raw ({ type: 'application/json' }), async ( req , res ) => {
const signature = req . headers [ 'x-lava-signature' ] as string ;
const body = req . body . toString ( 'utf8' );
// Verify signature
const expectedSignature = crypto
. createHmac ( 'sha256' , process . env . LAVA_WEBHOOK_SECRET ! )
. update ( body )
. digest ( 'hex' );
if ( signature !== expectedSignature ) {
return res . status ( 401 ). json ({ error: 'Invalid signature' });
}
const event = JSON . parse ( body );
try {
await handleWebhookEvent ( event );
res . json ({ received: true });
} catch ( error ) {
console . error ( 'Webhook handler error:' , error );
res . status ( 500 ). json ({ error: 'Handler failed' });
}
});
Always use raw body for signature verification. Body parsing middleware like express.json() modifies the request body, breaking signature verification. Use express.raw() or access raw body before parsing.
Step 2: Register Webhook in Dashboard
Log in to Lava dashboard
Navigate to Monetize > Webhooks
Click “Add Endpoint”
Enter your webhook URL: https://yourapp.com/api/webhooks/lava
Select events to receive:
checkout.completed (user completes onboarding)
usage.recorded (API request processed)
balance.low (wallet balance below threshold)
connection.deleted (user removes connection)
Copy the Webhook Secret and add to environment:
LAVA_WEBHOOK_SECRET = whsec_xxxxxxxxxxxxxxxxxxxxxxxxxx
Multiple endpoints supported. You can register different URLs for different event types (e.g., one for checkout events, another for usage tracking).
Signature Verification
Why Verify Signatures?
Without signature verification, malicious actors could:
Send fake checkout.completed events (fraudulent access)
Trigger false balance.low alerts
Simulate usage.recorded events (incorrect billing)
HMAC SHA-256 Verification
Lava signs all webhook requests with HMAC SHA-256:
import crypto from 'crypto' ;
function verifyWebhookSignature (
body : string ,
signature : string ,
secret : string
) : boolean {
const expectedSignature = crypto
. createHmac ( 'sha256' , secret )
. update ( body )
. digest ( 'hex' );
return signature === expectedSignature ;
}
// Usage
const isValid = verifyWebhookSignature (
requestBody ,
req . headers [ 'x-lava-signature' ],
process . env . LAVA_WEBHOOK_SECRET !
);
if ( ! isValid ) {
return res . status ( 401 ). json ({ error: 'Invalid signature' });
}
Timing Attack Protection
Use constant-time comparison to prevent timing attacks:
import { timingSafeEqual } from 'crypto' ;
function verifyWebhookSignatureSecure (
body : string ,
signature : string ,
secret : string
) : boolean {
const expectedSignature = crypto
. createHmac ( 'sha256' , secret )
. update ( body )
. digest ( 'hex' );
// Constant-time comparison
const signatureBuffer = Buffer . from ( signature , 'hex' );
const expectedBuffer = Buffer . from ( expectedSignature , 'hex' );
if ( signatureBuffer . length !== expectedBuffer . length ) {
return false ;
}
return timingSafeEqual ( signatureBuffer , expectedBuffer );
}
Event Types Reference
checkout.completed
Fired when a user completes checkout and creates a wallet connection.
Event Data:
{
type : 'checkout.completed' ,
data : {
connection_id : 'conn_xxxxx' ,
wallet_id : 'wa_xxxxx' ,
merchant_id : 'mer_xxxxx' ,
reference_id : 'user_123' , // Your reference ID
amount : 5000 , // Initial deposit (cents)
metadata : { // Your custom metadata
userId : 'user_123' ,
plan : 'pro'
},
status : 'active' ,
created_at : '2025-01-15T10:30:00Z'
}
}
Handler Example:
async function handleCheckoutCompleted ( data : any ) {
const { connection_id , reference_id , wallet_id , amount } = data ;
// Retrieve full connection details
const connection = await lava . connections . retrieve ( connection_id );
// Save to database
await db . users . update ({
where: { id: reference_id },
data: {
lavaWalletId: wallet_id ,
lavaConnectionId: connection_id ,
lavaConnectionSecret: connection . connection_secret ,
onboardingCompleted: true ,
initialCreditAmount: amount / 100 // Convert cents to dollars
}
});
// Send welcome email
await sendEmail ({
to: data . metadata . email ,
subject: 'Welcome to Your AI Assistant!' ,
body: `Your account is ready with $ ${ amount / 100 } credits.`
});
}
usage.recorded
Fired when API request is processed and usage is charged.
Event Data:
{
type : 'usage.recorded' ,
data : {
request_id : 'req_xxxxx' ,
connection_id : 'conn_xxxxx' ,
provider : 'openai' ,
model : 'gpt-4' ,
usage : {
input_tokens : 150 ,
output_tokens : 300 ,
total_tokens : 450
},
costs : {
base_cost : 0.015 , // Provider cost
merchant_fee : 0.003 , // Your fee
service_charge : 0.00015 , // Lava's 1.9% fee
total : 0.01815
},
timestamp : '2025-01-15T10:35:22Z'
}
}
Handler Example:
async function handleUsageRecorded ( data : any ) {
const { connection_id , usage , costs , provider , model } = data ;
// Track usage analytics
await db . usageStats . create ({
data: {
connectionId: connection_id ,
provider: provider ,
model: model ,
tokensUsed: usage . total_tokens ,
costUsd: costs . total ,
timestamp: new Date ( data . timestamp )
}
});
// Check if user approaching usage limits
const monthlyUsage = await getMonthlyUsage ( connection_id );
if ( monthlyUsage > USAGE_LIMIT_THRESHOLD ) {
await sendUsageWarning ( connection_id );
}
}
balance.low
Fired when wallet balance falls below configured threshold.
Event Data:
{
type : 'balance.low' ,
data : {
wallet_id : 'wa_xxxxx' ,
connection_id : 'conn_xxxxx' ,
current_balance : 2.50 ,
threshold : 5.00 ,
autopay_enabled : false
}
}
Handler Example:
async function handleBalanceLow ( data : any ) {
const { connection_id , current_balance , threshold } = data ;
// Notify user via email/SMS
await sendNotification ({
connectionId: connection_id ,
type: 'balance_low' ,
message: `Your balance ($ ${ current_balance } ) is below $ ${ threshold } . Add funds to continue using AI services.` ,
urgency: 'medium'
});
// Log for analytics
await logEvent ( 'balance_low' , {
connectionId: connection_id ,
balance: current_balance
});
}
connection.deleted
Fired when user or merchant deletes a connection.
Event Data:
{
type : 'connection.deleted' ,
data : {
connection_id : 'conn_xxxxx' ,
wallet_id : 'wa_xxxxx' ,
merchant_id : 'mer_xxxxx' ,
deleted_by : 'wallet' , // 'wallet' or 'merchant'
deleted_at : '2025-01-15T11:00:00Z'
}
}
Handler Example:
async function handleConnectionDeleted ( data : any ) {
const { connection_id } = data ;
// Remove from database
await db . connections . update ({
where: { lavaConnectionId: connection_id },
data: { status: 'deleted' , deletedAt: new Date () }
});
// Revoke any active sessions
await revokeUserSessions ( connection_id );
// Send confirmation email
await sendEmail ({
subject: 'Connection Removed' ,
body: 'Your Lava wallet connection has been removed.'
});
}
Retry Logic
Lava automatically retries failed webhooks with exponential backoff:
Retry Schedule:
Attempt 1: Immediate
Attempt 2: 5 seconds later
Attempt 3: 25 seconds later (5s × 5)
Attempt 4: 125 seconds later (25s × 5)
Attempt 5: 625 seconds later (~10 minutes)
Successful Delivery Criteria:
HTTP status: 200-299
Response received within 30 seconds
No network errors
Best Practices:
export async function POST ( req : NextRequest ) {
// 1. Verify signature FIRST (cheap operation)
const isValid = verifySignature ( req );
if ( ! isValid ) {
return NextResponse . json ({ error: 'Invalid' }, { status: 401 });
}
// 2. Return 200 QUICKLY (< 30 seconds)
const event = JSON . parse ( await req . text ());
// Process in background (don't await)
processWebhookAsync ( event ). catch ( err => {
console . error ( 'Background processing failed:' , err );
});
// Return immediately
return NextResponse . json ({ received: true });
}
async function processWebhookAsync ( event : any ) {
// Long-running operations here
await handleWebhookEvent ( event );
}
Return 200 quickly. Process webhooks asynchronously to avoid timeouts. Lava will retry if your endpoint doesn’t respond within 30 seconds.
Testing Webhooks Locally
Option 1: Webhook Proxy (Recommended)
Use a tool like ngrok or localtunnel to expose your local endpoint:
# Install ngrok
npm install -g ngrok
# Start your local server
npm run dev
# Expose port 3000
ngrok http 3000
# Copy the HTTPS URL: https://abc123.ngrok.io
Configure webhook:
Go to Lava dashboard > Webhooks
Add endpoint: https://abc123.ngrok.io/api/webhooks/lava
Select events
Test by completing checkout in browser
Option 2: Manual Webhook Trigger
Create a test script to send webhook events locally:
// scripts/test-webhook.ts
import crypto from 'crypto' ;
const WEBHOOK_SECRET = 'your_webhook_secret' ;
const WEBHOOK_URL = 'http://localhost:3000/api/webhooks/lava' ;
const testEvent = {
type: 'checkout.completed' ,
data: {
connection_id: 'conn_test123' ,
wallet_id: 'wa_test123' ,
merchant_id: 'mer_test123' ,
reference_id: 'test_user' ,
amount: 5000 ,
metadata: { userId: 'test_user' , plan: 'pro' },
status: 'active' ,
created_at: new Date (). toISOString ()
}
};
const body = JSON . stringify ( testEvent );
const signature = crypto
. createHmac ( 'sha256' , WEBHOOK_SECRET )
. update ( body )
. digest ( 'hex' );
const response = await fetch ( WEBHOOK_URL , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'x-lava-signature' : signature
},
body: body
});
console . log ( 'Status:' , response . status );
console . log ( 'Response:' , await response . json ());
Run with: npx tsx scripts/test-webhook.ts
Troubleshooting
Webhook signature verification failing
Common causes:
Using parsed JSON body instead of raw body
Express middleware (express.json()) parsing body before verification
Incorrect webhook secret in environment variables
Signature header name mismatch (x-lava-signature vs x-webhook-signature)
Solutions:
Use express.raw() middleware for webhook routes
Access raw request body before any parsing
Verify LAVA_WEBHOOK_SECRET matches dashboard value
Check exact header name: x-lava-signature
Webhooks not being received
Check:
Webhook URL is publicly accessible (not localhost)
Endpoint returns 200 status code
No firewall blocking Lava’s webhook IPs
Events are selected in dashboard configuration
Webhook endpoint is registered and enabled
Test:
Use webhook proxy (ngrok) during development
Check Lava dashboard > Webhooks > Delivery Attempts
Verify endpoint responds to test POST request
Enable logging to see if requests reach your server
Reasons:
Handler takes > 30 seconds to respond
Blocking on external API calls in webhook handler
Database operations not optimized
Synchronous processing of heavy operations
Solutions:
Return 200 immediately, process asynchronously
Use background job queue (Bull, BullMQ, etc.)
Move heavy operations to separate worker processes
Log errors for failed background processing
Duplicate webhook events received
Why it happens:
Automatic retries after timeouts
Network issues causing duplicate delivery
Multiple webhook endpoints configured
Solutions:
Implement idempotency using event.id or data.connection_id
Check if event already processed before handling
Use database unique constraints on event IDs
Example: async function handleWebhookEvent ( event : any ) {
const exists = await db . processedWebhooks . findUnique ({
where: { eventId: event . id }
});
if ( exists ) {
console . log ( 'Event already processed:' , event . id );
return ;
}
// Process event
await handleEvent ( event );
// Mark as processed
await db . processedWebhooks . create ({
data: { eventId: event . id , processedAt: new Date () }
});
}
What’s Next