Example app walkthrough
The complete Recommendations example file by file, from its manifest and server to five workers, publishing, review, install, update and removal.
On this page
Recommendations by Poltura Labs is the largest example app: five native workers, a server with OAuth, a session check, actions and webhooks, and app storage. This page walks through it in the order you would build it. Every code block on this page is taken from the example's source by the docs build, so it is the real, current code.
The example is not public yet (see Examples), so the code is shown here in full. For a smaller start, follow Build your first app first.
1. The project
poltura app create writes a smaller project than this one; the walkthrough follows the finished example.
Scaffold a new app project.
USAGE
$ poltura app create [FLAGS] <dir>
ARGUMENTS
dir Directory to create the project in (must be empty).
FLAGS
--template <template> Project template (choices: "hosted", "native", default:
"native").
-h, --help Show help.packages/app-sdk/examples/complete-app/
poltura.app.json
package.json
src/server.ts
src/elements.ts
src/workers/admin-page.tsx
src/workers/admin-product.tsx
src/workers/storefront-product.tsx
src/workers/storefront-footer.tsx
src/workers/app-page.tsx
src/styles.css
src/locales/en.json
src/locales/hu.json
README.md2. The manifest
poltura.app.json declares the five workers, the pages that make two of them reachable, two actions, two
webhooks and the permissions the app needs. Every field is explained on Manifest.
{
"schemaVersion": 1,
"apiVersion": "v1",
"handle": "recommendations",
"releaseVersion": "1.0.0",
"displayName": "Recommendations by Poltura Labs",
"description": "Product recommendations for admin settings, product panels, storefront blocks, footer capture, and an app-owned page.",
"translations": [
{
"locale": "hu-HU",
"displayName": "Ajánlások a Poltura Labs-tól",
"description": "Termékajánlók az adminhoz, a termékoldalhoz, a lábléchez és egy alkalmazásoldalhoz."
}
],
"supportUrl": "https://recommendations.apps.example.com/support",
"privacyUrl": "https://recommendations.apps.example.com/privacy",
"homepageUrl": "https://recommendations.apps.example.com/",
"backendOrigin": "https://recommendations.apps.example.com",
"redirectUrls": ["https://recommendations.apps.example.com/oauth/callback"],
"permissions": {
"required": [
{
"id": "settings.read",
"purpose": "Read store locale, currency, and public capabilities"
},
{
"id": "products.read",
"purpose": "List products and collections used as recommendation sources"
},
{
"id": "orders.read",
"purpose": "Read new orders that refresh recommendation weights"
}
],
"optional": []
},
"webhooks": [
{
"topic": "order.created",
"destination": "https://recommendations.apps.example.com/webhooks/orders"
},
{
"topic": "product.updated",
"destination": "https://recommendations.apps.example.com/webhooks/products"
}
],
"settings": [
{
"id": "highlight_mode",
"type": "enum",
"label": "Highlight mode",
"options": ["quiet", "featured"],
"default": "quiet"
}
],
"actions": [
{
"id": "save_featured",
"purpose": "Save featured product ids for this installation",
"surfaces": ["admin"]
},
{
"id": "recommend",
"purpose": "Return a product recommendation for the current shopper",
"surfaces": ["storefront"]
}
],
"destinations": [
{
"id": "newsletter_capture",
"url": "https://recommendations.apps.example.com/capture",
"purpose": "Receive optional newsletter captures from the footer worker"
}
],
"adminPages": [
{
"id": "recommendations",
"path": "workers/admin-page.js",
"title": "Recommendations",
"translations": [
{
"locale": "hu-HU",
"title": "Ajánlások"
}
],
"icon": "star"
}
],
"storefrontPages": [
{
"id": "recs_page",
"path": "workers/app-page.js",
"title": "Recommendations",
"slug": "recommendations",
"translations": [
{
"locale": "hu-HU",
"title": "Ajánlások"
}
],
"icon": "star"
}
],
"elements": "src/elements.ts",
"workers": [
{
"target": "admin.page",
"entry": "src/workers/admin-page.tsx",
"stylesheet": "src/styles.css",
"element": "recommendations-panel"
},
{
"target": "admin.product",
"entry": "src/workers/admin-product.tsx",
"stylesheet": "src/styles.css"
},
{
"target": "storefront.product",
"entry": "src/workers/storefront-product.tsx",
"stylesheet": "src/styles.css",
"element": "recommendations"
},
{
"target": "storefront.layout.footer",
"entry": "src/workers/storefront-footer.tsx",
"stylesheet": "src/styles.css",
"element": "newsletter-capture"
},
{
"target": "storefront.app-page",
"entry": "src/workers/app-page.tsx",
"stylesheet": "src/styles.css",
"element": "recommendations-page"
}
]
}3. The server
src/server.ts is a plain Node HTTP server on port 8787. /connect starts the connection of one store and
/oauth/callback finishes it. Read Authentication for the rest.
Your server calls the App API as the app itself with its machine user's token and the store installation in Poltura-Installation; see Auth.
if (sessions === null) {
send(res, 503, 'Session verification is not configured.');
return;
}
const header = requestHeaders(req).get('authorization') ?? '';
const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
const assertion = await sessions.verify(token);
send(
res,
200,
JSON.stringify({ installationId: assertion.installationId, storeId: assertion.storeId }),
'application/json; charset=utf-8',
);Every request to /actions/<id> is an action call from a worker. actions.handle checks Poltura's signature and
runs save_featured or recommend:
const raw = await readBody(req);
const output = await actions.handle(raw, requestHeaders(req));
send(res, 200, JSON.stringify(output), 'application/json; charset=utf-8');Run it with pnpm start. Nothing changes for the merchant until they connect their store.
The server subscribes to order.created and product.updated. Each handler verifies the signature on the raw
body before reading it:
const raw = await readBody(req);
const event = verifier.verify(raw, requestHeaders(req));
if (event.topic === PublicTopics.ORDER_CREATED) {
await saveLastOrder(client(), event.data.orderId, 0, event.eventId);
}
send(res, 200, event.topic);const raw = await readBody(req);
const event = verifier.verify(raw, requestHeaders(req));
send(res, 200, event.topic);The featured products are kept in app storage, a small key-value store per installation, through the App API. A write names the version it replaces and carries an idempotency key, so a retried request is applied once:
export async function saveFeatured(
client: PolturaClient,
productIds: readonly string[],
expectedVersion: number,
idempotencyKey: string,
): Promise<StorageRecord> {
const value = FeaturedProducts.parse({ productIds: [...productIds] });
return client.storage.put(STORAGE_NAMESPACE, FEATURED_KEY, value, {
expectedVersion,
idempotencyKey,
});
}Once the app is installed, a new order or a product edit in the store sends a webhook to these routes.
5. Admin page
The admin.page worker is the app's own page in the admin. It reads its settings, lets the merchant pick
products with the platform's picker and saves them through the save_featured action.
export function AdminPage(): ReactNode {
const copy = copyFor(useLocale());
const settings = useSettings();
const navigate = useNavigate();
const toast = useToast();
const pick = useResourcePicker();
const saveFeatured = useAction('save_featured');
const heading = String(settings.heading ?? copy.heading);
const [picked, setPicked] = useState('');
return (
<Card className='offer-panel rounded-theme bg-surface text-foreground p-4'>
<Stack>
<Heading>{heading}</Heading>
<Text>{copy.adminIntro}</Text>
<TextField label={copy.featuredLabel} value={picked} onChange={setPicked} />
<Inline>
<Button
label={copy.pickProducts}
onPress={async () => {
const ids = await pick('product', true);
if (ids === null) return;
setPicked(ids.join(','));
await toast('success', copy.pickedToast);
}}
/>
<Button
label={copy.save}
onPress={async () => {
const productIds = picked
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
await saveFeatured({
productIds,
expectedVersion: 0,
idempotencyKey: crypto.randomUUID(),
});
await toast('success', copy.savedToast);
}}
/>
<Button
label={copy.openPage}
onPress={() => navigate({ kind: 'app_page', pageId: RECOMMENDATIONS_PAGE })}
/>
</Inline>
</Stack>
</Card>
);
}The merchant finds it under Apps, then Recommendations.
6. Product panel
The admin.product worker adds a panel to every product page in the admin. It reads only the product the
merchant has open, and its button opens the app's admin page.
export function AdminProduct(): ReactNode {
const copy = copyFor(useLocale());
const selected = useSelectedEntity();
const navigate = useNavigate();
if (selected.status !== 'ready' || selected.entity.kind !== 'product') {
return <Text>{copy.unavailable}</Text>;
}
return (
<Card className='rounded-theme bg-surface text-foreground p-4'>
<Stack>
<Heading>{selected.entity.product.title}</Heading>
<Button
label={copy.openSettings}
onPress={() => navigate({ kind: 'app_page', pageId: RECOMMENDATIONS_PAGE })}
/>
</Stack>
</Card>
);
}7. Storefront block
The storefront.product worker is a block merchants place on the product template in the Customizer. It reads
the product being viewed, asks the server for a recommendation through the recommend action and adds to the
cart.
export function StorefrontProduct(): ReactNode {
const copy = copyFor(useLocale());
const settings = useSettings();
const theme = useTheme();
const cart = useCart();
const toast = useToast();
const host = useStorefrontContext();
const recommend = useAction('recommend');
const heading = String(settings.heading ?? copy.alsoLike);
const storeName = theme?.settings?.store_name;
const [contextLabel, setContextLabel] = useState(copy.alsoLike);
useEffect(() => {
void host.read().then(async (context) => {
if (context.kind === 'product') setContextLabel(context.product.title);
const suggestion = await recommend({
productId: context.kind === 'product' ? context.product.id : null,
});
const title = z.object({ title: z.string().min(1) }).safeParse(suggestion);
if (title.success) setContextLabel(title.data.title);
});
}, [host, recommend]);
return (
<Card className='offer-panel rounded-theme bg-surface text-foreground p-4'>
<Stack>
<Heading className='font-display'>{heading}</Heading>
<Text>{z.string().min(1).safeParse(storeName).data ?? contextLabel}</Text>
<Button
label={copy.addToCart}
className='bg-primary text-on-primary'
onPress={async () => {
const parsed = VariantId.safeParse(settings.variant_id);
if (!parsed.success) {
await toast.show({ tone: 'warning', message: copy.cartRejected });
return;
}
const reply = await cart.add({ variantId: parsed.data, quantity: 1 });
if (reply.status === 'ok') {
await toast.show({ tone: 'success', message: copy.added });
return;
}
await toast.show({ tone: 'warning', message: copy.cartRejected });
}}
/>
</Stack>
</Card>
);
}Shoppers see the block where the merchant placed it, with an Add to cart button.
8. Footer zone
The storefront.layout.footer worker sits in the theme's footer with a newsletter form. It checks the address
in the browser and shows a toast; the example sends the address nowhere.
export function StorefrontFooter(): ReactNode {
const copy = copyFor(useLocale());
const toast = useToast();
const [email, setEmail] = useState('');
const [error, setError] = useState('');
return (
<Card className='rounded-theme bg-muted text-foreground p-4'>
<Stack>
<Text>{copy.footerHelp}</Text>
<TextField
label={copy.emailLabel}
value={email}
error={error}
onChange={(value) => {
setEmail(value);
setError('');
}}
/>
<Button
label={copy.subscribe}
onPress={async () => {
const parsed = Email.safeParse(email);
if (!parsed.success) {
setError(copy.invalidEmail);
await toast.show({ tone: 'warning', message: copy.invalidEmail });
return;
}
setError('');
await toast.show({ tone: 'success', message: copy.subscribed });
}}
/>
</Stack>
</Card>
);
}9. Storefront page
The storefront.app-page worker is a whole page of the store, at /apps/recommendations, because
storefrontPages gives it the slug recommendations.
export function AppPage(): ReactNode {
const copy = copyFor(useLocale());
const settings = useSettings();
const navigate = useNavigate();
const heading = String(settings.heading ?? copy.heading);
return (
<Card className='rounded-theme bg-surface text-foreground p-4'>
<Stack>
<Heading>{heading}</Heading>
<Text>{copy.pageIntro}</Text>
<Button label={copy.shopProducts} onPress={() => navigate.to('/products')} />
</Stack>
</Card>
);
}10. Styling
All five workers share src/styles.css. It adds one colour and one utility on top of Poltura's theme layer:
@theme {
--color-offer-highlight: #fef3c7;
}
@utility offer-panel {
border-width: 1px;
}inheritTheme is on by default, so the workers take the admin's or the store's colours, fonts and spacing. See
Styling.
11. Test, build and check
pnpm test:unit
pnpm build
pnpm validatepnpm build bundles the workers and writes the release:
built recommendations@1.0.0 sha256-qwcsxz+/Sn1wjxUbCKy7Ah/ROvTpZrZMf/8OTBIGg9Q= 5 workerspnpm validate builds into a temporary folder and checks the result against the release format:
built recommendations@1.0.0 sha256-qwcsxz+/Sn1wjxUbCKy7Ah/ROvTpZrZMf/8OTBIGg9Q= 5 workers
ok recommendations@1.0.0
manifest.json 7765 bytes12. Publish, review, install
poltura app publish --api "$POLTURA_API" --app <app id> --token "$POLTURA_APP_TOKEN"The app id and the developer token (pltd_…, or a personal pltu_…) come from Developer apps in the admin. Add --admin-origin <admin origin> to also print the review link. The CLI prints published <releaseId> and the review link. A Poltura reviewer approves the production release. The store owner installs it in Poltura ID at https://id.poltura.com/install?app=<app id> and sees it under Installed apps (Auth).
13. Update with a new permission
Add a permission request to permissions.optional or permissions.required (at most 32 together). Bump releaseVersion. Publish a new production release. Independent review. The installation moves to the new release at once (app.updated); new required permissions wait until an administrator approves them in Poltura ID, and the App API answers 403 insufficient_scope for them until then (installation.permissions_changed follows the approval).
14. Removal
The merchant removes the app under Connected apps. The installation moves to removing, then removed, and
you receive app.suspended and then app.uninstalled. A later install starts with empty app storage. Data on your
own servers stays there: delete it on app.uninstalled as your privacy policy says.