lomi.

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 POST with a JSON body
  • Webhook signing secret (whsec_…) from the dashboard, stored as LOMI_WEBHOOK_SECRET, not sent by lomi. on delivery
  • Middleware that preserves the raw request body for HMAC (see below)
  • Verify X-Lomi-Signature before parsing JSON or running auth middleware meant for your REST API
  • Return 200 / 204 within 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:

HeaderPurpose
Content-Typeapplication/json
X-Lomi-SignatureHMAC-SHA256 (hex) of the raw JSON body using this endpoint’s webhook signing secret
X-Lomi-EventSame value as top-level "event" in the JSON body
User-Agentlomi.-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-level id (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 OK Response: Even if your internal processing fails later, ensure you have already sent the 200 OK response 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.

Next steps

On this page