HubSpot WooCommerce Integration: Create & Update Deals
Two webhooks, one cohesive flow. Create a HubSpot deal the instant a WooCommerce order is placed, then flip it to Closed Won when the order completes — using the 2026-03 CRM API, Code Glue, and zero Zapier.
TL;DR — What you'll build
- Webhook 1 — Create Deal: fires on
woocommerce_checkout_order_created, POSTs order data to HubSpot, gets back a deal ID - Post-dispatch Code Glue: stores the returned deal ID in WC order meta as
_hubspot_deal_id - Webhook 2 — Update Deal Stage: fires on
woocommerce_order_status_changed, PATCHes the deal to Closed Won when the order completes - Full delivery log, retries, and replay — no Zapier, no extra SaaS
What You Need Before Starting
- WordPress + WooCommerce with at least one published product
Any WooCommerce-compatible theme. You need to be able to place a real test order through the storefront or REST API.
- Webhook Actions (free) — v1.12.0 or later
Search for FlowSystems in Plugins → Add Plugin. Install and activate.
- Webhook Actions Pro Pro — v1.1.0 or later
Required for Code Glue (PHP snippets that run pre/post dispatch) and
{{ field.path }}URL template syntax. - HubSpot Service Key or Private App access token
In HubSpot: Settings → Integrations → Service Keys → Create service key. Grant scopes
crm.objects.deals.readandcrm.objects.deals.write. Copy the token — starts withpat-.... - Webhook Actions API token with
fullscopeIn WP Admin → Webhook Actions → API Tokens → Add Token. Select scope
full. Copy the token — shown only once.set shell variables
export SITE="https://your-wordpress-site.com" export TOKEN="fswa_full_..." # Webhook Actions API token (full scope) export HS_PAT="pat-eu1-xxxxxxxx..." # HubSpot Private App token
Two Webhooks, One Shared State
A clean HubSpot WooCommerce integration needs two webhooks that share a single piece of state: the deal ID returned when the first webhook creates the deal. Without that shared state, the second webhook has nothing to PATCH against.
The plugin solves this with two Pro features working together: pre-dispatch Code Glue for payload shaping and URL template injection, and post-dispatch Code Glue for persisting the returned deal ID.
flow diagram
# 1. ORDER CREATED WooCommerce order placed └── fires woocommerce_checkout_order_created └── Webhook #1 POST https://api.hubapi.com/crm/objects/2026-03/deals └── HubSpot responds 201 + {"id": "502475571425", ...} └── post-glue: update_post_meta($order_id, "_hubspot_deal_id", "502475571425") # 2. ORDER COMPLETED (minutes, hours, or days later) Order status changed → "completed" └── fires woocommerce_order_status_changed └── pre-glue: read _hubspot_deal_id from order meta → inject as _hs_deal_id └── URL template {{ _hs_deal_id }} resolves → /crm/objects/2026-03/deals/502475571425 └── Webhook #2 PATCH with {"properties": {"dealstage": "closedwon"}} └── HubSpot responds 200 — deal moved to Closed Won
update_post_meta, no outbound HTTP, no DB writes. Pre-glue can run multiple times (dispatch, retry, replay, preview). Reads like get_post_meta are fine. All write side effects go in post-dispatch glue.Create the "Deal Create" Webhook
The first webhook fires on woocommerce_checkout_order_created. If your store uses the block-based checkout, add woocommerce_store_api_checkout_order_processed to the triggers array — both fire with the same payload shape.
We POST to HubSpot's new dated CRM endpoint: https://api.hubapi.com/crm/objects/2026-03/deals. The legacy /crm/v3/objects/deals path still works but HubSpot is migrating to dated paths — any new integration should start there.
step 1.1 — create the webhook via REST
curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Create Deal", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals", "http_method": "POST", "auth_header": "Bearer '"$HS_PAT"'", "is_enabled": true, "is_synchronous": true, "retry_limit": 1, "triggers": [ "woocommerce_checkout_order_created", "woocommerce_store_api_checkout_order_processed" ] }'
response — 201 Created
{ "id": "29", "webhook_uuid": "a8f1d3...", "name": "HS - Create Deal", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals", "http_method": "POST", "is_synchronous": true, "triggers": [ "woocommerce_checkout_order_created", "woocommerce_store_api_checkout_order_processed" ] }
Webhook ID 29 created. is_synchronous: true matters here — the next webhook depends on the deal ID stored by this one's post-glue, and async dispatch would race the order-status-changed trigger.
Why two triggers? You need both.
WooCommerce has two checkout types and each fires its own action: woocommerce_checkout_order_created (classic shortcode — still common on customised storefronts and B2B portals) and woocommerce_store_api_checkout_order_processed (block-based checkout — default in WooCommerce 8+ block themes). Each order fires exactly one of them. Registering on both makes the webhook source-agnostic. Why not woocommerce_new_order? It fires before line items and totals are populated — pre-glue would see an empty order.
for TRIGGER in …; do …; done loop. Skip the loop and only one checkout type gets the glue./crm/objects/2026-03/deals.Map WC Order → Deal, Capture the Deal ID
The raw WooCommerce trigger payload isn't shaped like a HubSpot deal — we need to map fields. And after HubSpot returns the new deal's ID, we need to store it on the order so the next webhook can find it. Two snippets: pre-glue for mapping, post-glue for persistence.
2.1 — Pre-dispatch: map order → deal properties
create the pre-dispatch snippet
curl -sk -X POST "$SITE/wp-json/fswa/v1/pro/snippets" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Map WC Order to Deal", "code": "<PHP body below>" }'
snippet code — PHP
// Pre-dispatch glue — payload shaping only. NO side effects. // Input: $payload, $args, $webhook, $trigger $order = $payload["args"][0] ?? []; $orderId = $order["id"] ?? null; if (!$orderId) return $payload; $billing = $order["billing"] ?? []; $name = trim(($billing["first_name"] ?? "") . " " . ($billing["last_name"] ?? "")); return [ "_wc_order_id" => (string) $orderId, "properties" => [ "dealname" => "WC Order #$orderId — $name", "amount" => (string) ($order["total"] ?? 0), "dealstage" => "appointmentscheduled", "pipeline" => "default", "closedate" => date("c", strtotime("+30 days")), ], ];
_wc_order_id: we add it at the root of the returned array, alongside properties. HubSpot ignores unknown root-level keys, but the same payload object is what post-glue receives — so we can read the order ID back without re-parsing args. A clean way to carry context across pre and post.2.2 — Post-dispatch: store the returned deal ID
Post-dispatch glue is where side effects belong. It fires after the HTTP response comes back, with the response body and status code available. We parse the JSON, pull the id, and write it to WC order meta as _hubspot_deal_id.
create the post-dispatch snippet
curl -sk -X POST "$SITE/wp-json/fswa/v1/pro/snippets" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Store Deal ID in WC Meta", "code": "<PHP body below>" }'
snippet code — PHP
// Post-dispatch glue — runs AFTER the HTTP response. Side effects belong here. // Available: $payload, $responseCode, $responseBody, $webhook, $trigger $data = json_decode($responseBody, true); $orderId = (int) ($payload["_wc_order_id"] ?? 0); if ((int)$responseCode === 201 && !empty($data["id"]) && $orderId) { $order = wc_get_order($orderId); if ($order) { // HPOS-safe: use $order->update_meta_data, not update_post_meta $order->update_meta_data("_hubspot_deal_id", $data["id"]); $order->save(); } }
wp_wc_orders_meta, not wp_postmeta. Using update_post_meta($orderId, ...) writes to the legacy table and is invisible to $order->get_meta(). Always use wc_get_order()->update_meta_data() + $order->save() for WC orders.2.3 — Attach both snippets to the webhook + trigger
Replace $PRE_ID and $POST_ID with the snippet IDs returned above. Attach to both checkout triggers.
attach pre + post glue to both triggers
for TRIGGER in woocommerce_checkout_order_created woocommerce_store_api_checkout_order_processed; do curl -sk -X POST \ "$SITE/wp-json/fswa/v1/pro/trigger-snippets/29/trigger/$TRIGGER" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"pre_snippet_id": '"$PRE_ID"', "pre_enabled": true, "post_snippet_id": '"$POST_ID"', "post_enabled": true}' done
Create the "Deal Stage Update" Webhook
The second webhook fires on woocommerce_order_status_changed. The URL template feature embeds the HubSpot deal ID directly in the endpoint URL — Pro's URL filter expands {{ _hs_deal_id }} at dispatch time, after pre-glue has populated it from order meta.
step 3.1 — create the update webhook
curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Update Deal Stage", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }}", "http_method": "PATCH", "auth_header": "Bearer '"$HS_PAT"'", "is_enabled": true, "is_synchronous": true, "retry_limit": 1, "triggers": ["woocommerce_order_status_changed"] }'
response — webhook ID 30
{ "id": "30", "name": "HS - Update Deal Stage", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }}", "http_method": "PATCH", "triggers": ["woocommerce_order_status_changed"] }
migrate legacy v3 endpoint → 2026-03
curl -sk -X PATCH "$SITE/wp-json/fswa/v1/webhooks/30" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }}"}'
{{ _hs_deal_id }} URL template.3.2 — Condition: only fire when order moves to "completed"
woocommerce_order_status_changed fires on every status transition. We only want to flip the deal when the new status is completed:
add condition: new_status equals completed
curl -sk -X POST \ "$SITE/wp-json/fswa/v1/schemas/webhook/30/trigger/woocommerce_order_status_changed" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [{ "field": "args.2", "operator": "equals", "value": "completed", "cast": "string" }] }, "conditions_evaluate_on": "original" }'
3.3 — Pre-glue: inject the deal ID for the URL template
Pre-glue reads _hubspot_deal_id from order meta and returns it as _hs_deal_id for the URL filter to consume, plus the actual PATCH body in properties.
create the update pre-dispatch snippet
curl -sk -X POST "$SITE/wp-json/fswa/v1/pro/snippets" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Inject Deal ID for URL + Stage Update", "code": "<PHP body below>" }'
snippet code — PHP
// Pre-dispatch glue — payload shaping only. // Reads _hubspot_deal_id stored by Step 2 post-glue (local read, allowed). $orderId = (int) ($payload["args"][0] ?? 0); $dealId = $orderId ? (string) get_post_meta($orderId, "_hubspot_deal_id", true) : ""; return [ "_hs_deal_id" => $dealId, // consumed by URL template, not sent to HubSpot "properties" => [ "dealstage" => "closedwon", ], ];
_hs_deal_id are safe. HubSpot's deals PATCH endpoint reads properties and associations at the body root and silently ignores everything else. Root-level keys act as a private channel for Webhook Actions features without polluting the HubSpot payload.attach pre-glue (no post-glue needed for this webhook)
curl -sk -X POST \ "$SITE/wp-json/fswa/v1/pro/trigger-snippets/30/trigger/woocommerce_order_status_changed" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"pre_snippet_id": '"$UPDATE_PRE_ID"', "pre_enabled": true, "post_enabled": false}'
End-to-End Test — Real Order, Real Deal
Place a real test order through your WooCommerce storefront. The instant the order is created, the first webhook fires and creates a HubSpot deal. Post-glue writes _hubspot_deal_id to the order, and HubSpot now shows a deal in Appointment Scheduled.
Now mark the order Completed in WP Admin → WooCommerce → Orders. The second webhook fires immediately — pre-glue reads _hubspot_deal_id from order meta, the URL template expands to /crm/objects/2026-03/deals/502475571425, and HubSpot PATCHes the deal to closedwon.
Things That Bite
Wrong endpoint version. Older HubSpot docs use /crm/v3/objects/deals. That still works, but mixed-version setups confuse maintainers. Standardise on /crm/objects/2026-03/deals for any new webhook.
Pipeline / dealstage internal IDs. HubSpot's UI shows "Closed Won" — the API needs the internal ID. For the default pipeline: appointmentscheduled, closedwon, closedlost. For custom pipelines, fetch them via GET /crm/v3/pipelines/deals.
Side effects in pre-glue. The hard rule: pre-dispatch glue must be pure. If you put update_post_meta in pre-glue, every retry doubles the meta write. Move it to post-glue.
Async dispatch racing the next trigger. If is_synchronous is false on webhook 29, post-glue may not have finished writing the deal ID before the status-changed trigger fires. Keep webhook 29 synchronous.
Missing condition on webhook 30. Without the args.2 = completed condition, webhook 30 PATCHes on every status change — hitting HubSpot three times per order. The condition keeps it single-fire.
Common questions always ask.
Don't see yours? Open an issue on GitHub or check the full reference in the API docs.