/ Part 1 of 5 — HubSpot × WooCommerce Series Filter OK Pro

HubSpot WooCommerce Integration:
Create & Update Deals from Orders

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

  • Webhook 1 — Create Deal: fires on woocommerce_checkout_order_created, POSTs WooCommerce order data to https://api.hubapi.com/crm/objects/2026-03/deals, gets back a HubSpot deal ID
  • Post-dispatch Code Glue: stores the returned deal ID in WC order meta as _hubspot_deal_id so the next webhook can find it
  • Webhook 2 — Update Deal Stage: fires on woocommerce_order_status_changed, PATCHes https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }} with the new stage when the order is completed
  • Full hubspot woocommerce integration with delivery log, retries, and replay — no Zapier, no extra SaaS
/ Prerequisites

What You Need Before Starting

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

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

  2. 2
    Webhook Actions (free) — v1.12.0 or later

    Search for FlowSystems in Plugins → Add Plugin. Install and activate.

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

  4. 4
    HubSpot Service Key or Private App access token

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

  5. 5
    Webhook Actions API token with full scope

    In 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
/ Architecture

Two Webhooks, One Shared State

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:

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

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.

/ Step 1

Create the "Deal Create" Webhook

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.

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 — 200 OK
{ "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 [woocommerce_checkout], still common on customised storefronts and B2B portals
  • woocommerce_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.

Webhook Actions admin Webhooks list showing the HS - Create Deal row with the 2026-03 HubSpot deals endpoint and Synchronous indicator enabled
Webhook Actions admin → Webhooks list with the new HS - Create Deal webhook pointing at /crm/objects/2026-03/deals.
/ Step 2 Filter OK fswa_webhook_payloadfswa_glue_post_dispatch Pro

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 runs before the HTTP request. // Input: $payload (current array — already mapped if Mapping configured) // $args, $originalPayload, $webhook, $trigger // Output: a new array that REPLACES $payload, or no return to keep it as-is. // // HARD RULE: NO SIDE EFFECTS. No update_post_meta, no wp_remote_*, no DB writes. // Local reads (get_post_meta, wc_get_order) are fine. $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")), ], ];

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.

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. // Available: $payload, $responseCode, $responseBody, $responseHeaders, // $webhook, $trigger, $args, $originalPayload // This is where side effects belong — write meta, log, fire actions. $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: never use update_post_meta on a WC order ID $order->update_meta_data("_hubspot_deal_id", $data["id"]); $order->save(); } }

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

2.3 — Attach both snippets to the webhook + trigger

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.

attach pre + post glue
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
Webhook Actions admin Code Glue Snippets list showing the pre-dispatch and post-dispatch snippets attached to the Create Deal webhook
Webhook Actions admin → Code Glue with both snippets attached to the Create Deal webhook (pre-dispatch mapping + post-dispatch persistence).
/ Step 3 Filter OK fswa_webhook_urlfswa_webhook_payload Pro

Create the "Deal Stage Update" Webhook

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.

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"] }

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:

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 }}"}'
Webhook Actions admin edit page for the HS - Update Deal Stage webhook showing the 2026-03 endpoint with the _hs_deal_id URL template placeholder and PATCH method
Webhook Actions admin → webhook 30 edit page with the 2026-03 endpoint and the dynamic {{ _hs_deal_id }} URL template.

3.2 — Condition: only fire when order moves to "completed"

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:

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

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.

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 from local DB (allowed) — gets the HubSpot deal ID stored by Step 2's post-glue. $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", ], ];

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.

attach pre-glue (no post-glue needed)
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}'
/ Step 4

End-to-End Test — Real Order, Real Deal

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.

HubSpot CRM Deals pipeline view with the newly created WooCommerce deal sitting in the Appointment Scheduled stage
HubSpot → CRM → Deals — the WooCommerce order has just created 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.

HubSpot CRM Deals pipeline view showing the same deal moved to the Closed Won stage after the WooCommerce order was marked completed
HubSpot → CRM → Deals — the same deal, now in Closed Won, after the WooCommerce order was marked completed.
Webhook Actions admin Logs page showing two successful deliveries — 201 Created from the Create Deal webhook and 200 OK from the Update Deal Stage webhook
Webhook Actions admin → Logs — both deliveries succeed: 201 from Create Deal, 200 from Update Deal Stage.

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.

/ Common Pitfalls

Things That Bite

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-holdprocessingcompleted, hitting HubSpot three times. The condition keeps it single-fire.

/ FAQ

Common Questions

The free plugin sets up the deal-create webhook with zero code — everything is REST API or admin UI. The deal-stage update half needs the Pro plugin's Code Glue feature to store the returned HubSpot deal ID and inject it into the next webhook's URL. That's two short PHP snippets pasted into the plugin admin, not custom theme code. If you prefer pure no-code, the series hub covers other examples (product sync, customer sync) that are pure REST.
HubSpot's official integration pushes a fixed schema you don't control. This setup lets you decide exactly which order fields become which deal properties, on which trigger, with which conditions — plus a full Webhook Actions delivery log with replay. Different tradeoff: less out-of-the-box, more precision. Most teams that outgrow the official plugin land here.
HubSpot's 2026-03 dated API endpoint is the current versioned path. The /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.
No. The post-dispatch Code Glue only writes _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.
The deal-create half — yes, fully free, no code required. The deal-stage update half needs to know the HubSpot deal ID from the create response, which is a stateful step. Without Code Glue you can hook 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.
Yes — Webhook Actions is a per-site plugin. If you run 5 stores, each gets its own install and its own API tokens. The Pro license covers unlimited sites, and webhooks/snippets can be exported and re-imported across sites (we ship JSON export/import in the admin).
/ HubSpot × WooCommerce Series