Webhooks
Receive real-time notifications when payment events occur.
Overview
Webhooks notify your application when:
- A payment is completed
- A payment expires
- A refund is processed
Instead of polling the API, webhooks push events to your server instantly.
How It Works
1. Customer pays Lightning invoice
2. OpenNode confirms payment
3. OpenNode sends webhook to Lightning Enable
4. Lightning Enable sends webhook to your endpoint
5. Your server processes the event
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 unpaid invoice expires.
{
"event": "payment.expired",
"timestamp": "2024-12-29T13:00:00Z",
"data": {
"invoiceId": "inv_abc123def456",
"orderId": "ORDER-12345",
"status": "expired",
"amount": 49.99,
"currency": "USD",
"expiredAt": "2024-12-29T13:00:00Z"
}
}
Refund Completed
Sent when a refund is successfully processed.
{
"event": "refund.completed",
"timestamp": "2024-12-29T14:01:00Z",
"data": {
"refundId": "ref_xyz789abc",
"invoiceId": "inv_abc123def456",
"status": "completed",
"amount": 49.99,
"currency": "USD",
"completedAt": "2024-12-29T14:01:00Z"
}
}
Configuring Webhooks
Set Webhook URL
Configure your webhook endpoint in your merchant settings:
curl -X PUT https://api.lightningenable.com/api/admin/merchants/{id} \
-H "X-API-Key: admin-api-key" \
-H "Content-Type: application/json" \
-d '{
"webhookUrl": "https://your-site.com/webhooks/lightning",
"webhookSecret": "your-webhook-secret"
}'
Webhook URL Requirements
- Must be HTTPS in production
- Must return 200 status within 30 seconds
- Must be publicly accessible
Verifying Webhooks
HMAC Signature
Webhooks include an HMAC-SHA256 signature for verification:
POST /webhooks/lightning HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: sha256=abc123...
Verification Process
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const expectedSig = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSig)
);
}
// Express.js example
app.post('/webhooks/lightning', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = req.body;
if (!verifyWebhook(payload, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
handleWebhookEvent(event);
res.status(200).send('OK');
});
C# Verification
public class WebhookController : ControllerBase
{
private readonly string _webhookSecret;
[HttpPost("webhooks/lightning")]
public async Task<IActionResult> HandleWebhook()
{
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync();
var signature = Request.Headers["X-Webhook-Signature"].FirstOrDefault();
if (!VerifySignature(payload, signature))
{
return Unauthorized("Invalid signature");
}
var webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(payload);
await ProcessEventAsync(webhookEvent);
return Ok();
}
private bool VerifySignature(string payload, string signature)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var expectedSig = "sha256=" + BitConverter.ToString(hash).Replace("-", "").ToLower();
return signature == expectedSig;
}
}
Python Verification
import hmac
import hashlib
def verify_webhook(payload, signature, secret):
expected = 'sha256=' + hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask example
@app.route('/webhooks/lightning', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature')
payload = request.get_data(as_text=True)
if not verify_webhook(payload, signature, WEBHOOK_SECRET):
return 'Invalid signature', 401
event = request.json
process_event(event)
return 'OK', 200
Handling Events
Best Practices
- Return 200 quickly - Process asynchronously if needed
- Handle duplicates - Events may be sent multiple times
- Verify signatures - Always validate HMAC
- Log everything - Keep records for debugging
Idempotency
Use the invoice/refund ID to handle duplicate events:
async function handlePaymentCompleted(data) {
const { invoiceId } = data;
// Check if already processed
const existing = await db.orders.findOne({
lightningInvoiceId: invoiceId,
status: 'fulfilled'
});
if (existing) {
console.log('Already processed:', invoiceId);
return;
}
// Process the payment
await db.orders.updateOne(
{ lightningInvoiceId: invoiceId },
{ $set: { status: 'fulfilled', paidAt: data.paidAt } }
);
await fulfillOrder(invoiceId);
}
Event Routing
app.post('/webhooks/lightning', async (req, res) => {
const event = req.body;
switch (event.event) {
case 'payment.completed':
await handlePaymentCompleted(event.data);
break;
case 'payment.expired':
await handlePaymentExpired(event.data);
break;
case 'refund.completed':
await handleRefundCompleted(event.data);
break;
default:
console.log('Unknown event:', event.event);
}
res.status(200).send('OK');
});
Retry Policy
If your endpoint fails to respond with 200:
| 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:
# Start your local server
npm start # Runs on http://localhost:3000
# In another terminal
ngrok http 3000
# Use the ngrok URL as your webhook endpoint
# https://abc123.ngrok.io/webhooks/lightning
Test Event
Send a test webhook to verify your endpoint:
curl -X POST https://your-site.com/webhooks/lightning \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=test" \
-d '{
"event": "payment.completed",
"timestamp": "2024-12-29T12:05:00Z",
"data": {
"invoiceId": "inv_test123",
"orderId": "TEST-ORDER",
"status": "paid",
"amount": 1.00,
"currency": "USD"
}
}'
Webhook Logs
View recent webhook deliveries:
curl https://api.lightningenable.com/api/webhooks/logs \
-H "X-API-Key: le_merchant_abc123"
Response:
{
"logs": [
{
"id": "wh_123",
"event": "payment.completed",
"invoiceId": "inv_abc123",
"status": "delivered",
"statusCode": 200,
"timestamp": "2024-12-29T12:05:00Z"
},
{
"id": "wh_124",
"event": "payment.completed",
"invoiceId": "inv_def456",
"status": "failed",
"statusCode": 500,
"attempts": 3,
"nextRetry": "2024-12-29T12:35:00Z"
}
]
}
Troubleshooting
Webhook Not Received
- Check URL - Ensure webhook URL is correct and HTTPS
- Check firewall - Allow incoming connections
- Check logs - View webhook delivery logs
- Test manually - Use curl to test your endpoint
Signature Mismatch
- Check secret - Ensure webhook secret matches
- Check encoding - Use raw request body, not parsed JSON
- Check algorithm - Use HMAC-SHA256
Timeout Errors
- Process async - Return 200 immediately, process in background
- Increase timeout - Ensure endpoint responds within 30 seconds
- Check latency - Use server closer to Lightning Enable
Security Best Practices
Always Verify Signatures
// Bad - No verification
app.post('/webhooks', (req, res) => {
processEvent(req.body);
res.send('OK');
});
// Good - Verify signature
app.post('/webhooks', (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send('Unauthorized');
}
processEvent(req.body);
res.send('OK');
});
Use HTTPS
Always use HTTPS for webhook endpoints to prevent interception.
Validate Event Data
Don't trust webhook data blindly:
async function handlePayment(data) {
// Verify with API
const payment = await getPayment(data.invoiceId);
if (payment.status !== 'paid') {
throw new Error('Payment not actually paid');
}
// Now safe to fulfill
await fulfillOrder(payment.orderId);
}
Next Steps
- Payments API - Create payments
- Errors - Error handling
- Rate Limiting - API limits