Skip to main content

Webhook Configuration

Webhooks provide real-time notifications when payment events occur. This guide explains how webhooks flow through the system and how to configure them.

Webhook Flow

Customer pays invoice

OpenNode confirms payment

OpenNode sends webhook to Lightning Enable

Lightning Enable processes and forwards to your endpoint

Your server fulfills the order

Architecture

Lightning Enable acts as a webhook proxy:

  1. OpenNode → Lightning Enable: OpenNode sends payment notifications
  2. Lightning Enable → Your Server: We forward events to your configured endpoint

This provides:

  • Consistent webhook format across payment providers
  • Signature verification
  • Retry handling
  • Event logging

Configure Webhooks

Step 1: Set Your Webhook URL

Configure your endpoint in Lightning Enable:

curl -X PUT https://api.lightningenable.com/api/admin/merchants/{merchantId} \
-H "X-API-Key: admin-api-key" \
-H "Content-Type: application/json" \
-d '{
"webhookUrl": "https://your-site.com/webhooks/lightning",
"webhookSecret": "your-webhook-secret"
}'

Step 2: OpenNode Webhook (Automatic)

Lightning Enable automatically configures OpenNode to send webhooks to our endpoint. No manual configuration needed in OpenNode dashboard.

Step 3: Implement Webhook Handler

Create an endpoint to receive webhooks:

// Express.js example -- use express.raw() for raw body access
app.post('/webhooks/lightning', express.raw({ type: 'application/json' }), (req, res) => {
const signatureHeader = req.headers['x-lightningenable-signature'];
const payload = req.body.toString('utf8');

// Verify signature (see Signature Verification section below)
if (!signatureHeader || !verifySignature(payload, signatureHeader, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}

const event = JSON.parse(payload);

// Handle event
switch (event.event) {
case 'payment.completed':
handlePaymentCompleted(event.data);
break;
case 'payment.expired':
handlePaymentExpired(event.data);
break;
case 'refund.completed':
handleRefundCompleted(event.data);
break;
}

res.status(200).send('OK');
});

Webhook Secret

The webhook secret is used to verify that webhooks are from Lightning Enable.

Generate a Secret

Generate a secure random string:

# Using OpenSSL
openssl rand -hex 32

# Using Python
python -c "import secrets; print(secrets.token_hex(32))"

# Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Store Securely

# Environment variable
WEBHOOK_SECRET=your-64-character-hex-string

Signature Verification

Always verify webhook signatures to ensure authenticity and prevent replay attacks.

Signature Format

Lightning Enable sends an X-LightningEnable-Signature header with every webhook:

X-LightningEnable-Signature: t=1704067200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
ComponentDescription
tUnix timestamp (seconds) when the signature was generated
v1HMAC-SHA256 hex digest of the signed payload

How Verification Works

  1. Parse the t (timestamp) and v1 (signature) from the header
  2. Check freshness -- reject if the timestamp is more than 5 minutes old (replay protection)
  3. Compute HMAC-SHA256 of {timestamp}.{raw_body} using your webhook secret
  4. Compare the result with v1 using a constant-time comparison

JavaScript Verification

const crypto = require('crypto');

const TOLERANCE_SECONDS = 300; // 5 minutes

function verifySignature(payload, signatureHeader, secret) {
// Parse "t={timestamp},v1={signature}"
const parts = signatureHeader.split(',');
let timestamp = null;
let signature = null;

for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith('t=')) timestamp = parseInt(trimmed.slice(2), 10);
else if (trimmed.startsWith('v1=')) signature = trimmed.slice(3);
}

if (!timestamp || !signature) return false;

// Replay protection
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

// Compute HMAC over "{timestamp}.{payload}"
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

C# Verification

using System.Security.Cryptography;
using System.Text;

public static bool VerifySignature(string payload, string signatureHeader, string secret)
{
// Parse "t={timestamp},v1={signature}"
long timestamp = 0;
string providedSignature = "";

foreach (var part in signatureHeader.Split(','))
{
var trimmed = part.Trim();
if (trimmed.StartsWith("t=") && long.TryParse(trimmed[2..], out var t))
timestamp = t;
else if (trimmed.StartsWith("v1="))
providedSignature = trimmed[3..].ToLowerInvariant();
}

if (timestamp == 0 || string.IsNullOrEmpty(providedSignature))
return false;

// Replay protection (5-minute tolerance)
var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestamp);
if (age.TotalSeconds > 300 || age.TotalSeconds < -30)
return false;

// Compute HMAC over "{timestamp}.{payload}"
var signedPayload = $"{timestamp}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
var expected = Convert.ToHexString(hash).ToLowerInvariant();

return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(providedSignature));
}

Python Verification

import hmac
import hashlib
import time

TOLERANCE_SECONDS = 300 # 5 minutes

def verify_signature(payload, signature_header, secret):
"""Verify X-LightningEnable-Signature with replay protection."""
timestamp = None
signature = None

for part in signature_header.split(','):
trimmed = part.strip()
if trimmed.startswith('t='):
timestamp = int(trimmed[2:])
elif trimmed.startswith('v1='):
signature = trimmed[3:]

if timestamp is None or signature is None:
return False

# Replay protection
now = int(time.time())
if abs(now - timestamp) > TOLERANCE_SECONDS:
return False

# Compute HMAC over "{timestamp}.{payload}"
signed_payload = f'{timestamp}.{payload}'
expected = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()

return hmac.compare_digest(signature, expected)

Webhook Events

payment.completed

Sent when a Lightning invoice is paid:

{
"event": "payment.completed",
"timestamp": "2024-12-29T12:05:00Z",
"data": {
"invoiceId": "inv_abc123def456",
"orderId": "ORDER-12345",
"status": "paid",
"amount": 49.99,
"currency": "USD",
"amountSats": 125000,
"paidAt": "2024-12-29T12:05:00Z"
}
}

payment.expired

Sent when an invoice expires without payment:

{
"event": "payment.expired",
"timestamp": "2024-12-29T13:00:00Z",
"data": {
"invoiceId": "inv_abc123def456",
"orderId": "ORDER-12345",
"status": "expired",
"expiredAt": "2024-12-29T13:00:00Z"
}
}

refund.completed

Sent when a refund is processed:

{
"event": "refund.completed",
"timestamp": "2024-12-29T14:01:00Z",
"data": {
"refundId": "ref_xyz789abc",
"invoiceId": "inv_abc123def456",
"status": "completed",
"amount": 49.99,
"currency": "USD"
}
}

Webhook Requirements

Endpoint Requirements

  • HTTPS in production (HTTP allowed for localhost)
  • Return 200-299 status code within 30 seconds
  • Accept POST requests with JSON body

Headers Sent

HeaderDescription
Content-Typeapplication/json
X-LightningEnable-SignatureTimestamped HMAC-SHA256 signature (t={timestamp},v1={hmac})

Retry Policy

If your endpoint fails, we retry with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours

After 5 failed attempts, the webhook is marked as failed.

Testing Webhooks

Local Development

Use ngrok to expose your local server:

# Terminal 1: Start your server
npm start # http://localhost:3000

# Terminal 2: Start ngrok
ngrok http 3000

# Copy the ngrok URL (e.g., https://abc123.ngrok.io)
# Use as your webhook URL

Send Test Webhook

To send a properly signed test webhook:

# Set your variables
WEBHOOK_SECRET="your-webhook-secret"
TIMESTAMP=$(date +%s)
PAYLOAD='{"event":"payment.completed","timestamp":"2024-12-29T12:05:00Z","data":{"invoiceId":"inv_test123","orderId":"TEST-001","status":"paid","amount":10.00}}'

# Compute the signature
SIG=$(echo -n "${TIMESTAMP}.${PAYLOAD}" | openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" | cut -d' ' -f2)

# Send the webhook
curl -X POST https://your-ngrok-url.ngrok.io/webhooks/lightning \
-H "Content-Type: application/json" \
-H "X-LightningEnable-Signature: t=${TIMESTAMP},v1=${SIG}" \
-d "${PAYLOAD}"

Webhook Testing Checklist

  • Endpoint returns 200 status
  • Signature verification works
  • Events are processed correctly
  • Duplicate events handled (idempotency)
  • Errors are logged
  • Timeout handling (return 200 quickly)

Troubleshooting

Webhooks Not Received

  1. Check URL - Verify webhook URL is correct
  2. Check HTTPS - Production requires HTTPS
  3. Check firewall - Allow incoming connections
  4. Check logs - View delivery status in dashboard

Signature Mismatch

  1. Raw body - Use the raw request body, not re-serialized JSON
  2. Correct secret - Verify webhook secret matches what you configured
  3. Correct header - The header is X-LightningEnable-Signature, not X-Webhook-Signature
  4. Signed payload format - HMAC is computed over {timestamp}.{payload}, not just the payload
  5. Replay protection - Ensure your tolerance is at least 5 minutes
  6. Encoding - Use UTF-8 encoding

Timeouts

  1. Async processing - Return 200 immediately
  2. Queue jobs - Process heavy work in background
  3. Check latency - Optimize endpoint response time
// Good - Return immediately, process async
app.post('/webhooks/lightning', (req, res) => {
// Acknowledge receipt immediately
res.status(200).send('OK');

// Process asynchronously
setImmediate(() => {
processWebhook(req.body);
});
});

Duplicate Events

Implement idempotency using the invoice ID:

async function handlePaymentCompleted(data) {
const { invoiceId } = data;

// Check if already processed
const order = await db.orders.findOne({
lightningInvoiceId: invoiceId
});

if (order.status === 'fulfilled') {
console.log('Already processed:', invoiceId);
return;
}

// Process and mark as fulfilled
await fulfillOrder(order.id);
await db.orders.update(order.id, { status: 'fulfilled' });
}

Best Practices

  1. Return 200 quickly - Acknowledge receipt, process later
  2. Verify signatures - Always validate HMAC
  3. Handle duplicates - Webhooks may be sent multiple times
  4. Log everything - Keep records for debugging
  5. Secure endpoint - Use HTTPS, validate signatures
  6. Test thoroughly - Use ngrok for local testing

Next Steps