Skip to main content

Webhook Configuration

Legacy provider

This page covers webhooks for OpenNode, a legacy per-merchant option. Strike is the default payment provider, and new accounts should start there. Unless you have a specific reason to stay on OpenNode, see Strike Webhooks.

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​