Skip to main content
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.
app/meridian.server.ts
It reads MERIDIAN_APP_ID, MERIDIAN_API_KEY and MERIDIAN_API_URL from the environment, or takes { appId, apiKey, baseUrl } explicitly. See 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. 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.
The lower-level createMeridianServerClient and createMeridianAdminClient do throw MeridianApiError. Reach for them when you want to handle failures yourself. See Errors.

afterAuth

One call in your Shopify afterAuth hook does four things:
  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. 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 and the trial 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 and Free trial.
  4. Attributes an affiliate referral, when you passed the OAuth callback request. See Referral attribution.
It returns a summary rather than throwing:
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:
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 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:
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

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.

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:
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:
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 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:
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 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 when you need to catch errors yourself: it still throws on failure. See Events for event fields and 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:
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.

What it exposes

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.
Last modified on October 7, 2026