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

# API Rate Limits

> Per-app sliding-window rate limits, headers, and best practices

# API Rate Limits

The LaunchMyStore scoped REST API (`/api/v1/`) is rate-limited per
app + store using a sliding window. Limits are
**per-second** and **tier-based** — the app's tier on the developer
dashboard controls the cap.

<Note>
  Rate limits apply only to the **OAuth-scoped REST API** under
  `/api/v1/`. Internal admin endpoints, public storefront routes, and
  the platform proxy routes (`/api/apps/*`) are not subject to this
  guard.
</Note>

## Limits by tier

| Tier | Requests / second | Use case |
| - | - | - |
| `free` | **20** | Hobby/test apps, internal tools. |
| `basic` | **40** | Default tier for new published apps. |
| `pro` | **100** | Production apps with batch sync or background workers. |
| `enterprise` | **500** | High-volume integrations, ERP/data-warehouse syncs. |

Tier is set per-app on the developer dashboard. See
[App Types & Tiers](/getting-started/app-types-and-tiers#app-tiers).

### Algorithm

A sliding 1-second window per `(app, store)` pair:

1. Every request's timestamp is recorded for the pair.
2. On each request, entries older than 1 second are evicted and the
   remaining count is checked against the tier limit.
3. If `count >= limit`, the request is rejected with **HTTP 429**.
   Otherwise the timestamp is added and the request proceeds.

This means the limit is a true rolling rate, not a fixed-bucket reset —
a burst of N requests blocks further calls until enough of those
timestamps age past one second.

## Response headers

Every response from `/api/v1/` includes these headers, whether the
request succeeded or was rate-limited:

| Header | Description |
| - | - |
| `X-RateLimit-Limit` | Maximum requests allowed per window for this app's tier. |
| `X-RateLimit-Remaining` | Approximate remaining requests in the current window. Floored at `0`. |
| `X-RateLimit-Reset` | Unix timestamp (seconds) when the current window resets. |
| `X-LMS-Api-Call-Limit` | Leaky-bucket-style `used/limit` (e.g. `3/40`) for SDK clients that auto-throttle. Also sent as the legacy alias `X-Shopify-Shop-Api-Call-Limit`. |
| `Retry-After` | **Only on 429.** Seconds to wait before retrying — equals the window size. |

Example:

```http theme={null}
HTTP/1.1 200 OK
X-RateLimit-Limit: 40
X-RateLimit-Remaining: 37
X-RateLimit-Reset: 1747402805
Content-Type: application/json

{ "data": { ... } }
```

## 429 response

When the cap is exceeded:

```http theme={null}
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 40
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1747402805
Retry-After: 1
Content-Type: application/json

{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Retry after 1 second(s)."
}
```

<Warning>
  A 429 means *this app*'s requests on *this store* exceeded the cap.
  Other stores using the same app are unaffected. Other apps on the
  same store are unaffected.
</Warning>

<Warning>
  **Never let a 429 fall through as "no data".**

  The two failure modes are both silent:

  * **Reads.** If a helper collapses a failed request into an empty result,
    a config read returns `{}` — indistinguishable from a merchant who has
    configured nothing. An app that gates paid features on that config will
    quietly downgrade a paying merchant to its free tier for the duration of
    the burst, then recover, leaving nothing in the logs.
  * **Writes.** A dropped write means the value was simply never stored. The
    call returned, your code moved on, and the data is gone.

  Retry on 429 (and 5xx), and make read helpers distinguish "the platform
  said there is nothing" from "the platform did not answer". Caching the last
  known-good config and reusing it when a refresh fails is a cheap way to
  keep entitlement stable across a burst.
</Warning>

## Best practices

### Exponential backoff on 429

Always honor `Retry-After` and add a small random jitter to spread
retries across multiple instances of your worker.

<CodeGroup>
  ```javascript Node.js theme={null}
  async function callWithRetry(fn, maxRetries = 5) {
    for (let attempt = 0; attempt < maxRetries; attempt++) {
      const res = await fn();
      if (res.status !== 429) return res;

      const retryAfter = parseInt(res.headers.get('retry-after') || '1', 10);
      const baseDelay = retryAfter * 1000;
      // Exponential backoff with jitter
      const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 250;
      await new Promise((r) => setTimeout(r, delay));
    }
    throw new Error('Rate limit exhausted after retries');
  }
  ```

  ```python Python theme={null}
  import asyncio, random

  async def call_with_retry(fn, max_retries=5):
      for attempt in range(max_retries):
          res = await fn()
          if res.status_code != 429:
              return res
          retry_after = int(res.headers.get('retry-after', '1'))
          base_delay = retry_after
          delay = base_delay * (2 ** attempt) + random.uniform(0, 0.25)
          await asyncio.sleep(delay)
      raise Exception('Rate limit exhausted after retries')
  ```

  ```ruby Ruby theme={null}
  def call_with_retry(max_retries: 5)
    max_retries.times do |attempt|
      res = yield
      return res unless res.code.to_i == 429
      retry_after = (res['retry-after'] || '1').to_i
      delay = retry_after * (2 ** attempt) + rand * 0.25
      sleep(delay)
    end
    raise 'Rate limit exhausted after retries'
  end
  ```
</CodeGroup>

### Read the headers proactively

Don't wait for a 429 — check `X-RateLimit-Remaining` after every
response and slow down when it drops below a safety threshold.

```javascript theme={null}
function shouldThrottle(res) {
  const remaining = parseInt(res.headers.get('x-ratelimit-remaining') || '40', 10);
  const limit = parseInt(res.headers.get('x-ratelimit-limit') || '40', 10);
  // Slow down when under 20% remaining
  return remaining < limit * 0.2;
}

if (shouldThrottle(res)) {
  await new Promise((r) => setTimeout(r, 250));
}
```

### Batch requests where the API supports it

Several REST resources expose bulk endpoints to amortize the cap:

* **Metafields**: `POST /metafields/bulk` upserts up to 250 metafields
  in one call. See [Metafields](/api-reference/metafields).
* **Products**: pagination via `?page=…&limit=…` returns up to 250
  products per call.
* **Orders**: bulk fetch with `?ids=id1,id2,id3,…`.

One batched call costs one slot vs. N individual calls — usually the
single biggest win for a sync-style app.

### Use webhooks instead of polling

Many sync workflows poll the API every few seconds to detect changes.
Subscribe to a [webhook topic](/api-reference/webhooks/topics) instead
— LaunchMyStore pushes the event to your endpoint as it happens,
costing zero rate-limit slots on your side.

### Use background workers, not on-request fan-out

If a single end-user action triggers ten outbound API calls, those ten
calls all count toward the per-second cap in the same window.
Enqueueing them into a background worker that paces itself smooths
the load.

### Avoid the leader-election trap

Two instances of your app both polling on a one-second schedule will
double the request rate. Use a Redis lock or a single scheduler to
ensure only one instance fires the polled call per interval.

## Multi-store apps

The rate-limit key includes both `appId` and `storeId`. A single app
installed across 1000 stores has its own per-store window for each —
the limits don't pool. So an app at the `basic` tier with 1000
installs can sustain `40 × 1000 = 40,000 req/sec` system-wide, as long
as the requests are spread across stores.

## Configuring tier on the developer dashboard

App tier is set on the developer dashboard's app settings page. Tier
upgrades are handled by:

1. The merchant accepting a higher subscription tier for the app, or
2. The developer requesting a tier upgrade via support (for first-party
   apps that don't go through the marketplace flow).

Tier lookups are cached in-process for 60 seconds — a tier change can
take up to 60s to propagate.

## Failures and fallbacks

<AccordionGroup>
  <Accordion title="Can rate limits be temporarily raised?">
    Not for individual apps via API. Tier-level limits apply
    uniformly. If your integration has a one-off catch-up backfill
    requirement, contact support — for some integrations a temporary
    `enterprise` tier can be granted.
  </Accordion>

  <Accordion title="Are GET and POST counted differently?">
    No. Every authenticated request to `/api/v1/` regardless of method
    counts as one slot.
  </Accordion>

  <Accordion title="Do 4xx responses count?">
    Yes — every request that reaches the guard counts toward the
    window, even if it ultimately 4xx's on validation. Avoid
    "ping-and-error" patterns.
  </Accordion>
</AccordionGroup>

## See also

<CardGroup cols={2}>
  <Card title="Authentication" icon="key" href="/api-reference/authentication">
    OAuth flow, scopes, and Bearer-token format.
  </Card>

  <Card title="App Types & Tiers" icon="layer-group" href="/getting-started/app-types-and-tiers">
    What each tier unlocks beyond rate limits.
  </Card>

  <Card title="Webhooks Overview" icon="bell" href="/api-reference/webhooks/overview">
    Push notifications — zero rate-limit cost on your side.
  </Card>

  <Card title="Function Active Limits" icon="bolt" href="/functions/active-limits">
    A parallel per-shop cap for declarative functions.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.