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:
- OpenNode → Lightning Enable: OpenNode sends payment notifications
- 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
| Component | Description |
|---|---|
t | Unix timestamp (seconds) when the signature was generated |
v1 | HMAC-SHA256 hex digest of the signed payload |
How Verification Works
- Parse the
t(timestamp) andv1(signature) from the header - Check freshness -- reject if the timestamp is more than 5 minutes old (replay protection)
- Compute HMAC-SHA256 of
{timestamp}.{raw_body}using your webhook secret - Compare the result with
v1using 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
| Header | Description |
|---|---|
Content-Type | application/json |
X-LightningEnable-Signature | Timestamped HMAC-SHA256 signature (t={timestamp},v1={hmac}) |
Retry Policy
If your endpoint fails, we retry with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 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
- Check URL - Verify webhook URL is correct
- Check HTTPS - Production requires HTTPS
- Check firewall - Allow incoming connections
- Check logs - View delivery status in dashboard
Signature Mismatch
- Raw body - Use the raw request body, not re-serialized JSON
- Correct secret - Verify webhook secret matches what you configured
- Correct header - The header is
X-LightningEnable-Signature, notX-Webhook-Signature - Signed payload format - HMAC is computed over
{timestamp}.{payload}, not just the payload - Replay protection - Ensure your tolerance is at least 5 minutes
- Encoding - Use UTF-8 encoding
Timeouts
- Async processing - Return 200 immediately
- Queue jobs - Process heavy work in background
- 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
- Return 200 quickly - Acknowledge receipt, process later
- Verify signatures - Always validate HMAC
- Handle duplicates - Webhooks may be sent multiple times
- Log everything - Keep records for debugging
- Secure endpoint - Use HTTPS, validate signatures
- Test thoroughly - Use ngrok for local testing
Next Steps
- Testing - Test your integration
- Webhooks API - API reference
- First Payment - Complete a test payment