Skip to main content

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.

Error Handling

App Bridge errors fall into three categories:
  1. Transport errors — the host never responds. The SDK rejects with a timeout after 10 seconds.
  2. Host-reported errors — the host runs your action but the underlying operation fails. The SDK rejects with an Error carrying the host’s reason string.
  3. User-cancellation — the host returns a successful response whose payload signals the user dismissed the prompt. These are not thrown; they resolve normally with a discriminator field (cancelled: true, empty selection, etc.). Treat them as a normal branch.
Knowing which category you’re in determines whether to retry, surface a toast, or render an empty state.

How errors flow through dispatchAndWait

The wire format for an error response is:
{
  type: 'APP_BRIDGE_RESPONSE',
  action: 'TOAST_SHOW',
  id: 'ab_173456789_42',
  payload: {},
  error: 'Toast queue full',   // optional — when present, the SDK rejects
}
When error is present on the response, the SDK rejects the promise with new Error(response.error) so callers can use a standard try / catch:
try {
  const { selection } = await app.dispatchAndWait('RESOURCE_PICKER_OPEN', {
    resourceType: 'product',
  });
  // host returned successfully — selection may be [] if user cancelled
} catch (err) {
  // host returned `error:` or the call timed out
  console.error('Picker failed:', err.message);
}
The current SDK (packages/app-bridge/src/index.ts) calls pendingCallbacks.delete(id) only on success. On error, the callback is still removed but the host’s error field is forwarded to the callback, and dispatchAndWait translates that into a rejection. Subscriptions created via subscribe() also receive the error as the second argument of the callback — handle it explicitly when subscribing.

Timeouts

dispatchAndWait rejects with Error('App Bridge: timeout waiting for <ACTION> response') after 10 000 ms if the host never responds. The timeout is hard-coded; you cannot tune it per call. Three things commonly cause a timeout:
  1. Action is unsupported on the current host. The admin host doesn’t wire CART_LINES_CHANGE; the checkout host doesn’t wire RESOURCE_PICKER_OPEN; the post-purchase host doesn’t wire MODAL_OPEN. Use the host capability table in each host’s reference page to feature-gate calls.
  2. The host hasn’t mounted yet. Checkout extensions sometimes dispatch before <CheckoutExtensionSlot> finishes mounting. Wait for BRIDGE_PING once before issuing real calls.
  3. A typo in the action name. Action names are case-sensitive and the host silently drops unknown names. 'TOAST_SHO' will time out ten seconds later — there is no synchronous “unknown action” error.
Recovery pattern:
async function dispatchWithRetry(action, payload, attempts = 2) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await app.dispatchAndWait(action, payload);
    } catch (err) {
      if (i === attempts - 1) throw err;
      if (!/timeout/.test(err.message)) throw err;   // only retry timeouts
      await new Promise((r) => setTimeout(r, 100 * (i + 1)));
    }
  }
}
Do not retry user-driven actions (modals, pickers, prints) — the user has already moved on. Only retry data-fetch RPCs (USER_FETCH, CONFIG_FETCH, ENVIRONMENT_FETCH, CART_GET, etc.) where a missed response means stale state.

Subscription errors

app.subscribe(action, callback) forwards the host’s error string as the second argument:
const off = app.subscribe('CART_UPDATED', (payload, error) => {
  if (error) {
    console.warn('cart update failed:', error);
    return;
  }
  setCart(payload);
});
Forgetting the second parameter means error responses look like normal data — your component will then read the empty payload ({}) and silently render an empty cart. Always destructure (payload, error).

Per-action failure modes

Toast

Toast.create(app, { … }).dispatch() is fire-and-forget — it doesn’t return a promise, so transport failures are invisible. If the toast must succeed (regulatory notice, payment receipt), use dispatchAndWait('TOAST_SHOW', …) and surface a fallback in catch. The admin host reads payload.type; the React checkout host reads payload.variant. The Toast helper sends type; in checkout iframes build the payload manually.
const { action } = await Modal.create(app, options).dispatch();
// action: 'primary' | 'secondary' | 'dismissed'
A modal always resolves — even when the user clicks the backdrop or hits Escape (action === 'dismissed'). Treat dismissed as a normal cancel, not an error. A timeout here means the host never opened the modal at all — usually because the iframe is in a context that doesn’t support modals (checkout, post-purchase).

Resource Picker

const { cancelled, selection } = await ResourcePicker.create(app, options).dispatch();
cancelled: true with selection: [] is the user-cancel signal — not an error. Check the discriminator before treating an empty selection as a problem:
const result = await picker.dispatch();
if (result.cancelled) return;          // user closed the picker
if (result.selection.length === 0) {   // empty result — unusual
  Toast.warning(app, 'No matching items');
  return;
}
A rejected promise from the picker usually means the host doesn’t support the requested resourceType for that surface — admin-only types (order, customer, etc.) on a non-admin host will time out.

Clipboard

Clipboard.write() runs iframe-side and silently falls back to the legacy document.execCommand('copy') path when the modern API throws or is unavailable. The promise still resolves either way — there is no indication to the caller that the legacy path ran. Clipboard.read() (and the paste() alias) does reject when the host denies the request — Chrome blocks clipboard-read for cross- origin iframes, so the SDK delegates to the host, and the host can reject if the user denies the permission prompt or the focused window isn’t authorised. Errors arrive as Error('clipboard read denied') or similar.
try {
  const text = await Clipboard.read(app);
  parseAndPrefillForm(text);
} catch (err) {
  if (/denied|permission/i.test(err.message)) {
    Toast.warning(app, 'Allow clipboard access to paste.');
  } else {
    throw err;
  }
}

Cart (checkout / post-purchase)

Cart.applyCartLinesChange, applyDiscountCodeChange, applyNoteChange, applyAttributeChange, applyMetafieldChange all throw when the host’s verify-cart pipeline rejects the mutation. Common reasons:
  • A cart_transform or order_validation function blocks the change.
  • The merchant’s discount rules reject the code (expired, min-cart not met, mutually exclusive with another active discount).
  • The line variant is sold out or no longer purchasable.
The host returns:
{ payload: {}, error: 'verify-cart blocked: out of stock' }
…which the SDK forwards as new Error('verify-cart blocked: out of stock'). Always wrap cart mutations in try / catch and re-fetch the cart (CART_GET) on failure so your UI mirrors host state. removeDiscountCode and addGiftCard are stubbed today — they resolve with { ok: false, applicable: false } instead of throwing. Inspect applicable before assuming success. METAFIELD_CHANGE on the checkout host is reserved for forward compatibility and currently rejects with error: 'cart metafields not supported yet'.

BuyerJourney

The intercept callback can be sync or async. If it throws, the SDK defaults to behavior: 'allow' so the buyer is never trapped by a broken extension. Watch your console — silent intercept failures cause your business rule to be bypassed.
journey.intercept(async () => {
  try {
    const ok = await checkInventory();
    return ok ? { behavior: 'allow' } : {
      behavior: 'block',
      reason: 'One of your items just went out of stock — please reload.',
    };
  } catch (err) {
    console.error('intercept failed:', err);
    return { behavior: 'allow' };   // explicit fallback
  }
});

RestApi

RestApi.fetch() reuses the standard Fetch API — it does not throw on non-2xx. Inspect response.ok yourself, or use fetchJson() which rejects on non-2xx with a body-aware message:
try {
  const { products } = await api.fetchJson('/api/v1/products?limit=50');
} catch (err) {
  // err.message: "RestApi: 429 Too Many Requests — { retryAfter: 30 }"
}
Two distinctive failures:
  • 'RestApi: host CONFIG_GET did not return apiBase' — the host responded but the payload lacks apiBase. The SDK clears its config cache so the next call retries. Verify the host implements CONFIG_GET.
  • 'App Bridge: timeout waiting for SESSION_TOKEN_REQUEST response' — the host never returned a JWT. Most often happens when the iframe is in a host that doesn’t authenticate apps (checkout, post-purchase).

Intents

Intents.launch() is fire-and-forget — it returns the postMessage id synchronously and never throws (beyond the empty-target guard). Intents.launchAndWait() rejects on timeout or when the target doesn’t resolve:
try {
  const result = await intents.launchAndWait('refunds-app.issue-refund', { orderId });
  if (result.error) {
    Toast.error(app, `Couldn't open refunds: ${result.error}`);
    return;
  }
  if (result.cancelled) return;
  toast.success('Refund issued');
} catch (err) {
  // Network / host wiring failure — fall back to a manual redirect
  Redirect.create(app, { url: '/admin/orders/' + orderId }).dispatch();
}
The launched extension can return either { error: '...' } (host could not resolve the target — typically an unknown handle) or { cancelled: true } (user closed the launched modal without completing).

Session Token

app.getSessionToken() and useSessionToken().getToken() both throw when the host rejects the JWT request — typically because the API key / shop combination is invalid. Catch once at the top of your data layer and surface a “reconnect” CTA:
try {
  const token = await app.getSessionToken();
  // …
} catch (err) {
  setSessionInvalid(true);    // your UI shows a "Reconnect" button
}
The wrapper caches tokens until 30s before their JWT exp claim, so a single failure doesn’t trigger a retry storm.

Error reference

SourceSymptomCauseRecovery
dispatchAndWaitError('App Bridge: timeout waiting for X response')Host doesn’t wire X or hasn’t mountedFeature-gate, retry once, fall back
dispatchAndWaitError('<host reason>')Host returned error: fieldShow user-friendly message
subscribe(action, cb)cb(payload, error) with error populatedHost emitted error for the channelIgnore payload, render fallback
Modal.dispatch()resolves with action: 'dismissed'User closed without clickingTreat as cancel
ResourcePicker.dispatch()resolves with cancelled: trueUser closed pickerTreat as cancel
Clipboard.read()Error('clipboard read denied')Permissions Policy or user denialPrompt user to allow paste
Cart.apply*Error('verify-cart blocked: ...')Function / merchant rule blocked mutationRe-fetch cart, show reason
BuyerJourney.intercept callback throwsDefaults to allowYour code erroredLog + add explicit fallback
RestApi.fetchJsonError('RestApi: 4xx ... — <body>')Backend rejected the requestInspect status, retry / surface
app.getSessionToken()rejectsHost couldn’t sign a JWTShow reconnect CTA, do not retry

Patterns

Show toast on transient failure, propagate on persistent

import { Toast } from '@launchmystore/app-bridge';

async function save() {
  try {
    await api.fetchJson('/api/v1/widgets', { method: 'POST', body: form });
    Toast.success(app, 'Saved');
  } catch (err) {
    if (/^RestApi: 5\d\d/.test(err.message)) {
      Toast.warning(app, 'Server hiccup — try again');
      return;
    }
    Toast.error(app, `Save failed: ${err.message}`);
    throw err;   // let an error boundary handle it
  }
}

Gracefully degrade when an action isn’t wired

import { Features, APP_BRIDGE_FEATURES } from '@launchmystore/app-bridge';

const { features } = await Features.create(app).query();
const canPickResources = features.includes(APP_BRIDGE_FEATURES.RESOURCE_PICKER);

if (canPickResources) {
  return <button onClick={picker.open}>Pick product</button>;
}
return <input placeholder="Product ID" onChange={…} />;

Surface BuyerJourney intercept failures without trapping the buyer

journey.intercept(async () => {
  try {
    const ok = await validateCart();
    return ok ? { behavior: 'allow' } : { behavior: 'block', reason: 'Cart invalid.' };
  } catch (err) {
    Sentry.captureException(err);
    Toast.error(app, 'Validation failed — proceeding without check.');
    return { behavior: 'allow' };
  }
});

See Also