---
title: "Gravity Forms to HubSpot: Send Entries to the CRM API"
description: "Gravity Forms HubSpot integration in code: the gform_after_submission signature, private app tokens, contact properties, and the real rate limits."
url: "https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/"
date: "2026-09-01"
---

# Gravity Forms to HubSpot: Send Entries to the CRM API

**TL;DR:** Gravity Forms gives you the entry; HubSpot wants properties. Almost every problem in this integration lives in the gap between those two vocabularies.

-   `gform_after_submission` fires with `$entry` and `$form`, **after** the entry is saved and notifications are sent.
-   HubSpot wants **internal property names** (`firstname`), not the labels you see in the CRM (_First Name_). Entry values are keyed by **field ID** (`'1.3'`), not by label either.
-   Authenticate with a **private app token**: `Authorization: Bearer …`.
-   Rate limits are per app and per account: **100 requests / 10 seconds** on Free and Starter, 190 on Professional and Enterprise.
-   Use `batch/upsert` keyed on email if the same person can submit twice.

/ Hook

## Which Gravity Forms hook should send the entry?

[`gform_after_submission`](https://docs.gravityforms.com/gform_after_submission/), which runs once the entry has been created and notifications have gone out. Its signature is two arguments in a fixed order:

PHP — the hook signature, and the form-specific variant

```
// All forms. Note the argument count — omit the 2 and $form arrives as null.
add_action( 'gform_after_submission', 'queue_entry_for_hubspot', 10, 2 );

// One form only, by form ID. Cheaper than an if() on every submission.
add_action( 'gform_after_submission_5', 'queue_entry_for_hubspot', 10, 2 );

function queue_entry_for_hubspot( $entry, $form ) {
    // $entry values are keyed by FIELD ID, not by label.
    // A name field's parts are decimals: 1.3 first, 1.6 last.
    $payload = [
        'email'     => rgar( $entry, '3' ),
        'firstname' => rgar( $entry, '1.3' ),
        'lastname'  => rgar( $entry, '1.6' ),
    ];

    // Hand off and return. Do not call HubSpot from here.
    do_action( 'my_queue_crm_delivery', $payload, $form['id'] );
}
```

Two details in that snippet cause most of the support threads. The **argument count** is one: `add_action` defaults to passing a single argument, so a callback declared with two parameters but registered without the trailing `2` receives `null` for `$form`. The other is that entry values are addressed by **field ID**. A Name field is a compound field whose parts are decimal keys — `1.3` for first, `1.6` for last — and they are stable identifiers, not labels, which means renaming a field label in the form editor does not break your mapping but reordering fields in a rebuilt form absolutely does.

Use `rgar()` rather than direct array access. Unfilled optional fields are simply absent from the entry array, and `$entry['7']` on an empty field emits a notice on every submission.

FIG 02 — Why the CRM call belongs after the submission, not inside it

/ Auth

## How do you authenticate against the HubSpot API?

With a [private app access token](https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/overview), sent as a bearer token. You create the app inside your HubSpot account, grant it scopes, and it issues a token:

`Authorization: Bearer <your-token>`

Creating a contact needs the `crm.objects.contacts.write` scope; reading one back needs `crm.objects.contacts.read`. Grant only what the integration uses — a token scoped to contacts cannot be turned into a deals-and-tickets token by an attacker who finds it in a database backup.

Treat the token as a credential, not configuration. It should not be in `wp-config.php` in a repository, not in a theme file, and not in a plugin setting that renders it back into an admin page in plain text. Encrypted-at-rest storage that returns a masked hint rather than the secret is the shape you want.

/ Payload

## What does the HubSpot contacts endpoint expect?

A `POST` to `/crm/v3/objects/contacts` with a single `properties` object. There is no envelope and no object-type field — the path carries the object type:

PHP — creating the contact from a queued worker

```
$res = wp_remote_post( 'https://api.hubapi.com/crm/v3/objects/contacts', [
    'timeout' => 15,
    'headers' => [
        'Authorization' => 'Bearer ' . $token,
        'Content-Type'  => 'application/json',
    ],
    'body' => wp_json_encode( [
        'properties' => [
            // INTERNAL names, lowercase — not the CRM's display labels.
            'email'     => $payload['email'],
            'firstname' => $payload['firstname'],
            'lastname'  => $payload['lastname'],
        ],
    ] ),
] );

$code = wp_remote_retrieve_response_code( $res );

if ( 429 === $code ) {
    // Rate limited. Back off — this one IS worth retrying.
    return new WP_Error( 'hubspot_rate_limited' );
}

if ( $code >= 400 && $code < 500 ) {
    // Unknown property, bad email. Retrying changes nothing.
    return new WP_Error( 'hubspot_bad_payload' );
}
```

The property names are the trap. HubSpot's API takes **internal names**, which are lowercase and often unpunctuated — `firstname`, not `First Name` and not `first_name`. Custom properties get an internal name generated when you create them, and it does not always match what you typed. Read the real name from the property settings in HubSpot rather than inferring it from the label, because an unknown property name returns a `400` that will fail identically on every retry.

> A 429 means "the same request, later." A 400 means "this request, never." Code that treats them the same either loses good data or hammers the API with a payload that cannot succeed. — the distinction that decides your retry policy

/ Limits

## What are the HubSpot API rate limits?

Two limits apply at once: a burst limit measured per app over ten seconds, and a daily limit measured per account across every app on it.¹

| Subscription | Per 10 seconds (per app) | Per day (per account) |
| --- | --- | --- |
| Free / Starter | 100 | 250,000 |
| Professional | 190 | 625,000 |
| Enterprise | 190 | 1,000,000 |
| With API Limit Increase | 250 | +1,000,000 per increase |

The per-account daily figure is the one to think about when several integrations share a portal — your form is not spending its own budget, it is spending the account's. The burst limit is the one a form actually hits, and it hits it in a specific way: not from steady traffic, but from a campaign, an import, or a retry storm after an outage, when a hundred queued deliveries all become due in the same second.

HubSpot answers a breach with `429` and an `errorType` of `RATE_LIMIT`, and every response carries the budget in headers you can read rather than guess at:

-   `X-HubSpot-RateLimit-Max` — requests allowed in the current window
-   `X-HubSpot-RateLimit-Remaining` — how many are left in it
-   `X-HubSpot-RateLimit-Daily-Remaining` — what is left of the account's day

Do the arithmetic for your own site. At the Free and Starter ceiling of 100 requests per 10 seconds, one contact call per submission supports 10 submissions per second sustained, which no ordinary form approaches. But a queue that drains 200 backed-up deliveries with no pacing sends all 200 as fast as the worker loops — roughly 20× the burst allowance — and the tail of that batch is rejected. A worker that spaces deliveries, or batches them, never sees a 429.

![Cyberpunk illustration: a chromed robotic hand seating a cable ferrule into one port of a dense termination block. The port under the hand is dark and cracked with light where the ferrule will not seat, while an identical port one row above glows open and accepting.](https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/og_image.jpg)

/ Duplicates

## How do you stop the same person becoming two contacts?

Upsert on email instead of creating. HubSpot exposes `POST /crm/v3/objects/contacts/batch/upsert`, which takes an `inputs` array and matches on an identifier you nominate, and `POST /crm/v3/objects/contacts/batch/create` for straight inserts.²

Batching pays twice here. Sending 100 contacts as a single batch call costs one request against the burst limit instead of 100, and the upsert semantics mean a repeated delivery updates the existing contact rather than creating a second one. Batch endpoints report per-item outcomes as a multi-status response, so partial failure is visible — check the individual results rather than assuming a 2xx means every record landed.

/ Licence

## Do you need the Gravity Forms Webhooks add-on for this?

Only if you want Gravity Forms itself to make the request. The [Webhooks add-on is bundled with the Elite licence](https://www.gravityforms.com/pricing/) at $259/year — Basic ($59) and Pro ($159) do not include it, which surprises people who reasonably assume webhooks are a core feature of a forms plugin in 2026.

The `gform_after_submission` hook, by contrast, is part of Gravity Forms itself and is available on every licence including Basic. That is the gap worth knowing about: the hook that lets you send an entry anywhere costs nothing extra, and what the Elite add-on sells is the interface for configuring it without code — a feed UI, field mapping and conditional logic — rather than the capability.

| Concern | Inline wp\_remote\_post in the hook | Webhook Actions |
| --- | --- | --- |
| HubSpot connector | None — you write the call | Also none. You point it at api.hubapi.com yourself; there is no HubSpot connector to select |
| Submission speed | Visitor waits for HubSpot | Entry is queued; the confirmation renders immediately |
| Behaviour on 429 | Contact is lost unless you wrote the retry | Backoff and retry, up to 5 attempts by default |
| Behaviour on 400 | Often retried pointlessly, or lost silently | Marked permanently failed — no wasted requests |
| Proof it was sent | error\_log(), if you remembered | Per-attempt log with request, response and replay |
| Token storage | Plain text in options or wp-config | Encrypted vault, never returned over the API |

If the destination is Salesforce rather than HubSpot, the auth model and the limit arithmetic are different enough to change the design — [the Salesforce integration guide](https://wpwebhooks.org/blog/wordpress-salesforce-integration/) covers the token flow and the org-wide daily allowance. For the general problem of deliveries that vanish without an error, [why WordPress webhooks fail silently in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) is the companion piece.

try\_it

Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.

[Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip)

/Footnotes

¹ API usage guidelines and limits, [HubSpot developer documentation](https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines).

² Contacts API endpoints and batch operations, [HubSpot CRM API reference](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/guide).

³ gform\_after\_submission signature and parameters, [Gravity Forms documentation](https://docs.gravityforms.com/gform_after_submission/).

⁴ Licence tiers and add-on availability, [Gravity Forms pricing](https://www.gravityforms.com/pricing/).

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"Gravity Forms to HubSpot: Send Entries to the CRM API","description":"Gravity Forms HubSpot integration in code: the gform_after_submission signature, private app tokens, contact properties, and the real rate limits.","datePublished":"2026-09-01","dateModified":"2026-09-01","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration: a chromed robotic hand seating a cable ferrule into one port of a dense termination block. The port under the hand is dark and cracked with light where the ferrule will not seat, while an identical port one row above glows open and accepting."},"keywords":["gravity forms hubspot","gravity forms to hubspot","gravity forms hubspot integration","gravity forms hubspot contact","hubspot crm api wordpress"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Gravity Forms to HubSpot: Send Entries to the CRM API","item":"https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Which Gravity Forms hook sends an entry to HubSpot?","acceptedAnswer":{"@type":"Answer","text":"gform_after_submission, registered with two arguments: add_action( \"gform_after_submission\", \"callback\", 10, 2 ). It receives the Entry Object and the Form Object and runs after the entry has been saved and notifications sent. A form-specific variant, gform_after_submission_5, targets a single form by ID."}},{"@type":"Question","name":"How do I authenticate a WordPress site against the HubSpot API?","acceptedAnswer":{"@type":"Answer","text":"With a private app access token sent as a bearer token: Authorization: Bearer YOUR_TOKEN. Create the private app inside your HubSpot account and grant it the crm.objects.contacts.write scope to create contacts. Store the token encrypted, never in a theme file or a plain-text plugin setting."}},{"@type":"Question","name":"What are the HubSpot API rate limits?","acceptedAnswer":{"@type":"Answer","text":"Private apps on Free and Starter are limited to 100 requests per 10 seconds per app and 250,000 requests per day per account. Professional and Enterprise allow 190 requests per 10 seconds, with 625,000 and 1,000,000 per day respectively. The daily limit is shared across every app on the account."}},{"@type":"Question","name":"Why does my HubSpot contact create return a 400 error?","acceptedAnswer":{"@type":"Answer","text":"Most often because the property name is wrong. HubSpot expects internal property names such as firstname, not the display labels shown in the CRM interface, and custom properties get an internal name generated at creation that may not match what you typed. A 400 from an unknown property will fail identically on every retry."}},{"@type":"Question","name":"How do I avoid creating duplicate HubSpot contacts?","acceptedAnswer":{"@type":"Answer","text":"Use POST /crm/v3/objects/contacts/batch/upsert keyed on email rather than the plain create endpoint. Batching also costs one request against the burst limit instead of one per contact. Batch endpoints return per-item results as a multi-status response, so check individual outcomes rather than assuming a 2xx means everything landed."}}]}

{"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/gravity-forms-hubspot.png","caption":"FIG 02 — Why the CRM call belongs after the submission, not inside it","description":"When gform_after_submission runs, the entry is already saved and the visitor is still waiting for the page. Calling the HubSpot API inline on that request means the visitor waits for HubSpot, and a slow or rate-limited CRM becomes a slow form. The safer shape hands the entry to a queue and returns immediately, so the confirmation renders regardless of what HubSpot does. The queued worker maps entry field ids to HubSpot internal property names, which are not the labels shown in the CRM interface, then posts to the contacts endpoint with a private app bearer token. A 429 response carries rate limit headers telling the worker how much budget is left, and is worth retrying; a 400 caused by an unknown property name is not, because the same payload will fail forever.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"}
```
