Skip to main content

Webhook Verification

Every webhook LaunchMyStore delivers is signed with HMAC-SHA256 using your app’s client secret as the key. Your endpoint MUST verify this signature before trusting the payload — otherwise anyone who knows your callback URL can post forged events to it. This page covers:
  • The exact signing algorithm and which body is signed
  • All headers sent with the request
  • Verification examples in Node.js, Python, Ruby, PHP, and Go using timing-safe comparison
  • Retry semantics — when LaunchMyStore re-attempts a failed delivery

Signature spec

The signature is computed exactly as:
signature = base64( HMAC_SHA256( client_secret, raw_request_body ) )
Where:
  • client_secret is the app’s secret returned by /apps/credentials on install. Treat it like a password — never embed it in client-side code.
  • raw_request_body is the exact bytes of the HTTP request body as it appears on the wire. Do not parse-and-reserialize the JSON before computing — a single whitespace difference fails the check.
  • base64 is standard (not URL-safe) base64 with +// and padding.
The result is sent in the X-LMS-Hmac-SHA256 header (see below).

Headers

Every webhook request includes these headers:
HeaderDescription
X-LMS-Hmac-SHA256Base64-encoded HMAC-SHA256 signature.
X-LMS-TopicThe webhook topic (e.g. orders/create).
X-LMS-Shop-DomainThe store’s domain slug.
X-LMS-API-VersionAPI version used to shape the payload.
X-LMS-Webhook-IdGlobally-unique delivery id — use for idempotency.
X-LMS-Delivery-Attempt1 on first try, 2 and 3 on retries.
X-LMS-Triggered-AtISO 8601 timestamp when the source event fired (not the delivery time).
Content-TypeAlways application/json.

Verification examples

All examples below:
  1. Read the raw body before any JSON parsing happens.
  2. Compute the same HMAC.
  3. Compare to the header using timing-safe comparison (not ==).
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// IMPORTANT: read the raw body so the HMAC matches.
app.use('/webhooks', express.raw({ type: 'application/json' }));

function verifyWebhook(req, clientSecret) {
  const headerHmac = req.headers['x-lms-hmac-sha256'];
  if (!headerHmac) return false;

  const computed = crypto
    .createHmac('sha256', clientSecret)
    .update(req.body) // req.body is a Buffer because of express.raw
    .digest('base64');

  // Buffers of different length crash timingSafeEqual — guard first.
  const a = Buffer.from(headerHmac, 'utf8');
  const b = Buffer.from(computed, 'utf8');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

app.post('/webhooks', (req, res) => {
  if (!verifyWebhook(req, process.env.CLIENT_SECRET)) {
    console.warn('[webhook] invalid signature');
    return res.status(401).send('Unauthorized');
  }

  const topic = req.headers['x-lms-topic'];
  const payload = JSON.parse(req.body.toString('utf8'));
  console.log(`[webhook] ${topic} for shop ${req.headers['x-lms-shop-domain']}`);

  // Always respond 200 quickly. Heavy work goes to a queue.
  res.status(200).send('OK');
});

Common verification pitfalls

The HMAC is computed over the raw bytes of the body. If your framework parses JSON and your handler re-serializes it, whitespace, key order, and floating-point formatting differences will fail the check. Always grab the raw body first.
Plain string comparison short-circuits on the first mismatched byte, leaking the prefix of a valid signature to an attacker who can measure response time. Use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hash_equals (PHP), secure_compare (Ruby), or hmac.Equal (Go).
Node’s crypto.timingSafeEqual throws if the inputs differ in length. Guard with an early length check and return false — don’t let the exception propagate as a 500.
The signature transmitted in the header is base64. Compare the base64 strings directly — don’t decode them to bytes first (works, but is two more lines of error-prone code).
If X-LMS-Hmac-SHA256 is not present, reject with 401. Never assume an unsigned request is legitimate.

Idempotency

LaunchMyStore retries on non-2xx responses (see below). Implement idempotency keyed on X-LMS-Webhook-Id to avoid double-processing when a retry races your slow first response:
const seen = new Map(); // delivery id -> processedAt

app.post('/webhooks', verifyAndDecodeMiddleware, (req, res) => {
  const id = req.headers['x-lms-webhook-id'];
  if (seen.has(id)) {
    // Already processed — return 200 immediately so we don't keep retrying.
    return res.status(200).send('OK (dedupe)');
  }
  // ... process payload ...
  seen.set(id, Date.now());
  res.status(200).send('OK');
});
For production, store the delivery id in a database or Redis with a 24-hour TTL — long enough to outlast LaunchMyStore’s retry window.

Retry semantics

If your endpoint returns a non-2xx response (or fails to respond within the 10-second timeout), LaunchMyStore retries automatically.
AttemptStatusDelay before next attemptNotes
1First sendImmediate on event.
2First retry60 seconds ± 10%Jitter prevents retry storms.
3Second retry300 seconds (5 min)± 10% jitter.
4Third (final) retry900 seconds (15 min)± 10% jitter. If this fails, delivery is marked FAILED.
Total: up to 3 retries after the initial attempt = 4 send attempts spread across roughly 16 minutes. Each attempt’s number is exposed in the X-LMS-Delivery-Attempt header (1, 2, 3, 4).

Status codes and retry behaviour

  • 2xx: Delivery marked SUCCESS. No further attempts.
  • 429 / 5xx: Delivery retried up to MAX_RETRIES (3) using the schedule above. After exhaustion: FAILED.
  • 4xx (except 429): Treated as a permanent client error. No retry. The webhook is marked FAILED immediately. If your endpoint returns 400 for a transient parse error, you will silently miss that event — return 5xx for transient errors so the retries run.
  • Network error / timeout: Treated the same as a transient failure and retried.

Timeout

Each delivery attempt has a 10-second HTTP timeout on LaunchMyStore’s side. If your handler doesn’t respond in 10 seconds the attempt is recorded as a network error and a retry is scheduled. Keep your handler fast — ack quickly, do work asynchronously:
app.post('/webhooks', (req, res) => {
  // 1. Verify
  if (!verifyWebhook(req, secret)) return res.status(401).send('Unauthorized');
  // 2. Enqueue
  queue.add('process-webhook', { body: req.body, headers: req.headers });
  // 3. Ack inside the 10-second budget
  res.status(200).send('OK');
});

IP allowlist

LaunchMyStore does not publish a stable allowlist of source IPs for webhook delivery — the delivery worker runs in a cluster whose egress can rotate. Rely on the HMAC signature, not IP filtering, to authenticate webhooks. If your network forces source-IP allowlists, contact LaunchMyStore support to discuss a fixed-egress arrangement (typically only granted on enterprise tier).

Testing webhooks

Trigger a test delivery to your registered endpoint:
curl -X POST "https://api.launchmystore.io/api/v1/webhooks/{webhookId}/test.json" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
The test delivery uses a sample payload for the topic, but otherwise matches the live wire format: same signing key, same headers, same HMAC computation. If your verifier passes the test request it’ll pass real deliveries.

See also

Webhooks Overview

Subscribe to topics, register endpoints, manage subscriptions.

Topics

The 31 supported topics and their payload shapes.

Authentication

OAuth flow and where the client secret comes from.

API Rate Limits

Limits on outbound API calls — webhooks are zero-cost on your side.