> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useparagon.com/llms.txt
> Use this file to discover all available pages before exploring further.

# HubSpot

> Connect to your users' HubSpot accounts.

export const IntegrationsCompatibility = ({workflows = true, actionkit = false, proxy = true, managedSync = false, authType = "Basic Auth", integrationName: integrationNameProp, integrationSlug: integrationSlugProp}) => {
  const FEATURE_REQUEST_ENDPOINT = "https://agosnlmllwykglihfhiw.supabase.co/functions/v1/handle-vote";
  const FEATURE_REQUEST_HEADERS = {
    "Content-Type": "application/json",
    "Authorization": `Bearer sb_publishable_DlIzCjWe8NiqjZnLziegbg_P-w5L9X4`,
    'apikey': `sb_pubishable_DlIzCjWe8NiqjZnLziegbg_P-w5L9X4`
  };
  const SESSION_VOTE_PREFIX = "paragon_compat_vote:";
  const PURPLE = "rgb(102, 69, 230)";
  const slugifyFeature = label => label.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
  const integrationKeyFromPathname = pathname => {
    if (!pathname) return "";
    const match = pathname.match(/\/(?:resources\/)?integrations\/([^/?#]+)/);
    return match ? decodeURIComponent(match[1]).replace(/\/$/, "") : "";
  };
  const voteStorageKey = (integrationKey, featureKey) => `${SESSION_VOTE_PREFIX}${integrationKey}:${featureKey}`;
  const readVoteFromStorage = (integrationKey, featureKey) => {
    if (typeof sessionStorage === "undefined") return false;
    try {
      return sessionStorage.getItem(voteStorageKey(integrationKey, featureKey)) === "1";
    } catch {
      return false;
    }
  };
  const writeVoteToStorage = (integrationKey, featureKey) => {
    try {
      sessionStorage.setItem(voteStorageKey(integrationKey, featureKey), "1");
    } catch {}
  };
  const readPageTitleFallback = () => {
    if (typeof document === "undefined") return "";
    const h1 = document.querySelector("article h1") || document.querySelector("main h1") || document.querySelector('[class*="title"] h1') || document.querySelector("h1");
    const text = h1?.textContent?.trim();
    return text || "";
  };
  const getRuntimeConfig = () => {
    if (typeof window === "undefined") return null;
    return window.__PARAGON_FEATURE_REQUEST__ ?? null;
  };
  const resolveEndpoint = () => {
    const rt = getRuntimeConfig();
    if (rt?.endpoint) return String(rt.endpoint).trim();
    return String(FEATURE_REQUEST_ENDPOINT || "").trim();
  };
  const resolveHeaders = () => {
    const rt = getRuntimeConfig();
    const base = {
      ...FEATURE_REQUEST_HEADERS,
      ...rt?.headers && typeof rt.headers === "object" ? rt.headers : {}
    };
    return base;
  };
  const isValidEmail = s => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/).test(String(s).trim());
  const renderCompatCheckSvg = (sizePx = 18) => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" fill={PURPLE} style={{
    width: `${sizePx}px`,
    height: `${sizePx}px`,
    flexShrink: 0,
    display: "block",
    verticalAlign: "middle",
    margin: "0 auto"
  }} aria-hidden>
      <path d="M320 576C178.6 576 64 461.4 64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576zM438 209.7C427.3 201.9 412.3 204.3 404.5 215L285.1 379.2L233 327.1C223.6 317.7 208.4 317.7 199.1 327.1C189.8 336.5 189.7 351.7 199.1 361L271.1 433C276.1 438 282.9 440.5 289.9 440C296.9 439.5 303.3 435.9 307.4 430.2L443.3 243.2C451.1 232.5 448.7 217.5 438 209.7z" />
    </svg>;
  const products = useMemo(() => [{
    label: "Managed Sync",
    value: managedSync
  }, {
    label: "ActionKit",
    value: actionkit
  }, {
    label: "Workflows",
    value: workflows
  }, {
    label: "Proxy API",
    value: proxy
  }, {
    label: "Auth Type",
    value: authType
  }], [actionkit, managedSync, workflows, proxy, authType]);
  const [mounted, setMounted] = useState(false);
  const [resolvedIntegrationKey, setResolvedIntegrationKey] = useState(() => integrationSlugProp?.trim() || "");
  const [resolvedDisplayName, setResolvedDisplayName] = useState(() => integrationNameProp?.trim() || "Integration");
  const [inlineFormOpen, setInlineFormOpen] = useState(false);
  const [formFeature, setFormFeature] = useState({
    featureLabel: "",
    featureKey: ""
  });
  const [email, setEmail] = useState("");
  const [description, setDescription] = useState("");
  const [submitting, setSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState("");
  const [voteClickError, setVoteClickError] = useState("");
  const [voteLogging, setVoteLogging] = useState(false);
  const [loggingFeatureKey, setLoggingFeatureKey] = useState("");
  const [, bumpVoteUi] = useState(0);
  const inlineCardRef = useRef(null);
  useEffect(() => {
    setMounted(true);
    const path = typeof window !== "undefined" ? window.location.pathname : "";
    const fromPath = integrationSlugProp?.trim() || integrationKeyFromPathname(path);
    setResolvedIntegrationKey(fromPath);
    const fromDom = readPageTitleFallback();
    const name = integrationNameProp?.trim() || fromDom || (fromPath ? fromPath.replace(/-/g, " ") : "Integration");
    setResolvedDisplayName(name);
  }, [integrationNameProp, integrationSlugProp]);
  const dismissInlineForm = useCallback(() => {
    setInlineFormOpen(false);
    setSubmitError("");
  }, []);
  const handleRequestClick = useCallback(async ({featureLabel, featureKey}) => {
    setVoteClickError("");
    const integrationKey = resolvedIntegrationKey || integrationKeyFromPathname(typeof window !== "undefined" ? window.location.pathname : "");
    if (!integrationKey) {
      setVoteClickError("Could not determine integration. Set the integrationSlug prop on this page.");
      return;
    }
    if (readVoteFromStorage(integrationKey, featureKey)) {
      return;
    }
    setLoggingFeatureKey(featureKey);
    setVoteLogging(true);
    const votedAt = new Date().toISOString();
    const ctaPayload = {
      integration_key: integrationKey,
      integration_name: resolvedDisplayName,
      feature_key: featureKey,
      feature_label: featureLabel,
      vote_phase: "cta_click",
      voted_at: votedAt
    };
    try {
      const endpoint = resolveEndpoint();
      if (endpoint) {
        const res = await fetch(endpoint, {
          method: "POST",
          headers: resolveHeaders(),
          body: JSON.stringify(ctaPayload)
        });
        if (!res.ok) {
          const text = await res.text().catch(() => "");
          throw new Error(text || `Request failed (${res.status})`);
        }
      } else if (typeof console !== "undefined" && console.warn) {
        console.warn("[IntegrationsCompatibility] FEATURE_REQUEST_ENDPOINT is empty; vote not sent. Set endpoint or window.__PARAGON_FEATURE_REQUEST__.");
      }
      writeVoteToStorage(integrationKey, featureKey);
      bumpVoteUi(v => v + 1);
      setFormFeature({
        featureLabel,
        featureKey
      });
      setEmail("");
      setDescription("");
      setSubmitError("");
      setInlineFormOpen(true);
    } catch (err) {
      setVoteClickError(err instanceof Error ? err.message : "Could not log your vote. Please try again.");
    } finally {
      setVoteLogging(false);
      setLoggingFeatureKey("");
    }
  }, [resolvedIntegrationKey, resolvedDisplayName]);
  useEffect(() => {
    if (!inlineFormOpen) return undefined;
    const id = window.setTimeout(() => {
      inlineCardRef.current?.scrollIntoView?.({
        behavior: "smooth",
        block: "nearest"
      });
    }, 80);
    return () => window.clearTimeout(id);
  }, [inlineFormOpen]);
  const submitEnrichment = useCallback(async () => {
    const trimmed = email.trim();
    if (!isValidEmail(trimmed)) {
      setSubmitError("Please enter a valid work email.");
      return;
    }
    const integrationKey = resolvedIntegrationKey || integrationKeyFromPathname(typeof window !== "undefined" ? window.location.pathname : "");
    if (!integrationKey) {
      setSubmitError("Could not determine integration. Set the integrationSlug prop on this page.");
      return;
    }
    const {featureKey, featureLabel} = formFeature;
    const endpoint = resolveEndpoint();
    const enrichedAt = new Date().toISOString();
    const payload = {
      integration_key: integrationKey,
      integration_name: resolvedDisplayName,
      feature_key: featureKey,
      feature_label: featureLabel,
      vote_phase: "enrichment",
      email: trimmed,
      description: description.trim() || undefined,
      voted_at: enrichedAt,
      enriched_at: enrichedAt
    };
    setSubmitting(true);
    setSubmitError("");
    try {
      if (endpoint) {
        const res = await fetch(endpoint, {
          method: "POST",
          headers: resolveHeaders(),
          body: JSON.stringify(payload)
        });
        if (!res.ok) {
          const text = await res.text().catch(() => "");
          throw new Error(text || `Request failed (${res.status})`);
        }
      } else if (typeof console !== "undefined" && console.warn) {
        console.warn("[IntegrationsCompatibility] FEATURE_REQUEST_ENDPOINT is empty; enrichment not sent. Set endpoint or window.__PARAGON_FEATURE_REQUEST__.");
      }
      setInlineFormOpen(false);
      setEmail("");
      setDescription("");
    } catch (err) {
      setSubmitError(err instanceof Error ? err.message : "Something went wrong. Please try again.");
    } finally {
      setSubmitting(false);
    }
  }, [email, description, formFeature, resolvedDisplayName, resolvedIntegrationKey]);
  const renderCompatProductCell = ({product, integrationKey, onRequestClick, alignWide, voteLogging, loggingFeatureKey}) => {
    const isAuthType = product.label === "Auth Type";
    const href = typeof product.value === "string" && !isAuthType ? product.value : null;
    const featureKey = slugifyFeature(product.label);
    const alreadyVoted = !isAuthType && integrationKey && !href && !product.value ? readVoteFromStorage(integrationKey, featureKey) : false;
    const unsupported = !isAuthType && !href && !product.value;
    const cellInner = isAuthType ? product.value : href ? <div style={{
      display: "inline-flex",
      alignItems: "center",
      gap: "6px"
    }}>
        {renderCompatCheckSvg()}
        <a href={href} className="compat-doc-link">
          Docs
          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17l9.2-9.2M17 17V7H7" /></svg>
        </a>
      </div> : product.value ? renderCompatCheckSvg() : alreadyVoted ? <span style={{
      fontSize: "12px",
      fontWeight: 500,
      color: "rgba(55, 55, 58, 0.85)"
    }} className="compat-requested-text">Requested</span> : <button type="button" className="compat-pill" disabled={voteLogging && loggingFeatureKey === featureKey} onClick={() => onRequestClick({
      featureLabel: product.label,
      featureKey
    })} style={{
      cursor: voteLogging && loggingFeatureKey === featureKey ? "not-allowed" : "pointer",
      opacity: voteLogging && loggingFeatureKey === featureKey ? 0.65 : 1
    }}>
        <svg className="compat-pill-plus" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{
      color: PURPLE
    }} aria-hidden>
          <line x1="12" y1="5" x2="12" y2="19"></line>
          <line x1="5" y1="12" x2="19" y2="12"></line>
        </svg>
        {voteLogging && loggingFeatureKey === featureKey ? "Submitting..." : "Request"}
      </button>;
    if (alignWide) {
      return <div style={{
        fontSize: "13px",
        padding: "8px 8px",
        minHeight: unsupported || alreadyVoted ? "40px" : undefined,
        flex: 1,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        textAlign: "center"
      }}>
          {cellInner}
        </div>;
    }
    return <span style={{
      fontSize: "13px",
      width: "80px",
      textAlign: "center",
      display: "inline-flex",
      justifyContent: "center"
    }}>
        {cellInner}
      </span>;
  };
  return <div style={{
    backgroundColor: "transparent",
    border: "0px solid rgba(225, 225, 229, 1)",
    borderRadius: "12px",
    padding: "0px 0px",
    overflow: "hidden"
  }}>
      <style>{`
        .compat-wide { display: flex; }
        .compat-narrow { display: none; }
        @media (max-width: 640px) {
          .compat-wide { display: none !important; }
          .compat-narrow { display: flex !important; }
        }
        
        .compat-doc-link {
          color: rgb(102, 69, 230);
          font-weight: 500;
          font-size: 13px;
          display: inline-flex;
          align-items: center;
          gap: 2px;
          text-decoration: none;
        }
        .compat-doc-link:hover {
          text-decoration: underline;
          text-underline-offset: 2px;
        }
        .dark .compat-doc-link {
          color: rgb(162, 140, 255);
        }

        .compat-pill {
          display: inline-flex;
          align-items: center;
          justify-content: center;
          gap: 4px;
          box-sizing: border-box;
          min-height: 32px;
          padding: 6px 12px;
          border-radius: 999px;
          border: 1px solid rgba(0, 0, 0, 0.12);
          background-color: rgba(250, 250, 249, 1);
          font-size: 12px;
          font-weight: 500;
          color: rgba(35, 35, 38, 1);
          font-family: inherit;
          line-height: 1.2;
          width: auto;
          text-decoration: none;
          margin: 0;
          transition: box-shadow 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
        }
        .compat-pill:hover:not(:disabled) {
          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        }
        
        a.compat-doc-link {
          text-decoration: underline !important;
          text-underline-offset: 2px !important;
          border-bottom: none !important;
          box-shadow: none !important;
          background: none !important;
        }
        a.compat-doc-link:hover {
          opacity: 0.8;
        }

        .dark .compat-pill {
          background-color: rgba(255, 255, 255, 0.05);
          border-color: rgba(255, 255, 255, 0.1);
          color: rgba(255, 255, 255, 0.85);
        }
        .dark .compat-pill:hover:not(:disabled) {
          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
          background-color: rgba(255, 255, 255, 0.1);
        }
        .dark .compat-pill-plus {
          color: rgba(255, 255, 255, 0.85) !important;
        }
        .dark .compat-requested-text {
          color: rgba(255, 255, 255, 0.6) !important;
        }

        .compat-form-card {
          margin-top: 24px;
          width: 100%;
          box-sizing: border-box;
          background-color: #fff;
          border-radius: 16px;
          border: 1px solid rgba(0, 0, 0, 0.1);
          box-shadow: 0 8px 24px rgba(0,0,0,0.06);
          padding: 24px;
        }
        .dark .compat-form-card {
          background-color: #0f1114;
          border-color: rgba(255, 255, 255, 0.1);
          box-shadow: 0 8px 24px rgba(0,0,0,0.4);
        }

        .compat-form-close-btn {
          flex-shrink: 0;
          display: inline-flex;
          align-items: center;
          justify-content: center;
          width: 36px;
          height: 36px;
          margin: 0;
          padding: 0;
          border-radius: 8px;
          background: #fff;
          color: rgba(35, 35, 38, 0.75);
          font-size: 24px;
          line-height: 1;
          font-family: inherit;
          border: none;
        }
        .dark .compat-form-close-btn {
          background: rgba(255,255,255,0.05);
          color: rgba(255,255,255,0.7);
        }

        .compat-form-title {
          margin: 0 0 16px;
          font-size: 20px;
          font-weight: 700;
          color: #111;
        }
        .dark .compat-form-title {
          color: rgba(255, 255, 255, 0.95);
        }

        .compat-form-text {
          display: block;
          margin: 0 0 20px;
          font-size: 14px;
          color: rgba(55, 55, 58, 0.72);
          line-height: 1.5;
        }
        .dark .compat-form-text {
          color: rgba(255, 255, 255, 0.6);
        }

        .compat-form-label {
          display: flex;
          flex-direction: column;
          gap: 6px;
          font-size: 13px;
          font-weight: 500;
          color: #333;
        }
        .dark .compat-form-label {
          color: rgba(255, 255, 255, 0.85);
        }

        .compat-form-input-readonly {
          width: 100%;
          box-sizing: border-box;
          padding: 10px 12px;
          border-radius: 8px;
          border: 1px solid rgba(0, 0, 0, 0.08);
          background-color: rgba(250, 248, 245, 0.95);
          font-size: 14px;
          color: rgba(35, 35, 38, 0.95);
        }
        .dark .compat-form-input-readonly {
          background-color: rgba(255, 255, 255, 0.05);
          border-color: rgba(255, 255, 255, 0.1);
          color: rgba(255, 255, 255, 0.7);
        }

        .compat-form-input {
          width: 100%;
          box-sizing: border-box;
          padding: 10px 12px;
          border-radius: 8px;
          border: 1px solid rgba(0, 0, 0, 0.15);
          background-color: #fff;
          font-size: 14px;
          font-family: inherit;
          color: #111;
        }
        .dark .compat-form-input {
          background-color: rgba(255, 255, 255, 0.05);
          border-color: rgba(255, 255, 255, 0.15);
          color: rgba(255, 255, 255, 0.95);
        }

        .compat-form-submit {
          padding: 10px 16px;
          border-radius: 8px;
          border: 1px solid rgba(0, 0, 0, 0.15);
          background: #fff;
          font-size: 14px;
          font-weight: 700;
          font-family: inherit;
          color: #111;
        }
        .dark .compat-form-submit {
          background: rgba(255, 255, 255, 0.1);
          border-color: rgba(255, 255, 255, 0.2);
          color: rgba(255, 255, 255, 0.95);
        }
        .compat-form-tag {
          display: inline-flex;
          align-items: center;
          gap: 6px;
          padding: 6px 10px;
          border-radius: 8px;
          background-color: rgba(102, 69, 230, 0.12);
          border: 1px solid rgba(102, 69, 230, 0.35);
          color: rgb(102, 69, 230);
          font-size: 12px;
          font-weight: 600;
        }
        .dark .compat-form-tag {
          background-color: rgba(162, 140, 255, 0.15);
          border-color: rgba(162, 140, 255, 0.4);
          color: rgb(180, 160, 255);
        }
      `}</style>

      <div className="compat-wide" style={{
    flexDirection: "row",
    gap: "0px"
  }}>
        {products.map(f => <div key={f.label} style={{
    flex: 1,
    minWidth: 0,
    textAlign: "center",
    display: "flex",
    flexDirection: "column"
  }}>
            <div style={{
    fontSize: "13px",
    fontWeight: 500,
    padding: "6px 8px",
    borderBottom: "1px solid rgba(102, 69, 230, 0.15)",
    overflow: "hidden",
    textOverflow: "ellipsis",
    whiteSpace: "nowrap",
    textAlign: "center"
  }}>
              {f.label}
            </div>
            {renderCompatProductCell({
    product: f,
    integrationKey: mounted ? resolvedIntegrationKey : "",
    onRequestClick: handleRequestClick,
    alignWide: true,
    voteLogging,
    loggingFeatureKey
  })}
          </div>)}
      </div>

      <div className="compat-narrow" style={{
    flexDirection: "column",
    gap: "0px"
  }}>
        <div style={{
    display: "flex",
    justifyContent: "space-between",
    borderBottom: "1px solid rgba(102, 69, 230, 0.15)",
    padding: "6px 0"
  }}>
          <span style={{
    fontSize: "13px",
    fontWeight: 500
  }}>Product</span>
          <span style={{
    fontSize: "13px",
    fontWeight: 500,
    width: "80px",
    textAlign: "center"
  }}>Supported</span>
        </div>
        {products.map(f => <div key={f.label} style={{
    display: "flex",
    justifyContent: "space-between",
    alignItems: "center",
    padding: "5px 0"
  }}>
            <span style={{
    fontSize: "13px"
  }}>{f.label}</span>
            {renderCompatProductCell({
    product: f,
    integrationKey: mounted ? resolvedIntegrationKey : "",
    onRequestClick: handleRequestClick,
    alignWide: false,
    voteLogging,
    loggingFeatureKey
  })}
          </div>)}
      </div>

      {voteClickError ? <p style={{
    margin: "16px 0 0",
    fontSize: "13px",
    color: "#b42318"
  }} role="alert">
          {voteClickError}
        </p> : null}

      {inlineFormOpen ? <div ref={inlineCardRef} role="region" aria-labelledby="compat-feature-request-title" className="compat-form-card">
          <div style={{
    display: "flex",
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    gap: "12px",
    marginBottom: "16px"
  }}>
            <div className="compat-form-tag">
              Feature request
            </div>
            <button type="button" onClick={dismissInlineForm} disabled={submitting} aria-label="Close feature request form" className="compat-form-close-btn" style={{
    cursor: submitting ? "not-allowed" : "pointer"
  }}>
              <span aria-hidden>×</span>
            </button>
          </div>
          <h2 id="compat-feature-request-title" className="compat-form-title">
            Request support for {formFeature.featureLabel}
          </h2>
          <p className="compat-form-text">
            Your vote has been recorded. Add your email and an optional note and we'll notify you when support is added.
          </p>

          <div style={{
    display: "flex",
    flexDirection: "column",
    gap: "16px"
  }}>
            <label className="compat-form-label">
              Integration
              <input type="text" readOnly value={resolvedDisplayName} className="compat-form-input-readonly" tabIndex={-1} />
            </label>
            <label className="compat-form-label">
              Feature
              <input type="text" readOnly value={formFeature.featureLabel} className="compat-form-input-readonly" tabIndex={-1} />
            </label>
            <label className="compat-form-label">
              Work email
              <input type="email" name="email" autoComplete="email" placeholder="you@company.com" value={email} onChange={e => setEmail(e.target.value)} className="compat-form-input" disabled={submitting} />
            </label>
            <label className="compat-form-label">
              Describe your use case (optional)
              <textarea placeholder="What would you build if this were supported?" value={description} onChange={e => setDescription(e.target.value)} rows={4} className="compat-form-input" style={{
    resize: "vertical",
    minHeight: "96px"
  }} disabled={submitting} />
            </label>
          </div>

          {submitError ? <p style={{
    margin: "14px 0 0",
    fontSize: "13px",
    color: "#b42318"
  }} role="alert">
              {submitError}
            </p> : null}

          <div style={{
    display: "flex",
    justifyContent: "flex-end",
    gap: "10px",
    marginTop: "22px",
    flexWrap: "wrap"
  }}>
            <button type="button" onClick={submitEnrichment} disabled={submitting} className="compat-form-submit" style={{
    cursor: submitting ? "not-allowed" : "pointer"
  }}>
              {submitting ? "Submitting…" : "Submit details"}
            </button>
          </div>
        </div> : null}
    </div>;
};

<IntegrationsCompatibility workflows={true} actionkit="/actionkit/integrations/hubspot" proxy={true} managedSync="/managed-sync/integrations/hubspot" authType="OAuth2" />

## Setup Guide

This guide walks you through creating a HubSpot App for testing and development purposes.

Once you have finished your integration and are ready to publish in the HubSpot Marketplace for public distribution, jump to [Publishing your HubSpot App](#publishing-your-hubspot-app).

### Prerequisites

* [Register a HubSpot developer account](https://developers.hubspot.com/) to associate your app with and to create test accounts.
* Install and authenticate the [HubSpot CLI](https://developers.hubspot.com/docs/getting-started/quickstart).

  If you don't already have the HubSpot CLI, install it with the following command (requires [Node.js](https://nodejs.org/)):

  ```bash Install the HubSpot CLI theme={null}
  npm install -g @hubspot/cli
  ```

  After installation, authenticate your developer account:

  ```bash Connect the CLI to your HubSpot account theme={null}
  hs account auth
  ```

### Creating a HubSpot Project

A HubSpot **project** is a local package of configuration files defining features of your app like requested OAuth scopes, allowed redirect URLs, and webhooks.

You will need to create and upload a project to generate an OAuth Client ID and Secret that we can reference in Paragon.

<Steps>
  <Step title="Create a project">
    Start by creating a project with the CLI using the following command:

    ```bash theme={null}
    hs project create \
      --project-base app \
      --distribution marketplace \
      --auth oauth \
      --features webhooks
    ```

    <Tip>
      We recommend (but do not require) checking this project into version control.
    </Tip>

    You will be prompted to choose a name and destination for the project.
  </Step>

  <Step title="Configure scopes and redirect URL">
    Open `src/app/app-hsmeta.json` to update your redirect URLs and scopes:

    ```diff Add Paragon URL theme={null}
    "redirectUrls": [
    - "http://localhost:3000"
    + "http://localhost:3000",
    + "https://passport.useparagon.com/oauth"
    ],
    ```

    ```json Paragon default scopes theme={null}
    "requiredScopes": [
      "oauth",
      "crm.lists.read",
      "crm.lists.write",
      "crm.objects.companies.read",
      "crm.objects.companies.write",
      "crm.objects.contacts.read",
      "crm.objects.contacts.write",
      "crm.objects.custom.read",
      "crm.objects.custom.write",
      "crm.objects.deals.read",
      "crm.objects.deals.write",
      "crm.objects.owners.read",
      "crm.schemas.companies.read",
      "crm.schemas.contacts.read",
      "crm.schemas.custom.read",
      "crm.schemas.deals.read",
      "e-commerce",
      "tickets"
    ],
    ```

    You can adjust the `requiredScopes` or `optionalScopes` to your use case; the only scope **required** for Paragon is the `oauth` scope.

    Learn more about available HubSpot scopes in [their documentation](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/scopes).
  </Step>

  <Step title="Configure webhooks">
    <Tip>
      If your HubSpot integration does not require webhooks or listening for data changes in Workflows or Triggers, you can skip this step.
    </Tip>

    Paragon provides a webhook Target URL to subscribe your HubSpot app to events in your users' HubSpot instances. Copy this URL from the Settings tab of your HubSpot integration in Paragon:

    <Frame>
      <img src="https://mintcdn.com/paragon/fpLCYjVDKL_JwtxK/assets/copying-the-hubspot-webhook-url-in-paragon.png?fit=max&auto=format&n=fpLCYjVDKL_JwtxK&q=85&s=6a0c6061b9062262a2b4061ecd25780b" alt="" width="949" height="530" data-path="assets/copying-the-hubspot-webhook-url-in-paragon.png" />
    </Frame>

    Then, open `src/app/webhooks/webhooks-hsmeta.json` and add the URL:

    ```diff Update targetUrl in webhooks-hsmeta.json theme={null}
    "settings": {
    - "targetUrl": "https://example.com/webhook",
    + "targetUrl": "https://hermes.useparagon.com/webhook/triggers/...",
      "maxConcurrentRequests": 10
    },
    ```

    Update `subscriptions` with any specific events that you will subscribe to via Workflows or Triggers. See [HubSpot's webhooks documentation](https://developers.hubspot.com/docs/api-reference/latest/webhooks/guide) for available subscription types.
  </Step>

  <Step title="Upload your project">
    Upload your HubSpot project to your developer account by saving all changes to project files and running the following command from within the project directory:

    ```bash theme={null}
    hs project upload
    ```

    <Tip>
      Whenever your integration's HubSpot features change (e.g. different scopes or webhooks), you will need to repeat this step to publish a new version of your HubSpot project.
    </Tip>

    After it has succeeded, open the project in the developer account:

    ```bash theme={null}
    hs project open
    ```
  </Step>

  <Step title="Save your OAuth Client ID and Secret">
    In the HubSpot developer account from the project view, click your app's name under Project Components and navigate to **Auth**.

    <Frame>
      <img src="https://mintcdn.com/paragon/lJYD6st7Kv4473Ps/assets/hubspot-projects-view.png?fit=max&auto=format&n=lJYD6st7Kv4473Ps&q=85&s=17a464cf24fea3231644d9b81f0e93b3" alt="HubSpot projects view" width="2784" height="1622" data-path="assets/hubspot-projects-view.png" />
    </Frame>

    Copy the Client ID and Client Secret values from HubSpot and navigate to **Integrations > HubSpot > Configure** in the Paragon dashboard to save your OAuth App credentials.

    <Frame>
      <img src="https://mintcdn.com/paragon/lJYD6st7Kv4473Ps/assets/hubspot-scopes-entry.png?fit=max&auto=format&n=lJYD6st7Kv4473Ps&q=85&s=e4a2ec98c23d8b6017a06b959bb7ad6e" alt="Enter HubSpot scopes in Paragon dashboard" width="2786" height="2390" data-path="assets/hubspot-scopes-entry.png" />
    </Frame>

    Verify that the list of requested scopes in the **App Configuration** section of Paragon exactly matches the listed scopes in your HubSpot project.
  </Step>
</Steps>

### Setup for Legacy Apps

If you are not yet using HubSpot's project-based framework, follow the below directions to configure your Legacy App in Paragon.

<Accordion title="Configuring HubSpot Legacy Apps">
  ### Add the Redirect URL to your HubSpot app

  Paragon provides a redirect URL to send information to your app. To add the redirect URL to your HubSpot app:

  1. Copy the link under "**Redirect URL**" in your integration settings in Paragon. The Redirect URL is:

  ```
  https://passport.useparagon.com/oauth
  ```

  2. Log in to your [HubSpot developer dashboard](https://developers.hubspot.com/).

  3. In **Legacy Apps**, select the app you'd like to use in Paragon.

  4. Under **Auth > Auth settings > Redirect URL**, paste in Paragon Connect's redirect URL found in Step 1.

  <Frame>
    <img src="https://mintcdn.com/paragon/DkcdqxlytLXlO_-w/assets/Adding%20the%20Paragon%20Connect%20Redirect%20URL%20to%20a%20HubSpot%20application%20for%20Paragon%20Connect.png?fit=max&auto=format&n=DkcdqxlytLXlO_-w&q=85&s=c7bda55a846fc093754d65a12739cd9d" alt="" width="1739" height="1269" data-path="assets/Adding the Paragon Connect Redirect URL to a HubSpot application for Paragon Connect.png" />
  </Frame>

  5. Select any scopes you'd like to use in your application. Note whether the scopes you permit are **required** or **optional** in HubSpot.

  <Info>
    **Note:** `crm.schemas.custom.read` is a **required** scope to enable Field Mapping in the Connect Portal.
  </Info>

  6. Press the **Save** button at the bottom of the page.

  HubSpot provides your **Client ID** and **Client Secret** needed for the next step.

  <Frame>
    <img src="https://mintcdn.com/paragon/PqlWbzgmbhNFByFv/assets/Adding%20HubSpot%20Scopes.gif?s=20c789feea78a632fae4c9e4a05e0060" alt="" width="1050" height="344" data-path="assets/Adding HubSpot Scopes.gif" />
  </Frame>

  ### Add your HubSpot app to Paragon

  Under **Integrations > HubSpot > App Configuration**, fill out your credentials from the end of [Step 1](/resources/integrations/hubspot#1-add-the-redirect-url-to-your-hubspot-app) in their respective sections:

  * **Client ID:** Found under Auth > Auth settings > Client ID on your HubSpot App page.
  * **Client Secret:** Found under Auth > Auth settings > Client secret on your HubSpot App page.
  * **Permissions:** Select the required scopes you've indicated as *Required scopes* for your HubSpot application.
  * **Optional Scopes**: Select any optional scopes indicated as *Optional scopes* in your HubSpot application.

  Press the purple "**Save Changes**" button to save your credentials.

  <Info>
    **Note:** The scopes you specify in Paragon should exactly match the scopes you permitted in your HubSpot application page.
  </Info>

  <Frame>
    <img src="https://mintcdn.com/paragon/p_CuCy_equ6Xxvlm/assets/hubspot_scopes.png?fit=max&auto=format&n=p_CuCy_equ6Xxvlm&q=85&s=947984c9d4592624c7bb2d1d95a0fdcd" alt="" width="1967" height="1025" data-path="assets/hubspot_scopes.png" />
  </Frame>
</Accordion>

## Connecting to HubSpot

Once your users have connected their HubSpot account, you can use the Paragon SDK to access the HubSpot API on behalf of connected users.

See the HubSpot [REST API documentation](https://developers.hubspot.com/docs/api/overview) for their full API reference.

Any HubSpot API endpoints can be accessed with the Paragon SDK as shown in this example.

```js theme={null}
// You can find your project ID in the Overview tab of any Integration

// Authenticate the user
paragon.authenticate(<ProjectId>, <UserToken>);
             
// List Companies
await paragon.request("hubspot", "/crm/v3/objects/companies", { 
  method: "GET"
});
  
  
// Create Company
await paragon.request("hubspot", "/crm/v3/objects/companies", { 
  method: "POST",
  body: {
    "properties": {
      "city": "Cambridge",
      "domain": "biglytics.net",
      "industry": "Technology",
      "name": "Biglytics",
      "phone": "(877) 929-0687",
      "state": "Massachusetts"
    }
  }
});
    
```

## Building HubSpot workflows

Once your HubSpot account is connected, you can add steps to perform the following actions:

* Create Record
* Update Record
* Get Records
* Get Record by ID
* Search Records
* Delete Record by ID
* Get Contacts by List ID

You can also use the [HubSpot Request](/workflows/requests#making-integration-requests) step to access any of HubSpot's API endpoints without the authentication piece.

When creating or updating records in HubSpot, you can reference data from previous steps by typing `{{` to invoke the variable menu.

<Frame>
  <img src="https://mintcdn.com/paragon/EuLlf5VxgsSnEq57/assets/Creating%20HubSpot%20contacts%20in%20Paragon%20(1).png?fit=max&auto=format&n=EuLlf5VxgsSnEq57&q=85&s=1682010ed531ddd3a955851a3ddd16a6" alt="" width="1140" height="774" data-path="assets/Creating HubSpot contacts in Paragon (1).png" />
</Frame>

## Working with HubSpot Custom Objects and Custom Fields

It's common that different HubSpot instances may be configured with different Custom Objects or Custom Fields. Paragon provides the ability for your users to choose their own Custom Object mapping.

<Frame>
  <img src="https://mintcdn.com/paragon/EuLlf5VxgsSnEq57/assets/Custom%20object%20mapping%20for%20HubSpot%20in%20Paragon.png?fit=max&auto=format&n=EuLlf5VxgsSnEq57&q=85&s=80c1deaf89047830d50c01413b59e2c6" alt="" width="1746" height="1634" data-path="assets/Custom object mapping for HubSpot in Paragon.png" />
</Frame>

### Custom Object Mapping

To allow your users to choose their own Custom Object Mapping, add the **Custom Object Mapping** user setting in your Connect Portal Editor. You should give this setting a descriptive user-setting name, for example, if you're mapping contacts from your app to HubSpot, you might call this "Map Contacts to this object".

<Frame>
  <img src="https://mintcdn.com/paragon/jCM_Y_j0HttScr1R/assets/Editing%20Custom%20Object%20Mapping%20for%20HubSpot%20in%20Paragon.png?fit=max&auto=format&n=jCM_Y_j0HttScr1R&q=85&s=c3a6ca96010f46562de5204fbc7070c8" alt="" width="3086" height="1628" data-path="assets/Editing Custom Object Mapping for HubSpot in Paragon.png" />
</Frame>

Below, **add a label for each object property that should be mapped from your app to a HubSpot object field**. In our contacts example, you might add labels for "First Name", "Last Name", and "Email".

In your Connect Portal, your users will be prompted to select an object from their HubSpot instance when enabling this workflow. For each of the object properties you labeled, your users will be prompted to select which object field that property should be mapped to.

In the workflow editor, you can now access your user's custom object mapping in the variable menu. For example:

<Frame>
  <img src="https://mintcdn.com/paragon/lKoo2WTuuveEy7P1/assets/accessing-custom-object-mapping-in-the-variable-menu-in-paragon.gif?s=bf64e5e13d0624ba968b2aff63a6e73c" alt="" width="699" height="956" data-path="assets/accessing-custom-object-mapping-in-the-variable-menu-in-paragon.gif" />
</Frame>

## Using Webhook Triggers

Webhook triggers can be used to run workflows based on events in your users' HubSpot account. For example, you might want to trigger a workflow whenever new contacts are created HubSpot to sync your users' HubSpot contacts to your application in real-time.

<Frame>
  <img src="https://mintcdn.com/paragon/7RZyQGncIlY8Xl4A/assets/HubSpot%20Webhook%20Triggers%20in%20Paragon%20Connect.png?fit=max&auto=format&n=7RZyQGncIlY8Xl4A&q=85&s=871ff316d26956f0fc06f1af733a3b34" alt="" width="2230" height="1228" data-path="assets/HubSpot Webhook Triggers in Paragon Connect.png" />
</Frame>

You can find the full list of Webhook Triggers for HubSpot below:

* **New Record**
* **Record Updated**
* **Record Deleted**
* **Record Deleted for Privacy (GDPR)**

### Add Webhooks to your HubSpot App

Using HubSpot triggers requires you to add webhooks as a feature to your HubSpot App.

To add webhooks to your HubSpot App, follow [Setup > Steps 3 and 4](#:~:text=3-,Configure%20webhooks,-If%20your%20HubSpot) to register Paragon's webhook URL with HubSpot and subscribe to the event types you are listening for.

### Webhooks for Legacy Apps

If you are not yet using HubSpot's project-based framework, follow the instructions below to register Paragon's Target URL in your Legacy App.

<Accordion title="Configuring webhooks for HubSpot Legacy Apps">
  Paragon provides a webhook Target URL to subscribe your HubSpot app to events in your users' HubSpot instances. To add the target URL to your HubSpot app:

  1. In the Settings tab of your HubSpot integration in Paragon, copy the link under **Webhook URL**.

  <Frame>
    <img src="https://mintcdn.com/paragon/fpLCYjVDKL_JwtxK/assets/copying-the-hubspot-webhook-url-in-paragon.png?fit=max&auto=format&n=fpLCYjVDKL_JwtxK&q=85&s=6a0c6061b9062262a2b4061ecd25780b" alt="" width="949" height="530" data-path="assets/copying-the-hubspot-webhook-url-in-paragon.png" />
  </Frame>

  2. Log in to your [HubSpot developer dashboard](https://developers.hubspot.com/), select your HubSpot developer account, and open your app under **Legacy Apps**.

  3. In your HubSpot App page sidebar, navigate to **Features > Webhooks**.

  4. Provide the **Target URL**. Paragon will automatically begin listening to events on behalf of your app.

  5. Click **Save** at the bottom of the HubSpot App dashboard.

  <Frame>
    <img src="https://mintcdn.com/paragon/lKoo2WTuuveEy7P1/assets/adding-the-target-url-to-a-hubspot-legacy-app.png?fit=max&auto=format&n=lKoo2WTuuveEy7P1&q=85&s=f2efa8d9ffeed24f385d2071fdb65fc6" alt="" width="1210" height="644" data-path="assets/adding-the-target-url-to-a-hubspot-legacy-app.png" />
  </Frame>

  You are now able to **Create subscriptions** against the events you want to subscribe to within HubSpot.
</Accordion>

## Publishing your HubSpot App

<Info>
  **Required for publishing:** In order to list your app on the HubSpot Marketplace, you must implement the following additional features in your integration:

  * [Setting up Redirect Pages](#setting-up-redirect-pages-in-your-app)

  For more information, see [HubSpot's documentation on publishing requirements](https://developers.hubspot.com/docs/apps/developer-platform/list-apps/listing-your-app/app-marketplace-listing-requirements).
</Info>

### Setting up Redirect Pages in your app

Your HubSpot App requires a Redirect Page hosted in **your application** to support an installation flow that begins in the HubSpot Marketplace (i.e., a user searches the HubSpot Marketplace for your app and clicks **Install**).

<Tip>
  For an example implementation of the Redirect Page using React (based on our Next.js sample app), [see here](https://github.com/ethanlee16/paragon-connect-nextjs-example/blob/redirect-page/pages/integrations/hubspot.js).
</Tip>

The Redirect Page should be implemented as follows:

* **Allow `X-Frame-Options` for `hubspot.com`.**
  * During the installation process, HubSpot will load your Redirect Page URL in a hidden `iframe` to complete the OAuth exchange.
  * If this is not possible, you will need to use the partner sign-in flow option described [here](https://developers.hubspot.com/docs/apps/developer-platform/list-apps/understand-app-install-flow#understand-the-install-flow-with-partner-sign-in). This will first redirect users to your app directly to begin an installation request (Initial Redirect), rather than using an `iframe` to complete the installation flow.

* **Import the Paragon SDK and authenticate a user.**

  * If the user is not yet logged into your app, we suggest that you redirect to a login form, while preserving the intended URL to redirect to upon successful login. After logging in, your user should see your Redirect Page.

* **Accept and read query parameters**, which will be:

  * `code`: The temporary authorization code to pass to Paragon for completing the OAuth exchange.
  * `returnUrl`: The URL to redirect to after completing the OAuth exchange.

* **Call `paragon.completeInstall` to complete the OAuth exchange** and save a new connected HubSpot account.

  ```javascript Complete the OAuth exchange theme={null}
  const params = new URLSearchParams(window.location.search);

  paragon.completeInstall("hubspot", {
    authorizationCode: params.get("code"),
    redirectUrl: "https://your-app.url/hubspot-redirect-page-path"
  }).then(() => {
    window.location = params.get("returnUrl");
  });
  ```

### Configuring your Redirect Page in your HubSpot App

First, add your production Redirect Page URL to your HubSpot App configuration by modifying your project's `app-hsmeta.json` to include it as an authorized redirect URL:

```diff Add Redirect Page URL theme={null}
"redirectUrls": [
  "http://localhost:3000",
- "https://passport.useparagon.com/oauth"
+ "https://passport.useparagon.com/oauth",
+ "https://your-app.url/hubspot-redirect-page-path"
],
```

Make sure to publish your changes by uploading a new version of the project:

```bash Update HubSpot App theme={null}
hs project upload
```

Next, if you have not already, create an App Listing for your HubSpot Project by navigating to **App Listings** in your HubSpot developer account.

Once viewing your App Listing draft:

* In **Listing Info > Information**, select your Redirect Page URL as the **Install button URL** (you can use a `localhost` URL while testing a draft).
* For **Sign-in configuration**, select "No partner login required, HubSpot OAuth only".

<Frame>
  <img src="https://mintcdn.com/paragon/lJYD6st7Kv4473Ps/assets/hubspot-marketplace-listing-draft.png?fit=max&auto=format&n=lJYD6st7Kv4473Ps&q=85&s=629babd40bb62dc7982b58928bf6dfba" alt="App Listing draft" width="2798" height="1806" data-path="assets/hubspot-marketplace-listing-draft.png" />
</Frame>

You can test your install flow by clicking **Preview** in the App Listing draft and clicking **Install** in your preview App Listing page.

If your installation succeeds, your Redirect Page is configured correctly!
