Handling webhooks
Webhooks provide real-time updates about events in your lomi. account. This guide explains how to securely receive and process these notifications.
For a general introduction and setup guide, see Setting up webhooks.
Operational behavior (retries, duplicate events, delivery logs): Webhook reliability.
Setup checklist
- HTTPS endpoint that accepts
POSTwith a JSON body - Webhook signing secret (
whsec_…) from the dashboard, stored asLOMI_WEBHOOK_SECRET, not sent by lomi. on delivery - Middleware that preserves the raw request body for HMAC (see below)
- Verify
X-Lomi-Signaturebefore parsing JSON or running auth middleware meant for your REST API - Return
200/204within a few seconds, then process the event asynchronously - Dedupe on top-level event
id(duplicates are normal)
Raw body: verify before you parse
Signature verification must use the exact bytes lomi. sent. Re-serializing JSON after express.json() changes spacing or key order and breaks HMAC.
Wrong: app.post('/webhook', express.json(), …) then JSON.stringify(req.body) for the MAC.
Correct: app.post('/webhook', express.raw({ type: 'application/json' }), …) and pass req.body (a Buffer) into your verifier, then JSON.parse only after the signature matches.
Signing secret ≠ API key
Outbound webhook POSTs use X-Lomi-Signature (HMAC of the raw body with your endpoint’s whsec_… secret). lomi. does not send X-API-Key or Authorization: Bearer on delivery. If you see 401 in delivery logs, disable global API-key middleware on the webhook route, see Signing secret vs Authorization.
Setup summary
Configure your endpoint
Ensure you have a dedicated HTTPS endpoint ready to receive POST requests with a JSON body.
import express from 'express';
import crypto from 'crypto';
const app = express();
// Define your webhook handling function
async function handleWebhook(req: express.Request, res: express.Response) {
const LOMI_WEBHOOK_SECRET = process.env.LOMI_WEBHOOK_SECRET;
if (!LOMI_WEBHOOK_SECRET) {
console.error('Webhook secret is not configured.');
return res.status(500).send('Webhook configuration error');
}
// Verify signature (implementation below)
const signature = req.headers['x-lomi-signature'] as string;
if (
!signature ||
!verifySignature(req.body, signature, LOMI_WEBHOOK_SECRET)
) {
return res.status(400).send('Invalid signature');
}
// Respond quickly to acknowledge receipt
res.status(200).json({ received: true });
// Process the event asynchronously
const event = JSON.parse(req.body.toString());
try {
await processWebhookEvent(event);
} catch (error) {
console.error('Error processing webhook event:', error);
// Log the error, but don't fail the response to lomi.
}
}
// Use express.raw() middleware to access the raw body for signature verification
app.post(
'/your-webhook-endpoint',
express.raw({ type: 'application/json' }),
handleWebhook,
);
// Your signature verification function (see below)
function verifySignature(
payload: Buffer,
signature: string,
secret: string,
): boolean {
// ... implementation ...
return true; // Placeholder
}
// Your event processing logic
async function processWebhookEvent(event: any): Promise<void> {
console.log(`Processing event: ${event.id}, Type: ${event.event}`);
// Add your business logic here based on event.event
}
// Start the server...Verify signatures
Always verify the X-Lomi-Signature header to ensure the request is genuinely from lomi. and hasn't been tampered with.
import crypto from 'crypto';
function verifySignature(
payload: Buffer, // Raw request body (Buffer)
signatureHeader: string,
secret: string,
): boolean {
if (!payload || !signatureHeader || !secret) {
return false;
}
try {
const hmac = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(hmac),
);
} catch (error) {
console.error('Error during signature verification:', error);
return false;
}
}Signing secret vs Authorization headers (avoid 401 failures)
Managing webhooks uses your merchant API key (X-API-Key), but lomi. does not send that key (or Authorization: Bearer …) when delivering events to your URL.
For outbound webhook HTTP requests, expect only:
| Header | Purpose |
|---|---|
Content-Type | application/json |
X-Lomi-Signature | HMAC-SHA256 (hex) of the raw JSON body using this endpoint’s webhook signing secret |
X-Lomi-Event | Same value as top-level "event" in the JSON body |
User-Agent | lomi.-Webhook/1.0 |
The whsec_… signing secret is for you to verify X-Lomi-Signature on the raw body, not a token lomi. places in Authorization.
If delivery logs show HTTP 401, or a response body like Authentication required, that answer almost always comes from your own server or proxy (framework auth middleware, API gateway, Cloudflare Access, a catch-all Bearer rule, etc.) before your webhook handler runs. lomi. cannot satisfy a custom Bearer or API-key gate on POST.
What to change on your side: expose a dedicated webhook route (path or subdomain) without global API-key middleware, verify X-Lomi-Signature first using the endpoint secret, then return 2xx. Your other REST routes can still require Bearer or API keys normally.
Related: delivery logs capture your endpoint’s HTTP status and response body; see Webhooks.
Processing events
Once the signature is verified, you can safely process the event payload.
interface LomiWebhookEvent {
id: string; // UUID: use for idempotency/dedupe
event: string; // e.g., 'PAYMENT_SUCCEEDED'
timestamp: string;
data: unknown; // Structure depends on the event type
/** Mirrors API host `NODE_ENV` (e.g. `production`, `development`), not a checkout sandbox discriminator */
lomi_environment: string;
}
async function processWebhookEvent(event: LomiWebhookEvent): Promise<void> {
// Optional: Check if event ID has already been processed for idempotency
if (await hasEventBeenProcessed(event.id)) {
console.log(`Event ${event.id} already processed. Skipping.`);
return;
}
console.log(`Processing event: ${event.id}, Type: ${event.event}`);
switch (event.event) {
case 'PAYMENT_SUCCEEDED':
const transaction = event.data; // Contains transaction object
console.log(
`Payment succeeded for transaction: ${transaction.transaction_id}`,
);
// Example: Fulfill order, grant access, update database
// await fulfillOrder(transaction.metadata?.order_id, transaction);
break;
case 'PAYMENT_FAILED':
const failedTxn = event.data;
console.log(
`Payment failed for transaction: ${failedTxn.transaction_id}`,
);
// Example: Notify customer, update order status to failed
// await handleFailedPayment(failedTxn.metadata?.order_id, failedTxn);
break;
case 'SUBSCRIPTION_CREATED':
const subscription = event.data;
console.log(`Subscription created: ${subscription.subscription_id}`);
// Example: Provision service for new subscription
break;
case 'SUBSCRIPTION_RENEWED':
const renewed = event.data;
console.log(`Subscription renewed: ${renewed.subscription_id}`);
// Example: Extend access for the new billing period
break;
case 'SUBSCRIPTION_CANCELLED':
const cancelledSub = event.data;
console.log(`Subscription cancelled: ${cancelledSub.subscription_id}`);
// Example: Revoke access at period end or immediately
break;
// Add cases for other events you subscribe to...
default:
console.warn(`Unhandled event type: ${event.event}`);
}
// Optional: Mark event as processed
await markEventAsProcessed(event.id);
}
// Placeholder functions for idempotency checks (implement with your database/cache)
async function hasEventBeenProcessed(eventId: string): Promise<boolean> {
// Check your storage if eventId exists
return false; // Replace with actual check
}
async function markEventAsProcessed(eventId: string): Promise<void> {
// Store eventId in your storage
}Best practices
Respond quickly
Acknowledgment-first design: Treat the webhook POST as a transport shim. Validate X-Lomi-Signature, persist or enqueue whatever you absolutely need so you cannot lose track of the envelope, then 200 / 204 back to lomi. immediately. Anything that touches external vendors, heavyweight SQL, or multi-step saga logic should execute after the HTTP lifecycle completes successfully from lomi.’s standpoint.
Acknowledge webhook receipt by returning a 2xx status code (e.g., 200) within a small number of seconds in the worst case: but aim for sub-second latency routinely. Reason: each outbound POST from lomi. is guarded by about a four-second client read timeout; blowing that budget marks the delivery as failed for that round and may consume one of your limited automatic retries (full matrix). Defer heavy work to your own queue afterward.
async function handleWebhook(req: express.Request, res: express.Response) {
// ... (Verify signature) ...
if (!isValidSignature) {
return res.status(400).send('Invalid signature');
}
// Acknowledge receipt immediately
res.status(200).json({ received: true });
// Add event to a background queue for processing
const event = JSON.parse(req.body.toString());
backgroundQueue.add('process-webhook', event);
}Handle duplicates (idempotency)
Network issues, automatic retries, manual replays from the dashboard, and idempotency safeguards on lomi.’s side all mean you should expect duplicate POSTs over time. That is normal rather than exceptional, bake deduplication into your schema and workflows from day one rather than treating it as a rare bug.
- Check payload
id: Store the top-levelid(UUID) of processed deliveries. Skip if already handled. - Database Constraints: Use unique constraints in your database where appropriate (e.g., on an order update based on the transaction ID) to prevent duplicate operations at the data layer.
async function processWebhookEvent(event: LomiWebhookEvent): Promise<void> {
const isProcessed = await database.checkIfEventProcessed(event.id);
if (isProcessed) {
console.log(`Event ${event.id} is a duplicate, skipping.`);
return;
}
// ... process the event ...
await database.markEventAsProcessed(event.id);
}Error handling
Implement robust error handling within your processWebhookEvent function.
- Log Errors: Log detailed errors encountered during processing.
- Retry Logic (Internal): For transient errors during processing (e.g., temporary database unavailability), consider internal retry logic within your background job handler.
- Monitoring: Monitor your webhook endpoint for failures and your background queue for processing errors.
- Do Not Fail the
200 OKResponse: Even if your internal processing fails later, ensure you have already sent the200 OKresponse to lomi.. lomi. only cares about the successful delivery acknowledgment.
Logging
Log key information for debugging:
- Log receipt of webhook events (including the event ID and type).
- Log the outcome of signature verification.
- Log the start and end of event processing.
- Log any errors during processing with relevant context (but avoid logging the full raw payload or sensitive data directly unless necessary and properly secured).
async function handleWebhook(req: express.Request, res: express.Response) {
const eventId = JSON.parse(req.body.toString())?.id || 'unknown';
console.log(`Received webhook request for event ID (potential): ${eventId}`);
// ... (Verify signature) ...
if (!isValidSignature) {
console.warn(`Invalid signature for event ID: ${eventId}`);
return res.status(400).send('Invalid signature');
}
console.log(`Signature verified for event ID: ${eventId}`);
res.status(200).json({ received: true });
// ... (Process asynchronously) ...
}Testing webhooks
Refer to the Testing guide for details on using lomi. Dashboard or CLI to send test events to your local endpoint.
Monitoring
Use lomi. Dashboard (Developers -> Webhooks) to monitor delivery attempts, view recent events, check response codes from your endpoint, and manually retry failed deliveries.