> ## Documentation Index
> Fetch the complete documentation index at: https://help.the-meridian.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and degradation

The SDK has two error behaviours. Which one you get depends on the layer you call.

| Layer | On failure |
| - | - |
| [`createMeridianApp`](/sdk-app) | Degrades. Returns a null / ungated / empty result and logs one `console.error` |
| [`createMeridianServerClient`](/sdk-server-client), [`createMeridianAdminClient`](/sdk-identify) | Throws `MeridianApiError` |
| [`MeridianProvider`](/sdk-provider) | Sets `error` on the context; write methods reject |
| `captureReferral`, contact capture | Silent. Best-effort by contract |

The facade a loader calls degrades, so a Meridian problem cannot stop a merchant's app from loading. The low-level clients hand you the error instead, for you to decide on.

For `401` and `403` refusals, the facade logs once per method and error code per process. A `403 FEATURE_NOT_AVAILABLE_FOR_PRIVATE_APP` explains the private app restriction without asking you to change `MERIDIAN_API_KEY`. Other `403` codes and messages are logged as received, unless the code identifies a credential rejection. See [Facade diagnostics](/sdk-app#it-never-throws-into-your-app).

## MeridianApiError

```ts theme={null}
import { MeridianApiError } from "@the-meridian/sdk/server"

catch (err) {
  if (err instanceof MeridianApiError) {
    err.status             // HTTP status
    err.code               // stable string code, or null
    err.details            // machine-readable context, or null
    err.retryAfterSeconds  // how long a 429 asked you to wait, or null
    err.message            // prose, do not branch on it
  }
}
```

Branch on `code`. The message is prose and can be reworded.

One failure is not a `MeridianApiError`: a [dated event](/events#dated-events) whose `Date` is invalid makes `track` and `trackMany` throw a `TypeError` before any request is sent. The `createMeridianApp` methods resolve `null` for it, like any other failure.

## The three states worth handling

Four guards are exported from both entries, for the failures that mean something specific:

### Shop token expired or revoked

```ts theme={null}
isShopTokenExpiredError(err)   // 401 SHOP_TOKEN_EXPIRED
isShopTokenRevokedError(err)   // 401 SHOP_TOKEN_REVOKED
```

An expired token has aged past its hour. The provider handles this for you: it rotates the token, replays the call, and fires `onTokenRefreshed`. On the server there is no automatic refresh: call `refreshShopToken()` and retry, or mint a fresh token.

A revoked token belonged to a shop that uninstalled the app. Once the shop reinstalls, a refresh returns the install's current token, so the provider handles both codes the same way and the merchant sees nothing. While the shop is still uninstalled, or once the revoked token's original hour is over, the refresh is refused too: mint a fresh token. The `createMeridianApp` methods do that themselves. See [After an uninstall](/authentification#after-an-uninstall).

### The merchant must re-authorize

```ts theme={null}
isShopifyAccessTokenStaleError(err)   // 401 SHOPIFY_ACCESS_TOKEN_STALE
```

Your app holds no usable Shopify offline session for this shop, or its refresh keeps failing. No retry can fix it: the merchant has to relaunch the app.

The provider calls `onShopifyAccessTokenStale` once per mount, defaulting to a page reload, which in an embedded app puts the merchant back through authorization. It then rethrows, so your component can react too.

### Meridian cannot bill this app at all

```ts theme={null}
isPlanBuilderUnavailableError(err)   // 403 PLAN_BUILDER_UNAVAILABLE
```

This one is **yours** to fix, not the merchant's: Meridian could not reach your app's Shopify session store, which in practice means the app is not [hosted on Meridian](/sdk-hosting). `err.details.plan_builder_billing_status` names the missing precondition:

| Status | What it means |
| - | - |
| `no_managed_database` | No session database is reachable for this app |
| `session_table_missing` | The database is reachable but has no `Session` table. Usually an app still shipping the template's SQLite datasource, so its migrations never touched the managed database |
| `no_offline_token` | The table exists but holds no offline session. A merchant has to install the app first |
| `probe_failed` | The readiness probe could not complete |

Your Meridian dashboard shows the same reason and disables publishing until it is fixed.

### The app's own Meridian plan ran out

```ts theme={null}
err.status === 402 && err.code === "PLAN_LIMIT_REACHED"
```

Your app's Meridian plan meters how many SDK requests and usage events it may make per month (see the [comparison](/subscription-2)). Past the allowance the API answers `402 PLAN_LIMIT_REACHED`, and `err.details` carries `meter`, `limit` and `used`. Importing history from before the app joined Meridian uses neither allowance during the app's first 30 days. See [History from before Meridian](/events#history-from-before-meridian).

This is yours to fix, not the merchant's, and it is not retried: the answer will be the same until the allowance resets at the start of next month or you upgrade the app's plan. Meridian emails your organization's owners at 80%, 95% and 100% of the allowance, so a 402 here should never be the first you hear of it.

Treat it like any other Meridian outage in your app: let the facade degrade, or fall back to your own behaviour. Do not surface it to the merchant.

## What gets retried

The SDK retries `429` and `5xx` responses up to **three attempts**, with exponential backoff from 250 ms, capped at 5 seconds, honouring a `Retry-After` header when one is sent. Network errors are retried the same way.

It only does that where a retry is safe:

| Retried | Sent exactly once |
| - | - |
| Every read (`/me`, entitlements, plans, feature check) | `subscribe` |
| `identify`, the referral stash, lifecycle events | `cancelSubscription` |
| Custom field writes, discount validation | `updateUsageCap` |
| `track` **with** an `idempotencyKey` | `track` without one, contact capture, token refresh |
| `trackMany` when **every** event has an `idempotencyKey` | `trackMany` when any event lacks one |

A billing write is never retried on its own. Where accuracy matters for tracking, send an `idempotencyKey`. See [Usage tracking](/events#idempotency). A [dated event](/events#dated-events) always carries one, so it is always retried.

## Rate limits

Reads and tracking share 120 requests per minute; billing writes share 30. A [batch](/events#sending-events-in-batch) of up to 50 events counts as one request. Both are keyed on the bearer token, so one shop cannot exhaust another's budget. A `429` on a retryable call is handled for you; on a billing write it reaches your code, and backing off is the correct response. `MeridianApiError.retryAfterSeconds` carries the wait the API asked for, in seconds.

[`createMeridianApp`](/sdk-app) goes one step further with a **breaker**: after a `429`, every later call on that app instance is skipped locally, without touching the network, until the `Retry-After` deadline passes. The breaker logs one `console.error` naming the deadline when it opens, and nothing for the calls it skips. During that window gates read as ungated and shop tokens as null for every shop, so a rate-limited app degrades deterministically instead of a random share of its calls failing. Nothing ever sleeps or retries on a deadline inside a loader.

If you see the breaker's log line in normal operation, your app is calling Meridian more often than its budget allows. The usual cause is minting a shop token per request on hosting where the in-process cache cannot help; [persist the token](/sdk-app#persisting-the-token-yourself) instead.

## Loading and error state in React

```tsx theme={null}
const { loading, error, isEnabled } = useMeridian()

if (loading) return <Skeleton />
if (error) return <YourFallback />
```

Gates read as **ungated** while `loading` is true and while `error` is set, so gating without checking `loading` flashes the upgrade prompt at someone who has already paid. See [fail open or fail closed](/features#fail-open-or-fail-closed).

## Running unconfigured

An app with no Meridian credentials is a degraded app, not a broken one: `configured` is `false`, `mintShopToken` returns `null`, gates read as ungated, and the SDK's page components render a quiet empty state with one console warning naming the likely cause. Mount the provider conditionally and your app renders either way.

## What is silent on purpose

Two things swallow every failure and log nothing:

* **Contact capture**, which runs beside the provider's load rather than gating it.
* **`captureReferral`**, so a bad URL or an unreachable API cannot break the page a merchant is on. Whatever was resolved before the failure, notably the cookie, still comes back.

Both report what happened in their return value. Read `stashed` and `cookieSkipped` to know whether a capture worked.


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