← HubSpot Submit
API
Tokens

Run HubSpot Submit from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page parses the app config, maps every scope to its object, holds the Shared data table against the scopes, counts the install gate from the pilot rows, screens eligibility, runs every check, ranks the pilot menu, lays out the timeline backward from launch, lists the deadlines, builds the packet and reads the Draft feedback, then hands it all to the run as facts. The run plans the listing or answers the Draft feedback; the page then holds it to its own facts (hsreply.js). HubSpot Submit is not affiliated with or endorsed by HubSpot.

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":"hubspot-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, shared, pilots, facts) is missing. The page always sends all eight of task, app, config, shared, pilots, feedback, carry, facts; feedback and carry are empty strings for plan, and shared and pilots 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 listing, then a listing sent back to Draft:

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 eligibility, fixes, scope changes 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: the names, what it does in HubSpot, who the product is for, restricted industries, a second app, the stage, the launch date with its days from today, the legacy app and its installs, listed since, active installs, the crawler, the setup guide, the install experience and returnUrl, the demo video, the Super Admin, another app in review, review incentives, the MCP auth app and the certification answers.
configyesthe app config as the page read it, field by field (URLs counted, never copied), the platformVersion, every scope with its object, access and feature note, the scoped objects with the direction read and write give, and the components; or a line saying none was pasted.
sharedyes (says so when no table was pasted)the Shared data table as pasted, one row per line with the direction as read; or a line saying none was pasted.
pilotsyes (says so when no pilot was pasted)the pilot installs, one per line: the portal, affiliated or not, the last successful call with its days ago, the kind, and whether it counts toward the gate and why not.
feedbacksend it (the Draft feedback or the review email for fixes, empty for plan)fixes task only: the Draft feedback or the review email as pasted.
carrysend it (empty for plan)fixes task only: the listing plan's eligibility, fixes, scope changes 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 eligibility verdict, the scope table, the pilot menu, the install gate, the timeline with the impossible goals first, the deadlines with their dates, the submission packet, the stage, the figure lists and the footnote; for fixes, the outcome and the numbered feedback points. 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/hubspot-marketplace-playbook - the only standard the run applies:

House rules

The replies

plan

Plan the listing: the eligibility verdict, the scope table, the Shared data rows, the pilot plan, one fix per failing check, the packet with an owner each, the timeline and the deadlines.

  1. summary - one sentence, at most 40 words: what stands between the app and the HubSpot Marketplace today and what the plan changes.
  2. eligibility - an object: verdict (exactly the facts' eligibility: eligible, not_yet or refused), why (one or two sentences naming the checks behind it).
  3. scopes - one object per scope in the facts' scope table, in its order: scope (exactly as given), object (exactly the facts' object, or other), feature (the input's feature note for that scope; when it has none, exactly "needs a feature note"), action (keep, optional or drop - drop only a scope no feature uses, optional for a scope only some customers need).
  4. shared_data - one object per scoped object: object (as the facts name it), direction (exactly the direction the facts derive from read and write: "HubSpot to app", "app to HubSpot" or "both").
  5. pilot - one object per rung of the facts' pilot menu, in its order: rung (existing_customers, design_partners or solutions_partner), action (one sentence: who to approach and when, naming the portals the input gives).
  6. 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 config, the Shared data table, the pilots, the listing or the submission). Every fail check appears.
  7. packet - the submission packet, one object per item: item (one sentence), owner (a role, one of engineering, design, product, marketing, support, submission owner, partnerships - never a person), done (true or false, as the facts' packet says).
  8. timeline - the steps, each an object: step (one sentence; any impossible goal from the facts first, with its realistic date), wait (the wait; a wait with a figure carries † or says "reported").
  9. deadlines - the facts' deadlines that apply to this app, each an object: what (one sentence), date (exactly one of the facts' dates, with †).
  10. footnote - exactly the footnote.
  11. caveats - at most 4 short strings: what the team must confirm; [] when there is nothing to say.
{
  "task": "plan",
  "summary": "one sentence",
  "eligibility": { "verdict": "not_yet", "why": "one or two sentences" },
  "scopes": [ { "scope": "crm.objects.contacts.read", "object": "contacts", "feature": "needs a feature note", "action": "keep" } ],
  "shared_data": [ { "object": "contacts", "direction": "HubSpot to app" } ],
  "pilot": [ { "rung": "existing_customers", "action": "one sentence" } ],
  "fixes": [ { "check": "install_gate", "change": "one sentence" } ],
  "packet": [ { "item": "one sentence", "owner": "engineering", "done": false } ],
  "timeline": [ { "step": "one sentence", "wait": "within 10 business days†" } ],
  "deadlines": [ { "what": "one sentence", "date": "March 2027†" } ],
  "footnote": "† verify against current HubSpot developer docs and changelog.",
  "caveats": [ ]
}

The page holds it to its own facts (check() in hsreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL with a scheme; no backticks; no implied HubSpot endorsement; HubSpot with a capital S; a summary of at most 40 words; every HubSpot figure with its dagger, every practitioner claim in a sentence that says reported and never with a dagger, and the exact footnote; the page's eligibility verdict (a different one is put back and flagged); every page scope exactly once and no invented scope, each with the page's object (a moved object is put back), the input's feature note or exactly "needs a feature note", and keep, optional or drop; one Shared data row per scoped object (an object whose every scope is dropped needs none) with the direction read and write give (a contradicting one is put back); the pilot rungs in the page's order; 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; timeline waits with a figure carrying a dagger or saying reported; deadline dates only from the page's list.

fixes

Answer the Draft feedback: one fix per feedback point, when to resubmit and how long it takes, then the reply to HubSpot's reviewer.

  1. summary - one sentence, at most 40 words: what the review flagged and what the resubmission changes.
  2. outcome - exactly the facts' outcome: draft (wrong or missing listing fields sent it back to Draft) or rejected (a missed minimum requirement rejected it).
  3. points - one object per point the feedback raises, in its order: n (1, 2, 3...), feedback (a short quote or paraphrase of the point, from the text), fix (the concrete change, one sentence), check (the page's check id the point matches, or ""). When the carried plan has a fix for that check, use it.
  4. resubmit - an object: when (one sentence: every point fixed in one pass, then one resubmission, with the scopes and the setup guide frozen during review), wait (one sentence: another 10 business days†, and chase by replying to the review email - silent weeks after resubmitting are reported).
  5. reply - the reply to HubSpot's 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 name no longer contains Hub.\n2. The Shared data table now lists Contacts as both.\nEvery point is fixed in this one pass." Polite, never argues, never asks to expedite, never claims HubSpot endorsement, no URL.
  6. footnote - exactly the footnote.
  7. caveats - at most 4 short strings: what to confirm before resubmitting; [] when there is nothing to say.
{
  "task": "fixes",
  "summary": "one sentence",
  "outcome": "draft",
  "points": [ { "n": 1, "feedback": "short quote or paraphrase", "fix": "one sentence", "check": "name_hubspot" } ],
  "resubmit": { "when": "one sentence", "wait": "one sentence" },
  "reply": "Thank you for the review.\n1. first point - its fix\n2. second point - its fix\nEvery point is fixed in this one pass.",
  "footnote": "† verify against current HubSpot developer docs and changelog.",
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; the same text rules; the outcome the text shows (put back if moved); at least as many points as the text numbers, only the page's check ids, a feedback quote and a fix on each; a when that says one pass, the scopes and the setup guide frozen; a wait that says another 10 business days†, the review email and 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 listing plan for a legacy app bounced to Draft, the answer to its Draft feedback carrying that plan, and the plan for an AI copilot that was rejected and launches soon. 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 legacy app bounced to Draft

The page read it as: not yet - fails to fix first; eligibility not_yet; stage bounced to Draft; install gate 1 of 3; scopes crm.objects.contacts.read (contacts), crm.objects.contacts.write (contacts), crm.objects.companies.read (companies), crm.lists.read (lists); fail checks: distribution_marketplace, auth_oauth, unlisted_cap, legacy_app, legacy_migration, classic_cards, scope_shared_missing, scope_direction, name_hubspot, install_gate, crawler, setup_guide, demo_video.

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

{
 "task": "plan",
 "app": "What the app does: HubZarn Pro keeps contacts and companies in step between HubZarn and HubSpot so sales and support teams see the same record.\nApp name: HubZarn Pro; listing name: HubZarn Pro\nIn HubSpot it does: data sync, app cards; the product does none of: enrich contacts, import prospect lists, identify website visitors, embed in other vendors' products\nServes only restricted industries: no; a second app for the same use case or a listing that needs another app: no\nStage: bounced to Draft; launch date: not given\nLegacy (pre-projects) public app: yes; its installs: 40; listed since: not given; active installs: 40\nHubSpot Crawler allowlisted: no; public HubSpot-specific setup guide: no; install experience: Login via partner site\nDemo video or walkthrough: no; submitting as Super Admin: yes; another app in review: no; review incentives: no\nBuilt on HubSpot's MCP auth app: no; certification wanted: no; verified domain: no; average API success: not given; security questionnaire: not known; Uninstall App API: not known; certification demo video: not known",
 "config": "The app config, the fields the review reads (flat shape):\nuid: hubzarn-pro; type: not given; name: HubZarn Pro; distribution: private; auth.type: static; isUserLevel: not set\nredirectUrls: 1 URL; permittedUrls: none; support: supportEmail\nplatformVersion: not given\nScopes (scope | required, optional or conditionally required | object | access | feature):\n- crm.objects.contacts.read | required | contacts | read | no feature note\n- crm.objects.contacts.write | required | contacts | write | no feature note\n- crm.objects.companies.read | required | companies | read | no feature note\n- crm.lists.read | optional | lists | read | no feature note\nScoped objects and the direction read and write give: contacts both; companies HubSpot to app; lists HubSpot to app\nComponents (name | kind): HubZarn Contact Panel | classic CRM card",
 "shared": "The Shared data table as pasted (object | direction as read):\n- Contacts | HubSpot to app\n- Companies | HubSpot to app",
 "pilots": "The pilot installs (portal | affiliated | last successful call | kind | counts toward the gate):\n- acme-ops.example | no | 2026-10-21 (12 days ago) | customer | counts\n- hubzarn-staff.example | yes | 2026-10-31 (2 days ago) | staff | not counted: affiliated, staff portal",
 "feedback": "",
 "carry": "",
 "facts": "The page's checks (13 fail, 1 warn, 15 pass, 1 info, 18 n/a); verdict: not yet - fails to fix first; rule | id | status | evidence:\n- eligibility | elig_brokering | pass | not the case (interview)\n- eligibility | elig_devinfra | pass | not the case (interview)\n- eligibility | elig_embedded | pass | not the case (interview)\n- eligibility | elig_emails | pass | not the case (interview)\n- eligibility | elig_linkedin | pass | not the case (interview)\n- eligibility | elig_prospects | pass | not the case (interview)\n- eligibility | elig_restricted | pass | not the case (interview)\n- eligibility | elig_second_app | pass | not the case (interview)\n- eligibility | extra_review | pass | none of the three (interview)\n- eligibility | browser_extension | na | no browser extension\n- distribution | distribution_marketplace | fail | distribution \"private\" - build with distribution \"marketplace\" and OAuth from the first pilot; it installs in up to 25 accounts† before listing\n- distribution | auth_oauth | fail | auth.type \"static\" - a static-token app serves one portal and never lists: rebuild its auth as OAuth with marketplace distribution, and plan for pilots to reinstall\n- distribution | private_caps | na | not private OAuth distribution\n- distribution | unlisted_cap | fail | installs: 40 (interview) - past the cap of 25 installs† before listing; keep pilots under it\n- platform | legacy_app\n\n[... 10528 more characters cut from this display ...]"
}

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

{
 "task": "plan",
 "summary": "HubZarn Pro is a legacy, static-token, privately distributed app with 40 installs, a classic CRM card and a Hub name; the plan contacts HubSpot before migrating, rebuilds on projects with OAuth, then fixes the listing.",
 "eligibility": {
  "verdict": "not_yet",
  "why": "No eligibility rule excludes a contact and company sync, so the app is not refused; it is not yet eligible because 13 checks fail, from the legacy architecture and the static token to the install gate and the crawler."
 },
 "scopes": [
  {
   "scope": "crm.objects.contacts.read",
   "object": "contacts",
   "feature": "needs a feature note",
   "action": "keep"
  },
  {
   "scope": "crm.objects.contacts.write",
   "object": "contacts",
   "feature": "needs a feature note",
   "action": "keep"
  },
  {
   "scope": "crm.objects.companies.read",
   "object": "companies",
   "feature": "needs a feature note",
   "action": "keep"
  },
  {
   "scope": "crm.lists.read",
   "object": "lists",
   "feature": "needs a feature note",
   "action": "drop"
  }
 ],
 "shared_data": [
  {
   "object": "contacts",
   "direction": "both"
  },
  {
   "object": "companies",
   "direction": "HubSpot to app"
  }
 ],
 "pilot": [
  {
   "rung": "existing_customers",
   "action": "This week, ask existing HubZarn customers already on HubSpot, starting with acme-ops.example, to install the rebuilt marketplace-distribution app themselves."
  },
  {
   "rung": "design_partners",
   "action": "Recruit design partners for one real contact sync workflow, so the count never rests on a single customer portal."
  },
  {
   "rung": "solutions_partner",
   "action": "If the first two rungs fall short, ask a Solutions Partner to install the app in its clients' own portals with their consent."
  }
 ],
 "fixes": [
  {
   "check": "distribution_marketplace",
   "change": "Set distribution to marketplace in the rebuilt project config before the first pilot installs it."
  },
  {
   "check": "auth_oauth",
   "change": "Rebuild the auth as OAuth with marketplace distribution and tell every pilot portal it will reinstall."
  },
  {
   "check": "unlisted_cap",
   "change": "Keep the rebuilt app under the cap of 25 installs† until the listing is approved, and ask HubSpot how the 40 existing installs move."
  },
  {
   "check": "legacy_app",
   "change": "Move HubZarn Pro onto the developer platform (projects), since legacy public apps are rejected for new listings from November 2, 2026†."
  },
  {
   "check": "legacy_migration",
   "change": "Open a support request before running hs app migrate, cite the 40 installs, ask what happens above the cap, whether new installs error and whether an exception exists, and migrate only with a written answer."
  }
 ],
 "packet": [
  {
   "item": "Fill the seven tabs of the listing wizard and run validation on Review info.",
   "owner": "submission owner",
   "done": false
  },
  {
   "item": "Choose a name and tagline that are your brand, never HubSpot or Hub.",
   "owner": "marketing",
   "done": false
  },
  {
   "item": "Make the icon 800x800 px†, JPG or PNG, with no text and no HubSpot sprocket.",
   "owner": "design",
   "done": false
  },
  {
   "item": "Take screenshots inside current HubSpot UI with placeholder data and alt text.",
   "owner": "design",
   "done": false
  },
  {
   "item": "Make the Shared data table mirror every scoped object and its direction.",
   "owner": "engineering",
   "done": false
  }
 ],
 "timeline": [
  {
   "step": "Contact HubSpot about the 40 legacy installs and wait for a written answer before migrating.",
   "wait": "before any migration"
  },
  {
   "step": "Rebuild on projects with OAuth, marketplace distribution and an app card, and get the app card through its own review.",
   "wait": "its own review, before the listing"
  },
  {
   "step": "Fix every feedback point in one pass with the scopes and the setup guide frozen, then resubmit once.",
   "wait": "one resubmission"
  },
  {
   "step": "The resubmission review.",
   "wait": "another 10 business days†"
  },
  {
   "step": "Chase by replying to the review email if it goes quiet.",
   "wait": "silent weeks after resubmitting are reported"
  },
  {
   "step": "The feedback loop's ceiling.",
   "wait": "HubSpot says it should take no more than 60 days† from feedback shared"
  }
 ],
 "deadlines": [
  {
   "what": "Classic CRM cards are banned for new listings, so the contact panel must be an app card.",
   "date": "October 31, 2026†"
  },
  {
   "what": "Legacy public apps are rejected for new listings.",
   "date": "November 2, 2026†"
  }
 ],
 "footnote": "† verify against current HubSpot developer docs and changelog.",
 "caveats": [
  "Confirm with HubSpot in writing how the 40 existing installs move before running any migration.",
  "Confirm no feature reads lists before dropping crm.lists.read."
 ]
}

Cut from this display only (the saved reply and the check use the full text): fixes (14 items, 5 shown); packet (14 items, 5 shown).

What step 5 prints for it: distribution_marketplace | auth_oauth | unlisted_cap | legacy_app | legacy_migration | classic_cards | scope_shared_missing | scope_direction | name_hubspot | install_gate | crawler | setup_guide | demo_video | scope_unused

The page's check: kept - nothing to flag

fixes - A legacy app bounced to Draft

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

Input (carry is 3256 characters; the first 1394 are shown; facts is 11509 characters; the first 1398 are shown):

{
 "task": "fixes",
 "app": "What the app does: HubZarn Pro keeps contacts and companies in step between HubZarn and HubSpot so sales and support teams see the same record.\nApp name: HubZarn Pro; listing name: HubZarn Pro\nIn HubSpot it does: data sync, app cards; the product does none of: enrich contacts, import prospect lists, identify website visitors, embed in other vendors' products\nServes only restricted industries: no; a second app for the same use case or a listing that needs another app: no\nStage: bounced to Draft; launch date: not given\nLegacy (pre-projects) public app: yes; its installs: 40; listed since: not given; active installs: 40\nHubSpot Crawler allowlisted: no; public HubSpot-specific setup guide: no; install experience: Login via partner site\nDemo video or walkthrough: no; submitting as Super Admin: yes; another app in review: no; review incentives: no\nBuilt on HubSpot's MCP auth app: no; certification wanted: no; verified domain: no; average API success: not given; security questionnaire: not known; Uninstall App API: not known; certification demo video: not known",
 "config": "The app config, the fields the review reads (flat shape):\nuid: hubzarn-pro; type: not given; name: HubZarn Pro; distribution: private; auth.type: static; isUserLevel: not set\nredirectUrls: 1 URL; permittedUrls: none; support: supportEmail\nplatformVersion: not given\nScopes (scope | required, optional or conditionally required | object | access | feature):\n- crm.objects.contacts.read | required | contacts | read | no feature note\n- crm.objects.contacts.write | required | contacts | write | no feature note\n- crm.objects.companies.read | required | companies | read | no feature note\n- crm.lists.read | optional | lists | read | no feature note\nScoped objects and the direction read and write give: contacts both; companies HubSpot to app; lists HubSpot to app\nComponents (name | kind): HubZarn Contact Panel | classic CRM card",
 "shared": "The Shared data table as pasted (object | direction as read):\n- Contacts | HubSpot to app\n- Companies | HubSpot to app",
 "pilots": "The pilot installs (portal | affiliated | last successful call | kind | counts toward the gate):\n- acme-ops.example | no | 2026-10-21 (12 days ago) | customer | counts\n- hubzarn-staff.example | yes | 2026-10-31 (2 days ago) | staff | not counted: affiliated, staff portal",
 "feedback": "The Draft feedback or the review email:\nHello,\n\nThank you for submitting HubZarn Pro. Your listing has been moved back to Draft because some listing fields need changes:\n\n1. The app name contains \"Hub\". App names cannot include \"HubSpot\" or \"Hub\".\n2. The Shared data table lists Contacts as HubSpot to app, but the app requests crm.objects.contacts.write. Update the table so it matches your scopes.\n3. The setup guide link opens a login page. The guide must be public and specific to the HubSpot integration.\n4. We could not crawl your pricing page: the HubSpot Crawler user agent was blocked.\n\nPlease update these fields and resubmit.\n\nHubSpot Ecosystem Quality",
 "carry": "The listing plan (eligibility not_yet):\nThe plan's fixes:\n- distribution_marketplace: Set distribution to marketplace in the rebuilt project config before the first pilot installs it.\n- auth_oauth: Rebuild the auth as OAuth with marketplace distribution and tell every pilot portal it will reinstall.\n- unlisted_cap: Keep the rebuilt app under the cap of 25 installs† until the listing is approved, and ask HubSpot how the 40 existing installs move.\n- legacy_app: Move HubZarn Pro onto the developer platform (projects), since legacy public apps are rejected for new listings from November 2, 2026†.\n- legacy_migration: Open a support request before running hs app migrate, cite the 40 installs, ask what happens above the cap, whether new installs error and whether an exception exists, and migrate only with a written answer.\n- classic_cards: Rebuild the HubZarn Contact Panel as an app card on the developer platform.\n- scope_shared_missing: Drop crm.lists.read, which no feature in the input uses, so no lists row is needed.\n- scope_direction: List Contacts as both in the Shared data table, since the app reads and writes contacts.\n- name_hubspot: Rename the app, the listing and the app card to a brand name with no HubSpot and no Hub in it.\n- install_gate: Reach 3 active installs† from unaffiliated customer, design partner or agency client portals with a successful call in the last 30\n\n[... 1862 more characters cut from this display ...]",
 "facts": "The page's checks (13 fail, 1 warn, 15 pass, 1 info, 18 n/a); verdict: not yet - fails to fix first; rule | id | status | evidence:\n- eligibility | elig_brokering | pass | not the case (interview)\n- eligibility | elig_devinfra | pass | not the case (interview)\n- eligibility | elig_embedded | pass | not the case (interview)\n- eligibility | elig_emails | pass | not the case (interview)\n- eligibility | elig_linkedin | pass | not the case (interview)\n- eligibility | elig_prospects | pass | not the case (interview)\n- eligibility | elig_restricted | pass | not the case (interview)\n- eligibility | elig_second_app | pass | not the case (interview)\n- eligibility | extra_review | pass | none of the three (interview)\n- eligibility | browser_extension | na | no browser extension\n- distribution | distribution_marketplace | fail | distribution \"private\" - build with distribution \"marketplace\" and OAuth from the first pilot; it installs in up to 25 accounts† before listing\n- distribution | auth_oauth | fail | auth.type \"static\" - a static-token app serves one portal and never lists: rebuild its auth as OAuth with marketplace distribution, and plan for pilots to reinstall\n- distribution | private_caps | na | not private OAuth distribution\n- distribution | unlisted_cap | fail | installs: 40 (interview) - past the cap of 25 installs† before listing; keep pilots under it\n- platform | legacy_app\n\n[... 10111 more characters cut from this display ...]"
}

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

{
 "task": "fixes",
 "summary": "The listing went back to Draft for a Hub name, a Shared data row that contradicts the contacts scopes, a setup guide behind a login and a blocked crawler; one pass fixes all four before one resubmission.",
 "outcome": "draft",
 "points": [
  {
   "n": 1,
   "feedback": "The app name contains \"Hub\".",
   "fix": "The app, the listing and the app card get a brand name with no HubSpot and no Hub in it.",
   "check": "name_hubspot"
  },
  {
   "n": 2,
   "feedback": "The Shared data table lists Contacts as HubSpot to app, but the app requests crm.objects.contacts.write.",
   "fix": "The Shared data table now lists Contacts as both, matching the read and write scopes.",
   "check": "scope_direction"
  },
  {
   "n": 3,
   "feedback": "The setup guide link opens a login page.",
   "fix": "The HubSpot-specific setup guide is now public, outside any login, with install and configure steps.",
   "check": "setup_guide"
  },
  {
   "n": 4,
   "feedback": "The HubSpot Crawler user agent was blocked on the pricing page.",
   "fix": "The HubSpot Crawler user agent is now allowlisted on every listing URL, the pricing page included.",
   "check": "crawler"
  }
 ],
 "resubmit": {
  "when": "Fix all four points in one pass, then resubmit once, keeping the scopes and the setup guide frozen during review.",
  "wait": "The resubmission takes another 10 business days†; silent weeks after resubmitting are reported, so chase by replying to the review email."
 },
 "reply": "Thank you for the review.\n1. The app name no longer contains Hub: the app, the listing and the app card now use a brand name with neither HubSpot nor Hub.\n2. The Shared data table now lists Contacts as both, matching the contacts read and write scopes.\n3. The [... 224 more characters cut from this display]",
 "footnote": "† verify against current HubSpot developer docs and changelog.",
 "caveats": [
  "The page also fails the static token, the private distribution, the legacy architecture and the install gate; a missed minimum requirement rejects the listing, so fix them before resubmitting.",
  "Confirm with HubSpot in writing how the 40 legacy installs move before any migration."
 ]
}

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

What step 5 prints for it: 1 name_hubspot | 2 scope_direction | 3 setup_guide | 4 crawler

The page's check: kept - nothing to flag

plan - An AI copilot, rejected, launching soon

The page read it as: not yet - fails to fix first; eligibility not_yet; stage in review; install gate 2 of 3; scopes crm.objects.contacts.read (contacts), crm.objects.contacts.write (contacts), crm.objects.deals.read (deals), crm.objects.deals.write (deals); fail checks: ai_user_level, install_gate, cert_months, cert_domain; impossible: Certification by launch (2026-12-17).

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

{
 "task": "plan",
 "app": "What the app does: Pipevorn Copilot reads a rep's request in plain words and decides which contact and deal updates to make in HubSpot, from an app card and a Chrome extension.\nApp name: Pipevorn Copilot; listing name: Pipevorn Copilot\nIn HubSpot it does: app cards, an AI assistant acting on CRM data, a browser extension; the product does none of: enrich contacts, import prospect lists, identify website visitors, embed in other vendors' products\nServes only restricted industries: no; a second app for the same use case or a listing that needs another app: no\nStage: in review; launch date: 2026-12-17 (45 days from today, 2026-11-02)\nLegacy (pre-projects) public app: no; listed since: not given; active installs: not given\nHubSpot Crawler allowlisted: yes; public HubSpot-specific setup guide: yes; install experience: Seamless install (an endpoint that redirects to returnUrl: yes)\nDemo video or walkthrough: yes; submitting as Super Admin: yes; another app in review: no; review incentives: no\nBuilt on HubSpot's MCP auth app: no; certification wanted: yes; verified domain: no; average API success: not given; security questionnaire: not known; Uninstall App API: not known; certification demo video: not known",
 "config": "The app config, the fields the review reads (projects shape, under config):\nuid: pipevorn_copilot; type: app; name: Pipevorn Copilot; distribution: marketplace; auth.type: oauth; isUserLevel: not set\nredirectUrls: 1 URL; permittedUrls: none; support: none\nplatformVersion: 2025.2 (hsproject.json)\nScopes (scope | required, optional or conditionally required | object | access | feature):\n- crm.objects.contacts.read | required | contacts | read | The copilot reads the contact before it suggests a next step\n- crm.objects.contacts.write | required | contacts | write | The copilot updates the contact properties the rep approves\n- crm.objects.deals.read | required | deals | read | The copilot reads the deal stage\n- crm.objects.deals.write | required | deals | write | The copilot moves the deal stage the rep approves\nScoped objects and the direction read and write give: contacts both; deals both\nComponents (name | kind): Pipevorn deal coach | app card; Pipevorn for Chrome | browser extension",
 "shared": "The Shared data table as pasted (object | direction as read):\n- Contacts | both\n- Deals | both",
 "pilots": "The pilot installs (portal | affiliated | last successful call | kind | counts toward the gate):\n- lumen.example | no | 2026-10-29 (4 days ago) | design partner | counts\n- cobaltworks.example | no | 2026-10-24 (9 days ago) | design partner | counts\n- pipevorn-test.example | no | 2026-11-01 (1 days ago) | test | not counted: test portal",
 "feedback": "",
 "carry": "",
 "facts": "The page's checks (4 fail, 3 warn, 29 pass, 1 info, 11 n/a); verdict: not yet - fails to fix first; rule | id | status | evidence:\n- eligibility | elig_brokering | pass | not the case (interview)\n- eligibility | elig_devinfra | pass | not the case (interview)\n- eligibility | elig_embedded | pass | not the case (interview)\n- eligibility | elig_emails | pass | not the case (interview)\n- eligibility | elig_linkedin | pass | not the case (interview)\n- eligibility | elig_prospects | pass | not the case (interview)\n- eligibility | elig_restricted | pass | not the case (interview)\n- eligibility | elig_second_app | pass | not the case (interview)\n- eligibility | extra_review | pass | none of the three (interview)\n- eligibility | browser_extension | warn | a browser extension (Pipevorn for Chrome) - it must itself be listed and approved in its browser's store\n- distribution | distribution_marketplace | pass | distribution \"marketplace\"\n- distribution | auth_oauth | pass | auth.type \"oauth\"\n- distribution | private_caps | na | not private OAuth distribution\n- distribution | unlisted_cap | pass | installs: 3 (pilot rows), within the cap of 25 installs† before listing\n- platform | legacy_app | pass | not a legacy public app (interview)\n- platform | legacy_migration | na | not a legacy public app\n- platform | classic_cards | pass | no classic CRM card listed\n- platform | platform_version |\n\n[... 9591 more characters cut from this display ...]"
}

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

{
 "task": "plan",
 "summary": "Certification by the launch is impossible; the copilot also needs user-level permissions, a third qualifying portal and its Chrome extension listed before HubSpot can approve it, with design partners recruited this week in parallel.",
 "eligibility": {
  "verdict": "not_yet",
  "why": "No eligibility rule excludes the copilot, but ai_user_level and install_gate fail the listing, and cert_months and cert_domain fail the certification you want."
 },
 "scopes": [
  {
   "scope": "crm.objects.contacts.read",
   "object": "contacts",
   "feature": "The copilot reads the contact before it suggests a next step",
   "action": "keep"
  },
  {
   "scope": "crm.objects.contacts.write",
   "object": "contacts",
   "feature": "The copilot updates the contact properties the rep approves",
   "action": "keep"
  },
  {
   "scope": "crm.objects.deals.read",
   "object": "deals",
   "feature": "The copilot reads the deal stage",
   "action": "keep"
  },
  {
   "scope": "crm.objects.deals.write",
   "object": "deals",
   "feature": "The copilot moves the deal stage the rep approves",
   "action": "keep"
  }
 ],
 "shared_data": [
  {
   "object": "Contacts",
   "direction": "both"
  },
  {
   "object": "Deals",
   "direction": "both"
  }
 ],
 "pilot": [
  {
   "rung": "existing_customers",
   "action": "This week, ask existing customers already on HubSpot to install the marketplace app themselves and run the copilot on real deals."
  },
  {
   "rung": "design_partners",
   "action": "Also this week, in parallel because the launch falls inside two months, recruit a third design partner beside lumen.example and cobaltworks.example, plus a spare."
  },
  {
   "rung": "solutions_partner",
   "action": "If both rungs fall short, ask a Solutions Partner to install the copilot in its clients' own portals with their consent."
  }
 ],
 "fixes": [
  {
   "check": "ai_user_level",
   "change": "Build the copilot on HubSpot's MCP auth app, or set isUserLevel to true on platform 2026.09† or later, so every action runs with the installing user's permissions."
  },
  {
   "check": "install_gate",
   "change": "Add a third unaffiliated portal with a successful call in the last 30 days† to reach 3 active installs†; the test portal never counts."
  },
  {
   "check": "cert_months",
   "change": "Apply for certification only after 6 months† listed, not at launch."
  },
  {
   "check": "cert_domain",
   "change": "Verify the developer account's domain before applying for certification."
  },
  {
   "check": "browser_extension",
   "change": "Get Pipevorn for Chrome listed and approved in the Chrome Web Store before resubmitting."
  },
  {
   "check": "platform_migrate",
   "change": "Move from platformVersion 2025.2 to 2026.09†, which isUserLevel needs anyway."
  }
 ],
 "packet": [
  {
   "item": "Fill the seven tabs of the listing wizard and run validation on Review info.",
   "owner": "submission owner",
   "done": false
  },
  {
   "item": "Keep the name and tagline as Pipevorn Copilot, never HubSpot or Hub.",
   "owner": "marketing",
   "done": true
  },
  {
   "item": "Make the icon 800x800 px†, JPG or PNG, with no text and no HubSpot sprocket.",
   "owner": "design",
   "done": false
  },
  {
   "item": "Take screenshots inside current HubSpot UI with placeholder data and alt text.",
   "owner": "design",
   "done": false
  },
  {
   "item": "Keep the Shared data table listing contacts and deals as both.",
   "owner": "engineering",
   "done": true
  }
 ],
 "timeline": [
  {
   "step": "Impossible first: certification by the launch on 2026-12-17; realistic 2027-05-30 at the earliest, once the app is listed.",
   "wait": "6 months† listed and 60 active installs†"
  },
  {
   "step": "Put the deal coach app card through its own review first.",
   "wait": "its own review, before the listing"
  },
  {
   "step": "Rebuild the permissions, reach the third portal and list the extension, then submit again.",
   "wait": "the gate of 3 active installs† first"
  },
  {
   "step": "The initial review.",
   "wait": "within 10 business days†"
  },
  {
   "step": "One Draft bounce, fixed in one pass.",
   "wait": "another 10 business days†"
  },
  {
   "step": "If approval cannot land by the launch, announce the integration as coming.",
   "wait": "keep pilots under the cap of 25 installs†"
  }
 ],
 "deadlines": [
  {
   "what": "New listings run only on a supported platform version.",
   "date": "November 2, 2026†"
  },
  {
   "what": "Projects on 2025.2† and older migrate to the current version.",
   "date": "March 2027†"
  },
  {
   "what": "v4 APIs turn unsupported, with about 60 days† to migrate during certification.",
   "date": "March 2027†"
  },
  {
   "what": "Calling any v1-v4 API after this date costs certification.",
   "date": "September 2027†"
  }
 ],
 "footnote": "† verify against current HubSpot developer docs and changelog.",
 "caveats": [
  "Confirm which copilot features and APIs isUserLevel covers before choosing it over the MCP auth app.",
  "An extension that injects into HubSpot's UI blocks certification; confirm how Pipevorn for Chrome works."
 ]
}

Cut from this display only (the saved reply and the check use the full text): packet (14 items, 5 shown).

What step 5 prints for it: ai_user_level | install_gate | cert_months | cert_domain | browser_extension | platform_migrate

The page's check: kept - nothing to flag

Build the input yourself

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

const K = HsKit, R = HsReply;
const f = K.form(Object.assign(K.blank(), { stage: "building", what: "Notes on every deal.", do_sync: "yes",
  config: JSON.stringify({ uid: "notes", type: "app", config: { name: "Notes", distribution: "private", auth: { type: "static", requiredScopes: ["crm.objects.deals.read", "crm.objects.deals.write"] } } }),
  shared: "Deals | HubSpot to app", pilots: "northwind.example | no | " + K.today() + " | customer" }));
// f.parsed (an error with its line and column), f.cfg (the config as read), f.scopes (each with its object and access),
// f.objects (the direction read and write give), f.sharedP (the table), f.gate (the install gate), f.rows (the checks),
// f.ladder, f.timeline (impossible goals first), f.deadlines, f.plan (packet, outcome, numbered feedback points)
// K.scopesCsv(f), K.checksCsv(f), K.board(f), K.applyPlan(f, K.board(f), planReply), K.fixedShared(f, planReply) - the exports
const b = K.buildInput(f, "plan");                           // or "fixes" (needs feedback; 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://hubspot-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":"hubspot-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: HubZarn Pro keeps contacts and companies in step between HubZarn and HubSpot so sales and support teams see the same record.
...", "config": "The app config, the fields the review reads (flat shape):
...", "shared": "The Shared data table as pasted (object | direction as read):
...", "pilots": "The pilot installs (portal | affiliated | last successful call | kind | counts toward the gate):
...", "feedback": "", "carry": "", "facts": "The page's checks (...); verdict: not yet - fails to fix first; rule | id | status | evidence:
- eligibility | elig_brokering | pass | ..."}
# - 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 hubspot-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, eligibility, scopes, shared_data, pilot, fixes, packet, timeline, deadlines, footnote and caveats; a fixes-lane reply carries summary, outcome, points, resubmit, reply, footnote and caveats. The code below prints the gist of either: each fix of the plan, or each feedback point with its fix.

KEY="hubspot-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). \(.feedback) -> \(.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: hubspot-submit:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json