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 or its status changes

Instead of polling the API, webhooks push events to your server instantly.

How It Works​

1. Customer pays Lightning invoice
2. Your payment provider (Strike or OpenNode) confirms payment
3. The provider sends a webhook to Lightning Enable
(POST /api/webhooks/strike or POST /api/webhooks/opennode)
4. Lightning Enable forwards a signed webhook to your callback URL
5. Your server processes the event

Webhook Payload​

Lightning Enable forwards one flat JSON object per event to your callback URL. There is no envelope.

Two families of event arrive on the same URL, with the same signature scheme, and they are told apart by which discriminator is present:

FamilyDiscriminatorSent when
Payment eventsstatus (no event field)A provider invoice from POST /api/payments changes state
L402 producer eventsevent (no status field)A challenge you minted is proven paid — see L402 producer events

Check for event first and fall through to status, so a new producer event never lands in your payment-status switch.

The payment payload's shape depends on which payment provider your merchant account uses.

OpenNode merchants:

{
"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\"}"
}

Strike merchants:

{
"invoiceId": "1042",
"orderId": "ORDER-12345",
"status": "paid",
"amount": 49.99,
"currency": "USD",
"providerChargeId": "8f6c3f5e-1c2d-...",
"provider": "strike",
"paidAt": "2026-07-03T12:05:00Z",
"metadata": null
}
FieldDescription
invoiceIdLightning Enable invoice ID (numeric string) — the same invoiceId returned by POST /api/payments
orderIdYour order ID from payment creation
statusPayment status string. Treat paid as the fulfillment trigger. Other values include processing, expired, and (OpenNode) underpaid / refunded
amount / currencyThe invoice amount and currency you created it with
openNodeChargeId / providerChargeIdThe provider's charge ID (provider: "strike" is included on the Strike path)
paidAtWhen Lightning Enable processed the provider event (UTC)
metadataThe metadata JSON string you supplied at creation, or null

L402 producer events​

If you sell through the L402 producer API, a second event type arrives on the same URL:

{
"event": "l402.challenge.paid",
"paymentHash": "abc123def456...",
"resource": "/api/premium/weather",
"amountSats": 100,
"paidAt": "2026-09-05T18:00:41Z",
"idempotencyKey": "req-abc-123"
}
FieldDescription
eventAlways l402.challenge.paid. Its presence is what distinguishes this from a payment event
paymentHashHex payment hash of the challenge's invoice — your correlation handle, and the key to dedupe on
resourceThe resource path the challenge was minted for
amountSatsPrice in satoshis the challenge required
paidAtWhen payment was first proven (UTC)
idempotencyKeyThe Idempotency-Key the challenge was minted under, or null

Fires once per challenge, on the first successful verification of a credential minted from it — through /api/l402/challenges/verify, /verify-credential, or an L402-gated proxy request. L402 tokens stay valid for repeated use until their expires caveat, so the same credential verifies many times per paid invoice; only the first transition notifies.

"Paid" means proven, not settled. The payer can only hold a valid credential by having settled the invoice with your payment provider — but the sats moved when the invoice was paid, which may be moments before this event. Lightning Enable does not hold funds; your provider is the record of what you were paid. An invoice that was paid but whose credential is never presented back produces no event; reconcile those with GET /api/l402/challenges?status=unpaid.

No callback URL configured means no event is sent and nothing is queued.

Configuring Webhooks​

Set your webhook URL + secret from the Lightning Enable dashboard. Navigate to Dashboard → Settings → Webhooks and provide:

  • Webhook URL (must be HTTPS in production)
  • Signing secret (used for HMAC verification)

Webhook URL Requirements​

  • Must be HTTPS in production
  • Must return a 2xx status within 10 seconds (the delivery request times out after 10s)
  • 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 handlePaid(payload) {
const { invoiceId } = payload;

// 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: payload.paidAt } }
);

await fulfillOrder(invoiceId);
}

Routing on event, then status​

Check for an event field first — that is an L402 producer event. Everything else is a payment event, dispatched on status:

app.post('/webhooks/lightning', async (req, res) => {
const payload = req.body; // verify the signature first — see above

if (payload.event === 'l402.challenge.paid') {
await handleChallengePaid(payload); // dedupe on payload.paymentHash
return res.status(200).send('OK');
}

switch (payload.status) {
case 'paid':
await handlePaid(payload);
break;

case 'expired':
await handleExpired(payload);
break;

default:
// processing, underpaid, etc. — log and wait for a terminal status
console.log('Payment update:', payload.invoiceId, payload.status);
}

res.status(200).send('OK');
});

Delivery & Reliability​

Webhook delivery is 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 the provider'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: on startup or on a schedule, reconcile any orders still pending on your side via GET /api/payments/{invoiceId} (authoritative status), or force a provider re-check with POST /api/payments/{invoiceId}/sync.

Operator tools (admin)​

These endpoints need the admin API key (X-API-Key), not a merchant key. They act on queued webhook events, not on the webhook subscriptions a merchant configures.

An event that exhausts its five delivery attempts is permanently failed, or dead. Dead events stay in place until an operator acts on them.

EndpointWhat it does
GET /api/admin/webhooks/dead?merchantId=&skip=&take=Lists dead events: id, merchant, source, event type, invoice id, attempt count, last delivery error (truncated), received and last-attempt times. Omit merchantId for a per-merchant summary across tenants. Never returns a payload or a secret. take defaults to 100 and is clamped to 1000.
POST /api/admin/webhooks/dead/retry?merchantId=&dryRun=&take=Resets the attempt counter on up to take of that merchant's dead events (newest first) so the background job delivers them again. Use after the merchant fixes their receiver. Idempotent; returns { retried, remaining, dryRun, ... }.
POST /api/admin/webhooks/dead/abandon?merchantId=&dryRun=Marks that merchant's dead events processed, with an ABANDONED note prefixed onto the recorded failure, so they stop counting as a backlog. Nothing is deleted. Idempotent; returns counts.

merchantId is required for retry and abandon, so neither can act on more than one merchant at a time. Pass dryRun=true to see the counts without writing.

Fix the receiver before you retry, and retry in batches. The background job pulls pending webhooks oldest-first, and dead rows are by definition the oldest pending rows — retrying an entire large backlog in one call puts it right back at the head of the queue and can starve every other tenant's webhooks for several minutely passes, worse if the receiver is still broken (each attempt burns the delivery timeout). retry defaults to take=50 (clamped to 1–500) and always resets the newest dead rows first, so confirm the fix on a small batch, watch GET /api/admin/webhooks/dead?merchantId= for the count to drop, then call again — or raise take — to work through remaining.

If your events keep dying with a 401: check that your merchant record has a webhook secret. A merchant with a callback URL but no secret is delivered to unsigned — no X-LightningEnable-Signature header — and a receiver that requires a signature rejects every attempt. Each unsigned delivery now logs a warning naming the merchant id.

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
TIMESTAMP=$(date +%s)
PAYLOAD='{"invoiceId":"1042","orderId":"ORDER-TEST","status":"paid","amount":25.00,"currency":"USD","openNodeChargeId":"test-charge","paidAt":"2026-07-03T12:05:00Z","metadata":null}'
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}"

Troubleshooting​

Webhook Not Received​

  1. Check URL - Ensure webhook URL is correct and HTTPS
  2. Check firewall - Allow incoming connections
  3. 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. Respond fast - The delivery request times out after 10 seconds
  3. Recover by polling - Missed a webhook? GET /api/payments/{invoiceId} is authoritative

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​

Stuck?​

Start with Troubleshooting for the common failures and their fixes. If something's broken or not working, email support@lightningenable.com — a real person reads it.