WP Webhooks / Blog / Integrations

Wiring Gravity Forms Entries into HubSpot Contacts

Gravity Forms HubSpot integration in code: the gform_after_submission signature, private app tokens, contact properties, and the real rate limits.

8 min 2026-09-01
#gravityforms#hubspot#integrations

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, 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, 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.¹

SubscriptionPer 10 seconds (per app)Per day (per account)
Free / Starter100250,000
Professional190625,000
Enterprise1901,000,000
With API Limit Increase250+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.

/ 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 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.

ConcernInline wp_remote_post in the hookWebhook Actions
HubSpot connectorNone — you write the callAlso none. You point it at api.hubapi.com yourself; there is no HubSpot connector to select
Submission speedVisitor waits for HubSpotEntry is queued; the confirmation renders immediately
Behaviour on 429Contact is lost unless you wrote the retryBackoff and retry, up to 5 attempts by default
Behaviour on 400Often retried pointlessly, or lost silentlyMarked permanently failed — no wasted requests
Proof it was senterror_log(), if you rememberedPer-attempt log with request, response and replay
Token storagePlain text in options or wp-configEncrypted 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 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 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.

/Footnotes
¹ API usage guidelines and limits, HubSpot developer documentation.
² Contacts API endpoints and batch operations, HubSpot CRM API reference.
³ gform_after_submission signature and parameters, Gravity Forms documentation.
Licence tiers and add-on availability, Gravity Forms pricing.
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

Which Gravity Forms hook sends an entry to HubSpot? +
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.
How do I authenticate a WordPress site against the HubSpot API? +
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.
What are the HubSpot API rate limits? +
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.
Why does my HubSpot contact create return a 400 error? +
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.
How do I avoid creating duplicate HubSpot contacts? +
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.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate