Skip to main content

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:

  1. OpenNode → Lightning Enable: OpenNode sends payment notifications
  2. 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 from the Lightning Enable dashboard (Settings → Webhooks), or via the merchant self-service API:

curl -X PUT https://api.lightningenable.com/api/merchant/webhook-url \
-H "X-API-Key: your-merchant-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 — the payload is a flat object; route on the status field
switch (event.status) {
case 'paid':
handlePaid(event);
break;
case 'expired':
handleExpired(event);
break;
default:
// processing, underpaid, etc. — log and wait for a terminal status
console.log('Payment update:', event.invoiceId, event.status);
}

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
ComponentDescription
tUnix timestamp (seconds) when the signature was generated
v1HMAC-SHA256 hex digest of the signed payload

How Verification Works

  1. Parse the t (timestamp) and v1 (signature) from the header
  2. Check freshness -- reject if the timestamp is more than 5 minutes old (replay protection)
  3. Compute HMAC-SHA256 of {timestamp}.{raw_body} using your webhook secret
  4. Compare the result with v1 using 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 Payload

Lightning Enable forwards a flat JSON object per payment event — there is no envelope and no event field. For OpenNode-configured merchants the payload is:

{
"invoiceId": "1042",
"orderId": "ORDER-12345",
"status": "paid",
"amount": 49.99,
"currency": "USD",
"openNodeChargeId": "abc123-def456-...",
"paidAt": "2026-07-03T12:05:00Z",
"metadata": "{\"customerId\":\"cust_42\"}"
}
  • status is the payment status — route on it. paid is the fulfillment trigger; you may also see processing, expired, underpaid, and refunded.
  • invoiceId is the Lightning Enable invoice ID (numeric string) from POST /api/payments.
  • metadata is the metadata JSON string you supplied at payment creation (or null).
  • Refunds do not generate merchant webhooks — poll GET /api/refunds/{refundId} instead.

(If your merchant account uses Strike instead, the payload carries providerChargeId and provider: "strike" in place of openNodeChargeId — see Webhooks API reference.)

Webhook Requirements

Endpoint Requirements

  • HTTPS in production (HTTP allowed for localhost)
  • Return 200-299 status code within 10 seconds (the delivery request times out after 10s)
  • Accept POST requests with JSON body

Headers Sent

HeaderDescription
Content-Typeapplication/json
X-LightningEnable-SignatureTimestamped HMAC-SHA256 signature (t={timestamp},v1={hmac})

Delivery & Reliability

Treat webhooks as a fast-path notification, not a guaranteed delivery channel:

  • Each delivery attempt has a 10-second timeout; the first attempt fires as soon as Lightning Enable processes OpenNode's webhook.
  • If your endpoint is down or errors, delivery is retried with exponential backoff — 30s, 60s, 120s, 240s, 480s (5 retry attempts, ~16-minute total window) — with identical payload bytes on every attempt (each attempt's X-LightningEnable-Signature is freshly timestamped and verifies against that same body — dedupe on payload content like invoiceId + status, never on the signature header) (as of the July 2026 update; earlier versions did not retry failed forwards).
  • After retries exhaust, the event is marked permanently failed — recover by polling.

Recovery pattern: reconcile pending orders via GET /api/payments/{invoiceId} (authoritative), or force a provider re-check with POST /api/payments/{invoiceId}/sync.

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='{"invoiceId":"1042","orderId":"TEST-001","status":"paid","amount":10.00,"currency":"USD","openNodeChargeId":"test-charge","paidAt":"2026-07-03T12:05:00Z","metadata":null}'

# 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

  1. Check URL - Verify webhook URL is correct
  2. Check HTTPS - Production requires HTTPS
  3. Check firewall - Allow incoming connections
  4. Check your server logs - and reconcile missed events via POST /api/payments/{invoiceId}/sync

Signature Mismatch

  1. Raw body - Use the raw request body, not re-serialized JSON
  2. Correct secret - Verify webhook secret matches what you configured
  3. Correct header - The header is X-LightningEnable-Signature, not X-Webhook-Signature
  4. Signed payload format - HMAC is computed over {timestamp}.{payload}, not just the payload
  5. Replay protection - Ensure your tolerance is at least 5 minutes
  6. Encoding - Use UTF-8 encoding

Timeouts

  1. Async processing - Return 200 immediately
  2. Queue jobs - Process heavy work in background
  3. 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 handlePaid(payload) {
const { invoiceId } = payload;

// 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

  1. Return 200 quickly - Acknowledge receipt, process later
  2. Verify signatures - Always validate HMAC
  3. Handle duplicates - Webhooks may be sent multiple times
  4. Log everything - Keep records for debugging
  5. Secure endpoint - Use HTTPS, validate signatures
  6. Test thoroughly - Use ngrok for local testing

Next Steps