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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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. |
| 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 listing, then a listing sent back to Draft:
plan- the listing planned: the page's eligibility verdict (eligible, not_yet or refused - a refused app is never planned), every scope with the page's object, its feature and keep, optional or drop, one Shared data row per scoped object with the direction read and write give, the pilot rungs in the page's order, one fix per failing check, the packet with an owner role per item, the timeline with the impossible goals first, and the deadlines with the reference's dates - every HubSpot figure with its dagger, every practitioner claim marked reported.fixes- Draft feedback answered: the outcome as the text gives it (draft or rejected), one fix per feedback point in its order, one pass and one resubmission with the scopes and the setup guide frozen, the wait of another review and the chase by replying to the review email, then the reply to HubSpot's reviewer - one point per line, never arguing, never asking to expedite. Needs the feedback infeedback.
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
| 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: 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. |
config | yes | the 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. |
shared | yes (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. |
pilots | yes (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. |
feedback | send 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. |
carry | send 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. |
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 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_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/hubspot-marketplace-playbook - the only standard the run applies:
- Scope: a vendor's own app onto the HubSpot Marketplace and kept there. Not listing copy or install conversion, not running your own marketplace, not CMS themes, not admins vetting another vendor's app, not CRM admin, API code or agency work.
- HubSpot charges no listing fee and takes no revenue share. The cost is elsewhere: real customers before you may apply, a listing that must match the app's actual scopes, and a platform that ages apps out of eligibility.
- Submit only with 3 active installs†: unaffiliated production accounts with successful API calls, webhook or app-card requests in the last 30 days†. Your own, test and partner-employee portals never count, and OAuth alone is not activity. The install counter lags by days (reported).
- Build the app with distribution "marketplace" and OAuth from the first pilot. It installs in up to 25 accounts† before listing; private OAuth distribution stops at 10 installs† (100 installs† from a Solutions Partner portal) and switches to marketplace later without breaking installs. A static-token app serves one portal and never lists: rebuild its auth as OAuth with marketplace distribution, and plan for pilots to reinstall.
- Use every scope you request, required or optional; HubSpot checks your API logs. Mirror every scoped object in the Shared data table; read plus write on an object means you advertise a bi-directional sync. A scope only a subset of customers needs is optional or conditionally required, never required. Sensitive-data scopes are barred for apps with app cards, agent tools or MCP components.
- Build on the developer platform (projects) at a supported version, with app cards, never classic CRM cards. From November 2, 2026† new listings run only on a supported platform version (2025.2† or later at announcement). Legacy (pre-projects) public apps can no longer be created (creation ended in June 2026†), are rejected for new listings from November 2, 2026†, and listed ones face delisting after the September 2027† enforcement date. Projects on 2025.2† and older migrate to the current version by March 2027†. Listed and certified apps had to move from classic CRM cards to app cards by October 31, 2026†. v4 APIs turn unsupported from March 2027†, with about 60 days† to migrate during certification; calling any v1-v4 API after September 2027† costs certification. Versions ship every March and September and turn unsupported 18 months† after release.
- An unlisted legacy public app with more than 25 installs† is stuck: it cannot list as legacy, and migrating it enforces the cap. Contact HubSpot first (a support request or the technology partner team), cite the install count, ask what happens to installs above 25†, whether new installs error and whether a cap exception exists, and migrate only once you hold a written answer - never run the migration first. A listed legacy app migrates without that cap.
- One app in review at a time; HubSpot rejects the second submission automatically.
- Never put "HubSpot" or "Hub" in the app's name, logo or app-card names, and write HubSpot with a capital S everywhere.
- Refuse to plan a listing for an app HubSpot excludes, and say which rule excludes it; no install count cures an excluded architecture: token or credential brokering, developer-only infrastructure, white-label integrations embedded invisibly in another vendor's product; importing non-opted-in emails, scraping LinkedIn, bulk-loading prospect databases; serving only restricted industries; a second app for the same use case, or a listing that needs another app. Expect extra review for contact enrichment, syncing contacts who engaged with the customer's outreach, and visitor de-anonymization. A companion browser extension must itself be listed and approved in its browser's store.
- AI that reads a request and decides which CRM actions to take must enforce the installing user's permissions: build on HubSpot's MCP auth app, or set isUserLevel: true on platform 2026.09† or later, which covers only some features and APIs. A standard OAuth token fails. A fixed sync that uses AI upstream is exempt. An MCP server lists as its own standalone app per the MCP listing requirements, on a server you own.
- The pilot menu, ranked by efficiency: existing customers already on HubSpot installing the marketplace-distribution app themselves > design partners recruited for one real workflow > a Solutions Partner installing in its clients' own portals with consent. Start at the first rung this week; a launch inside two months starts design-partner recruiting in parallel. Pilot installers see an "unverified app" warning and type "I accept the risk"; warn them beforehand. Drive one scheduled sync or webhook so activity never lapses. Two design partners never reach the gate: line up a third portal and a spare.
- Submission: allowlist the HubSpot Crawler user agent (a blocked page bounces the listing); publish a HubSpot-specific setup guide, public, not behind login, not your docs homepage; choose the install experience (Seamless install inside HubSpot is recommended, and it needs an endpoint HubSpot can frame that redirects to returnUrl, or installs loop); replace testing credentials with a demo video or guided walkthrough of install, configuration and permission use; submit as a Super Admin of the developer account. Wrong or missing listing fields send the listing back to Draft; a missed minimum requirement rejects it.
- Review and timeline: the initial review comes within 10 business days†; HubSpot says the feedback loop should take no more than 60 days† from feedback shared. Silent weeks after resubmitting are reported: reply to the review email to chase. Fix every feedback point in one pass, keep the scopes and the setup guide frozen during review, and ship other app updates freely. App cards and agent tools go through their own reviews before the listing; a listing resubmitted before app-card approval is rejected. Plan backward from the launch date with one Draft bounce, counted as another 10 business days†; when approval cannot land in time, announce the integration as coming and keep pilots under the cap of 25 installs†.
- Keep the listing: stay above 3 installs† - the partner agreement lets HubSpot terminate below that 90 days† after publication, and an app not installed in 180 days† is removed. Every listing edit returns to review; unpublishing takes up to 10 business days†; leaving the program obliges one year† of customer support. Reply to every negative review (two replies† per review); stars show from 10 reviews†. Review incentives of any kind are banned.
- Certification: a badge, a search filter and curated-collection eligibility. Gate: 6 months† listed, 60 active installs†, average API success above 95%†, a verified domain, the security questionnaire, the Uninstall App API, and a demo video of at least 3 minutes†. An extension that injects into HubSpot's UI blocks certification. Fix 429s (throttle to 100 requests per 10 seconds† per account, batch, use webhooks), 401s from expired tokens and 403s on portals missing a scope before applying. A rejection blocks re-applying for 6 months†; recertify every two years†.
- Two labels, kept distinct: every HubSpot figure carries † right after it (counts, durations, dates, versions); every practitioner claim sits in a sentence that says "reported" and never carries †. Close with the footnote: "† verify against current HubSpot developer docs and changelog."
House rules
- Use only the input. Never invent a scope, an object, a portal, a count, a date, a version, a tier, a URL, an id, a person or a review outcome; the only figures you may quote are the skill's, each with † as the facts mark it, and the input's own values. When the input does not say, write "not stated in the input".
- The page's verdicts are settled: every check status, the eligibility verdict, each scope's object, each object's direction, the pilot order and the outcome. Never move them; explain them.
- Every HubSpot figure carries † right after it; every practitioner claim sits in a sentence that says "reported" and never carries †; the
footnotefield is exactly "† verify against current HubSpot developer docs and changelog." - Write HubSpot with a capital S, every time.
- Never imply that HubSpot endorses, recommends, vets 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 setup guide", "the redirect URL" or "the listing URL" 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 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.
summary- one sentence, at most 40 words: what stands between the app and the HubSpot Marketplace today and what the plan changes.eligibility- an object:verdict(exactly the facts' eligibility:eligible,not_yetorrefused),why(one or two sentences naming the checks behind it).scopes- one object per scope in the facts' scope table, in its order:scope(exactly as given),object(exactly the facts' object, orother),feature(the input's feature note for that scope; when it has none, exactly "needs a feature note"),action(keep,optionalordrop- drop only a scope no feature uses, optional for a scope only some customers need).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").pilot- one object per rung of the facts' pilot menu, in its order:rung(existing_customers,design_partnersorsolutions_partner),action(one sentence: who to approach and when, naming the portals the input gives).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.packet- the submission packet, one object per item:item(one sentence),owner(a role, one ofengineering,design,product,marketing,support,submission owner,partnerships- never a person),done(true or false, as the facts' packet says).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").deadlines- the facts' deadlines that apply to this app, each an object:what(one sentence),date(exactly one of the facts' dates, with †).footnote- exactly the footnote.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.
summary- one sentence, at most 40 words: what the review flagged and what the resubmission changes.outcome- exactly the facts' outcome:draft(wrong or missing listing fields sent it back to Draft) orrejected(a missed minimum requirement rejected it).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.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).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.footnote- exactly the footnote.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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://hubspot-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"]
// hubspot-submit.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://hubspot-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://hubspot-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
}
// HubSpotSubmit.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 HubSpotSubmit {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://hubspot-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://hubspot-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://hubspot-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://hubspot-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":"hubspot-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": "hubspot-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: "hubspot-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": "hubspot-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", "hubspot-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: "hubspot-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" => "hubspot-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 = "hubspot-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: 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}'
inp = json.load(open("input.json")) # {"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
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, shared, pilots, feedback, 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 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
import hashlib, time
key = "hubspot-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["feedback"], "->", x["fix"])
print(reply["outcome"], reply["resubmit"]["when"])
else:
for x in reply["fixes"]:
print(x["check"], x["change"])
import { createHash } from "node:crypto";
const key = `hubspot-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.feedback, "->", x.fix); console.log(reply.outcome, 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 := "hubspot-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", "feedback", "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 = "hubspot-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("feedback").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 = "hubspot-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["feedback"]} -> #{x["fix"]}" }
else
reply["fixes"].each { |x| puts "#{x["check"]} #{x["change"]}" }
end
$key = "hubspot-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["feedback"], " -> ", $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 = $"hubspot-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!["feedback"]} -> {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: hubspot-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: 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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "hubspot-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": `hubspot-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", "hubspot-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", "hubspot-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" => "hubspot-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: hubspot-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", $"hubspot-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);