Every store that grows past a few hundred orders a month eventually needs to integrate third party CRM with WooCommerce, because the sales and support teams stop wanting to look customers up in two places. The request sounds small: “put our orders in HubSpot”. What it actually involves is a data model decision, an authentication decision, a queue, error handling, and a plan for the first time the two systems disagree about a customer’s email address.
This guide is for the business owner or CTO evaluating the plugin, middleware and custom code options for HubSpot, Zoho CRM or Salesforce, and for the developer who has been asked to build it. It follows the approach we use in every WooCommerce CRM integration project: define the mapping first, queue everything, and make each sync idempotent so it can be rerun safely.
what “integration” actually means: four decisions
- Which objects. Customers become contacts (and often companies for B2B). Orders become deals in HubSpot, deals or sales orders in Zoho, opportunities or orders in Salesforce. Line items map to products in each CRM, which means the product catalogue has to exist there first.
- Which direction. One-way (WooCommerce to CRM) covers most reporting and marketing needs. Two-way is needed when sales reps update a contact in the CRM and the store must reflect it, and it roughly doubles the work.
- When. Real-time on order events, or batch every 15 minutes. Real-time is what everyone asks for; batch is what makes the CRM’s rate limits manageable at volume.
- Which system wins. Per field, not per record. Email and phone usually belong to the store because the customer typed them; lifecycle stage and owner belong to the CRM.
Write these down before choosing a tool to integrate third party CRM with WooCommerce. Most integration failures we get called in to fix were not code bugs; they were undecided mapping questions that a plugin answered with a default nobody read.
three ways to integrate third party CRM with WooCommerce
| Approach | Examples | Good for | Where it breaks |
|---|---|---|---|
| Vendor or third party plugin | HubSpot for WooCommerce (MakeWebBetter), Zoho CRM integrations by CRM Perks, WP Fusion, Salesforce connectors | Standard contact and deal sync, fast to start | Custom fields, subscriptions, B2B company logic, high order volume |
| Middleware | Zapier, Make, n8n, Zoho Flow | Quick one-way pushes, non-developers maintaining it | Cost per task at volume, retries, ordering guarantees, anything two-way |
| Custom code | WooCommerce hooks plus the CRM REST API, Action Scheduler queue | Exact mapping, high volume, two-way sync, audit trail | Needs a developer to own it and monitor it |
The plugin route is right for a store with a standard catalogue and a CRM used mostly for marketing email. Middleware is right for a small store that needs something working this week. Custom code is right the moment the mapping involves a business rule, or when order volume makes per-task pricing or per-request rate limits a problem. The rest of this article is the custom route, because that is where the technical questions live.
the custom route: hooks, a queue and the CRM API
never call the CRM inside the request
The single rule that matters most. A customer completing checkout should not wait on HubSpot’s API, and a gateway webhook that updates an order should not fail because Salesforce was slow. Every WooCommerce event enqueues a job; a worker does the API call.
add_action( 'woocommerce_order_status_processing', 'swd_queue_order_sync' );
add_action( 'woocommerce_order_status_completed', 'swd_queue_order_sync' );
add_action( 'woocommerce_order_refunded', 'swd_queue_order_sync' );
function swd_queue_order_sync( $order_id ) {
// Debounce: one pending job per order, whichever hook fired.
if ( as_has_scheduled_action( 'swd_sync_order_to_crm', [ 'order_id' => (int) $order_id ], 'swd-crm' ) ) {
return;
}
as_schedule_single_action( time() + 30, 'swd_sync_order_to_crm', [ 'order_id' => (int) $order_id ], 'swd-crm' );
}
add_action( 'woocommerce_created_customer', function ( $customer_id ) {
as_enqueue_async_action( 'swd_sync_contact_to_crm', [ 'customer_id' => (int) $customer_id ], 'swd-crm' );
} );
Action Scheduler stores these jobs in wp_actionscheduler_actions, retries are yours to implement, and a real system cron running wp action-scheduler run --group=swd-crm every minute keeps the queue moving even when the site has no traffic.
Rate limits are the reason the queue exists. HubSpot allows a fixed burst of requests per ten seconds per private app, Zoho meters API credits per day by plan, and Salesforce counts calls per 24 hours per org. A flash sale that creates 2,000 orders in an hour will blow through all three if every order fires a synchronous call. With a queue, the worker processes a batch, backs off on a 429 with the delay the CRM asks for, and catches up over the following hour without losing anything. Use the CRM’s batch endpoints for the catch-up: HubSpot’s /crm/v3/objects/contacts/batch/upsert takes up to 100 records per call, which is a hundred times cheaper against the limit than single upserts.
upsert, never create
Every CRM call should be safe to run twice. Store the CRM’s id on the WooCommerce side (_swd_hubspot_contact_id on the customer, _swd_hubspot_deal_id on the order) and look for it before creating anything. HubSpot’s v3 API supports searching by email; Zoho and Salesforce both support upsert by external id. A HubSpot contact upsert with the WordPress HTTP API:
function swd_hubspot_upsert_contact( WC_Customer $customer ) {
$token = defined( 'SWD_HUBSPOT_TOKEN' ) ? SWD_HUBSPOT_TOKEN : ''; // private app token in wp-config.php
$props = [
'email' => $customer->get_email(),
'firstname' => $customer->get_first_name(),
'lastname' => $customer->get_last_name(),
'phone' => $customer->get_billing_phone(),
'woo_customer_id' => $customer->get_id(),
'woo_total_spent' => wc_get_customer_total_spent( $customer->get_id() ),
];
$existing = $customer->get_meta( '_swd_hubspot_contact_id' );
$url = $existing
? "https://api.hubapi.com/crm/v3/objects/contacts/{$existing}"
: 'https://api.hubapi.com/crm/v3/objects/contacts';
$response = wp_remote_request( $url, [
'method' => $existing ? 'PATCH' : 'POST',
'timeout' => 15,
'headers' => [ 'Authorization' => "Bearer {$token}", 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( [ 'properties' => $props ] ),
] );
$code = (int) wp_remote_retrieve_response_code( $response );
if ( is_wp_error( $response ) || 429 === $code || $code >= 500 ) {
throw new RuntimeException( 'Retry later: HTTP ' . $code ); // the worker wrapper reschedules with backoff
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 409 === $code && ! $existing && preg_match( '/Existing ID: (d+)/', $body['message'] ?? '', $m ) ) {
// Contact already exists in HubSpot: store its id and PATCH next time.
$customer->update_meta_data( '_swd_hubspot_contact_id', $m[1] );
$customer->save();
return;
}
if ( ! empty( $body['id'] ) ) {
$customer->update_meta_data( '_swd_hubspot_contact_id', $body['id'] );
$customer->save();
}
}
Zoho CRM and Salesforce use OAuth 2 with refresh tokens instead of a static token. Store the refresh token in wp-config.php or a secrets manager, keep the short-lived access token in a transient, and refresh on a 401. Salesforce’s /services/data/v60.0/sobjects/Contact/External_Id__c/{value} PATCH endpoint and Zoho’s /crm/v6/Contacts/upsert both give you idempotent writes without a search call first.
deals and line items
Create the deal when the order reaches processing, not on woocommerce_new_order, or you will fill the CRM with abandoned pending orders. Set the deal amount from $order->get_total(), the stage from the order status, and associate it with the contact using the CRM’s association endpoint. Line items need the product to exist in the CRM; sync the catalogue nightly with wc_get_products() and store the CRM product id as product meta. Refunds update the deal amount rather than creating a new deal.
two-way sync: WooCommerce webhooks out, CRM webhooks in
For the CRM to push changes back, expose an endpoint. WooCommerce’s own webhooks (WooCommerce > Settings > Advanced > Webhooks, stored in wp_wc_webhooks) are the outbound side if you would rather have the CRM or middleware pull data; they sign payloads with an HMAC in the X-WC-Webhook-Signature header. The inbound side is a REST route that verifies the CRM’s signature and, again, queues the work:
add_action( 'rest_api_init', function () {
register_rest_route( 'swd/v1', '/crm/hubspot', [
'methods' => 'POST',
'permission_callback' => '__return_true', // the signature check below is the auth
'callback' => function ( WP_REST_Request $request ) {
$secret = SWD_HUBSPOT_CLIENT_SECRET;
$timestamp = (string) $request->get_header( 'x-hubspot-request-timestamp' );
$base = 'POST' . home_url( '/wp-json/swd/v1/crm/hubspot' ) . $request->get_body() . $timestamp;
$expected = base64_encode( hash_hmac( 'sha256', $base, $secret, true ) );
$given = (string) $request->get_header( 'x-hubspot-signature-v3' );
if ( abs( time() * 1000 - (int) $timestamp ) > 300000 || ! hash_equals( $expected, $given ) ) {
return new WP_REST_Response( [ 'error' => 'bad signature' ], 401 );
}
foreach ( (array) $request->get_json_params() as $event ) {
as_enqueue_async_action( 'swd_apply_crm_event', [ 'event' => $event ], 'swd-crm' );
}
return new WP_REST_Response( [ 'queued' => true ], 202 );
},
] );
} );
Conflict handling is where the per-field ownership decision pays off. When an inbound event changes a field the store owns, log it and ignore it. When it changes a CRM-owned field, apply it. Never let an inbound event trigger an outbound sync of the same record without a loop guard, or the two systems will update each other forever; a _swd_crm_last_inbound timestamp on the record, checked before enqueuing, is enough.
the mapping decisions that cause most support tickets
- Guest checkouts. There is no
WC_Customerrecord, only billing fields on the order. Sync from the order’s billing email and mark the contact as a guest so the CRM does not treat them as a registered account. - Email changes. If the store changes an email, PATCH by CRM id, not by email, or you create a duplicate contact.
- Partial refunds. Update the deal amount and add a note; do not move the stage to lost.
- Subscription renewals. A renewal order should update the existing deal or create one per period depending on how finance wants to see recurring revenue. Decide this with them, and if the billing logic is custom, read our subscription workflow guide for where the renewal hooks live.
- Marketing consent. Add a checkbox through
woocommerce_checkout_fields, store it on the order, and map it to the CRM’s consent or subscription status property. Syncing everyone as opted in is a compliance problem, not a technical one. - Multi-currency. Send the currency code with every amount and let the CRM’s currency settings convert, or convert to the reporting currency in the worker. Doing neither produces meaningless pipeline totals.
monitoring and failure handling
An integration that fails silently is worse than none, because people trust the CRM data. Log every outbound call with wc_get_logger() under a dedicated source (swd-crm) so it appears in WooCommerce > Status > Logs. Record _swd_crm_synced_at and the last error on each order. Run a nightly reconciliation that compares counts of processing and completed orders from the last seven days against deals created in the CRM, and emails the difference. Watch the Action Scheduler failed list; a spike almost always means a token expired or the CRM changed a property name.
Plan the historical backfill as its own job rather than replaying live events. Iterate orders with wc_get_orders() in pages of a few hundred, oldest first, through the same upsert functions, with a longer delay between batches than the live worker uses. Run it on staging against a CRM sandbox first; a backfill that maps a field wrongly writes that mistake to every historical record, and cleaning that up inside the CRM is slower than the backfill itself.
wp action-scheduler action list --group=swd-crm --status=failed --per-page=20
wp wc order list --status=processing --after="$(date -d '-7 days' +%F)" --fields=id,billing_email --format=csv --user=admin | wc -l
wp db query "SELECT COUNT(*) FROM wp_wc_orders_meta WHERE meta_key='_swd_crm_synced_at' AND meta_value > NOW() - INTERVAL 7 DAY"
The REST layer, queue and secrets handling here are the same building blocks as any other custom REST API integration for WordPress; once one system is wired in this way, adding an ERP or a helpdesk is mostly mapping work.
when to do this yourself vs hire someone
Do it yourself if a vendor plugin covers your mapping, your CRM is used for email marketing rather than as the system of record, and volume is low enough that a failed sync is an inconvenience rather than a lost sale.
Hire someone to integrate third party CRM with WooCommerce when the mapping involves business rules, when the CRM is what sales and finance report from, when you need two-way sync, or when order volume means rate limits and retries have to be engineered rather than hoped for. It is also worth hiring for the migration of historical orders, which is a bulk job against the CRM’s batch endpoints with limits of its own. If the same project needs other systems connected, our custom REST API integration service is where that work is scoped, and a maintenance plan keeps the queue and tokens watched after launch.
next step
Tell us which CRM, roughly how many orders a month, and what the sales team needs to see. We reply within 24 hours with the approach we recommend, a fixed scope and a fixed price to integrate third party CRM with WooCommerce for your store, and you deal directly with the engineer building it. Contact us here.




