WP Webhooks / Examples/ HubSpot × WooCommerce / Create & Update Deals
Example · HubSpot × WooCommerce

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.

~12 min read May 15, 2026 Part 1 of 5
Filter OK Pro hubspotwoocommerce

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

What You Need Before Starting

This 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. 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. Webhook Actions (free) — v1.12.0 or later

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

  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. 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 token — starts with pat-....

  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 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
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 (dispatch, retry, replay, preview). 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. 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.

Two triggers means double the setup. Conditions and Code Glue snippet bindings are per-(webhook, trigger). When you attach snippets in Step 2.3, you need to do it once per trigger — that's why the curl examples use a for TRIGGER in …; do …; done loop. Skip the loop and only one checkout type gets the glue.
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 — 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")),
    ],
];
About _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();
    }
}
HPOS heads-up. WooCommerce 8+ stores order meta in 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
Webhook Actions admin → Code Glue with both snippets attached (pre-dispatch mapping + post-dispatch persistence).
/ Step 3 Filter OK fswa_webhook_urlfswa_webhook_payload Pro

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"]
}
If you already set up this integration before May 2026 and your existing webhook 30 points at the v3 endpoint, 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 → webhook 30 with the 2026-03 endpoint and 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. 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",
    ],
];
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. 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}'
/ Step 4

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.

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 — the same deal, now in Closed Won, after the order was marked completed.
Webhook Actions admin → Logs — both deliveries succeed: 201 from Create Deal, 200 from Update Deal Stage.
That's the full 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 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 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.

/ HubSpot × WooCommerce Series
/Notes
WordPress Webhook REST API — full reference for webhooks, schemas, conditions, and logs
Webhook Actions by Flow Systems — plugin overview
FAQ

Common questions always ask.

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

Does this HubSpot WordPress plugin work without writing code? +
The free plugin sets up the deal-create webhook with zero code — REST API or admin UI. The deal-stage update half needs Code Glue: two short PHP snippets pasted into the plugin admin, not custom theme code. If you prefer pure no-code, the series hub covers examples that are pure REST.
How is this different from the official HubSpot WooCommerce integration? +
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 delivery log with replay. Less out-of-the-box, more precision.
Why use the new 2026-03 HubSpot endpoint instead of /crm/v3/objects/deals? +
HubSpot's 2026-03 dated API endpoint is the current versioned path. The /crm/v3/objects/deals path still works but HubSpot is migrating examples to the dated paths. If your existing webhook still uses v3, update endpoint_url via a single PATCH — auth header, condition, and pre-glue stay identical.
What happens if the Create Deal webhook fails — do we still try to update? +
No. The post-dispatch Code Glue only writes _hubspot_deal_id to WC order meta on a 201 response. The Update Deal Stage webhook depends on that meta via the URL template — if empty, the URL is malformed and HubSpot returns 400, visible in the log. Add an is_not_empty condition on webhook 30 to skip cleanly.
Can I do this on the free plugin without Code Glue? +
The deal-create half — yes, fully free. The deal-stage update needs the HubSpot deal ID from the create response. Without Code Glue you can hook fswa_webhook_url and fswa_glue_post_dispatch in your own mu-plugin. The Pro snippet-based flow is recommended because snippets are portable, editable in admin, and visible to non-developers.
Do I need to install this on every WordPress site that runs WooCommerce? +
Yes — Webhook Actions is a per-site plugin. If you run 5 stores, each gets its own install and API tokens. The Pro license covers unlimited sites, and webhooks/snippets can be exported and re-imported via JSON export/import in the admin.
Ready

Stop losing webhooks.
Start logging them.

$ wp plugin install wp-webhooks --activate