> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Verification

> HMAC-SHA256 signature spec, header reference, retry semantics, and language-by-language verification examples

# Webhook Verification

Every webhook LaunchMyStore delivers is signed with HMAC-SHA256 using a
**signing secret**, and the signature is sent in the `X-LMS-Hmac-SHA256`
header. Your endpoint MUST verify this signature before trusting the
payload — otherwise anyone who knows your callback URL can post forged
events to it.

Which secret signs the delivery depends on how the webhook was registered:

| How the webhook was created                                                                      | Signing secret                                                                                       |
| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| App webhook registered via the API (`POST /api/v1/webhooks.json`)                                | The per-subscription `secret` returned in that response.                                             |
| App webhook declared in the app manifest (`extensions.webhooks[]`), and GDPR compliance webhooks | The app's **Client Secret** (`clientSecret`).                                                        |
| Store-level webhook created from the merchant admin                                              | The per-webhook `secret` shown on the webhook in **Store Admin → Tools → Webhooks** (Reveal / Copy). |

<Warning>
  Use the secret that matches how the webhook was created:

  * **API-registered** app webhooks → the subscription's own `secret`,
    returned **once** in the `POST /api/v1/webhooks.json` create response.
    Store it then — it is never shown again when you list or read
    subscriptions.
  * **Manifest-declared** app webhooks and **GDPR** compliance webhooks →
    your app's `clientSecret`.
  * **Store-level** webhooks → the webhook's own `secret`, re-viewable any
    time in **Store Admin → Tools → Webhooks** (Reveal / Copy).

  Treat whichever applies like a password — never embed it in client-side code.
</Warning>

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( webhook_secret, raw_request_body ) )
```

Where:

* `webhook_secret` is the signing secret for this webhook — see the table
  above for which one applies (the per-subscription `secret` from
  `POST /api/v1/webhooks.json`, your app's `clientSecret` for
  manifest-declared and GDPR webhooks, or the per-webhook `secret` shown in
  the merchant admin for store-level webhooks). Treat it like a password —
  never embed it in client-side code. (The `WEBHOOK_SECRET` variable in the
  examples below holds whichever of these applies to your webhook.)
* `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:

| Header                   | Description                                        |
| ------------------------ | -------------------------------------------------- |
| `X-LMS-Hmac-SHA256`      | Base64-encoded HMAC-SHA256 signature.              |
| `X-LMS-Topic`            | The webhook topic (e.g. `orders/create`).          |
| `X-LMS-Shop-Domain`      | The store's domain slug.                           |
| `X-LMS-Api-Version`      | API version used to shape the payload.             |
| `X-LMS-Webhook-Id`       | Globally-unique delivery id — use for idempotency. |
| `X-LMS-Delivery-Attempt` | `1` on first try, `2` and `3` on retries.          |
| `Content-Type`           | Always `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 `==`).

<CodeGroup>
  ```javascript Node.js theme={null}
  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, webhookSecret) {
    const headerHmac = req.headers['x-lms-hmac-sha256'];
    if (!headerHmac) return false;

    const computed = crypto
      .createHmac('sha256', webhookSecret)
      .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.WEBHOOK_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');
  });
  ```

  ```python Python theme={null}
  import hmac, hashlib, base64
  from flask import Flask, request

  app = Flask(__name__)
  WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']

  def verify_webhook(req) -> bool:
      header_hmac = req.headers.get('X-LMS-Hmac-SHA256')
      if not header_hmac:
          return False

      # request.get_data() returns the raw bytes — must call this BEFORE
      # any access to request.get_json(), which consumes the stream.
      body = req.get_data()
      computed = base64.b64encode(
          hmac.new(WEBHOOK_SECRET.encode(), body, hashlib.sha256).digest()
      ).decode()

      return hmac.compare_digest(header_hmac, computed)

  @app.route('/webhooks', methods=['POST'])
  def webhooks():
      if not verify_webhook(request):
          return 'Unauthorized', 401
      topic = request.headers.get('X-LMS-Topic')
      payload = request.get_json()  # safe — raw body already read
      print(f'[webhook] {topic}')
      return 'OK', 200
  ```

  ```ruby Ruby theme={null}
  require 'openssl'
  require 'base64'
  require 'rack/utils'
  require 'sinatra'

  WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']

  def verify_webhook(body, header_hmac)
    return false if header_hmac.nil?
    computed = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', WEBHOOK_SECRET, body)
    )
    Rack::Utils.secure_compare(header_hmac, computed)
  end

  post '/webhooks' do
    request.body.rewind
    body = request.body.read
    header_hmac = request.env['HTTP_X_LMS_HMAC_SHA256']
    halt 401, 'Unauthorized' unless verify_webhook(body, header_hmac)

    topic = request.env['HTTP_X_LMS_TOPIC']
    payload = JSON.parse(body)
    puts "[webhook] #{topic}"
    status 200
    'OK'
  end
  ```

  ```php PHP theme={null}
  <?php
  $webhookSecret = getenv('WEBHOOK_SECRET');

  // Must read php://input BEFORE any framework parsing.
  $body = file_get_contents('php://input');
  $headerHmac = $_SERVER['HTTP_X_LMS_HMAC_SHA256'] ?? null;

  function verifyWebhook(string $body, ?string $headerHmac, string $secret): bool {
      if ($headerHmac === null) return false;
      $computed = base64_encode(hash_hmac('sha256', $body, $secret, true));
      return hash_equals($headerHmac, $computed);
  }

  if (!verifyWebhook($body, $headerHmac, $webhookSecret)) {
      http_response_code(401);
      echo 'Unauthorized';
      exit;
  }

  $payload = json_decode($body, true);
  $topic = $_SERVER['HTTP_X_LMS_TOPIC'] ?? '';
  error_log("[webhook] $topic");
  http_response_code(200);
  echo 'OK';
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/base64"
      "io"
      "net/http"
      "os"
  )

  var webhookSecret = os.Getenv("WEBHOOK_SECRET")

  func verifyWebhook(body []byte, headerHmac string) bool {
      if headerHmac == "" {
          return false
      }
      mac := hmac.New(sha256.New, []byte(webhookSecret))
      mac.Write(body)
      computed := base64.StdEncoding.EncodeToString(mac.Sum(nil))
      return hmac.Equal([]byte(headerHmac), []byte(computed))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      body, err := io.ReadAll(r.Body)
      if err != nil {
          http.Error(w, "bad request", http.StatusBadRequest)
          return
      }
      headerHmac := r.Header.Get("X-LMS-Hmac-SHA256")
      if !verifyWebhook(body, headerHmac) {
          http.Error(w, "Unauthorized", http.StatusUnauthorized)
          return
      }
      // process payload, return 200 quickly...
      w.WriteHeader(http.StatusOK)
      w.Write([]byte("OK"))
  }
  ```
</CodeGroup>

## Common verification pitfalls

<AccordionGroup>
  <Accordion title="Parsing JSON before signing">
    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.
  </Accordion>

  <Accordion title="Using == instead of timing-safe compare">
    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).
  </Accordion>

  <Accordion title="Comparing buffers of different length">
    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.
  </Accordion>

  <Accordion title="Decoding the base64 before comparing">
    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).
  </Accordion>

  <Accordion title="Trusting the body when the header is missing">
    If `X-LMS-Hmac-SHA256` is not present, reject with 401. Never
    assume an unsigned request is legitimate.
  </Accordion>
</AccordionGroup>

## 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:

```javascript theme={null}
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.

| Attempt | Status              | Delay before next attempt | Notes                                                     |
| ------- | ------------------- | ------------------------- | --------------------------------------------------------- |
| 1       | First send          | —                         | Immediate on event.                                       |
| 2       | First retry         | **60 seconds** ± 10%      | Jitter prevents retry storms.                             |
| 3       | Second retry        | **300 seconds** (5 min)   | ± 10% jitter.                                             |
| 4       | Third (final) retry | **900 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:

```javascript theme={null}
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).

## Re-delivering a past event

There is no synthetic "send a sample payload" test endpoint. What you
can do is **re-send a real delivery that already happened** — useful
when your endpoint was down or returned an error and you want to replay
the exact same payload (and signature) again. This is a
merchant/developer-portal action, authenticated with the merchant or
partner session (not an OAuth app access token):

```bash theme={null}
# As the store owner (merchant / staff-admin):
curl -X POST "https://api.launchmystore.io/apps/store/webhook-logs/{deliveryId}/retry" \
  -H "Authorization: Bearer $MERCHANT_TOKEN"

# As the app developer, scoped to one of your apps:
curl -X POST "https://api.launchmystore.io/apps/developer/{appId}/webhook-deliveries/{deliveryId}/retry" \
  -H "Authorization: Bearer $PARTNER_TOKEN"
```

`{deliveryId}` is the id of an existing delivery-log entry (the same
value sent in the `X-LMS-Webhook-Id` header). The retry re-POSTs the
original payload to your endpoint with the live wire format — same
signing key, same headers, same HMAC computation — so it's a faithful
way to confirm your verifier handles a real delivery. The endpoint
returns the updated delivery-log record in the standard envelope:

```json theme={null}
{
  "status": 200,
  "state": "success",
  "message": "Webhook delivery retry initiated",
  "data": { "...": "delivery log record" }
}
```

## See also

<CardGroup cols={2}>
  <Card title="Webhooks Overview" icon="bell" href="/api-reference/webhooks/overview">
    Subscribe to topics, register endpoints, manage subscriptions.
  </Card>

  <Card title="Topics" icon="list" href="/api-reference/webhooks/topics">
    The supported topics and their payload shapes.
  </Card>

  <Card title="Authentication" icon="key" href="/api-reference/authentication">
    OAuth flow and where the client secret comes from.
  </Card>

  <Card title="API Rate Limits" icon="gauge-high" href="/api-reference/rate-limits">
    Limits on outbound API calls — webhooks are zero-cost on your side.
  </Card>
</CardGroup>
