Skip to main content

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?

MethodProsCons
PollingSimple to implementDelayed detection, API overhead
WebhooksInstant, efficientRequires 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

FieldTypeDescription
invoiceIdstringLightning Enable invoice ID
orderIdstringYour order ID
openNodeChargeIdstringOpenNode charge ID
statusstringPayment status
amountdecimalPayment amount
currencystringCurrency code
amountSatsintegerAmount in satoshis
paidAtdatetimePayment timestamp (if paid)
metadataobjectYour custom metadata

Payment Statuses

StatusDescriptionAction
paidPayment confirmedFulfill order
processingPayment detectedWait for confirmation
expiredInvoice expiredNotify customer
underpaidInsufficient amountHandle manually
refundedPayment refundedUpdate 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:

AttemptDelay
1st retry1 minute
2nd retry5 minutes
3rd retry15 minutes
4th retry1 hour
Final24 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

  1. Verify webhook URL is publicly accessible
  2. Check for HTTPS requirement
  3. Ensure firewall allows incoming connections
  4. Review webhook logs in Lightning Enable dashboard

Signature Verification Fails

  1. Ensure you're using the correct webhook secret
  2. Verify you're using the raw request body, not re-serialized JSON
  3. Check the header name is X-LightningEnable-Signature (not X-Webhook-Signature)
  4. Ensure you're computing the HMAC over {timestamp}.{payload}, not just the payload
  5. Check that your replay protection tolerance is at least 5 minutes
  6. Check for encoding issues (UTF-8)

Duplicate Webhooks

  1. Implement idempotent processing
  2. Store invoice IDs that have been processed
  3. Check before fulfilling orders

Next Steps