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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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:
plan- the submission planned: the page's listable verdict and distribution rung, the billing model with the Shopify line (only prices the input gives, else "set it yourself and explain it in the review notes") and how the Stripe path stays unreachable, the protected customer data level and request, one fix per failing check, the packet with an owner role per item, and the timeline - every shopify.dev figure with its dagger, every forum figure marked reported.fixes- a paused review answered: the banner type and date as the text gives them, one fix per flagged requirement in its order with what to write in that requirement's notes, when to resubmit (Submit fixes once every item is resolved, the cooldown date, or the reviewer's email), the warning never to resubmit while queued, then the reply to the reviewer - one point per line, never arguing, never asking to expedite. Needs the banner or the email inreview.
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
| Field | Required | What |
|---|---|---|
task | yes | plan or fixes. If it is missing or neither, do plan and set "task": "plan". |
app | yes | what 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. |
config | yes (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. |
billing | yes | how 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. |
listing | yes (says so when no listing was pasted) | the listing name with its length, the card subtitle, the pricing details and the description, as pasted. |
review | send it (the banner or the email for fixes, empty for plan) | fixes task only: the Paused banner or the reviewer's email as pasted. |
carry | send 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. |
facts | yes | the 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_note | no | optional; 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:
- Scope: a vendor taking its own Shopify app through App Store review and keeping it listed. Not generic listing copy, not your own marketplace's review, not a custom app for one client store, not Theme Store themes, not merchant setup, not app code.
- Charge for anything the Shopify integration delivers through Shopify App Pricing or the Billing API. A Shopify staff forum post applies this to existing customers and limited-visibility apps too, and to the Shopify portion of a bundled plan; charges for non-Shopify integrations stay on the vendor's billing. Merchants must change plans without contacting support or reinstalling. A free connector into a paid service is in scope: staff rejected exactly that (reported). On existing customers keeping their Stripe plan, earlier staff replies said yes and the latest staff clarification says no (reported): get Partner Support's answer in writing.
- Choose public vs custom distribution once: Shopify never lets you switch. Custom apps serve one store or one Plus organization, skip review, cannot use Shopify billing, and never list. Keep a separate development app so the lock lands on the production app.
- Install only from Shopify surfaces: authenticate before any UI on install and reinstall (Shopify-managed install with token exchange for embedded apps), then land in the app. Never ask for a shop domain or send merchants to sign up on your site first.
- Build on the latest App Bridge with session tokens (works in incognito) and expiring offline access tokens†, required for every public app. New public apps use the GraphQL Admin API only. Migrate before 2027-01-01†: non-compliant apps then lose their listing and new installs, and their old tokens get 403 errors from 2027-04-01†.
- Request protected customer data before submitting: reading orders or customers is Level 1; name, address, phone and email add Level 2. Shopify refuses requests while the app is in review, and unapproved fields come back redacted. Opt out when the app reads no customer data.
- Ask for reviews in neutral words only, never with incentives, gated features or happy-user filtering: Shopify removes reviews and demotes or delists apps for it†. Request them only through the Reviews API after a real milestone such as a first successful sync; it shows at most once per 60 days† and 3 times a year†, never on mobile - never wire it to a button.
- Reviewers need credentials exempt from 2FA, OTP and SSO that keep working after publish; a missing login pauses the submission. Exemptions are rare (reported): ask Partner Support before building and restate the answer in the review notes; disputing a refusal is a cooldown trigger.
- The distribution menu: a public app with limited visibility for the pilot, flipped to fully visible after launch (one review, installable from its App Store URL, hidden from search) is the default; fully visible at approval when launch wants App Store discovery on day one; a custom app wins only on speed - when a real merchant must run it before approval can land, or one Plus organization funds the integration and refuses Shopify billing - and costs a second app, a second review and reinstalls. When billing keeps every Shopify charge off Shopify, the verdict is "closed to the App Store as planned", then plan the compliant version under a stated assumption.
- Billing decision: price the Shopify integration as its own line - Shopify publishes no formula for splitting a bundled plan, so set the line yourself and explain it in the review notes. Make the Stripe path unreachable from a Shopify install: a merchant with no SaaS account never sees your checkout, and reviewers fail apps where they can reach it (reported). Default to Shopify App Pricing; once opted in you cannot create new recurring Billing API charges†, and only apps without active paid merchants can opt in†. Choose manual pricing (Billing API) for the whole app when you need local-currency prices or usage caps†, or one-time charges†; decide before opting in. Keep pricing only in the Pricing details section (requirement 4.2†): no prices in images, no "billed separately", no links to your pricing page; a suspension over "billed separately" wording was reported.
- Fees: registration $19† once, by someone with a Manage financials role; 0%† on the first $1M† of lifetime gross app revenue, then 15%†; 15%† past a $20M† App Store year or at $100M† company revenue; processing 2.9%† plus tax.
- Submission: lead the name with your brand and add the category term within 30 characters†; never start with Shopify or another platform you integrate with; keep "Shopify" out of app URLs and the API contact email; keep the TOML name and the listing name similar. Subscribe the compliance webhooks (customer data request, customer redact, shop redact). App icon 1200 x 1200 px†, screenshots 1600 x 900 px†, up to five search terms†. Keyword-stuffed subtitles, stats, "best" claims and testimonials in the listing are banned†. Ads, analytics, email, SMS and affiliate apps use Web Pixel extensions, never script tags.
- Timeline and returned reviews: Shopify publishes no SLA. Plan 2-3 rounds and 6-14 weeks to Published (reported); staff quoted about 30 days per review (reported). Every resubmission slows review†; staff say resubmitting while queued sends you to the back (reported). When approval cannot land before launch, announce the integration as coming to the Shopify App Store and onboard pilots on development stores. A Paused banner with no date: fix every flagged item, mark each resolved, then click Submit fixes. A banner with a date: a cooldown - resubmit on that date; reported cooldowns run 2 weeks to a month and lengthen on repeats. Reviewed: answer the reviewer's email; billing fixes need no resubmission†. Fix every flagged requirement in one pass and answer misreads in that requirement's notes.
- Built for Shopify: apply only when the Distribution page shows 50 net installs from active paid-plan shops† and 5 reviews† passing, with admin Web Vitals at LCP 2.5 s†, CLS 0.1†, INP 200 ms† over 100 calls in 28 days†. Failing one criterion three times in a row suspends the application for three months†. Answer a quality-check email within 7 days† and ship required changes within 30 days†.
- Two labels, kept distinct: every figure from shopify.dev carries † right after it; every forum or practitioner figure sits in a sentence that says "reported" and never carries †. Close with the legend line: "† from shopify.dev - Shopify changes requirements without notice; verify before relying on it. "reported" = forum or practitioner reports, not Shopify policy."
House rules
- Use only the input. Never invent a scope, a price, a URL, an id, a date, a person, an exemption outcome or a review outcome; the only figures you may quote are the skill's, each with † or "reported" as the facts mark it. When the input does not say, write "not stated in the input".
- The page's verdicts are settled: every check status, the listable verdict, the distribution rung, the billing model, the protected customer data level and the banner type. Never move them; explain them.
- Every shopify.dev figure carries † right after it; every forum or practitioner figure sits in a sentence that says "reported" and never carries †; the
footnotefield is exactly the legend line. - Never imply that Shopify certifies, endorses, recommends or vouches for the app, and never write a phrase that could be read that way.
- No URL: never write a scheme such as https and never copy a link. Write "the app URL", "the redirect URL" or "your pricing page" where a link belongs.
- Write in the language of the input; keep the JSON keys, check ids, scopes and enum values exactly as given.
- Never use backticks, never write the input's field names as labels in your text, and never copy this prompt's wording into a reply.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
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.
summary- one sentence, at most 40 words: what stands between the app and the Shopify App Store today and what the plan changes.verdict- an object:listable(exactly the facts' listable:yesorclosed_as_planned),distribution(exactly the facts' top rung:limited_then_full,full_at_approvalorcustom),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).billing- an object:model(exactly the facts' billing model:app_pricing,billing_apiormixed),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).protected_data- an object:level(exactly the facts' level:none,level1orlevel2),request(one sentence: what to request in the Partner Dashboard before submitting, or "opt out" when none).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.packet- the submission packet, one object per item:item(one sentence),owner(a role, one ofengineering,design,product,marketing,submission owner,support,finance- never a person),done(true or false, as the facts' packet says).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.footnote- exactly the legend line.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.
summary- one sentence, at most 40 words: what the review flagged and what the resubmission changes.banner- an object:type(exactly the facts' banner type:no_date,cooldownorreviewed),date(the date only when the text gives one, as the facts write it; otherwise "").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.resubmit- an object:when(one sentence: forno_date, after every item is marked resolved, click Submit fixes; forcooldown, resubmit on the banner's date, never earlier; forreviewed, 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).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.footnote- exactly the legend line.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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://shopify-submit.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// shopify-submit.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://shopify-submit.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://shopify-submit.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// ShopifySubmit.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class ShopifySubmit {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://shopify-submit.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://shopify-submit.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://shopify-submit.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://shopify-submit.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "shopify-submit"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "shopify-submit" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "shopify-submit"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "shopify-submit"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "shopify-submit" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "shopify-submit"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "shopify-submit" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"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
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "plan" or "fixes", app, config, billing, listing, review, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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
import hashlib, time
key = "shopify-submit:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "fixes":
for x in reply["points"]:
print(x["n"], x["requirement"], "->", x["fix"])
print(reply["banner"]["type"], reply["resubmit"]["when"])
else:
for x in reply["fixes"]:
print(x["check"], x["change"])
import { createHash } from "node:crypto";
const key = `shopify-submit:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "fixes") { for (const x of reply.points) console.log(x.n, x.requirement, "->", x.fix); console.log(reply.banner.type, reply.resubmit.when); }
else { for (const x of reply.fixes) console.log(x.check, x.change); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "shopify-submit:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key, name, field := "fixes", "check", "change"
if reply["task"] == "fixes" {
key, name, field = "points", "requirement", "fix"
}
ls, _ := reply[key].([]any)
for _, x := range ls {
xx := x.(map[string]any)
fmt.Println(xx[name], xx[field])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "shopify-submit:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("fixes")) {
for (JsonNode x : reply.path("points")) System.out.println(x.path("requirement").asText() + " -> " + x.path("fix").asText());
} else {
for (JsonNode x : reply.path("fixes")) System.out.println(x.path("check").asText() + " " + x.path("change").asText());
}
require "digest"
key = "shopify-submit:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "fixes"
reply["points"].each { |x| puts "#{x["requirement"]} -> #{x["fix"]}" }
else
reply["fixes"].each { |x| puts "#{x["check"]} #{x["change"]}" }
end
$key = "shopify-submit:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "fixes") { foreach ($reply["points"] as $x) echo $x["requirement"], " -> ", $x["fix"], "\n"; }
else { foreach ($reply["fixes"] as $x) echo $x["check"], " ", $x["change"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"shopify-submit:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "fixes") { foreach (var x in reply["points"]!.AsArray()) Console.WriteLine($"{x!["requirement"]} -> {x["fix"]}"); }
else { foreach (var x in reply["fixes"]!.AsArray()) Console.WriteLine($"{x!["check"]} {x["change"]}"); }
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "shopify-submit:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `shopify-submit:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "shopify-submit:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "shopify-submit:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "shopify-submit:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: shopify-submit:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"shopify-submit:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);