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

# createMeridianApp

`createMeridianApp` is the server entrypoint for an embedded app. Build it once, import it everywhere, and it covers the whole server side of a Meridian integration: install wiring, shop tokens, gates, webhook registration and referral capture.

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

export const meridian = createMeridianApp()
```

It reads `MERIDIAN_APP_ID`, `MERIDIAN_API_KEY` and `MERIDIAN_API_URL` from the environment, or takes `{ appId, apiKey, baseUrl }` explicitly. See [Credentials](/sdk-credentials).

## It never throws into your app

That is what makes it safe in a loader. Whether Meridian is unconfigured, unreachable, or rejecting a rotated API key, every method returns its degraded result. Failures log a `console.error` naming the likely fix, with repeated refusals handled as described below. Unconfigured calls are silent.

| Method | Degraded result |
| - | - |
| `mintShopToken` | `null` |
| `mintShopTokenDetailed` | `null` |
| `serverClientForShop` | `null` |
| `gatesForShop` | Ungated gates |
| `registerWebhooks` | Empty result |
| `syncSubscriptions` | `null` |
| `track` | `null` |
| `trackMany` | `null` |
| `afterAuth` | A summary with the failures in `errors` |
| `captureReferral` | A result with nothing captured |

A `401`, or a `403` with a credential rejection code (`UNAUTHORIZED`, `INVALID_API_KEY` or `SHOP_TOKEN_EXPIRED`), keeps the credentials diagnostic. Other `403` responses log the API's code and message without blaming `MERIDIAN_API_KEY`.

For `403 FEATURE_NOT_AVAILABLE_FOR_PRIVATE_APP`, the log explains that the app is private and usage tracking and billing are only available for public apps. There is nothing to change in `MERIDIAN_API_KEY`. `track` and `trackMany` return `null` for this refusal.

Each `401` or `403` refusal is logged once per method and error code per process, across shops and `createMeridianApp` instances. Responses without a code are grouped by HTTP status. This suppresses repeated logs only: later calls still reach the API and can succeed if access changes.

A rate limit (`429`) opens a **breaker**: the refused call degrades like any other failure, and every later call on the same app instance is skipped locally, with no network, until the `Retry-After` deadline the API sent has passed. One `console.error` names the deadline when the breaker opens; the skipped calls are silent. For that window gates read as ungated and shop tokens as null for every shop, which is the trade for never blocking a loader on a retry. See [Rate limits](/sdk-errors#rate-limits).

<Note>
  The lower-level [`createMeridianServerClient`](/sdk-server-client) and [`createMeridianAdminClient`](/sdk-identify) do throw `MeridianApiError`. Reach for them when you want to handle failures yourself. See [Errors](/sdk-errors).
</Note>

## afterAuth

One call in your Shopify `afterAuth` hook does four things:

```ts theme={null}
hooks: {
  afterAuth: async ({ session, request }) => {
    await meridian.afterAuth(session, { request })
  },
},
```

1. **Registers your Shopify webhooks.** Meridian resolves the topic set itself (the managed billing topics plus whatever you routed in the dashboard), so you never declare topics. If the configuration is unreachable it falls back to the managed set, and it always unions those in, so billing webhooks can never be dropped by an outage.
2. **Reports the authentication**, which fires your install and reinstall [automations](/automations). On an install or a reinstall it also drops the shop token this instance cached for the shop, because the uninstall before it revoked that token.
3. **Reports your Shopify subscriptions.** If you bill through the Shopify Billing API yourself, the SDK reads the shop's active subscriptions with the session's access token and sends their name, status, trial length and creation date to Meridian. That is what powers the Trial status on the Stores screen, the [Trials report](/analytics-trials) and the trial [automations](/automations) for apps Meridian does not bill. At an install this step runs before the merchant has had the chance to subscribe, so a trial they start afterwards needs one more call. See [Reporting a trial the merchant just started](#reporting-a-trial-the-merchant-just-started) and [Free trial](/free-trial#trials-on-subscriptions-you-bill-yourself).
4. **Attributes an affiliate referral**, when you passed the OAuth callback `request`. See [Referral attribution](/sdk-attribution).

It returns a summary rather than throwing:

```ts theme={null}
{ registered, updated, unchanged, errors, lifecycleNotified, subscriptionsSynced, referralCode, referralForwarded }
```

`registered`, `updated` and `unchanged` list topics: created on this call, rewritten because the existing subscription differed, or already correct and not touched. On a re-authentication every topic is normally `unchanged`, and the SDK makes no Shopify write for those. When the same process already reconciled that shop cleanly against the same topic set within the last 24 hours, a plain re-authentication makes no Shopify call at all; an install or reinstall always reconciles. The topic set itself is remembered for five minutes between authentications. `errors` is per topic, so one failing topic never blocks the rest. Inspect it to log or alert on failures. A rejected API key is also logged as a `[Meridian]` error, once per method and error code per process, so an invalid or rotated `MERIDIAN_API_KEY` is visible in your logs even if you never read `errors`. `subscriptionsSynced` is how many subscriptions were reported, or `null` when that step was skipped or failed (the failure is then in `errors` under the topic `subscriptions`).

### Syncing subscriptions on demand

`afterAuth` runs on every authentication, and a merchant opening the app is usually not one. Shopify's app libraries only authenticate again when a shop has no stored session, its access token expires or its scopes change, so with non-expiring offline tokens a store that installed before you upgraded the SDK stays unknown to Meridian until it reinstalls. To pick those stores up, the same step is available as its own call:

```ts theme={null}
for (const shop of shops) {
  const { session } = await shopify.unauthenticated.admin(shop)
  await meridian.syncSubscriptions({ shop, accessToken: session.accessToken })
}
```

Refresh the session inside the loop rather than passing the access token you have on file. With expiring offline tokens the stored token lives 60 minutes, so every shop that has not opened the app within the last hour answers `[Meridian] syncSubscriptions failed for <shop>: Shopify Admin API responded 401.` and is skipped. Going through your framework's unauthenticated admin client uses the shop's stored refresh token and hands back a live access token. Expiring offline tokens are already mandatory for public apps created after 1 April 2026 and become mandatory for every public app on 1 January 2027, so write the backfill this way even if your app still holds non-expiring tokens.

Every call is idempotent: Meridian never fires an automation for a status it already knew, and never overwrites a trial end it already had. Each call is one Shopify Admin API request plus one Meridian request, so pace a large backfill rather than firing it all at once.

### Reporting a trial the merchant just started

Approving a charge on Shopify's confirmation screen is not an authentication, so `afterAuth` never sees the subscription the merchant just took. Shopify's `APP_SUBSCRIPTIONS_UPDATE` webhook is all Meridian gets at that moment, and it carries no trial length, so until the shop authenticates again the store reads as active with no trial and the [Trials report](/analytics-trials) stays empty.

When that next authentication happens is up to your offline tokens. With expiring ones Shopify's app libraries authenticate again on the first embedded request after the 60-minute token has lapsed, so the trial reaches Meridian within about an hour. With non-expiring ones the shop never authenticates again, and the trial never reaches Meridian by this path at all.

Sync on the route the merchant lands on after approving the charge and the trial is reported straight away, on either kind of token:

```ts theme={null}
// in the loader of your billing return route
const { session } = await authenticate.admin(request)
await meridian.syncSubscriptions({ shop: session.shop, accessToken: session.accessToken })
```

That session is freshly authenticated, so its access token is live. The call is idempotent, so a merchant reloading the page costs one Shopify read and changes nothing.

## Minting shop tokens

```ts theme={null}
const shopToken = await meridian.mintShopToken({ shop: session.shop })
```

This is the `identify` handshake: it upserts the shop as an install and returns the per-shop token your browser code uses. Call it in the loader for your app shell. It is idempotent and returns the shop's current token.

It also takes `name`, `email`, `contacts`, `mref` and `mrefClickedAt`. See [Identify and contacts](/sdk-identify).

### Cached per shop and payload

Calling this in a loader costs one `identify` per shop per token lifetime, not one per request. The app instance caches the handshake for exactly as long as Meridian says the token lives, then replaces it, and concurrent calls for the same shop share one request instead of each starting their own.

Two rules shape the cache, and both exist to protect you:

* **A payload that changes always reaches Meridian.** `identify` is not just a token read: it upserts contacts, attributes affiliate referrals and tags the developer database. The cache only short-circuits a call identical to one it has already sent for that shop, so nothing you send can be silently swallowed.
* **A live token is never rotated.** The cache serves the token for its full reported lifetime and only replaces it once it has actually expired. Refreshing early would rotate it server-side and invalidate the copy you persisted or handed to the browser, turning a recoverable expiry into a hard `401`.

And one rule protects the cache from itself: a cached token the API refuses (`401`) is dropped on the spot. `gatesForShop`, `track`, `trackMany` and `sendEmail` then identify again and replay the call once, so a token that expired, was rotated out from under the cache or was revoked by an uninstall costs one extra request, not a degraded call. That is also how the other instances of a multi-instance app catch up with a reinstall, since only the one that ran `afterAuth` hears of it. A token you handed to the browser recovers there: the provider refreshes it.

### Persisting the token yourself

The cache is per process, so on serverless or multi-instance hosting every cold start pays for a fresh handshake. If you already persist Shopify sessions, persist the shop token with them and skip the call while it is live. `mintShopTokenDetailed` is the same call, same cache, same best-effort contract, but returns the expiry too:

```ts theme={null}
const details = await meridian.mintShopTokenDetailed({ shop: session.shop })
// { shopToken: "mrd_shop_…", shopTokenExpiresAt?: "2026-08-27T12:00:00.000Z" }
```

`shopTokenExpiresAt` is the expiry Meridian reported, verbatim, and absent when it reported none. Re-mint shortly before it, or whenever a call comes back with an expired-token error.

## Resolving gates without a token

For a loader, a webhook handler or a job that just needs to know what the shop is entitled to:

```ts theme={null}
const gates = await meridian.gatesForShop(session.shop)
if (gates.can("advanced_reports")) { /* … */ }
```

`gatesForShop` mints a token and resolves entitlements in one call, and returns **ungated** gates on any failure. `serverClientForShop(shop)` gives you the full [server client](/sdk-server-client) instead, or `null`.

The mint is served from the cache on a repeat call, but resolving entitlements is a network read every time, so hold the client or the gates for the duration of a request rather than calling per gate.

## Tracking usage events

Record one event with `track`, or send up to 50 events together with `trackMany`:

```ts theme={null}
const result = await meridian.track({
  shop: session.shop,
  eventKey: "order_created",
  quantity: 1,
  properties: { total_price: 41.28 },
  idempotencyKey: "order:1001",
})
// { id, eventKey, quantity, timestamp } or null

const batch = await meridian.trackMany({
  shop: session.shop,
  events: [
    { eventKey: "order_created", idempotencyKey: "order:1002" },
    { eventKey: "order_created", idempotencyKey: "order:1003" },
  ],
})
// { events: [{ id, eventKey, quantity, timestamp, created }, ...] } or null
```

Either method records usage that happened earlier when an event carries a `timestamp` (with an `idempotencyKey`, which a timestamp requires): a delayed sync, a replay after an incident. See [Dated events](/events#dated-events) for what that does to billing and automations. An invalid `Date` is a failure like any other and resolves `null`.

Both methods resolve the shop's token through the identify cache and return the API result unchanged on success. They are **best-effort**: you receive `null` when Meridian is unconfigured, identify fails, tracking fails, or the rate-limit breaker is open. A failed call logs one `console.error`, except repeated `401` or `403` refusals with the same method and code in this process. Unconfigured calls and calls skipped by the breaker are silent. `shop` selects the token and is not sent in the event payload.

The underlying client retries `429` and `5xx` only when `track` has an `idempotencyKey`, or every event in `trackMany` has one. The facade adds one retry of its own, for a refused shop token: a `401` on the cached token, or a `SHOP_TOKEN_REVOKED`, drops that token, identifies again and replays the call once. The replay is safe because a `401` is answered before the request does anything. A token the facade has just minted is not retried. A final `429` opens the shared breaker.

Use the [server client's `track`](/sdk-server-client) when you need to catch errors yourself: it still throws on failure. See [Events](/events) for event fields and [Sending events in batch](/events#sending-events-in-batch) for batch semantics.

## Re-registering webhooks

`afterAuth` only runs when a shop has no session, its offline token is expiring or its scopes changed. A topic you add in the dashboard is therefore **not** registered on an already-installed shop until it next authenticates, and with non-expiring offline tokens, that may never happen.

`registerWebhooks` reconciles a shop immediately, with the same topic set and no re-install:

```ts theme={null}
const result = await meridian.registerWebhooks({
  shop: session.shop,
  accessToken: session.accessToken,
})
```

It is create-or-update per topic, so it is safe on every load of a settings page, or from a one-off route you hit after changing the topic set. It always fetches the current topic set and always reads the shop's subscriptions from Shopify, whatever `afterAuth` remembered.

## Capturing referrals

`captureReferral(request, options?)` reads an affiliate code off a landing-page request and returns the cookies to set. It never throws, so it is safe to await on a page whose only job is to render. See [Referral capture](/sdk-referrals).

## What it exposes

| Member | Notes |
| - | - |
| `configured` | `true` when both an app ID and an API key are available |
| `baseUrl` | The resolved API-root override, or `undefined` |
| `lifecycle` | The lifecycle client, or `null` when unconfigured |
| `mintShopToken(input)` | Upsert a shop and return its token, or `null` |
| `mintShopTokenDetailed(input)` | The same, plus the token's reported expiry, or `null` |
| `serverClientForShop(shop)` | A per-shop server client, or `null` |
| `gatesForShop(shop)` | Entitlements plus gating helpers, ungated on failure |
| `registerWebhooks({ shop, accessToken })` | Reconcile a shop's Pub/Sub subscriptions |
| `syncSubscriptions({ shop, accessToken })` | Report a shop's active Shopify subscriptions and their trials, or `null` |
| `captureReferral(request, options?)` | Capture an affiliate referral |
| `afterAuth(session, options?)` | The one-call install wiring |
| `track({ shop, ...event })` | Records one usage event, [dated](/events#dated-events) or not, and returns `{ id, eventKey, quantity, timestamp }`, or `null` |
| `trackMany({ shop, events })` | Records up to 50 usage events for a shop in one request, each dated or not, or `null`. See [Sending events in batch](/events#sending-events-in-batch) |

`lifecycle.notifyAuthenticated({ shopDomain })` reports an install or re-auth on its own, if you want that step without the rest of `afterAuth`. Uninstall does not go through it. That reaches Meridian as the `APP_UNINSTALLED` webhook the SDK registered.


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