@startstorez/portal-sdk
v0.1.0
Published
Server SDK and React widget for StartStorez Apps Portal — report installs, fire events, read plan limits, and render in-app popups. Fails open by design.
Maintainers
Readme
@startstorez/portal-sdk
Server SDK and React popup widget for the StartStorez Apps Portal.
Report installs, fire events, read plan limits, and show in-app messages your team edits from the portal — without redeploying your Shopify app.
pnpm add @startstorez/portal-sdk- Zero runtime dependencies. Node built-ins only. Nothing to conflict with what your app already has.
- Never throws. Every call returns a result carrying its own error.
- Fails open. Hub down? Your app behaves exactly as it does on a normal day.
- React is a peer dependency. Never bundled, so no "Invalid hook call".
- ESM + CJS, full types, Node 20+.
The one rule
This package cannot break your app. Hub down, network gone, response malformed — the merchant's storefront and admin keep working.
Every function is written assuming the hub just fell over. Concretely:
| What happens | What you get |
|---|---|
| Hub returns 500 | { ok: false, error }, popup null |
| Network unreachable | same, after one hard time budget |
| Hub hangs forever | timeout at 3s, not a stalled request |
| Hub down, config never cached | permissive config, enforce: false — nothing gated |
| Hub down for an hour | circuit opens; calls cost microseconds, not 3s each |
There is a test suite dedicated to this (test/fail-open.spec.ts). If it ever fails, the package
should not ship.
Quick start
1. Create the client (server-side, once per process)
// app/portal.server.ts
import { createPortalClient } from '@startstorez/portal-sdk'
export const portal = createPortalClient({
apiUrl: process.env.PORTAL_API_URL!, // https://portal.startstorez.com
token: process.env.PORTAL_TOKEN!, // ptk_… from Admin → Apps → Setup
appSlug: 'sparrow-upsell',
onError: (error) => console.warn('[portal]', error.toJSON()),
})The token is a server secret. Never import this file from client code and never expose
PORTAL_TOKENto the browser. The React widget never receives it — see Browser access.
Build one client and reuse it. The config cache and circuit breaker live on the instance; a fresh client per request throws both away.
2. Report installs and uninstalls
await portal.install(shop, {
email: shopData.email,
contactEmail: shopData.contactEmail ?? shopData.email,
ownerName: shopData.shopOwner,
shopifyPlan: shopData.plan.displayName,
country: shopData.billingAddress?.country,
currency: shopData.currencyCode,
ianaTimezone: shopData.ianaTimezone,
planKey: 'free',
})
await portal.uninstall(shop, 'app/uninstalled webhook')Both are idempotent. Calling uninstall twice is safe, and so is calling it from your app and
the Shopify webhook — which you should, because the two race and either one can lose.
3. Fire an event, get a popup
Your app decides when. The portal decides what happens.
if (revenueThisMonth >= cap * 0.8) {
const { popup, emailQueued } = await portal.event('quota_reached', shop, {
usagePct: Math.round((revenueThisMonth / cap) * 100),
revenueUsd: revenueThisMonth,
})
return json({ popup })
}popup is null when there is nothing to show — including when the hub is unreachable — so you
never need to branch on failure.
4. Read limits and enforce locally
import { getLimit, shouldEnforce } from '@startstorez/portal-sdk'
const config = await portal.config(shop)
const cap = getLimit(config, 'monthlyRevenueUsd', Infinity)
if (shouldEnforce(config) && typeof cap === 'number' && revenue >= cap) {
return { blocked: true, cap }
}shouldEnforce() is the only correct way to decide whether to gate. Do not branch on
config.plan.limits alone: a store can have a plan while sitting outside the current rollout
cohort, or while the portal's kill switch is off. During an outage shouldEnforce() returns
false, which is exactly what fail-open means.
Pass your app's permissive value as the fallback to getLimit, not your free-tier value — the
fallback is what applies when the hub has not told you otherwise.
5. Show the popup
import { PortalProvider, PortalPopup } from '@startstorez/portal-sdk/react'
import '@startstorez/portal-sdk/theme.css'
import '@startstorez/portal-sdk/polaris.css' // embedded Shopify admin
<PortalProvider namespace='sparrow-upsell' theme='polaris'>
<PortalPopup popup={popup} />
</PortalProvider>Browser access: the server route
The widget can fetch its own popups, but the portal token must never reach the browser. Mount one route in your app and the widget talks to you instead of the hub.
// app/routes/api.portal.$.tsx (Remix)
import { createPortalHandler } from '@startstorez/portal-sdk'
import { portal } from '~/portal.server'
import { authenticate } from '~/shopify.server'
const handler = createPortalHandler({
portal,
// Resolve the shop from YOUR session. Never from the request body.
resolveShop: async (request) => {
const { session } = await authenticate.admin(request)
return session.shop
},
allowEvents: ['quota_reached'],
onTrack: (event, { shop }) => analytics.track(shop, event),
})
export const loader = ({ request }: LoaderFunctionArgs) => handler(request)
export const action = ({ request }: ActionFunctionArgs) => handler(request)<PortalProvider endpoint='/api/portal' namespace='sparrow-upsell' theme='polaris'>
<Dashboard />
</PortalProvider>function Dashboard() {
const { popup, fire } = usePortalEvent('quota_reached')
return (
<>
<button onClick={() => fire({ usagePct: 92 })}>Check my usage</button>
<PortalPopup popup={popup} />
</>
)
}The handler speaks Web Request/Response, so it drops into Remix, Next app router, Hono, Bun
and Deno unchanged. For Express:
app.all('/api/portal/*', async (req, res) => {
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body)
const response = await handler(
new Request(url, { method: req.method, headers: req.headers as HeadersInit, body }),
)
res.status(response.status)
response.headers.forEach((value, key) => res.setHeader(key, value))
res.send(Buffer.from(await response.arrayBuffer()))
})Security notes
resolveShopis required and is the security boundary. The browser sends no shop and is never believed about one. Without this, any authenticated merchant could fire an event — and trigger an email — against a competitor's store.allowEventsdefaults to nothing. Browser-initiated events cost real emails, so the set has to be written down on the server.POST /eventanswers403 events_disableduntil you list them.- Per-shop rate limit, 30 events/minute by default. A merchant with the console open should not be able to turn a cooldown-gated event into a mail flood.
/configreturns display data only. Never gate on it in the browser — enforcement belongs on your server.
Config caching
portal.config() is on your hot path, so it is stale-while-revalidate:
| Situation | Behaviour |
|---|---|
| Cache fresh (inside TTL) | serve cached — no request at all |
| Cache stale, under 7 days | serve stale now, refresh in the background |
| No usable cache, hub reachable | fetch, cache, serve |
| No usable cache, hub down | permissive defaults, degraded: true |
Revalidation uses If-None-Match; a 304 extends the cached entry without re-downloading it.
Concurrent reads for the same shop collapse into one request, so a restart under load is not its
own denial-of-service.
config.source tells you where the answer came from — 'network' | 'revalidated' | 'cache' |
'stale' | 'fallback' — which is worth putting on a dashboard.
Surviving restarts
The default cache is in-process and dies with the process. If you run more than one instance, or deploy often, back it with Redis:
import Redis from 'ioredis'
import { createPortalClient, createRedisStore } from '@startstorez/portal-sdk'
export const portal = createPortalClient({
// …
cache: createRedisStore(new Redis(process.env.REDIS_URL!), { keyPrefix: 'sparrow:' }),
})createRedisStore takes your client — the package still has zero dependencies. Any object with
get, set, expire and del works, which both ioredis and node-redis v4 satisfy as-is.
After a plan change
await portal.invalidate(shop) // e.g. in your Shopify billing callbackAPI
createPortalClient(options)
| Option | Default | Notes |
|---|---|---|
| apiUrl | — | Hub base URL. Required. |
| token | — | ptk_…. Required. Server-side only. |
| appSlug | — | Your app's slug in the portal. Required. |
| timeout | 3000 | Total budget per call, retries and backoff included. |
| retries | 2 | Extra attempts, on 5xx and network errors only. Never on 4xx. |
| onError | warns | Called for every failure. Wire it to your logger. |
| cache | memory | Any CacheStore. |
| circuit | on | { failureThreshold, openMs }, or false to disable. Leave it on. |
| fetch | global | Override for proxies, tests, instrumentation. |
The constructor never throws. Misconfiguration is reported through onError at construction
and every call fails fast — an app should not crash at boot because the portal is misconfigured.
Methods
| Method | Returns |
|---|---|
| install(shop, profile, opts?) | PortalResult<InstallResponse> |
| uninstall(shop, reason?, opts?) | PortalResult<UninstallResponse> |
| backfill(shops, opts?) | PortalResult<BackfillResponse> — chunks automatically, no events fired |
| event(key, shop, data?, opts?) | PortalEventResult — always destructurable |
| config(shop, opts?) | PortalConfig — never fails, may be degraded |
| invalidate(shop) | void |
| circuitState | 'closed' \| 'open' \| 'half_open' — useful on a health endpoint |
Errors
Errors are reported, not thrown. PortalError carries a stable code:
timeout, network, unauthorized, not_found, validation, rate_limited, http_error,
bad_response, circuit_open, aborted, config.
not_found on an event means the event key does not exist in the portal — create it there first.
circuit_open means the SDK deliberately did not call the hub; it is not the same as a timeout,
and telling them apart is what lets a dashboard show the moment the hub recovered.
error.toJSON() is safe to log: no token, no request body.
React
| Export | Purpose |
|---|---|
| <PortalProvider> | Endpoint, theme, namespace, telemetry sink |
| <PortalPopup popup={…} /> | Renders all four formats. null renders nothing |
| usePortalEvent(key, opts?) | { popup, loading, fire, clear } |
| usePortalConfig() | { config, loading, refresh } — display only |
| usePortalDismissals() | { dismissed, restore, reset } — for a "show tips again" control |
Formats: BANNER, MODAL, INLINE_CARD, TOOLTIP.
- One impression per session, counted only after the popup has been genuinely visible for 300ms — a popup scrolled past does not count.
- Dismissals persist in
localStorage, keyed bypopup.key, and survive reloads. - Modals portal to
document.body, trap focus, close on Escape and on an overlay click, and restore focus when they close. - Set
namespaceto your app slug. Embedded admin puts every app on the same origin, and without it one app's dismissals silently suppress another's messages.
Theming
Ship unstyled, or import a theme:
import '@startstorez/portal-sdk/theme.css' // neutral default
import '@startstorez/portal-sdk/polaris.css' // reads real Polaris tokensEverything is CSS custom properties — --portal-accent, --portal-bg, --portal-radius, … — so
restyling means overriding a variable, not fighting a selector. A popup's theme JSON in the
portal admin is applied as inline custom properties and wins over the stylesheet, so an admin can
recolour one message without an app deploy.
With App Bridge session tokens:
<PortalProvider
endpoint='/api/portal'
headers={async () => ({ Authorization: `Bearer ${await getSessionToken(app)}` })}
>The function is awaited on every call, which is what session tokens need — they expire after a minute, so a header captured once at mount is already stale.
Verifying fail-open in your own app
Do this before you trust it in production. It takes five minutes and it is the whole point of the package.
- Deploy with the SDK wired in and confirm real calls land in the portal's Request Logs.
- Stop the hub, or firewall it:
PORTAL_API_URL=http://127.0.0.1:1also works. - Use your app as a merchant would — open the admin, hit the flow that fires an event, load the page that reads limits.
- Check: nothing is blocked, nothing errors, and the pages are not measurably slower after the first few requests (the circuit breaker should have opened).
- Bring the hub back. Within one circuit window calls resume on their own — no restart.
If anything is slower or broken, that is a bug worth reporting, not something to work around.
What this package does not do
- ❌ Usage tracking — your app knows what a merchant used; the hub does not.
- ❌ Threshold logic — your app decides when an event fires.
- ❌ Enforcement — the hub supplies numbers and an on/off switch; your app enforces.
- ❌ Client-side event calls without your route — the token stays on your server.
License
MIT
