← Shopify Submit
API
Tokens

Run Shopify Submit from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads shopify.app.toml, classifies the scopes, lints the listing, runs every check, re-ranks the distribution menu, decides the billing model and the protected data level, lays out the timeline from today, builds the packet and reads the Paused banner, then hands it all to the run as facts. The run plans the submission or answers a paused review; the page then holds it to its own facts (shreply.js). Shopify Submit is not affiliated with or endorsed by Shopify.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"shopify-submit"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, app, config, billing, listing, facts) is missing. The page always sends all eight of task, app, config, billing, listing, review, carry, facts; review and carry are empty strings for plan, and config and listing say so when nothing was pasted.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - the submission, then a paused review:

A missing or unknown task is answered as plan. Both lanes send the same eight fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the plan's verdict, fixes and packet into carry for the fixes lane.

The input

FieldRequiredWhat
taskyesplan or fixes. If it is missing or neither, do plan and set "task": "plan".
appyeswhat the app does and the interview answers: distribution and the development app, the paid SaaS account, the install flow, App Bridge, expiring offline access tokens†, the Admin API, the data read and the protected customer data request, the storefront and the category, the reviewer credentials, the review requests, the Plus organization, discovery on day one, the stage, the launch date with its days from today, and the Built for Shopify numbers.
configyes (says so when no TOML was pasted)the shopify.app.toml as the page read it, field by field (URLs described, never copied), with the scopes by kind; or a line saying none was pasted.
billingyeshow Shopify merchants pay, existing customers, the Stripe path, the need for local-currency prices, usage caps or one-time charges, the plans and charges as given and the listing's pricing details.
listingyes (says so when no listing was pasted)the listing name with its length, the card subtitle, the pricing details and the description, as pasted.
reviewsend it (the banner or the email for fixes, empty for plan)fixes task only: the Paused banner or the reviewer's email as pasted.
carrysend it (empty for plan)fixes task only: the submission plan's verdict, fixes and packet when the plan ran first, and the previous resubmission's points once one is logged; may be empty.
factsyesthe page's checks, one line each with the rule, the id, the status and the evidence separated by vertical bars; then the listable verdict, the distribution menu, the billing model, the protected customer data level, the fees, the timeline, the submission packet, the stage, the two figure lists and the legend; for fixes, the banner type, its date and its numbered requirements. A line starting "must:" applies to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it and reply with only the JSON object.

The rules the model applies

From the source skill, @samber/shopify-app-store-playbook - the only standard the run applies:

House rules

The replies

plan

Plan the submission: the verdict, the billing plan, the protected data request, one fix per failing check, the packet with an owner each, and the timeline.

  1. summary - one sentence, at most 40 words: what stands between the app and the Shopify App Store today and what the plan changes.
  2. verdict - an object: listable (exactly the facts' listable: yes or closed_as_planned), distribution (exactly the facts' top rung: limited_then_full, full_at_approval or custom), why (one or two sentences naming the interview answer behind the rung; when closed, open with the verdict and name the assumption the plan makes).
  3. billing - an object: model (exactly the facts' billing model: app_pricing, billing_api or mixed), shopify_line (what the Shopify integration costs a merchant arriving from Shopify - only prices the input gives; otherwise exactly "set it yourself and explain it in the review notes"), stripe_path (one sentence: how the Stripe path stays unreachable from a Shopify install).
  4. protected_data - an object: level (exactly the facts' level: none, level1 or level2), request (one sentence: what to request in the Partner Dashboard before submitting, or "opt out" when none).
  5. fixes - one object per check the facts mark fail, and any warn worth fixing: check (the page's check id), change (one concrete sentence: what to change in the TOML, the billing, the install, the listing, the Partner Dashboard or the review notes). Every fail check appears.
  6. packet - the submission packet, one object per item: item (one sentence), owner (a role, one of engineering, design, product, marketing, submission owner, support, finance - never a person), done (true or false, as the facts' packet says).
  7. timeline - the steps from today, each an object: step (one sentence), wait (the wait; a wait with a figure carries † or says "reported"); two returned rounds included when the app is not yet published.
  8. footnote - exactly the legend line.
  9. caveats - at most 4 short strings: what the team must confirm; [] when there is nothing to say.
{
  "task": "plan",
  "summary": "one sentence",
  "verdict": { "listable": "yes", "distribution": "limited_then_full", "why": "one or two sentences" },
  "billing": { "model": "app_pricing", "shopify_line": "set it yourself and explain it in the review notes", "stripe_path": "one sentence" },
  "protected_data": { "level": "level1", "request": "one sentence" },
  "fixes": [ { "check": "expiring_tokens", "change": "one sentence" } ],
  "packet": [ { "item": "one sentence", "owner": "engineering", "done": false } ],
  "timeline": [ { "step": "one sentence", "wait": "about 30 days per review (reported)" } ],
  "footnote": "† from shopify.dev - Shopify changes requirements without notice; verify before relying on it. \"reported\" = forum or practitioner reports, not Shopify policy.",
  "caveats": [ ]
}

The page holds it to its own facts (check() in shreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL with a scheme; no backticks; no implied Shopify endorsement; no price the input does not give; a summary of at most 40 words; every shopify.dev figure with its dagger, every forum or practitioner figure in a sentence that says reported and never with a dagger, and the exact legend line; the page's listable verdict, distribution rung, billing model and protected data level (a different one is put back and flagged); a Shopify line that names only input prices or says "set it yourself and explain it in the review notes"; a Stripe path and a data request; a fix for every fail check and only the page's check ids; packet owners that are roles and a done that does not contradict the page's packet; a timeline whose waits with a figure carry a dagger or say reported.

fixes

Answer the paused review: one fix per requirement the banner or the email raises, what to write in that requirement's notes, when to resubmit, then the reply to the reviewer.

  1. summary - one sentence, at most 40 words: what the review flagged and what the resubmission changes.
  2. banner - an object: type (exactly the facts' banner type: no_date, cooldown or reviewed), date (the date only when the text gives one, as the facts write it; otherwise "").
  3. points - one object per requirement the text raises, in its order: n (1, 2, 3...), requirement (a short quote or paraphrase of the requirement, from the text), fix (the concrete change, one sentence), notes (one sentence to write in that requirement's notes - where a reviewer misread, answer it there, politely), check (the page's check id the requirement matches, or ""). When the carried plan has a fix for that check, use it.
  4. resubmit - an object: when (one sentence: for no_date, after every item is marked resolved, click Submit fixes; for cooldown, resubmit on the banner's date, never earlier; for reviewed, answer the reviewer's email - billing fixes need no resubmission†), warning (one sentence: never resubmit while queued, and never dispute an exemption refusal - both reported to make the review slower or longer).
  5. reply - the reply to the reviewer: thanks them, then each point on its own line with its fix, numbered in the text's order, then a closing line. Separate the lines with a newline, written \n inside the JSON string - for example "Thank you for the review.\n1. The app now authenticates before any screen.\n2. Charges now go through Shopify billing.\nEvery item is fixed in this one pass." Polite, never argues, never asks to expedite, never claims Shopify endorsement, no URL.
  6. footnote - exactly the legend line.
  7. caveats - at most 4 short strings: what to confirm before resubmitting; [] when there is nothing to say.
{
  "task": "fixes",
  "summary": "one sentence",
  "banner": { "type": "no_date", "date": "" },
  "points": [ { "n": 1, "requirement": "short quote or paraphrase", "fix": "one sentence", "notes": "one sentence", "check": "install_surface" } ],
  "resubmit": { "when": "one sentence", "warning": "one sentence" },
  "reply": "Thank you for the review.\n1. first point - its fix\n2. second point - its fix\nEvery item is fixed in this one pass.",
  "footnote": "† from shopify.dev - Shopify changes requirements without notice; verify before relying on it. \"reported\" = forum or practitioner reports, not Shopify policy.",
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; the same text rules; the banner type the text shows (put back if moved) and a date only when the text gives one; at least as many points as the text numbers, only the page's check ids, a fix and notes on each; a when that waits for the cooldown date (never now), says resolved and Submit fixes for a banner with no date, or answers the email when reviewed; a warning about the queue and exemption disputes, marked reported; a reply that lists every point - numbered points count on their own lines or inline - never asks to expedite and never argues.

Worked examples

Three runs from the page's free samples (invented products, hosts on the reserved .example domain): the submission plan for a CRM connector billing on its own Stripe, the fixes for its paused review carrying that plan, and the plan for an app torn between a custom app and the public listing. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the long fields, strings and lists are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

plan - A CRM connector billing on its own Stripe

The page read it as: closed to the App Store as planned; listable closed_as_planned; stage paused - no date; distribution limited_then_full; billing app_pricing; protected data level1; scopes read_customers (protected), read_products (standard); fail checks: billing_shopify, stripe_path, listing_pricing, install_surface, app_bridge, expiring_tokens, graphql_only, protected_data, compliance_customers_data_request, compliance_customers_redact, compliance_shop_redact, shopify_in_urls, name_platform, review_requests, reviewer_credentials.

Input (facts is 11325 characters; the first 1399 are shown):

{
 "task": "plan",
 "app": "What the app does: Acme CRM Sync copies Shopify customers into Acme CRM so a sales team sees purchase history beside each contact. It needs a paid Acme CRM account, and merchants pay us through our own Stripe checkout.\nDistribution (interview): public; a separate development app exists: no\nNeeds a paid account on your SaaS to deliver value: yes\nInstall flow: asks for the shop domain or a signup on your site first; latest App Bridge with session tokens: no; expiring offline access tokens†: no; Admin API: uses REST\nData read: none; protected customer data requested in the Partner Dashboard: no\nStorefront or checkout: no; category: a connector\nReviewer test credentials exempt from 2FA, OTP and SSO: no; review requests: a button\nOne Plus organization funds the integration and refuses Shopify billing: no; launch needs App Store discovery on day one: no\nStage: paused - no date; launch date: 2026-12-17 (45 days from today, 2026-11-02)\nBuilt for Shopify numbers: net installs from paid-plan shops not given; reviews not given",
 "config": "shopify.app.toml, the fields the review reads:\nclient_id given; name: Acme CRM Sync; handle: acme-crm-sync; application_url https, contains Shopify; embedded false\naccess_scopes.scopes: read_customers, read_products; optional_scopes: none; use_legacy_install_flow: true\nauth.redirect_urls: https, contains Shopify\nwebhooks.api_version: 2025-07; subscriptions: topics app/uninstalled, uri given\nCompliance topics subscribed: customers/data_request no, customers/redact no, shop/redact no\napp_proxy: none; pos.embedded: false; build: none\nScopes by kind (scope | kind | optional): read_customers | protected | required; read_products | standard | required",
 "billing": "How Shopify merchants pay: your own Stripe or invoices\nExisting customers keep paying outside Shopify: yes; the Stripe or signup path is reachable from a Shopify install: yes\nNeeds local-currency prices, usage caps or one-time charges: no\nPlans and charges as given:\nAcme CRM Team plan: 49 USD per seat per month on our own Stripe checkout. The Shopify connector itself is free.\nPricing details from the listing:\nFree to install. Requires an Acme CRM account.",
 "listing": "Listing name: Shopify CRM Sync by Acme (24 characters)\nCard subtitle: The #1 CRM sync, trusted by 5,000+ merchants\nPricing details: Free to install. Requires an Acme CRM account.\nDescription:\nAcme CRM Sync sends every new Shopify customer to Acme CRM, so your sales team sees purchase history next to each contact.\nCRM seats are billed separately - see our pricing page for plans from $49 per month.",
 "review": "",
 "carry": "",
 "facts": "The page's checks (15 fail, 7 warn, 5 pass, 1 info, 7 n/a); verdict: closed to the App Store as planned; rule | id | status | evidence:\n- billing | billing_shopify | fail | your own Stripe or invoices (interview) - charge for anything the Shopify integration delivers through Shopify App Pricing or the Billing API; closed to the App Store as planned until that line moves\n- billing | billing_existing | warn | existing customers keep paying outside Shopify (interview) - earlier staff replies said yes, the latest staff clarification says no (reported); get Partner Support's answer in writing before relying on either\n- billing | stripe_path | fail | reachable from a Shopify install (interview) - reviewers fail apps where they can reach it (reported); a merchant with no SaaS account never sees your checkout\n- billing | app_pricing_fit | na | not on App Pricing (interview)\n- billing | listing_pricing | fail | \"billed separately\" (\"billed separately\"), a pricing page (\"see our pricing\"), a price (\"$49\"), a monthly charge (\"per month\") outside Pricing details - requirement 4.2† keeps pricing in that section; a suspension over \"billed separately\" wording was reported\n- distribution | distribution_choice | pass | public (interview) - locked once chosen\n- distribution | dev_app | warn | no separate development app (interview) - keep one so the distribution lock lands on the production app\n\n[... 9926 more characters cut from this display ...]"
}

Reply (shortened for display - see the cuts below):

{
 "task": "plan",
 "summary": "Billing on your own Stripe closes the App Store as planned; the plan assumes the Shopify line moves to App Pricing, then fixes the install, App Bridge, tokens, GraphQL, data request, compliance webhooks, name and listing.",
 "verdict": {
  "listable": "closed_as_planned",
  "distribution": "limited_then_full",
  "why": "Closed to the App Store as planned: every Shopify charge goes through your own Stripe checkout, so this plan assumes the Shopify integration moves to Shopify App Pricing. On that assumption the default rung holds - a public app with limited visibility for the [... 85 more characters cut from this display]"
 },
 "billing": {
  "model": "app_pricing",
  "shopify_line": "set it yourself and explain it in the review notes",
  "stripe_path": "A merchant who installs from Shopify with no Acme CRM account never sees the Stripe checkout, and a detected existing account pays the Shopify portion as Partner Support confirms in writing."
 },
 "protected_data": {
  "level": "level1",
  "request": "Request Level 1 protected customer data for read_customers in the Partner Dashboard before resubmitting, and add a Level 2 field with a one-line reason only if the sync copies names, emails, phones or addresses."
 },
 "fixes": [
  {
   "check": "billing_shopify",
   "change": "Charge for the Shopify integration through Shopify App Pricing as its own line, and keep charges for non-Shopify integrations on your own billing."
  },
  {
   "check": "stripe_path",
   "change": "Remove every link to the Stripe checkout from the install and the app, so a merchant arriving from Shopify changes plans only on the Shopify-hosted plan page."
  },
  {
   "check": "listing_pricing",
   "change": "Delete \"billed separately\", the price and the pricing page reference from the app details and keep every charge in the Pricing details section, as requirement 4.2† asks."
  },
  {
   "check": "install_surface",
   "change": "Authenticate before any screen on install and reinstall with Shopify-managed install and token exchange, then land the merchant in the app instead of asking for a shop domain or a signup."
  },
  {
   "check": "app_bridge",
   "change": "Load the latest App Bridge script first in every document and authenticate requests with session tokens, so the app works in incognito with third-party cookies blocked."
  }
 ],
 "packet": [
  {
   "item": "Test the app URL and the redirect URLs so install lands on the grant screen, then in the app.",
   "owner": "engineering",
   "done": false
  },
  {
   "item": "Subscribe the three compliance webhooks.",
   "owner": "engineering",
   "done": false
  },
  {
   "item": "File the Level 1 protected customer data request before resubmitting.",
   "owner": "engineering",
   "done": false
  },
  {
   "item": "Pass the embedded app checks: latest App Bridge, session tokens, GraphQL Admin API only, expiring offline access tokens†.",
   "owner": "engineering",
   "done": false
  },
  {
   "item": "Move plan upgrades and downgrades into the app on Shopify App Pricing, with charges in the merchant's charge history.",
   "owner": "engineering",
   "done": false
  }
 ],
 "timeline": [
  {
   "step": "Fix every flagged item and every page fail in one pass, mark each resolved, then click Submit fixes.",
   "wait": "after every item is marked resolved"
  },
  {
   "step": "The next review round; plan for two returned rounds in all (reported).",
   "wait": "about 30 days per review (reported)"
  },
  {
   "step": "Migrate to expiring offline access tokens†.",
   "wait": "before 2027-01-01†"
  },
  {
   "step": "Launch only if approval has landed; otherwise announce the integration as coming to the Shopify App Store and onboard pilots on development stores.",
   "wait": "6-14 weeks to Published (reported)"
  }
 ],
 "footnote": "† from shopify.dev - Shopify changes requirements without notice; verify before relying on it. \"reported\" = forum or practitioner reports, not Shopify policy.",
 "caveats": [
  "Confirm with Partner Support in writing whether existing Acme CRM customers may keep paying on Stripe, since the latest staff clarification says no (reported).",
  "Confirm whether the sync copies customer names or emails, which would add Level 2 fields to the request."
 ]
}

Cut from this display only (the saved reply and the check use the full text): verdict.why (344 characters, 259 shown); fixes (19 items, 5 shown); packet (11 items, 5 shown).

What step 5 prints for it: billing_shopify | stripe_path | listing_pricing | install_surface | app_bridge | expiring_tokens | graphql_only | protected_data | compliance_customers_data_request | compliance_customers_redact | compliance_shop_redact | shopify_in_urls | name_platform | review_requests | reviewer_credentials | toml_legacy_install | toml_embedded | listing_subtitle | dev_app

The page's check: kept - nothing to flag

fixes - A CRM connector billing on its own Stripe

The input carries the saved plan (R.laneForm fills carry from the plan reply), as the page's handoff does, and the banner, read as no_date with 4 numbered requirements.

Input (carry is 4028 characters; the first 1393 are shown; facts is 11273 characters; the first 1399 are shown):

{
 "task": "fixes",
 "app": "What the app does: Acme CRM Sync copies Shopify customers into Acme CRM so a sales team sees purchase history beside each contact. It needs a paid Acme CRM account, and merchants pay us through our own Stripe checkout.\nDistribution (interview): public; a separate development app exists: no\nNeeds a paid account on your SaaS to deliver value: yes\nInstall flow: asks for the shop domain or a signup on your site first; latest App Bridge with session tokens: no; expiring offline access tokens†: no; Admin API: uses REST\nData read: none; protected customer data requested in the Partner Dashboard: no\nStorefront or checkout: no; category: a connector\nReviewer test credentials exempt from 2FA, OTP and SSO: no; review requests: a button\nOne Plus organization funds the integration and refuses Shopify billing: no; launch needs App Store discovery on day one: no\nStage: paused - no date; launch date: 2026-12-17 (45 days from today, 2026-11-02)\nBuilt for Shopify numbers: net installs from paid-plan shops not given; reviews not given",
 "config": "shopify.app.toml, the fields the review reads:\nclient_id given; name: Acme CRM Sync; handle: acme-crm-sync; application_url https, contains Shopify; embedded false\naccess_scopes.scopes: read_customers, read_products; optional_scopes: none; use_legacy_install_flow: true\nauth.redirect_urls: https, contains Shopify\nwebhooks.api_version: 2025-07; subscriptions: topics app/uninstalled, uri given\nCompliance topics subscribed: customers/data_request no, customers/redact no, shop/redact no\napp_proxy: none; pos.embedded: false; build: none\nScopes by kind (scope | kind | optional): read_customers | protected | required; read_products | standard | required",
 "billing": "How Shopify merchants pay: your own Stripe or invoices\nExisting customers keep paying outside Shopify: yes; the Stripe or signup path is reachable from a Shopify install: yes\nNeeds local-currency prices, usage caps or one-time charges: no\nPlans and charges as given:\nAcme CRM Team plan: 49 USD per seat per month on our own Stripe checkout. The Shopify connector itself is free.\nPricing details from the listing:\nFree to install. Requires an Acme CRM account.",
 "listing": "Listing name: Shopify CRM Sync by Acme (24 characters)\nCard subtitle: The #1 CRM sync, trusted by 5,000+ merchants\nPricing details: Free to install. Requires an Acme CRM account.\nDescription:\nAcme CRM Sync sends every new Shopify customer to Acme CRM, so your sales team sees purchase history next to each contact.\nCRM seats are billed separately - see our pricing page for plans from $49 per month.",
 "review": "The banner or the reviewer's email:\nYour app submission is paused.\n\nWe could not complete testing of Acme CRM Sync because core requirements are not met. Fix every item below, mark each one as resolved, then click Submit fixes.\n\n1. The app asks merchants for their shop domain and an Acme CRM signup before authenticating. Apps must authenticate immediately after install, before any other step.\n2. Merchants are charged through an external checkout. Charges for the app must go through the Shopify Billing API or Shopify App Pricing.\n3. The test account we were given asks for a one-time passcode at login, so we could not reach the app.\n4. The listing says CRM seats are billed separately (requirement 4.2). Keep pricing in the Pricing details section only.\n\nThe Shopify App Review team",
 "carry": "The submission plan (listable closed_as_planned, distribution limited_then_full, billing app_pricing, protected data level1):\nThe plan's fixes:\n- billing_shopify: Charge for the Shopify integration through Shopify App Pricing as its own line, and keep charges for non-Shopify integrations on your own billing.\n- stripe_path: Remove every link to the Stripe checkout from the install and the app, so a merchant arriving from Shopify changes plans only on the Shopify-hosted plan page.\n- listing_pricing: Delete \"billed separately\", the price and the pricing page reference from the app details and keep every charge in the Pricing details section, as requirement 4.2† asks.\n- install_surface: Authenticate before any screen on install and reinstall with Shopify-managed install and token exchange, then land the merchant in the app instead of asking for a shop domain or a signup.\n- app_bridge: Load the latest App Bridge script first in every document and authenticate requests with session tokens, so the app works in incognito with third-party cookies blocked.\n- expiring_tokens: Request expiring offline access tokens† now; non-compliant apps lose their listing and new installs after 2027-01-01†.\n- graphql_only: Move every Admin API call from REST to the GraphQL Admin API before resubmitting.\n- protected_data: Submit the Level 1 protected customer data request for read_customers in the\n\n[... 2635 more characters cut from this display ...]",
 "facts": "The page's checks (15 fail, 7 warn, 5 pass, 1 info, 7 n/a); verdict: closed to the App Store as planned; rule | id | status | evidence:\n- billing | billing_shopify | fail | your own Stripe or invoices (interview) - charge for anything the Shopify integration delivers through Shopify App Pricing or the Billing API; closed to the App Store as planned until that line moves\n- billing | billing_existing | warn | existing customers keep paying outside Shopify (interview) - earlier staff replies said yes, the latest staff clarification says no (reported); get Partner Support's answer in writing before relying on either\n- billing | stripe_path | fail | reachable from a Shopify install (interview) - reviewers fail apps where they can reach it (reported); a merchant with no SaaS account never sees your checkout\n- billing | app_pricing_fit | na | not on App Pricing (interview)\n- billing | listing_pricing | fail | \"billed separately\" (\"billed separately\"), a pricing page (\"see our pricing\"), a price (\"$49\"), a monthly charge (\"per month\") outside Pricing details - requirement 4.2† keeps pricing in that section; a suspension over \"billed separately\" wording was reported\n- distribution | distribution_choice | pass | public (interview) - locked once chosen\n- distribution | dev_app | warn | no separate development app (interview) - keep one so the distribution lock lands on the production app\n\n[... 9874 more characters cut from this display ...]"
}

Reply (shortened for display - see the cuts below):

{
 "task": "fixes",
 "summary": "The review paused on the install flow, the external checkout, the reviewer login and pricing in the listing; one pass fixes all four, then every item is marked resolved before Submit fixes.",
 "banner": {
  "type": "no_date",
  "date": ""
 },
 "points": [
  {
   "n": 1,
   "requirement": "The app asks for the shop domain and an Acme CRM signup before authenticating.",
   "fix": "The app now authenticates with Shopify-managed install and token exchange before any screen, then lands the merchant in the app.",
   "notes": "Install and reinstall now open the app straight after authentication; connecting Acme CRM is a step inside the app.",
   "check": "install_surface"
  },
  {
   "n": 2,
   "requirement": "Merchants are charged through an external checkout.",
   "fix": "Charges for the Shopify integration now go through Shopify App Pricing, and the Stripe checkout is unreachable from a Shopify install.",
   "notes": "A merchant arriving from Shopify sees only the Shopify-hosted plan page; no link to the Stripe checkout remains in the app.",
   "check": "billing_shopify"
  },
  {
   "n": 3,
   "requirement": "The test account asks for a one-time passcode at login.",
   "fix": "The reviewer account is now exempt from 2FA, OTP and SSO and keeps working after publish.",
   "notes": "The testing instructions name the reviewer account and every step inside Acme CRM.",
   "check": "reviewer_credentials"
  },
  {
   "n": 4,
   "requirement": "The listing says CRM seats are billed separately, against requirement 4.2†.",
   "fix": "The app details no longer mention billing, prices or a pricing page; every charge sits in the Pricing details section.",
   "notes": "Pricing now appears only in Pricing details, as requirement 4.2† asks.",
   "check": "listing_pricing"
  }
 ],
 "resubmit": {
  "when": "After every item is marked resolved in the Partner Dashboard, click Submit fixes once, with all four fixes in one pass.",
  "warning": "Never resubmit while the app is queued, and never dispute an exemption refusal - both are reported to slow the review or trigger a cooldown."
 },
 "reply": "Thank you for the review.\n1. The app now authenticates through Shopify-managed install before any screen and no longer asks for a shop domain or a signup.\n2. Charges for the app now go through Shopify App Pricing, and the external checkout is no longer [... 281 more characters cut from this display]",
 "footnote": "† from shopify.dev - Shopify changes requirements without notice; verify before relying on it. \"reported\" = forum or practitioner reports, not Shopify policy.",
 "caveats": [
  "File the Level 1 protected customer data request before resubmitting: Shopify refuses requests while the app is in review.",
  "The page also fails expiring offline access tokens†, REST use and the compliance webhooks - fix them in this pass, since a reviewer stops at a blocking failure."
 ]
}

Cut from this display only (the saved reply and the check use the full text): reply (533 characters, 252 shown).

What step 5 prints for it: 1 install_surface | 2 billing_shopify | 3 reviewer_credentials | 4 listing_pricing

The page's check: kept - nothing to flag

plan - Custom or public, with Built for Shopify

The page read it as: returned as it stands; listable yes; stage applying for Built for Shopify; distribution custom; billing billing_api; protected data level1; scopes read_orders (protected), read_products (standard), write_fulfillments (standard), read_all_orders (protected); fail checks: app_pricing_fit, bfs_installs, bfs_reviews.

Input (facts is 10014 characters; the first 1398 are shown):

{
 "task": "plan",
 "app": "What the app does: Crewline turns Shopify orders that need an installation into scheduled crew visits and writes the visit back to the order. It is listed today and the team is applying for Built for Shopify.\nDistribution (interview): public; a separate development app exists: yes\nNeeds a paid account on your SaaS to deliver value: yes\nInstall flow: Shopify-managed install with token exchange; latest App Bridge with session tokens: yes; expiring offline access tokens†: not sure; Admin API: GraphQL only\nData read: orders or customers (Level 1); protected customer data requested in the Partner Dashboard: yes\nStorefront or checkout: no; category: a connector\nReviewer test credentials exempt from 2FA, OTP and SSO: yes; review requests: the Reviews API after a milestone\nOne Plus organization funds the integration and refuses Shopify billing: yes; launch needs App Store discovery on day one: no\nStage: applying for Built for Shopify; launch date: 2026-11-16 (14 days from today, 2026-11-02)\nBuilt for Shopify numbers: net installs from paid-plan shops 38; reviews 4",
 "config": "shopify.app.toml, the fields the review reads:\nclient_id given; name: Crewline Field Service Sync; handle: crewline-sync; application_url https; embedded true\naccess_scopes.scopes: read_orders, read_products, write_fulfillments; optional_scopes: read_all_orders; use_legacy_install_flow: absent\nauth.redirect_urls: https\nwebhooks.api_version: 2025-07; subscriptions: topics orders/create, app/uninstalled, uri given; no topics, compliance_topics customers/data_request, customers/redact, shop/redact, uri given\nCompliance topics subscribed: customers/data_request yes, customers/redact yes, shop/redact yes\napp_proxy: none; pos.embedded: not set; build: none\nScopes by kind (scope | kind | optional): read_orders | protected | required; read_products | standard | required; write_fulfillments | standard | required; read_all_orders | protected | optional",
 "billing": "How Shopify merchants pay: Shopify App Pricing\nExisting customers keep paying outside Shopify: no; the Stripe or signup path is reachable from a Shopify install: no\nNeeds local-currency prices, usage caps or one-time charges: yes\nPlans and charges as given:\nStarter 29 USD per month with 100 jobs; usage 0.25 USD per extra job with a 75 USD monthly cap. One Plus organization funds the integration, refuses Shopify billing and wants its own installer rules before its season starts.\nPricing details from the listing:\nStarter: 29 USD per month including 100 jobs. Each extra job: 0.25 USD, capped at 75 USD per month.",
 "listing": "Listing name: Crewline Field Service Sync (27 characters)\nCard subtitle: Turn installation orders into scheduled crew visits\nPricing details: Starter: 29 USD per month including 100 jobs. Each extra job: 0.25 USD, capped at 75 USD per month.\nDescription:\nCrewline books an installation visit for every order that needs one, assigns a crew and writes the schedule back to the order timeline.",
 "review": "",
 "carry": "",
 "facts": "The page's checks (3 fail, 1 warn, 26 pass, 2 info, 3 n/a); verdict: returned as it stands; rule | id | status | evidence:\n- billing | billing_shopify | pass | Shopify App Pricing (interview); merchants must change plans without contacting support or reinstalling\n- billing | billing_existing | pass | existing customers do not keep paying outside Shopify (interview)\n- billing | stripe_path | pass | unreachable (interview) - a detected existing account pays the Shopify portion as Partner Support confirmed in writing\n- billing | app_pricing_fit | fail | local-currency prices, usage caps or one-time charges are needed (interview) - App Pricing lacks them; choose manual pricing (Billing API) for the whole app and decide before opting in: once opted in you cannot create new recurring Billing API charges†\n- billing | listing_pricing | pass | no price, \"billed separately\", monthly charge or pricing page outside Pricing details\n- distribution | distribution_choice | pass | public (interview) - locked once chosen\n- distribution | dev_app | pass | a separate development app exists (interview) - the lock lands on the production app\n- install | install_surface | pass | Shopify-managed install with token exchange (interview)\n- install | install_token_exchange | pass | Shopify-managed install with token exchange (interview)\n- install | toml_legacy_install | pass | use_legacy_install_flow is\n\n[... 8616 more characters cut from this display ...]"
}

Reply (shortened for display - see the cuts below):

{
 "task": "plan",
 "summary": "Usage caps rule out App Pricing, so billing moves to the Billing API; the Plus organization's 14-day launch promotes a custom app, and Built for Shopify waits for 50 net installs† and 5 reviews†.",
 "verdict": {
  "listable": "yes",
  "distribution": "custom",
  "why": "One Plus organization funds the integration and refuses Shopify billing, and its launch is 14 days from today, inside the 6 weeks a review takes at its fastest (reported); a custom app for that organization wins on speed, at the price of a second app, a [... 62 more characters cut from this display]"
 },
 "billing": {
  "model": "billing_api",
  "shopify_line": "Starter at 29 USD per month including 100 jobs, then 0.25 USD per extra job capped at 75 USD per month, as the Pricing details give them.",
  "stripe_path": "There is no Stripe path on the public listing, since every charge goes through the Billing API; the custom app for the Plus organization is billed outside Shopify, which custom apps allow."
 },
 "protected_data": {
  "level": "level1",
  "request": "Keep the Level 1 approval for read_orders on file, and say in the review notes why read_all_orders is needed."
 },
 "fixes": [
  {
   "check": "app_pricing_fit",
   "change": "Move the whole app to manual pricing through the Billing API, since App Pricing cannot carry the usage cap, and leave App Pricing before any further opt-in."
  },
  {
   "check": "bfs_installs",
   "change": "Apply for Built for Shopify only once the Distribution page shows 50 net installs from active paid-plan shops† passing; 38 today."
  },
  {
   "check": "bfs_reviews",
   "change": "Wait for 5 reviews† before applying, requested through the Reviews API after a crew visit is first scheduled; 4 today."
  },
  {
   "check": "expiring_tokens",
   "change": "Confirm expiring offline access tokens† are requested before 2027-01-01†."
  },
  {
   "check": "review_note_scopes",
   "change": "Explain in the review notes that read_all_orders reaches installation orders older than 60 days† still awaiting a crew visit."
  }
 ],
 "packet": [
  {
   "item": "Keep install landing in the app after the grant screen, from the app URL and the redirect URLs.",
   "owner": "engineering",
   "done": true
  },
  {
   "item": "Keep the three compliance webhooks subscribed.",
   "owner": "engineering",
   "done": true
  },
  {
   "item": "Keep the Level 1 protected customer data approval on file.",
   "owner": "engineering",
   "done": true
  },
  {
   "item": "Pass the embedded app checks: latest App Bridge, session tokens, GraphQL Admin API only, expiring offline access tokens†.",
   "owner": "engineering",
   "done": false
  },
  {
   "item": "Move plan upgrades and downgrades onto the Billing API, with every charge in the merchant's charge history.",
   "owner": "engineering",
   "done": false
  }
 ],
 "timeline": [
  {
   "step": "Build the custom app for the Plus organization and install it on its store.",
   "wait": "before its launch, 14 days from today"
  },
  {
   "step": "Fix the three flagged points on the public app and resubmit it on the cooldown date, not before.",
   "wait": "on the banner's date; reported cooldowns run 2 weeks to a month"
  },
  {
   "step": "Migrate to expiring offline access tokens†.",
   "wait": "before 2027-01-01†"
  },
  {
   "step": "Apply for Built for Shopify once the prerequisites pass.",
   "wait": "50 net installs† and 5 reviews†"
  }
 ],
 "footnote": "† from shopify.dev - Shopify changes requirements without notice; verify before relying on it. \"reported\" = forum or practitioner reports, not Shopify policy.",
 "caveats": [
  "Confirm whether the app has active paid merchants on App Pricing, since that decides how the move to the Billing API runs.",
  "Confirm the Plus organization accepts a separate custom app that never lists."
 ]
}

Cut from this display only (the saved reply and the check use the full text): verdict.why (315 characters, 253 shown); packet (8 items, 5 shown).

What step 5 prints for it: app_pricing_fit | bfs_installs | bfs_reviews | expiring_tokens | review_note_scopes

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./shkit.js"); require("./shreply.js")), then:

const K = ShKit, R = ShReply;
const f = K.form(Object.assign(K.blank(), { pay: "own", install: "domain", stage: "building",
  toml: "client_id = \"x1\"\nname = \"Notes\"\napplication_url = \"https://app.notes.example\"\n[access_scopes]\nscopes = \"read_orders\"",
  listing: "name: Notes\nsubtitle: A note on every order\n\nSeats are billed separately.", what: "Notes on every order." }));
// f.parsed (errors with their line), f.cfg (the TOML as read), f.scopes (each with its kind), f.L (the listing),
// f.rows (the checks), f.ladder, f.billing, f.level, f.timeline, f.plan (route, packet, banner, numbered requirements)
// K.scopesCsv(f), K.checksCsv(f), K.lintMd(f), K.board(f), K.applyPlan(f, K.board(f), planReply) - the exports
const b = K.buildInput(f, "plan");                           // or "fixes" (needs review; R.laneForm(form, "fixes", planReply) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "plan");
// R.check(reply, b)                                         // {status: kept or flagged, notes, fixed}

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://shopify-submit.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"shopify-submit"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "plan", "app": "What the app does: Acme CRM Sync copies Shopify customers into Acme CRM so a sales team sees purchase history beside each contact. It needs a paid Acme CRM account, and merchants pay us through our own Stripe checkout.
...", "config": "shopify.app.toml, the fields the review reads:
...", "billing": "How Shopify merchants pay: your own Stripe or invoices
...", "listing": "Listing name: Shopify CRM Sync by Acme (24 characters)
...", "review": "", "carry": "", "facts": "The page's checks (...); verdict: closed to the App Store as planned; rule | id | status | evidence:
- billing | billing_shopify | fail | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses shopify-submit:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A plan reply carries summary, verdict, billing, protected_data, fixes, packet, timeline, footnote and caveats; a fixes-lane reply carries summary, banner, points, resubmit, reply, footnote and caveats. The code below prints the gist of either: each fix of the plan, or each flagged requirement with its fix.

KEY="shopify-submit:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "fixes" then (.points[] | "\(.n). \(.requirement) -> \(.fix)") 
       else (.fixes[] | "\(.check) \(.change)") end' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: shopify-submit:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json