Webhooks
Webhooks provide instant notifications when payment status changes. Instead of polling the API, your server receives an HTTP POST request with payment details.
Why Use Webhooks?
| Method | Pros | Cons |
|---|---|---|
| Polling | Simple to implement | Delayed detection, API overhead |
| Webhooks | Instant, efficient | Requires public endpoint |
For production use, webhooks are strongly recommended.
Setup
1. Create a Webhook Endpoint
Create an HTTP POST endpoint in your application:
// Express.js
app.post('/webhooks/lightning', express.json(), async (req, res) => {
try {
// Process webhook
console.log('Received webhook:', req.body);
// Always return 200 quickly
res.status(200).send('OK');
// Process asynchronously
await processPayment(req.body);
} catch (error) {
console.error('Webhook error:', error);
res.status(500).send('Error');
}
});
2. Configure Webhook URL
Set your webhook URL in Lightning Enable:
Option A: During merchant setup
{
"callbackUrl": "https://yourapp.com/webhooks/lightning"
}
Option B: Via admin API
curl -X PUT https://api.lightningenable.com/api/admin/merchants/{id} \
-H "X-API-Key: admin-key" \
-d '{"callbackUrl": "https://yourapp.com/webhooks/lightning"}'
3. Verify Webhook Signatures
Always verify webhook signatures to ensure authenticity. Lightning Enable sends an X-LightningEnable-Signature header with every webhook, in the format t={unix_timestamp},v1={hmac_sha256_hex}.
The HMAC is computed over {timestamp}.{raw_body} using your webhook secret. You should also reject signatures older than 5 minutes to prevent replay attacks.
const crypto = require('crypto');
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // 5 minutes
function verifyWebhookSignature(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 expected 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));
}
// Important: use express.raw() to get the raw body for signature verification
app.post('/webhooks/lightning', express.raw({ type: 'application/json' }), (req, res) => {
const signatureHeader = req.headers['x-lightningenable-signature'];
const payload = req.body.toString('utf8');
if (!signatureHeader || !verifyWebhookSignature(payload, signatureHeader, WEBHOOK_SECRET)) {
console.warn('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
// Process verified webhook
const event = JSON.parse(payload);
processPayment(event);
res.status(200).send('OK');
});
Webhook Payload
When a payment status changes, you receive:
{
"invoiceId": "inv_abc123def456",
"orderId": "ORDER-12345",
"openNodeChargeId": "charge_xyz789",
"status": "paid",
"amount": 25.00,
"currency": "USD",
"amountSats": 62500,
"paidAt": "2024-12-29T12:03:45Z",
"metadata": {
"customerId": "cust_123"
}
}
Payload Fields
| Field | Type | Description |
|---|---|---|
invoiceId | string | Lightning Enable invoice ID |
orderId | string | Your order ID |
openNodeChargeId | string | OpenNode charge ID |
status | string | Payment status |
amount | decimal | Payment amount |
currency | string | Currency code |
amountSats | integer | Amount in satoshis |
paidAt | datetime | Payment timestamp (if paid) |
metadata | object | Your custom metadata |
Payment Statuses
| Status | Description | Action |
|---|---|---|
paid | Payment confirmed | Fulfill order |
processing | Payment detected | Wait for confirmation |
expired | Invoice expired | Notify customer |
underpaid | Insufficient amount | Handle manually |
refunded | Payment refunded | Update records |
Implementation Examples
Node.js / Express
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // 5 minutes
function verifySignature(payload, signatureHeader, secret) {
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));
}
// Use express.raw() to access the raw body for signature verification
app.post('/webhooks/lightning', express.raw({ type: 'application/json' }), async (req, res) => {
const signatureHeader = req.headers['x-lightningenable-signature'];
const payload = req.body.toString('utf8');
if (!signatureHeader || !verifySignature(payload, signatureHeader, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
const { invoiceId, orderId, status } = event;
switch (status) {
case 'paid':
await fulfillOrder(orderId);
await sendConfirmationEmail(event.customerEmail);
break;
case 'expired':
await markOrderExpired(orderId);
break;
case 'refunded':
await processRefund(orderId);
break;
}
res.status(200).send('OK');
});
app.listen(3000);
C# / ASP.NET Core
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
[ApiController]
[Route("webhooks")]
public class WebhooksController : ControllerBase
{
private readonly IOrderService _orderService;
private readonly string _webhookSecret;
private const int ToleranceSeconds = 300; // 5 minutes
public WebhooksController(IOrderService orderService, IConfiguration config)
{
_orderService = orderService;
_webhookSecret = config["WebhookSecret"]!;
}
[HttpPost("lightning")]
public async Task<IActionResult> HandleWebhook()
{
// Read the raw body for signature verification
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync();
var signatureHeader = Request.Headers["X-LightningEnable-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(signatureHeader) || !VerifySignature(payload, signatureHeader))
{
return Unauthorized("Invalid signature");
}
var webhookPayload = JsonSerializer.Deserialize<WebhookPayload>(payload);
switch (webhookPayload.Status)
{
case "paid":
await _orderService.FulfillOrderAsync(webhookPayload.OrderId);
break;
case "expired":
await _orderService.ExpireOrderAsync(webhookPayload.OrderId);
break;
}
return Ok();
}
private bool VerifySignature(string payload, string signatureHeader)
{
// 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
var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestamp);
if (age.TotalSeconds > ToleranceSeconds || age.TotalSeconds < -30)
return false;
// Compute HMAC over "{timestamp}.{payload}"
var signedPayload = $"{timestamp}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
var expected = Convert.ToHexString(hash).ToLowerInvariant();
// Constant-time comparison
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(providedSignature));
}
}
public record WebhookPayload(
string InvoiceId,
string OrderId,
string Status,
decimal Amount,
string Currency
);
Python / Flask
from flask import Flask, request, jsonify
import hmac
import hashlib
import time
import os
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
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)
@app.route('/webhooks/lightning', methods=['POST'])
def handle_webhook():
signature_header = request.headers.get('X-LightningEnable-Signature')
payload = request.get_data(as_text=True) # Raw body, not parsed JSON
if not signature_header or not verify_signature(payload, signature_header, WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401
data = request.json
status = data.get('status')
order_id = data.get('orderId')
if status == 'paid':
fulfill_order(order_id)
elif status == 'expired':
expire_order(order_id)
return 'OK', 200
Retry Logic
Lightning Enable retries failed webhooks:
| Attempt | Delay |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 15 minutes |
| 4th retry | 1 hour |
| Final | 24 hours |
A webhook is considered successful when your endpoint returns a 2xx status code.
Local Development
Use ngrok to test webhooks locally:
# Install ngrok
npm install -g ngrok
# Start your local server
npm start
# Expose port 3000
ngrok http 3000
Configure the ngrok URL as your webhook endpoint:
https://abc123.ngrok.io/webhooks/lightning
Best Practices
Return 200 Quickly
Process webhooks asynchronously to avoid timeouts:
app.post('/webhooks/lightning', (req, res) => {
// Return 200 immediately
res.status(200).send('OK');
// Process asynchronously
processWebhookAsync(req.body).catch(console.error);
});
Idempotent Processing
Handle duplicate webhooks gracefully:
async function processPayment(payload) {
// Check if already processed
const existing = await db.payments.findOne({ invoiceId: payload.invoiceId });
if (existing && existing.processed) {
console.log('Already processed:', payload.invoiceId);
return;
}
// Process payment
await fulfillOrder(payload.orderId);
// Mark as processed
await db.payments.updateOne(
{ invoiceId: payload.invoiceId },
{ $set: { processed: true, processedAt: new Date() } }
);
}
Log Everything
Keep detailed logs for debugging:
app.post('/webhooks/lightning', express.raw({ type: 'application/json' }), (req, res) => {
const payload = req.body.toString('utf8');
const event = JSON.parse(payload);
console.log('Webhook received:', {
invoiceId: event.invoiceId,
status: event.status,
signature: req.headers['x-lightningenable-signature']?.substring(0, 20) + '...'
});
// ... process webhook
});
Troubleshooting
Webhooks Not Received
- Verify webhook URL is publicly accessible
- Check for HTTPS requirement
- Ensure firewall allows incoming connections
- Review webhook logs in Lightning Enable dashboard
Signature Verification Fails
- Ensure you're using the correct webhook secret
- Verify you're using the raw request body, not re-serialized JSON
- Check the header name is
X-LightningEnable-Signature(notX-Webhook-Signature) - Ensure you're computing the HMAC over
{timestamp}.{payload}, not just the payload - Check that your replay protection tolerance is at least 5 minutes
- Check for encoding issues (UTF-8)
Duplicate Webhooks
- Implement idempotent processing
- Store invoice IDs that have been processed
- Check before fulfilling orders
Next Steps
- Refunds - Handle returns
- Error Handling - Error codes and handling
- API Reference - Complete webhook documentation