splitforms.com
All articles/ INTEGRATIONS9 MIN READPublished May 2, 2026

How to Send Form Submissions to HubSpot CRM

Capture contact form submissions as HubSpot CRM contacts without the HubSpot Forms watermark. Create a private app token, map custom properties, dedupe by email, and forward splitforms webhooks to the HubSpot v3 contacts API.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Why bypass HubSpot Forms

HubSpot is a great CRM. HubSpot Forms are a serviceable but visually-mediocre form builder bolted onto it. For most teams the friction shows up in three places: the "Powered by HubSpot" watermark on every free-tier form, the styling that's locked to a small set of HubSpot CSS variables (you cannot ship a Tailwind-styled form through it cleanly), and the ~80KB JavaScript loader that has to run on every page where a form is embedded.

For a high-conversion landing page or a polished product site, owning the form HTML — styling it however you want, keeping the page weight minimal — and just routing the data into HubSpot via the API is the cleaner setup. splitformshandles the form-side concerns (spam filtering, webhook delivery, dashboard) and a tiny proxy handles the HubSpot API call. You get the CRM, you keep your design, and there's no watermark to remove.

Step 1 — Create a Private App and copy the token

In HubSpot, navigate to Settings → Integrations → Private Apps. (If you don't see Private Apps, you don't have admin scope on the portal — ask your HubSpot admin to create the app, or be granted Super Admin.)

  1. Click Create a private app.
  2. Name it "splitforms webhook". The logo and description are optional.
  3. On the Scopes tab, search and check crm.objects.contacts.write. Add crm.objects.contacts.read if you want to look up contacts before writing (you don't need this if you're using the upsert pattern below). Add crm.schemas.contacts.read if you want to fetch property metadata.
  4. Click Create app → confirm. You'll see the token on the next screen — copy it. It starts with pat-na1- or similar depending on your HubSpot region.

The token doesn't expire. If it leaks, regenerate from the same screen — the URL stays valid, only the secret changes.

Step 2 — Map form fields to HubSpot properties

HubSpot contacts have a fixed set of standard properties (firstname, lastname, email, phone, company, website, address, etc.) plus any custom properties you've created. Property names in API requests are always the internal name (lowercase, snake_case for custom ones), not the human-readable label.

The contact create/upsert payload looks like this:

POST https://api.hubapi.com/crm/v3/objects/contacts
Authorization: Bearer pat-na1-...
Content-Type: application/json

{
  "properties": {
    "firstname":          "Ada",
    "lastname":           "Lovelace",
    "email":              "ada@example.com",
    "phone":              "+1-555-0100",
    "company":            "Lovelace Industries",
    "website":            "https://example.com",
    "lifecyclestage":     "lead",
    "hs_lead_status":     "NEW",
    "lead_source_form":   "Contact form on /pricing",
    "lead_message":       "Hi — I'd like a 15-min demo.",
    "utm_source":         "google",
    "utm_campaign":       "spring-2026"
  }
}

Custom properties (lead_source_form, lead_message) need to exist before you POST — create them under Settings → Properties → Contact properties → Create property. Use the "Single-line text" type for most form fields and "Multi-line text" for long messages. Internal property names get lowercased and underscored automatically.

Step 3 — The splitforms → HubSpot proxy

splitforms can deliver webhooks to any HTTPS endpoint, but the HubSpot API needs a Bearer token. Drop a stateless proxy in front. Here's the Cloudflare Worker version using the upsert endpoint so duplicate emails update the existing contact rather than 409-ing:

export default {
  async fetch(req, env) {
    if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });

    const submission = await req.json();
    const f = submission.fields ?? submission.data ?? {};

    if (!f.email) {
      return new Response("Missing email", { status: 400 });
    }

    // Best-effort split of "Ada Lovelace" -> firstname, lastname.
    const [first, ...rest] = (f.name || "").trim().split(/\s+/);
    const last = rest.join(" ");

    const properties = {
      email:            f.email,
      firstname:        first || undefined,
      lastname:         last  || undefined,
      phone:            f.phone   || undefined,
      company:          f.company || undefined,
      website:          f.website || undefined,
      lifecyclestage:   "lead",
      hs_lead_status:   "NEW",
      lead_source_form: env.LEAD_SOURCE_LABEL || "splitforms contact form",
      lead_message:     f.message || undefined,
      utm_source:       f.utm_source   || undefined,
      utm_medium:       f.utm_medium   || undefined,
      utm_campaign:     f.utm_campaign || undefined,
    };

    Object.keys(properties).forEach(
      (k) => properties[k] === undefined && delete properties[k]
    );

    // Upsert by email — creates or updates atomically.
    const body = {
      inputs: [{ idProperty: "email", id: f.email, properties }],
    };

    const res = await fetch(
      "https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert",
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${env.HUBSPOT_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
      }
    );

    if (!res.ok) {
      const err = await res.text();
      console.error("HubSpot error", res.status, err);
      return new Response(err, { status: 502 });
    }

    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

Set the secrets and deploy:

wrangler secret put HUBSPOT_TOKEN        # paste pat-na1-...
wrangler secret put LEAD_SOURCE_LABEL    # e.g. "Homepage contact form"
wrangler deploy

Copy the resulting URL into your splitforms dashboard under Webhooks → Add → Generic JSON. If you have multiple forms (one for sales, one for support, one for jobs), deploy multiple workers with different LEAD_SOURCE_LABELvalues — that's how you keep clean attribution in HubSpot reports.

Deduping by email (upsert pattern)

The /crm/v3/objects/contacts/batch/upsert endpoint is doing the heavy lifting. The idProperty: "email" tells HubSpot "use email as the matching key" — if a contact with that email exists, update it; otherwise create one. This is preferable to the standard POST /crm/v3/objects/contacts endpoint, which 409s on duplicate emails and forces you to write a try/catch retry loop.

The batch in the URL is misleading — you can pass a single record in the inputs array. The endpoint just supports up to 100 records per request if you happen to need it.

One subtle thing: HubSpot updates only the properties you send. So if a contact already has firstname="Ada" and your form submission only includes email and message, the update preserves the existing firstname. You don't lose data on re-submission. This is generally what you want; it does mean you can't blank a property just by omitting it.

Lead source attribution

The point of a CRM is being able to answer "which channel brings in the best leads". HubSpot has a few standard properties for this and a long tradition of teams getting them wrong.

  • hs_analytics_source — HubSpot writes this automatically based on its tracking code. If your site already loads HubSpot tracking, leave this alone — overwriting it via API confuses the reports.
  • lifecyclestage — set to "lead" for inbound contact forms. Other valid values: "subscriber", "marketingqualifiedlead", "salesqualifiedlead", "opportunity", "customer".
  • hs_lead_status — set to "NEW". Your sales team moves it through the pipeline.
  • Custom: lead_source_form — the human-readable form name. This is what you actually filter HubSpot views by.
  • utm_source / utm_medium / utm_campaign — pull from URL query params or hidden form fields. The proxy reads them from the form submission so make sure you populate them client-side.

For UTM capture without a JS framework, the cheapest move is a tiny script that reads document.location.search and sets hidden inputs on every form on the page:

<script>
(function () {
  var p = new URLSearchParams(location.search);
  ["utm_source","utm_medium","utm_campaign"].forEach(function (k) {
    var v = p.get(k);
    if (!v) return;
    document.querySelectorAll('input[name="' + k + '"]').forEach(function (el) {
      el.value = v;
    });
  });
})();
</script>

Add hidden inputs <input type="hidden" name="utm_source" /> for each UTM key on the form. splitforms passes those through as fields, the proxy reads them, HubSpot stores them.

Test with curl

Before pointing the live form at the worker, fire a synthetic request:

curl -X POST https://splitforms-hubspot.your.workers.dev \
  -H "Content-Type: application/json" \
  -d '{
    "submitted_at": "2026-05-02T12:00:00Z",
    "fields": {
      "name":         "Ada Lovelace",
      "email":        "ada+test@example.com",
      "phone":        "+1-555-0100",
      "company":      "Lovelace Industries",
      "message":      "Demo request — testing.",
      "utm_source":   "google",
      "utm_campaign": "spring-2026"
    }
  }'

You should see {"ok":true} back in 200-400ms. In HubSpot, go to Contacts → Contacts and search for the email — the new contact (or an updated existing one) should be there with all properties populated. If it's missing, check the worker logs (wrangler tail) — most failures are missing scopes on the private app or a custom property that doesn't exist yet.

Once it's working, drop the splitforms form into your site (grab a starter from templates) and pair this CRM wiring with the rest of your lead capture flow. Full webhook spec including HMAC verification and retry behavior is in the splitforms docs.

Tech support and troubleshooting

The five HubSpot webhook errors that come up over and over:

  • 401 INVALID_AUTHENTICATIONToken was regenerated or pasted with whitespace. Recreate it from Settings > Integrations > Private Apps and re-deploy the worker secret.
  • 403 MISSING_SCOPESYour private app lacks crm.objects.contacts.write. Open the app, edit scopes, save, and re-paste the new token.
  • 409 CONFLICT on duplicate emailYou're calling /contacts instead of /contacts/batch/upsert. Switch to upsert with idProperty: 'email' and the duplicate becomes an update.
  • Custom property missingProperties must be created in HubSpot before the API can write them. Add lead_source_form and lead_message under Settings > Properties.
  • UTM values empty in HubSpotHidden inputs aren't being populated. Verify the UTM script runs before form submit, and that the form has matching <input type='hidden' name='utm_source'> entries.

Webhook signature, retry behavior, and event payload schema live in the splitforms docs; raw endpoint reference is in the API reference. Account or billing questions belong in the splitforms FAQ.

FAQ

Why not just use HubSpot's built-in forms?

HubSpot Forms are functional but constrained. The free tier ships every form with an 'Powered by HubSpot' watermark, the styling options are limited to the HubSpot form CSS (no Tailwind, no real custom design), and embedding the form on a non-HubSpot site requires a JavaScript loader that adds 80KB+ to your page. For a marketing-grade landing page where the form is part of the visual brand, owning the markup and just using HubSpot as the CRM destination is much cleaner.

What's a HubSpot Private App and how is it different from an API key?

HubSpot deprecated the legacy API keys (hapikey) in late 2022. Private Apps replaced them — same idea (one secret, scoped permissions) but now per-portal and with proper scope control. You create one inside your HubSpot account, pick exactly the scopes you need (crm.objects.contacts.write is the only one for this use case), and you get a token starting with 'pat-na1-...' or similar. The token never expires unless you regenerate it.

Will the HubSpot API automatically dedupe by email?

The standard contacts.create endpoint will throw a 409 CONFLICT if the email already exists. Two ways to handle this: (1) catch the 409 and PATCH the existing contact instead, or (2) use the upsert endpoint at /crm/v3/objects/contacts/batch/upsert which takes an idProperty: 'email' parameter and creates-or-updates atomically. The proxy code below uses the upsert pattern.

Can I attach a file (resume, portfolio) to the HubSpot contact?

Yes, but it's a two-step API dance: upload the file to the Files API to get a fileId, then attach it to the contact via an engagement (note or task). For a contact form, the cleaner pattern is to skip file engagement and just write the file's CDN URL into a custom contact property called 'Attachment URL' — that's what most teams actually want.

How do I track lead source attribution properly?

Set the standard 'hs_analytics_source' property on the contact, plus a custom 'lead_source_form' property with the form's name. Pull UTM parameters from the splitforms 'referrer' field or from hidden form inputs you populate via JavaScript on form load. The proxy below shows the full pattern.

What's the API rate limit on HubSpot?

Free and Starter HubSpot accounts get 100 requests per 10 seconds and 250,000 per day per private app. Professional and Enterprise get 150 per 10 seconds. The contact form will never come close — even a viral form pushing 100 submissions/minute is well under the limit.

Should I use HubSpot Forms API instead of the contacts API?

Possibly. The HubSpot Forms API endpoint /forms/v2/forms/{portalId}/{formGuid}/submit accepts a form payload and triggers all the HubSpot form-level workflows (autoresponders, lead-scoring rules, list memberships). The contacts API just creates a record — it doesn't fire any HubSpot workflow tied to a form. If you've already built a marketing automation around 'Form X submitted' triggers, use the Forms API endpoint and route the splitforms webhook there instead.

Can a non-developer set this up without writing the proxy?

Yes — use Zapier or Make. The HubSpot connector in both is well-maintained and ships native 'Create Contact' and 'Update Contact' actions. The trade-off is the Zapier subscription ($19.99+/mo) and the 5-30s latency vs the 200ms proxy below. For a low-volume contact form with no developer on staff, Zapier is fine.

Next steps

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open