Two webhooks, one cohesive flow. When a WooCommerce order is placed, create a deal in HubSpot. When the order is marked completed, flip the same deal to Closed Won. Built on the new 2026-03 HubSpot CRM API — no v3 legacy paths.
TL;DR — What you'll build
woocommerce_checkout_order_created, POSTs WooCommerce order data to https://api.hubapi.com/crm/objects/2026-03/deals, gets back a HubSpot deal ID_hubspot_deal_id so the next webhook can find itwoocommerce_order_status_changed, PATCHes https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }} with the new stage when the order is completedThis hubspot woocommerce integration uses Webhook Actions by Flow Systems — a free WordPress webhook plugin. The deal-create half runs on the free plugin alone. The deal-stage update half uses the Pro plugin's Code Glue feature for storing the returned deal ID and injecting it into the next webhook's URL.
Any WooCommerce-compatible theme. You need to be able to place a real test order through the storefront or REST API.
Search for FlowSystems in Plugins → Add Plugin. Install and activate.
Required for Code Glue (PHP snippets that run pre/post dispatch) and {{ field.path }} URL template syntax.
In HubSpot: Settings → Integrations → Service Keys → Create service key. Grant scopes crm.objects.deals.read and crm.objects.deals.write. Copy the access token (starts with pat-...).
full scope
In WP Admin → Webhook Actions → API Tokens → Add Token. Select scope full. Copy the token — shown only once.
A clean hubspot woocommerce integration needs two webhooks that share a single piece of state: the HubSpot 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:
Hard rule for Code Glue. Pre-dispatch glue is for payload shaping only. Never do side effects there — no update_post_meta, no outbound HTTP, no DB writes. Pre-glue can run multiple times (initial dispatch, retry, replay, preview), so side effects would duplicate. Reads like get_post_meta are fine. All write side effects go in post-dispatch glue.
The first webhook fires on woocommerce_checkout_order_created — emitted by the classic WooCommerce checkout shortcode. 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 so a single webhook + glue pair handles both.
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's documentation and examples are migrating to the 2026-03 path, so any new hubspot woocommerce integration should start there.
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 [woocommerce_checkout], still common on customised storefronts and B2B portalswoocommerce_store_api_checkout_order_processed — block-based checkout (Store API), the default in WooCommerce 8+ block themes
The two paths are mutually exclusive at the request level: each order fires exactly one of them. Registering on both makes the webhook source-agnostic. Why not woocommerce_new_order? It fires the instant the order post is created — before line items, billing, and totals are populated. Pre-glue would see an empty order. Skip it.
Two triggers means double the setup. In Webhook Actions, conditions and Code Glue snippet bindings are per-(webhook, trigger), not per-webhook. So when you attach snippets in step 2.3 or set conditions, you need to do it once per trigger — that's why the curl examples wrap the calls in a for TRIGGER in …; do …; done loop. Skip the loop and only the classic checkout (or only the block one) gets the glue.
/crm/objects/2026-03/deals.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.
About _wc_order_id: we add it at the root of the returned array, alongside properties. HubSpot ignores unknown root-level keys (it only consumes properties and associations), but the same payload object is what post-glue receives — so we can read the order ID back without re-parsing args. This is a clean way to carry context across pre and post.
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.
HPOS heads-up. WooCommerce 8+ stores order meta in wp_wc_orders_meta, not wp_postmeta. 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. Same applies to Parts 3, 4 and 5. (Verified live on HPOS-enabled webhook-actions.local 2026-05-15.)
Replace $PRE_ID and $POST_ID below with the snippet IDs returned from the two POSTs above. Attach to both checkout triggers so classic and block checkouts both fire.
Now the second webhook. It fires on woocommerce_order_status_changed, which passes (int $order_id, string $old_status, string $new_status, WC_Order $order) as its action args. We use the URL template feature to embed the HubSpot deal ID directly in the endpoint URL — Pro's fswa_webhook_url filter expands {{ _hs_deal_id }} at dispatch time, after pre-glue has populated it from order meta.
If you already set up this integration before May 2026 and your existing webhook 30 points at https://api.hubapi.com/crm/v3/objects/deals/{{ _hs_deal_id }}, update it with a single PATCH — no other changes needed:
{{ _hs_deal_id }} URL template.
woocommerce_order_status_changed fires on every status transition — processing, on-hold, refunded, cancelled, and so on. We only want to flip the deal to Closed Won when the new status is completed. Add a condition that evaluates against the original (pre-mapping) payload:
The URL template {{ _hs_deal_id }} is resolved from the post-pre-glue payload. Our pre-glue reads _hubspot_deal_id from order meta (written by Step 2's post-glue when the deal was created) and returns it as the new root-level key _hs_deal_id for the URL filter to consume, plus the actual PATCH body in properties.
Why root-level keys like _hs_deal_id are safe. HubSpot's deals PATCH endpoint reads properties and associations at the body root and silently ignores everything else. So we can use the root namespace as a private channel for Webhook Actions features (URL templating, post-glue context) without polluting the HubSpot payload.
Place a real test order through your WooCommerce storefront — pick any product, complete checkout. The instant the order is created, the first webhook fires and creates a HubSpot deal. The plugin captures the response, post-glue writes _hubspot_deal_id to the order, and HubSpot's UI now shows a deal in the Appointment Scheduled stage.
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.
That's the full hubspot woocommerce integration. Every WooCommerce order now creates a HubSpot deal automatically, and every completed order flips that deal to Closed Won. Failures are visible in the Webhook Actions log, can be replayed with one click, and the per-webhook delivery view shows exactly what payload was sent and what HubSpot returned.
Wrong endpoint version. Older HubSpot docs and Stack Overflow answers use /crm/v3/objects/deals. That still works, but mixed-version setups confuse later maintainers. Standardise on /crm/objects/2026-03/deals for any new webhook 30 in your hubspot woocommerce integration.
Pipeline / dealstage internal IDs. HubSpot's UI shows friendly names like "Closed Won" — the API needs the internal ID. For the default pipeline these are appointmentscheduled, qualifiedtobuy, presentationscheduled, decisionmakerboughtin, contractsent, closedwon, closedlost. For custom pipelines, fetch them via GET /crm/v3/pipelines/deals.
Side effects in pre-glue. The hard rule again: 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, the post-glue may not have finished writing _hubspot_deal_id before woocommerce_order_status_changed fires (admin status changes can be near-instant). Keep webhook 29 synchronous. Webhook 30 is naturally safe because the status change is a separate event.
Missing condition on webhook 30. Without the args.2 = completed condition, webhook 30 PATCHes on every status change — including on-hold → processing → completed, hitting HubSpot three times. The condition keeps it single-fire.
/crm/v3/objects/deals path still works but is the legacy URL — HubSpot is migrating examples and documentation to the dated paths. If your existing webhook still uses v3, update endpoint_url to /crm/objects/2026-03/deals/{id} via PATCH — no other config changes needed. The auth header, condition, and pre-glue stay identical.
_hubspot_deal_id to WC order meta on a 2xx response (specifically $responseCode === 201). The Update Deal Stage webhook depends on that meta value via the URL template — if it's empty, the URL is malformed and HubSpot returns 400, visible in the Webhook Actions log. You can add an explicit is_not_empty condition on the second webhook to skip cleanly rather than dispatch a malformed request.
fswa_webhook_url and fswa_glue_post_dispatch in your own mu-plugin to achieve the same result. The Pro snippet-based flow is the recommended path because snippets are portable, editable in admin, and visible to non-developers — but the underlying filters are documented and free.