Skip to main content

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

  1. Log in to Lava dashboard
  2. Navigate to Monetize > Webhooks
  3. Click “Add Endpoint”
  4. Enter your webhook URL: https://yourapp.com/api/webhooks/lava
  5. 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)
  6. 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

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:
  1. Go to Lava dashboard > Webhooks
  2. Add endpoint: https://abc123.ngrok.io/api/webhooks/lava
  3. Select events
  4. 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

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
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
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