Auth
Permissions, installing an app, calling as the app or for a person, and session JWTs.
On this page
| Credential | Holder | Use |
|---|---|---|
| App access token | Your server | PolturaClient bearer for the App API (/apps-api/v1), with Poltura-Installation |
| Merchant session JWT | RAM for one request | Prove which merchant is looking at a hosted iframe. Not an API bearer. JWT type poltura-app-session+jwt, valid for 60 seconds, at most 4096 bytes |
Permissions
An app asks for permission ids, the same permissions merchants give their team members. The permissions the
merchant granted are in installation.retrieve().permissions. Required permissions are always there; optional
ones the merchant turned down are missing. A call that needs a permission the installation lacks fails with
403 forbidden: tell the merchant which permission the feature needs instead of retrying. The App API uses these:
| Permission | App API access |
|---|---|
settings.read | GET /store: the store name, locale, currency, and public capabilities. |
products.read | Products, collections, and inventory levels. |
products.write | Product title, description, handle, status, and metadata; collection title, handle, and metadata. Not prices or stock. |
orders.read | Order status, totals, items, and shipping address. |
orders.write | Order metadata, tags, and note. Not status, payment, or fulfillment. |
customers.read | Customer name, email, phone, tags, and metadata. |
customers.write | Customer metadata and tags. Not account status. |
GET /installation and installation storage need no permission: storage holds the installation's own data, not the merchant's. Every permission an app may request is listed in Permissions and topics, and the full catalogue with labels and descriptions is served at GET https://api.poltura.com/v1/access/catalog?locale=en (or locale=hu). The purpose on each permission in the manifest is 8 to 280 characters and is what the merchant sees.
Credentials
In Developer apps → Credentials, register one or more public keys of your own (RSA 2048+ with
RS256, or EC P-256 with ES256) as a JWKS. The private keys never leave your servers. Poltura ID
answers with:
- the app's client id, for calls a person makes through your app;
- the app's machine user id and, for each key, the key id your JWT profile assertions name.
Rotate by registering the new key next to the old one, deploying, then removing the old one.
Installing
A merchant installs your app in Poltura ID at https://id.poltura.com/install?app=<app id>. Link
there from your onboarding. Someone who may install apps chooses the stores (a list, or every store
including future ones), sees the required permissions locked with your purposes, and turns on any
optional ones. Nobody can give your app more than they hold themselves. The installation then has
one installation id (a UUID such as 0199f3c4-2a6e-7f81-b3d5-6e7f8091a2b3) per store; app.installed and GET /installation tell you
which.
Calling as your app
Your server acts as the app itself with the JWT profile (RFC 7523): it signs an assertion for its
machine user and exchanges it at https://id.poltura.com/oauth/v2/token. Every App API call names
the store installation in Poltura-Installation; the installation's permissions decide.
import { PolturaClient } from '@poltura/app-sdk';
import { createAppTokenSource } from '@poltura/app-sdk/oauth';
const tokens = createAppTokenSource({
issuer: 'https://id.poltura.com',
userId: process.env.POLTURA_MACHINE_USER_ID,
keyId: process.env.POLTURA_KEY_ID,
privateKey: privateJwk,
});
const client = new PolturaClient({
origin: 'https://api.poltura.com',
accessToken: await tokens.accessToken(),
installationId: '0199f3c4-2a6e-7f81-b3d5-6e7f8091a2b3',
});Calling for a person
To act for the person using your app, run the authorization code flow with PKCE against Poltura ID
with createPublicAppOAuthClient, authenticating with your own key (private_key_jwt). Ask for the
permission ids you need, within your manifest's required and optional ones. The person consents in
Poltura ID. Such a call may do only what the installation, the person's grant and the person's
current role all allow, and the store's audit names the person through your app.
Refusals and changes
403witherror.code: "insufficient_scope"names thepermissionthe call needed. For a person whose grant lacks a permission the installation holds,WWW-Authenticatelists the scope to ask for again; anything the installation lacks waits for a merchant's approval.- A release that adds required permissions deploys at once; the installation keeps what it was given until an administrator approves the new ones in Poltura ID. Permissions a release drops are removed at once. Optional permissions are never prompted; ask a person through consent.
installation.permissions_changedandinstallation.stores_changedtell you to readGET /installationagain: it returns the stores, the permissions this call may use and the pending ones.- Uninstalling revokes the installation and every grant a person gave your app in that organisation; old tokens stop working at once.
Session tokens for hosted pages
A hosted page runs in an iframe in the admin. When it calls your server, your server needs to know which merchant and which installation it is talking to. A cookie cannot tell it: the page is on your origin inside Poltura's, and browsers block such cookies. The page asks the admin for a session token instead and sends it with each request.
A session token is a signed statement from Poltura: this merchant has this installation of this app open right now. It lasts at most 60 seconds, carries no permissions and is not an API token: the App API refuses it. Your server uses it only to pick the stored installation token.
- Add
get_session_tokentoadminFrame.actionsin the manifest. - In the page, connect to the admin and request a token. The SDK holds it in memory only:
const session = connectAppFrame({
window,
parentWindow: window.parent,
parentOrigin: root.dataset.parentOrigin ?? '',
approvedFrameOrigin: root.dataset.frameOrigin ?? '',
actions: ['get_session_token'],
});
try {
await ready(session);
const { token } = await requestAppSession(session);
const response = await fetch('/api/overview', {
headers: { authorization: `Bearer ${token}` },
credentials: 'omit',
});parentOrigin is the Poltura admin origin and approvedFrameOrigin your own; the example's server writes both
into the page. requestAppSession throws AppFrameSessionError before the admin has connected, when the
manifest does not list get_session_token, or after the frame is gone.
- On your server, create one verifier at start-up:
const sessions = createAppSessionVerifier({
issuer: env.POLTURA_SESSION_ISSUER,
audience: appOrigin,
appId: env.POLTURA_CLIENT_ID,
jwks: JSON.parse(env.POLTURA_SESSION_JWKS),
oauthIssuer: env.POLTURA_ISSUER,
apiAudience: env.POLTURA_RESOURCE,
merchantIssuer: env.POLTURA_MERCHANT_ISSUER,
});audience is your page's origin. jwks is the public key set from the session issuer; the SDK never fetches
keys on its own, so reload it when Poltura adds a key. oauthIssuer, apiAudience and merchantIssuer must be
set and must all differ from each other and from the session issuer, so a token meant for one cannot pass as
another.
- Verify each request, then match it to the installation you stored at install time:
const header = req.headers.authorization ?? '';
const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
const session = await sessions.verify(token);
const connection = connections.get(session.installationId);
const bound =
connection !== undefined &&
connection.installation.appId === session.appId &&
connection.installation.storeId === session.storeId &&
connection.installation.releaseId === session.releaseId &&
connection.installation.generation === session.generation;
const reconnect = { connectUrl: `${appOrigin}/connect?installation=${session.installationId}` };
if (connection === undefined || !bound) {
json(res, 409, reconnect);
return;
}verify checks the ES256 signature, the poltura-app-session+jwt type, the issuer, the audience, your app id
and a lifetime of at most 60 seconds (with 5 seconds of clock tolerance), and refuses tokens over 4096 bytes.
On any failure it throws AppSessionError without details. On success it returns the merchant (subject),
appId, storeId, installationId, releaseId, generation, revision, manifestDigest, frameId, jti
and the times.
- A valid signature says the merchant had the page open; it does not prove the installation is still active. Before anything sensitive, call the App API with the stored token, which fails once the app is removed.
- A token can be sent again until it expires. If an operation must happen once, record its
jti. - If you have no stored installation yet, the page cannot fix that from inside the iframe: it cannot open
pop-ups or navigate the admin. The example answers
409with the connect address, and the page asks the merchant to open it in a new tab.
Signed actions
A native worker calls your server through an action: useAction('suggest_box') in the
worker becomes a POST to backendOrigin plus the action's path, signed by Poltura. Verify it with
createActionHandler from @poltura/app-sdk/actions:
const actions = createActionHandler({
trustedKeys: [{ kid: env.POLTURA_WEBHOOK_KID, publicKeyPem: env.POLTURA_WEBHOOK_PUBLIC_KEY }],
handlers: {
suggest_box(request) {
return suggestBox(SuggestBoxInput.parse(request.input).quantity);
},
check_listing(request) {
return { hints: listingHints(CheckListingInput.parse(request.input)) };
},
},
});actions.handle(rawBody, headers) verifies the request, runs the handler named by the request and returns its
JSON answer. Pass the body exactly as received, as bytes; parsing it first breaks the signature.
| Header | Meaning |
|---|---|
poltura-signature | v1= and an Ed25519 signature, base64url without padding, over <timestamp>.<raw body> |
poltura-timestamp | Unix seconds when Poltura signed the request |
poltura-action | The action id; picks the handler |
poltura-installation, poltura-store | The installation and store; must match the body |
poltura-surface | admin or storefront; must match the body |
poltura-request-id | A unique id for this call |
poltura-shopper | Present on storefront calls |
The body is { "input": ..., "context": { "installationId", "storeId", "surface", "locale", "instanceId", ... } },
at most 64 KiB. request.input is whatever the worker passed; validate it like any other input.
- Timestamps more than 300 seconds old or ahead are refused (
expired_timestamp,future_timestamp). - Replays inside that window carry a valid signature. If an action changes something, store
poltura-request-idand ignore a second request with the same id. - Keys: actions are signed with the same platform key as webhooks. Pass 1 to 8 public keys; during a rotation, trust the old and the new one.
- Errors are
PolturaActionErrorwith acode:invalid,wrong_algorithm,unknown_kid,expired_timestamp,future_timestamp,noncanonical_signature,payload_too_large,altered_bodyornot_declared(no handler for that action). Answer them with a4xx; the worker's call then fails.
Signed webhooks
Webhooks use the same signature: Ed25519 over <timestamp>.<raw body>, a 300-second window and the platform
signing key, picked by the poltura-key-id header. createWebhookVerifier from @poltura/app-sdk/webhooks
checks them and can also require one store and installation. Details, topics and retries are on
Webhooks.
What the browser never gets
- Access and refresh tokens. Only your server holds them. Never send one to a worker, a hosted page or a URL, not even to work around a blocked cookie.
- Your private signing key and the authorization state. The browser carries only the redirect address and an opaque cookie that points at the state on your server.
- Session tokens stay in the page's memory for one request: not in cookies,
localStorage, URLs or logs. - Workers get none of the above. They reach your server through actions, which Poltura signs and sends for them, and read store data only through the hooks the platform gives them.