# Example app walkthrough (/apps/tutorial)

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](/apps/examples)), so the code is shown here in full. For a smaller
start, follow [Build your first app](/apps/build-your-first-app) first.

## 1. The project [#1-the-project]

`poltura app create` writes a smaller project than this one; the walkthrough follows the finished example.



```text
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.
```



```text
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.md
```

## 2. The manifest [#2-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](/apps/manifest).



```json
{
  "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 [#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](/apps/auth) 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](/apps/auth#calling-as-your-app).



```ts
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`:



```ts
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 example shows only the second half of OAuth**
>
> The backend above handles the callback. It never starts the authorization request, so on its own
> the callback answers 400 for every visit. A real app must begin the flow first: call
> `createAuthorizationRequest()` on the OAuth client, store the returned state for a short time
> (bound to the visitor's browser, for example with a cookie), and redirect the merchant to the
> returned authorization URL. The hosted catalog companion example does this in a `/connect` route
> and then checks the stored state in its callback.

The server subscribes to `order.created` and `product.updated`. Each handler verifies the signature on the raw
body before reading it:



```ts
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);
```



```ts
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:



```ts
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 [#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.



```tsx
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 [#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.



```tsx
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 [#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.



```tsx
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 [#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.



```tsx
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 [#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`.



```tsx
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 [#10-styling]

All five workers share `src/styles.css`. It adds one colour and one utility on top of Poltura's theme layer:



```css
@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](/apps/styling).

## 11. Test, build and check [#11-test-build-and-check]



```bash
pnpm test:unit
pnpm build
pnpm validate
```

`pnpm build` bundles the workers and writes the release:



```text
built recommendations@1.0.0  sha256-qwcsxz+/Sn1wjxUbCKy7Ah/ROvTpZrZMf/8OTBIGg9Q=  5 workers
```

`pnpm validate` builds into a temporary folder and checks the result against the release format:



```text
built recommendations@1.0.0  sha256-qwcsxz+/Sn1wjxUbCKy7Ah/ROvTpZrZMf/8OTBIGg9Q=  5 workers
ok recommendations@1.0.0
  manifest.json  7765 bytes
```

## 12. Publish, review, install [#12-publish-review-install]



```bash
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](/apps/auth#installing)).

## 13. Update with a new permission [#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 [#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.
