Skip to main content

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

Every webhook request from Lightning Enable includes an X-LightningEnable-Signature header. You must verify this signature to confirm that the webhook is authentic and has not been tampered with.

Signature Format

The signature header uses a timestamped HMAC scheme:

POST /webhooks/lightning HTTP/1.1
Content-Type: application/json
X-LightningEnable-Signature: t=1704067200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9

The header contains two comma-separated components:

ComponentDescription
tUnix timestamp (seconds) when the signature was generated
v1HMAC-SHA256 hex digest of the signed payload

Verification Steps

To verify a webhook signature:

  1. Extract the timestamp (t) and signature (v1) from the X-LightningEnable-Signature header
  2. Construct the signed payload by concatenating the timestamp, a period (.), and the raw request body: {timestamp}.{payload}
  3. Compute the HMAC-SHA256 of the signed payload using your webhook secret as the key
  4. Compare the computed signature with the v1 value using a constant-time comparison function to prevent timing attacks
  5. Check freshness -- reject signatures where the timestamp is more than 5 minutes old to prevent replay attacks

Replay Protection

Lightning Enable enforces a 5-minute tolerance on webhook signatures. You should implement the same check on your end:

  • Reject webhooks where current_time - t > 300 seconds (5 minutes)
  • Optionally reject webhooks where t is more than 30 seconds in the future (clock skew protection)

This prevents attackers from intercepting a valid webhook and replaying it later.

Node.js / JavaScript Verification

const crypto = require('crypto');

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // 5 minutes

/**
* Parse the X-LightningEnable-Signature header into its components.
*/
function parseSignatureHeader(header) {
const parts = header.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);
}
}

return { timestamp, signature };
}

/**
* Verify a webhook signature with replay protection.
* Returns true if the signature is valid and the timestamp is fresh.
*/
function verifyWebhookSignature(payload, signatureHeader, secret) {
const { timestamp, signature } = parseSignatureHeader(signatureHeader);

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

// Check timestamp freshness (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
return false;
}

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

// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}

// Express.js example -- use express.raw() to get the raw body
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)) {
return res.status(401).send('Invalid signature');
}

const event = JSON.parse(payload);
handleWebhookEvent(event);

res.status(200).send('OK');
});
Use the raw request body

You must verify the signature against the raw request body string, not a re-serialized JSON object. Re-serialization may change whitespace or key ordering, causing signature mismatches.

C# Verification

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

[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
private readonly string _webhookSecret;
private const int ToleranceSeconds = 300; // 5 minutes

public WebhookController(IConfiguration config)
{
_webhookSecret = config["WebhookSecret"]!;
}

[HttpPost("lightning")]
public async Task<IActionResult> HandleWebhook()
{
// Read the raw body
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 webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(payload);
await ProcessEventAsync(webhookEvent);

return Ok();
}

private bool VerifySignature(string payload, string signatureHeader)
{
// Parse "t={timestamp},v1={signature}"
if (!TryParseSignatureHeader(signatureHeader, out var timestamp, out var providedSignature))
{
return false;
}

// Replay protection: reject if older than 5 minutes
var signatureTime = DateTimeOffset.FromUnixTimeSeconds(timestamp);
var age = DateTimeOffset.UtcNow - signatureTime;
if (age.TotalSeconds > ToleranceSeconds || age.TotalSeconds < -30)
{
return false;
}

// Compute HMAC-SHA256 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 expectedSignature = Convert.ToHexString(hash).ToLowerInvariant();

// Constant-time comparison
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedSignature),
Encoding.UTF8.GetBytes(providedSignature));
}

private static bool TryParseSignatureHeader(string header, out long timestamp, out string signature)
{
timestamp = 0;
signature = string.Empty;

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

return timestamp > 0 && !string.IsNullOrEmpty(signature);
}
}

Python Verification

import hmac
import hashlib
import time

WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
TOLERANCE_SECONDS = 300 # 5 minutes


def parse_signature_header(header):
"""Parse the X-LightningEnable-Signature header."""
timestamp = None
signature = None

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

return timestamp, signature


def verify_webhook_signature(payload, signature_header, secret):
"""Verify a webhook signature with replay protection."""
timestamp, signature = parse_signature_header(signature_header)

if timestamp is None or signature is None:
return False

# Replay protection: reject if older than 5 minutes
now = int(time.time())
if abs(now - timestamp) > TOLERANCE_SECONDS:
return False

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

# Constant-time comparison
return hmac.compare_digest(signature, expected)


# Flask example
@app.route('/webhooks/lightning', methods=['POST'])
def handle_webhook():
signature_header = request.headers.get('X-LightningEnable-Signature')
payload = request.get_data(as_text=True)

if not signature_header or not verify_webhook_signature(payload, signature_header, WEBHOOK_SECRET):
return 'Invalid signature', 401

event = request.json
process_event(event)

return 'OK', 200

Handling Events

Best Practices

  1. Return 200 quickly - Process asynchronously if needed
  2. Handle duplicates - Events may be sent multiple times
  3. Verify signatures - Always validate HMAC
  4. 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:

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:

# 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:

# Generate a test signature (Node.js one-liner)
# TIMESTAMP=$(date +%s)
# PAYLOAD='{"event":"payment.completed","data":{"invoiceId":"inv_test123"}}'
# SIG=$(echo -n "${TIMESTAMP}.${PAYLOAD}" | openssl dgst -sha256 -hmac "your-webhook-secret" | cut -d' ' -f2)

curl -X POST https://your-site.com/webhooks/lightning \
-H "Content-Type: application/json" \
-H "X-LightningEnable-Signature: t=${TIMESTAMP},v1=${SIG}" \
-d "${PAYLOAD}"

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

  1. Check URL - Ensure webhook URL is correct and HTTPS
  2. Check firewall - Allow incoming connections
  3. Check logs - View webhook delivery logs
  4. Test manually - Use curl to test your endpoint

Signature Mismatch

  1. Check secret - Ensure webhook secret matches what you configured
  2. Check encoding - Use the raw request body, not a re-serialized JSON object
  3. Check algorithm - Use HMAC-SHA256 over {timestamp}.{payload}
  4. Check header name - The header is X-LightningEnable-Signature, not X-Webhook-Signature
  5. Check timestamp - Ensure replay protection tolerance is at least 5 minutes

Timeout Errors

  1. Process async - Return 200 immediately, process in background
  2. Increase timeout - Ensure endpoint responds within 30 seconds
  3. Check latency - Use server closer to Lightning Enable

Security Best Practices

Always Verify Signatures

// Bad - No verification
app.post('/webhooks', express.json(), (req, res) => {
processEvent(req.body);
res.send('OK');
});

// Good - Verify signature with raw body
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const sigHeader = req.headers['x-lightningenable-signature'];
const payload = req.body.toString('utf8');

if (!sigHeader || !verifyWebhookSignature(payload, sigHeader, WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized');
}

const event = JSON.parse(payload);
processEvent(event);
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