Webhook setup, testing and debugging guide
You’ve picked a topic, written a handler, deployed it to a server. Now what? This guide walks through a complete webhook integration from local testing to verifying HMAC signatures, inspecting delivery logs, and recovering from failed deliveries.
Step 1: build your handler
The simplest webhook handler reads the body, verifies the signature, queues a job, and returns 200. Here’s an Express example:
const express = require('express');
const crypto = require('crypto');
const app = express();
const PORT = 4000;
const APP_SECRET = process.env.LMS_APP_SECRET;
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const topic = req.get('X-LMS-Topic');
const deliveryId = req.get('X-LMS-Webhook-Id');
const hmacHeader = req.get('X-LMS-Hmac-SHA256');
const expected = crypto
.createHmac('sha256', APP_SECRET)
.update(req.body)
.digest('base64');
if (hmacHeader !== expected) {
return res.status(401).send('invalid signature');
}
// Idempotency: skip if we’ve already processed this delivery
// (look up deliveryId in your DB, return 200 if found)
res.status(200).send('ok');
// Queue background processing here
});
app.listen(PORT);
Important: the body MUST be the raw bytes — not the parsed JSON object — or the HMAC won’t match.
Step 2: tunnel your local server
LMS needs to call a publicly reachable URL. While testing, use a tunnel like ngrok or cloudflared:
ngrok http 4000
# → https://abc123.ngrok-free.app
cloudflared tunnel --url http://127.0.0.1:4000
# → https://xxxx.trycloudflare.com
Use the HTTPS URL as your callbackUrl when registering the webhook.
Step 3: register the webhook
Via the merchant API:
curl -X POST https://api.launchmystore.io/webhooks \
-H "Authorization: Bearer <merchant-jwt>" \
-H "Content-Type: application/json" \
-d '{
"event": "orders/paid",
"callbackUrl": "https://abc123.ngrok-free.app/webhooks",
"isEnabled": true
}'
Or from Settings → Notifications → Webhooks → Add webhook in your admin.
Step 4: fire a real event
The cleanest way to test is to fire a real event:
orders/paid— complete a test checkout with the test gateway.products/update— change a product title.customers/create— sign up a customer from the storefront.
Tail your local server log and watch the request arrive.
Step 5: verify the HMAC signature
For app webhooks, the request includes X-LMS-Hmac-SHA256 — a base64-encoded HMAC-SHA256 of the raw body using your app secret:
const expected = crypto
.createHmac('sha256', APP_SECRET)
.update(rawBody)
.digest('base64');
const valid = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(hmacHeader),
);
Use timingSafeEqual — a regular string compare leaks timing information.
Step 6: inspect the delivery log
Every dispatch is recorded in the delivery log with:
- Status — PENDING, SUCCESS, RETRYING, FAILED.
- Attempts — 1, 2, or 3.
- Response code — the HTTP code your endpoint returned.
- Response body — the first 2000 chars of the response.
- Error message — network errors or non-2xx reason.
- Next retry at — when the next attempt is scheduled (if any).
The log shows exactly what LMS sent and exactly what your server returned — if HMAC verification fails, this is where you confirm whether you stored the wrong secret or parsed the body wrong.
Common pitfalls
- Parsing the body before verifying HMAC. JSON parsing can re-serialise floats and reorder keys — verify on the raw bytes, then parse.
- Returning the wrong status code. Any 4xx (except 429) skips retries. If your handler is temporarily overloaded, return 503 or 429 to keep the retry going.
- Slow handlers. The 10-second timeout is shared across the connection and the response. Return 200 immediately, then process async.
- Sequence assumptions. Don’t assume
orders/createarrives beforeorders/paid. Persist whatever state is in each payload and reconcile.
FAQ
How do I verify the webhook came from LaunchMyStore?
App-scoped webhooks include the X-LMS-Hmac-SHA256 header. Compute HMAC-SHA256 of the raw body with your app secret and compare with a timing-safe equality check. If they don’t match, reject with 401.
Why is my webhook being retried — did my server return a non-2xx?
Yes. Anything outside the 200–299 range schedules a retry, except for permanent 4xx errors (400, 401, 403, 404, 422, etc.). 429 is treated as retryable. Look at the response code column in your delivery log to confirm.
Can I disable a webhook temporarily?
Yes — flip the isEnabled flag from the admin or via the API. Disabled webhooks stop receiving new events but the existing delivery log is preserved.
What if I miss events while my server is down?
Each event is retried 3 times (1m / 5m / 15m). If all attempts fail, the delivery is marked FAILED. You can re-trigger from the delivery log, or call the relevant REST endpoint to backfill state.
Is there a way to replay a delivery?
Yes — open the failed delivery in the webhook’s log and click Re-send. The same payload is dispatched again as a new attempt.
Can I test webhooks against localhost without a tunnel?
Not directly — LMS needs a publicly reachable HTTPS URL. ngrok and cloudflared are free for development; for production, host on any provider that exposes HTTPS.