Lumeo Docs
Guides

Handle webhook retries

Lumeo's retry schedule, signature verification, and idempotent handler patterns.

Lumeo delivers webhook events at-least-once. Your handler must be idempotent — it may receive the same event more than once if a previous delivery timed out or returned a non-2xx response.

Delivery expectations

Lumeo calls your endpoint with an HTTP POST and expects a 2xx response within 10 seconds. If your handler doesn't respond in time, or returns a non-2xx status, the delivery is marked as failed and retried.

Do not do slow work synchronously inside the handler. Respond with 200 immediately, then process the event in a background queue.

app.post("/webhooks/lumeo", async (req, res) => {
  // Verify the signature (fast — see below)
  const verified = verifySignature(req);
  if (!verified) return res.sendStatus(403);

  // Queue the work, respond immediately
  await queue.enqueue(req.body);
  res.sendStatus(200);
});

// Actual processing happens in a separate worker
queue.process(async (event) => {
  switch (event.type) {
    case "payout.settled": await handleSettled(event); break;
    case "payout.failed":  await handleFailed(event);  break;
    // ...
  }
});

Retry schedule

AttemptDelay after previous failure
1Immediate
230 seconds
32 minutes
410 minutes
530 minutes
62 hours
76 hours

After 7 failed attempts (approximately 9 hours total), the delivery is marked ABANDONED. No further retries occur — the event is still queryable in the delivery log at GET /webhooks/:id/deliveries.

Signature verification

Every request includes an X-Lumeo-Signature header: an HMAC-SHA256 hex digest of the raw request body. Get your signing secret from the endpoint's detail page in the portal, or by calling GET /webhooks/:id.

import crypto from "crypto";

function verifySignature(req: Request): boolean {
  const sig = req.headers["x-lumeo-signature"] as string;
  if (!sig) return false;

  const expected = crypto
    .createHmac("sha256", process.env.LUMEO_WEBHOOK_SECRET!)
    .update(JSON.stringify(req.body), "utf8") // ⚠️ use raw body — see note below
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(sig),
  );
}

Use raw body, not parsed JSON

Signature verification runs over the exact bytes Lumeo sent. If your framework parses the body before your handler runs (e.g. express.json() middleware), the raw bytes are gone. Capture the raw body first:

app.use("/webhooks/lumeo", express.raw({ type: "application/json" }));

Idempotent handling

Key all state changes off the event id field:

async function handleSettled(event: LumeoEvent) {
  // Check whether we've already processed this event
  const alreadyProcessed = await db.processedEvents.exists(event.id);
  if (alreadyProcessed) return;

  await db.processedEvents.insert(event.id);
  await markInvoicePaid(event.data.paymentId);
}

Storing processed event IDs prevents duplicate state changes when Lumeo retries an event your handler received but didn't respond to in time.

Rotating the signing secret

When you rotate an endpoint's signing secret (POST /webhooks/:id/rotate), the old secret remains valid for a 15-minute grace period. This gives you time to deploy the new secret without dropping legitimate deliveries.

During the grace period, incoming requests may be signed with either the old or the new secret. Verify against both during this window:

function verifyEither(rawBody: string, sig: string): boolean {
  return (
    verify(rawBody, sig, process.env.LUMEO_WEBHOOK_SECRET!) ||
    verify(rawBody, sig, process.env.LUMEO_WEBHOOK_SECRET_PREV!)
  );
}

Testing retries in sandbox

Use the sandbox trigger endpoint to simulate a delivery and test your retry handling without waiting for a real payment cycle:

curl -X POST https://api-sandbox.lumeo.co.in/api/v1/sandbox/trigger-event \
  -H "Authorization: Bearer sk_sandbox_..." \
  -H "Content-Type: application/json" \
  -d '{
    "event": "payout.settled",
    "paymentId": "clx1a2b3c4d5e6f7g8h9"
  }'

To test the retry schedule, return a 500 from your handler on the first delivery and verify the retry arrives within 30 seconds.

Common pitfalls

Verifying the signature against re-serialized JSON instead of the raw body. Even semantically identical JSON (different key order, different whitespace) produces a different HMAC. If your framework parses the body before your handler runs, capture the raw bytes first — see the raw-body callout above.

Not handling payout.manual_review. It's easy to build a handler that only reacts to payout.settled and payout.failed and silently ignores everything else. A payment stuck in manual review never fires either of those — your integration will look "stuck" from the user's side with no error surfaced.

Assuming delivery order. Retries and new events can interleave. If your handler does anything order-dependent (e.g. assuming payout.confirmed always arrives before payout.settled), guard against out-of-order delivery by checking current state before applying a transition.

On this page