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_submissionfires with$entryand$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/upsertkeyed 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.
/ 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.¹
| 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 windowX-HubSpot-RateLimit-Remaining— how many are left in itX-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.
/ 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.
| 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 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.
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.