App Manifest

Every release of an app is defined by a single manifest.json file. The manifest is the release: it declares the release's version, the API scopes it requests, and everything the app does inside Stok.ly. That covers the pages it adds, where it adds UI, which existing actions it hooks into, and which core UI it hides.

🚧

The developer portal is still under active development. Field names on this page are final for schemaVersion 1, but the set of published extension points grows over time.

Top-level shape

{
  "schemaVersion": 1,
  "version": "2.4.0",
  "scopes": ["catalogue.items.read"],
  "oauth": {
    "redirectUrls": ["https://app.example.com/oauth/callback"]
  },
  "webhooks": [
    { "topic": "<topic from the topics catalog>", "url": "https://app.example.com/webhooks/stokly" }
  ],
  "worker_url": "https://app.example.com/stokly/worker",
  "pages": [
    { "id": "dashboard", "route": "dashboard", "title": "Dashboard", "content_url": "https://app.example.com/stokly/dashboard" }
  ],
  "extensions": [
    { "id": "nav", "extension_key": "web.sidebar.stock-inventory", "label": "Inventory Sync", "page": "dashboard", "after": "items" }
  ],
  "hooks": [
    { "target": "web.items.view.actions.save", "phases": ["before", "after"] }
  ],
  "removeElements": [
    "web.items.view.actions.duplicate"
  ]
}
FieldTypeRequiredDescription
schemaVersionintegeryesVersion of this manifest format. Currently always 1. Not the same as version below; see Versioning.
versionstringyesThis release's own version (e.g. "2.4.0"). Must be unique across your app's releases.
scopesstring[]noAPI scopes this release requests. See Version and scopes.
oauthobjectyesOAuth install-flow settings. See OAuth.
permissionsarraynoPermissions your app defines, which account admins can give to roles. See Permissions.
webhooksarraynoEvents Stok.ly should deliver to your backend. See Webhooks.
worker_urlstringconditionalYour app's hidden worker frame. Required when you use hooks, columns, or actions without a content_url. See Worker.
pagesarraynoFull pages your app adds to the webapp. See Pages.
extensionsarraynoUI your app adds to Stok.ly's own pages. See Extensions.
hooksarraynoExisting Stok.ly actions your app wants to check or react to. See Hooks.
removeElementsstring[]noCore Stok.ly UI to hide for accounts with this app installed. See Removing core UI.

When you upload a manifest, every validation problem in it is reported at once, as a list, so you can fix them all in one pass. A file that isn't valid JSON, or isn't a JSON object, is rejected on its own, and conflicts with your other releases (a version already used, or an origin another developer owns) are reported separately after the manifest itself passes.

Versioning

schemaVersion describes the shape of this file. It lets Stok.ly evolve the manifest format over time without breaking manifests written against an older version. Change it only when Stok.ly's documentation tells you a new version is available; day-to-day changes to your app don't need it.

version is a completely different thing: it's your app's version for this release (e.g. "2.4.0"). It must be a semantic version (major.minor.patch), and versions are compared numerically, so 1.10.0 is newer than 1.9.0. Accounts that have your app installed are offered an update when you publish a release with a higher version than the one they run.

Version and scopes

"version": "2.4.0",
"scopes": ["catalogue.items.read", "catalogue.items.create"]
FieldTypeRequiredDescription
versionstringyesA semantic version in the form major.minor.patch (e.g. "2.4.0"), with no v prefix, pre-release or build suffix, no leading zeros (01.0.0 is rejected) and up to 32 characters. Must be unique across this app's releases: you can't reuse a version used by any of your existing releases, draft or published. To publish, it must also be greater than your latest published version. See Updating your app.
scopesstring[]noThe API scopes this release needs. Every scope must be one of Stok.ly's published access scopes. Omit it or leave it empty to request none. Duplicates are ignored.

Re-uploading a manifest while the release is still a draft can change version and scopes freely, like any other field. Nothing is locked in until you publish.

Permissions

"permissions": [
  { "key": "approve", "label": "Approve drop shipments", "description": "Lets the user approve drop shipments in your app." },
  { "key": "reports.view", "label": "View reports" }
]

Permissions let your app decide what each of an account's users may do inside it. You define them, account admins give them to roles, and your own API enforces them.

FieldTypeRequiredDescription
keystringyesYour stable identifier for the permission, unique within the array. Lowercase letters, digits, _ and -, starting with a letter, with optional .-separated parts (approve, reports.view). It must match ^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$ and be up to 64 characters.
labelstringyesWhat users see in the Roles editor. Up to 100 characters.
descriptionstringnoExplains what the permission allows. Up to 300 characters.

A release can define at most 50 permissions.

  • Users get permissions through roles. The Roles editor shows a group for each installed app, listing that app's permissions, and stores each one as apps.<appId>.<key>.
  • Account admins hold all of an app's permissions.
  • Your frames' session token carries permissions: the short keys (approve, not apps.<appId>.approve) the signed-in user holds for your app. Enforce them in your own API, since only your backend can stop a user doing something.
  • Adding or removing permissions in a release shows in the update diff, but doesn't need the account's consent the way new scopes do.

OAuth

"oauth": {
  "redirectUrls": [
    "https://app.example.com/oauth/callback"
  ]
}
FieldTypeRequiredDescription
redirectUrlsstring[]yesHTTPS URLs Stok.ly may redirect to after a user installs your app and authorizes its requested scopes. At least one is required. Must be an exact match, with no wildcards. The first entry is the install redirect: when a user installs your app, Stok.ly redirects to it with code and state query parameters. See Installing your app.

Webhooks

"webhooks": [
  { "topic": "<topic from the topics catalog>", "url": "https://app.example.com/webhooks/stokly" }
]
FieldTypeRequiredDescription
topicstringyesEvent name, up to 128 characters; see Webhook topics. Each topic can appear only once.
urlstringyesHTTPS endpoint Stok.ly will POST the event to.

See Webhooks overview for delivery and retries, Verifying webhooks below for signatures, and Webhook topics for the current list of topics you can subscribe to. Requesting a topic that isn't listed there fails manifest validation, and so does requesting a topic without the scope it needs: the topics page shows each topic's scope, and it must be in your release's scopes.

When an account installs your app, Stok.ly subscribes it to your release's webhooks whose scope the account granted (a topic stops if its scope is later revoked), and when the account updates your app the subscriptions move to the new release's. When your app is uninstalled, nothing more is delivered for that account and anything still queued for it is dropped.

Order topics have these payloads. sale_order.* topics need the sales.orders.read scope, and purchase_order.* topics need purchasing.orders.read.

TopicPayload
sale_order.cancelled{ saleOrderId }
sale_order.items_cancelled{ saleOrderId, lineIds }
sale_order.delivery_changed{ saleOrderId }
sale_order.hold_changed{ saleOrderId, onHold }
sale_order.payment_made{ saleOrderId, paymentId, method, amount, currency? }
purchase_order.submitted{ purchaseOrderId, supplierId, deliveryLocationId }
purchase_order.cancelled{ purchaseOrderId, deliveryLocationId? }
purchase_order.closed{ purchaseOrderId, deliveryLocationId? }

app.uninstalled is the exception: it's about your app rather than an account's data, so it's sent from your own developer account and always uses your latest published release's URL. Its body names the account that uninstalled your app:

{ "appId": "<your app id>", "accountkey": "acme" }

It can occasionally arrive more than once, so handle it idempotently.

Verifying webhooks

Every delivery is signed with your app's Webhook Secret (see Getting your credentials). Check each request before you trust it:

HeaderValue
X-Stokly-Signaturesha256= followed by the hex HMAC-SHA256 of <X-Stokly-Timestamp>.<raw body>, keyed with your Webhook Secret
X-Stokly-Timestampwhen this attempt was sent, as an ISO 8601 date
X-Stokly-Topicthe topic
X-Stokly-Delivery-Idthe same on every retry of one delivery, so you can ignore repeats
X-Stokly-Accountkeythe account the delivery is about. For app.uninstalled it's your developer account, so read the account from the body
X-Stokly-Subscription-Idthe subscription the delivery belongs to
X-Stokly-Payload-Versionthe payload version of the subscription
  1. Compute the HMAC over the raw body exactly as received, before any JSON parsing.
  2. Compare it with the header in constant time.
  3. Reject the request if the timestamp is more than 5 minutes from your clock, so a captured request can't be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(rawBody, headers) {
	const timestamp = headers["x-stokly-timestamp"];
	const expected = "sha256=" + createHmac("sha256", process.env.STOKLY_WEBHOOK_SECRET).update(`${timestamp}.${rawBody}`).digest("hex");
	const actual = headers["x-stokly-signature"] ?? "";
	const fresh = Math.abs(Date.now() - Date.parse(timestamp)) < 5 * 60 * 1000;
	return fresh && actual.length === expected.length && timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
}

Monitoring deliveries

Your app's Webhooks tab in the developer portal shows deliveries across every account that has your app installed: counts, success rate, response times, failures by error type and a breakdown by topic. Its delivery log lists each delivery with its status, response code and attempts. Open one to see the payload that was sent, and for each attempt the request headers (with the signature hidden), the response code, the first 4KB of your response body and any error.

The delivery log keeps the last 30 days. Older deliveries are kept only as monthly totals in the charts. When an account uninstalls your app, its delivery history is removed too.

Worker

"worker_url": "https://app.example.com/stokly/worker"

Your worker is a page you host that Stok.ly loads in a hidden frame while a user is signed in. It receives events from Stok.ly: page actions and bulk actions being clicked, column values being requested, and hooks running. It handles them using the App Bridge SDK. The worker is required when your manifest uses hooks, columns, or actions without a content_url.

Pages

"pages": [
  { "id": "dashboard", "route": "dashboard", "title": "Dashboard", "content_url": "https://app.example.com/stokly/dashboard" },
  { "id": "report", "route": "reports/{id}", "title": "Report", "content_url": "https://app.example.com/stokly/report" }
]

A page is a full screen inside the webapp, served at /apps/<appId>/<route>, that renders content_url in a frame. It's a real, bookmarkable URL. A page exists on its own: link to it from the sidebar or a chip (see Extensions), or navigate to it from your own frames with the App Bridge.

FieldTypeRequiredDescription
idstringyesYour identifier for the page, unique within the manifest. See Ids.
routestringyesThe path after /apps/<appId>/. Unique within the manifest. See Routes.
titlestringyesShown in the page header. Up to 128 characters.
content_urlstringyesHTTPS URL to render in the page's frame.

Routes

A route is one or more segments separated by /, up to 128 characters. Each segment is either a literal (lowercase letters, digits and hyphens) or a param in braces, e.g. {id}.

  • Segments can't be empty, so no leading, trailing or repeated /.
  • The first segment must be a literal: reports/{id} is valid, {id} is not.
  • Param names start with a letter and contain only letters, digits and underscores. Each name can appear once per route.
  • accountkey and extensionKey are reserved and can't be used as param names, in any letter case.
  • Two routes can't have the same shape: reports/{id} and reports/{name} conflict. A literal and a param can coexist, and the literal wins, so reports/new and reports/{id} are both valid.

Route params, and any query parameters on the page's URL, are passed to your page. See How your frames get their data. A page with route params can't be linked from the sidebar or a chip, since there'd be no values to fill them with. Navigate to it from your frames instead.

Extensions

Each entry in extensions adds one thing to one place inside Stok.ly: a sidebar item, a chip, a tab, a section, an action, a bulk action or a table column. Where it goes, and what type of thing it is, both come from its extension_key. You don't declare a type separately.

Tabs, sections, and dialogs opened by actions render your content_url in a sandboxed frame. Stok.ly never injects your markup into its own pages.

Only the web platform publishes extension points today, so keys starting epos. or warehouse. are rejected. A key must also be a container that accepts additions (for example web.items.view.tabs, not one of its tabs).

Fields

FieldTypeRequiredDescription
idstringyesYour stable identifier for this extension, unique within the manifest (e.g. "order-shipping-tab"). See Ids and Adding, changing and removing your own extensions.
extension_keystringyesThe extension point this attaches to, e.g. web.items.view.tabs. Must be one of Stok.ly's published extension points, and one that accepts additions. Up to 128 characters.
labelstringyesDisplay text: the nav label, tab label, section title, button label, chip label or column header. Up to 128 characters.
tooltipstringnoActions only. Hover text, up to 128 characters; defaults to the label.
iconstringnoA Material Symbols icon name, e.g. sync. Used by top-level sidebar items (web.sidebar), chips and actions; not allowed on items inside a sidebar group. The name isn't checked, so a misspelt icon just doesn't show.
pagestringconditionalThe id of one of your pages. Required for sidebar items and chips, and not allowed for anything else. Up to 128 characters.
content_urlstringconditionalHTTPS URL to render in a frame. Required for tabs and sections, optional for actions, not allowed for sidebar items, chips and columns. See Types of extension point.
confirmbooleannoActions only, default false. When true, Stok.ly asks the user to confirm before running the action.
parentstringnoSections only: the tab or step to put the section in, up to 256 characters. See Choosing a tab or step for a section.
beforestringnoPlace this extension before an existing element of the extension point, up to 128 characters. See Positioning.
afterstringnoPlace this extension after an existing element of the extension point, up to 128 characters. See Positioning.

Types of extension point

What an extension may contain depends on the type of extension point it targets. The extension point reference lists each point's type.

TypeExample extension_keypagecontent_urlPositioningNeeds a worker
Sidebar (root or a group)web.sidebar.stock-inventoryrequirednot allowedyesno
Chipsweb.items.list.linksrequirednot allowedyesno
Page actionsweb.items.view.actionsnot allowedoptional: opens it in a dialogyesyes, when there's no content_url
Sub-actions (an action's menu)web.items.list.actions.importnot allowedoptional: opens it in a dialogyesyes, when there's no content_url
Bulk actionsweb.items.list.table.bulk-actionsnot allowedoptional: opens it in a dialogyesyes, when there's no content_url
Tabsweb.items.view.tabsnot allowedrequiredyesno
Sectionsweb.items.sectionsnot allowedrequiredyesno
Table columnsweb.items.list.table.columnsnot allowednot allowednoyes
  • Sidebar items and chips link to one of your pages. Chips are the rounded links above a list page's table. Pages with route params can't be linked.
  • Actions and bulk actions either open content_url in a dialog, or, with no content_url, send an event to your worker, which can then show a toast, open a dialog or navigate. Bulk actions receive the ids of the selected rows.
  • Tabs and sections render content_url inside the page.
  • Columns are added to the end of a table; users can reorder and hide them in the table's settings. Your worker supplies the values for each page of rows (see App Bridge). App columns can't be sorted or filtered.

confirm only has an effect on page actions, sub-actions and bulk actions.

The extension points on your app's own page (web.passport.apps.view.tabs, web.passport.apps.sections and web.passport.apps.view.actions) only ever show your app's extensions, and none of their elements can be removed.

Positioning

before and after place your extension next to one of the extension point's existing elements. The value is the last segment of that element's key, which the extension point reference lists for each point. For example, after: "inventory" on web.items.view.tabs puts your tab after web.items.view.tabs.inventory.

  • Set at most one of before and after. With neither, your extension goes at the end.
  • Columns can't be positioned.
  • If the element you anchor to isn't on the page (for example, a tab that's hidden for that item's format), your extension goes at the end.

Choosing a tab or step for a section

Section keys belong to the entity, not to a tab: web.items.sections is the sections container for items, web.item-types.sections for item types, web.item-attributes.sections for item attributes and web.passport.apps.sections for your app's own page. Where the entity's create page has sections (item types and item attributes, for example), the same container is used there too, so one section shows on both. The item create page has no sections.

With no parent, a section goes in the first section area on the page (on the item page, the Basic information tab). To put it in a particular tab (or, on a create page, a particular wizard step), set parent:

{ "id": "sync-notes", "extension_key": "web.items.sections", "label": "Sync notes", "content_url": "https://app.example.com/stokly/item-section", "parent": "basic-information", "after": "details" }
  • parent is only valid on extensions that add to a sections container.
  • It takes the short form (the last segment of the host's key, e.g. basic-information or inventory) or a full key (e.g. web.items.view.tabs.basic-information).
  • Valid values are the tabs and steps the extension point reference lists as hosts, including the create wizard steps web.item-types.create.steps.details, web.item-attributes.create.steps.details and web.item-attributes.create.steps.configuration (or just details and configuration).
  • The tab or step must actually contain the sections container. On the item page, that's basic-information, inventory, shipping, variations and variations-bill-of-materials. A tab that's on the page but holds no sections area (such as sales) passes validation but your section isn't shown at all.
  • If the named tab or step isn't on the page (it was renamed or removed, or the page has no tabs), your section goes in the page's default section area instead, so your manifest keeps working when the layout changes.
  • parent never changes how before and after work. Anchors are section keys, which are unique per entity, whichever tab or step they sit in.
⚠️

The old tab-scoped section keys, such as web.items.view.basic-information.sections, no longer exist and have no alias. A manifest that still uses one must be updated to the new keys and republished.

Ids

Page and extension ids use lowercase letters, digits and hyphens, start with a letter or digit, and are up to 64 characters long. For example, order-shipping-tab.

How your frames get their data

Stok.ly never sends record data to your frames, only references to it. Your app fetches whatever it needs through the Stok.ly API, using the access it was granted at install. Scopes are enforced there. Each frame's URL carries these query parameters:

ParamSent toDescription
extensionKeytabs, sections and action dialogsThe full key of the extension point, e.g. web.items.view.tabs.
context keystabs, sections and action dialogsThe ids of whatever the extension point is showing, e.g. itemId on the item view.
route paramspagesEach param in the page's route, e.g. id for reports/{id}.
querypagesThe query parameters on the page's URL. Keys already set on the frame URL (by your content_url's query, a route param or a context key) or reserved (accountkey, extensionKey) are dropped, as are entries over the query limits.

Any query string already on your content_url is kept. The same values, plus your extension's extensionId, are available from the App Bridge (bridge.context), which is also how your worker receives the context of an action and the ids of a bulk action's selected rows.

Frame URLs don't carry accountkey. To know which user and account your frame is being used by, get a signed session token with the bridge's getSessionToken() and verify it on your backend. See Session token.

🔒

Treat every value from the URL or the bridge as untrusted input, and authorise data access on your backend. Only a session token your backend has verified proves the user and account.

Adding, changing and removing your own extensions

The manifest describes your entire desired set of extensions for that release, not a diff. To remove one of your own extensions, publish a new release whose manifest leaves it out. Keep an extension's id stable across releases if you're only changing its label, content_url or other fields. Changing the id is the same as removing the old extension and adding a new one.

This is about your own extensions. To hide something that's part of Stok.ly's core product, see Removing core UI.

Hooks

"hooks": [
  { "target": "web.items.view.actions.save", "phases": ["before", "after"] },
  { "target": "web.items.list.table.bulk-actions.delete", "phases": ["before"] }
]

Hooks let your app react to Stok.ly's own actions, such as an item being saved. A before hook can stop the action, for example when your section's inputs aren't valid. An after hook runs once the action has succeeded, for example to save your own data alongside the record. Your worker handles hooks; see App Bridge for the events and what a before hook can return.

FieldTypeRequiredDescription
targetstringyesThe key of a hookable action. The extension point reference marks which actions are hookable and which phases each supports. Each target can appear once.
phasesstring[]yes"before", "after" or both. At least one, with no duplicates.
  • Hooks require a worker_url.

  • Most create, save, delete and status actions are hookable, and so are the delete, update and status bulk actions (cancel, pay, void, approve, submit) on list pages (for example web.items.create.actions.create, web.purchase-orders.view.actions.cancel and web.items.list.table.bulk-actions.delete). See Hooks in the App Bridge page for what they receive.

  • Your worker only receives the hooks you declare here.

  • If your worker fails, or doesn't answer within 5 seconds (before) or 30 seconds (after), the action goes ahead and the user sees a warning. A broken app never stops users working.

  • A before hook on a bulk action can skip individual rows instead of cancelling the whole action.

  • Hooks on create actions include the draft (before) and the created record (after) only if your release requests the entity's read scope. Secrets are removed.

  • Hooks can read and cancel, but can't change what's saved.

❗️

Hooks only run in the Stok.ly web UI. Changes made through the API, CSV imports, integrations or scheduled rules never trigger them, so don't rely on a before hook as your only validation.

Removing core UI

"removeElements": [
  "web.items.view.actions.duplicate"
]

Beyond adding your own UI, an app can hide existing Stok.ly UI, for accounts that have the app installed only. Use this when your app fully replaces built-in functionality and showing both would be confusing. For example, your own bulk action might supersede Stok.ly's.

Each entry is the full key of an existing element, taken from the extension point reference. Only elements marked as removable can be hidden, and duplicates are ignored. Like extensions, this list is your entire desired set for the release: a key that drops out of removeElements in a later release becomes visible again.

Removing core UI is a more invasive request than adding your own, so expect it to get closer scrutiny during app review.

URLs and origins

Every URL your app serves content from (worker_url, and each page's and extension's content_url) must be HTTPS on port 443. It must also pass the same checks as webhook URLs: the hostname must resolve in DNS, no localhost, private or internal addresses, and no credentials in the URL. URLs can be up to 2048 characters, hostnames up to 253 and origins up to 255. Stok.ly also checks these URLs when you publish; see Security review.

Each of those URLs' origins (scheme, host and port) must belong to your developer account alone. An origin is claimed as soon as you upload a release that uses it, draft or published, and freed when none of your releases use it. OAuth redirect URLs don't count.

  • An origin that another developer's app already uses is rejected.
  • Your own apps can share an origin, but then they can reach each other's frames, so use a separate origin per app if that matters to you.
  • Stok.ly's own domains (stok.ly and its subdomains) can't be used.

Local development

On the dev environment only, you can run your app on your own machine. There, the worker_url, each page's and extension's content_url, and the OAuth redirectUrls may be HTTP or HTTPS URLs on localhost, 127.0.0.1 or [::1], with any port, for example http://localhost:3000/stokly/dashboard. The host must match exactly, so localhost.example.com is treated like any other domain.

  • Webhook URLs must always be public HTTPS, because Stok.ly's servers call them and a local address would point back at Stok.ly itself.
  • In production, every URL must be public HTTPS on port 443, so swap your local URLs for your real ones before you publish a release there.
  • Origins are still owned by one developer account each, so a local origin such as http://localhost:3000 can't be claimed by two developers. Pick a port that no other developer's app is likely to use.

Security review

When you publish a release, Stok.ly reviews its manifest and requests the URLs it serves. Stok.ly never sees your source. The review sends a GET request to each URL, reads only the response headers, and identifies itself with User-Agent: Stokly-AppReview/1. Webhook URLs only get a TLS and DNS check: Stok.ly never posts to them during review. On the dev environment, local URLs aren't requested and the tunnel rule doesn't apply (see Local development).

Every finding is one of two kinds:

  • Blocking findings stop the release being published until you fix them. They can't be overridden. If you think one is wrong, contact support.
  • Warnings don't stop you, but you must acknowledge each one to publish.

The developer portal shows the findings when you click Publish.

ℹ️

The review is a point-in-time check of what your server returned when you published. It doesn't stop you changing your server later, and it isn't a substitute for securing your app.

"Content URLs" below means worker_url and each page's and extension's content_url.

Blocking checks

CodeWhat it means and how to fix it
secret-in-urlA content URL has a query parameter named token, secret, api_key, apikey, access_token or password. Frame URLs are visible to every user of every account that installs your app, so pass secrets another way, such as the session token.
dns-blockedA URL's host now resolves to a private, loopback or link-local address. Point it at a public address.
tunnel-hostIn production, a URL uses an ephemeral tunnel host such as ngrok, trycloudflare.com, loca.lt, localtunnel.me, serveo.net or localhost.run. Host your app on a stable domain.
tls-invalidThe TLS certificate is expired, self-signed or doesn't match the hostname. Install a valid certificate.
redirect-cross-originA content URL redirects to a different origin. Stok.ly identifies your frames by origin (see URLs and origins), so use the final URL in the manifest.
redirect-insecureA URL redirects to http. Redirect only to https.

Warnings

CodeWhat it means and how to fix it
secret-in-urlA content URL has a query parameter named key, auth or sig, or a webhook or OAuth redirect URL has any of the secret names listed above. Move the value out of the URL.
ip-hostA URL's host is an IP address. Use a hostname.
shared-originA content origin is also used by another of your apps, so they can reach each other's frames. Use a separate origin per app.
tls-expiringThe certificate expires within 14 days. Renew it.
redirect-cross-originAn OAuth redirect URL redirects to another origin, which could forward the authorization code. Register the final URL instead.
no-frame-ancestorsContent responses have no Content-Security-Policy frame-ancestors, or allow *, so other sites could frame your pages. Restrict it to Stok.ly.
frame-blockedContent responses send X-Frame-Options DENY or SAMEORIGIN, or frame-ancestors 'none' or 'self', so your page won't load inside Stok.ly. Allow Stok.ly to frame it.
no-hstsContent responses have no Strict-Transport-Security header. Add one.
insecure-cookieA content response sets a cookie without Secure. Add the Secure flag.
unreachableA URL couldn't be checked: timeout, connection error or DNS failure. Make sure it's up and publicly reachable, so the other checks can run on it.

Full example

This manifest passes validation once app.example.com is replaced with your own hostname: every hostname must resolve.

{
  "schemaVersion": 1,
  "version": "2.4.0",
  "scopes": ["catalogue.items.read", "catalogue.items.modify", "sales.orders.read"],
  "oauth": {
    "redirectUrls": ["https://app.example.com/oauth/callback"]
  },
  "webhooks": [
    { "topic": "sale_order.cancelled", "url": "https://app.example.com/webhooks/stokly" }
  ],
  "worker_url": "https://app.example.com/stokly/worker",
  "pages": [
    { "id": "dashboard", "route": "dashboard", "title": "Inventory Sync", "content_url": "https://app.example.com/stokly/dashboard" },
    { "id": "sync-report", "route": "reports/{id}", "title": "Sync report", "content_url": "https://app.example.com/stokly/report" }
  ],
  "extensions": [
    { "id": "nav", "extension_key": "web.sidebar.stock-inventory", "label": "Inventory Sync", "page": "dashboard", "after": "items" },
    { "id": "reports-chip", "extension_key": "web.items.list.links", "label": "Sync reports", "icon": "sync", "page": "dashboard", "after": "item-types" },
    { "id": "sync-tab", "extension_key": "web.items.view.tabs", "label": "Sync", "after": "inventory", "content_url": "https://app.example.com/stokly/item-tab" },
    { "id": "sync-notes", "extension_key": "web.items.sections", "label": "Sync notes", "content_url": "https://app.example.com/stokly/item-section", "parent": "basic-information", "after": "details" },
    { "id": "sync-now", "extension_key": "web.items.view.actions", "label": "Sync now", "icon": "sync", "after": "save", "confirm": true },
    { "id": "bulk-sync", "extension_key": "web.items.list.table.bulk-actions", "label": "Sync selected", "icon": "sync" },
    { "id": "sync-status", "extension_key": "web.items.list.table.columns", "label": "Sync status" }
  ],
  "hooks": [
    { "target": "web.items.view.actions.save", "phases": ["before", "after"] }
  ],
  "removeElements": [
    "web.items.view.actions.duplicate"
  ]
}

Did this page help you?