Connect Xero with HubSpot
Implementation Guide
Overview: Connecting Xero and HubSpot
Connecting Xero and HubSpot builds a financially-aware CRM ecosystem that eliminates the operational gap between your accounting ledger and your revenue pipeline. For B2B SaaS organisations, the synchronisation between these two platforms is foundational to accurate revenue operations. The core problem this integration addresses is the manual handoff that occurs after a deal is marked "Closed Won" in HubSpot: a finance team member must then open Xero, recreate the customer contact, enter invoice line items, and manually reconcile payment status at a later date. This process introduces data inconsistency, delays revenue recognition, and prevents customer success teams from having a real-time view of a client's payment standing.
When properly implemented, the Xero-HubSpot integration enables a continuous, bidirectional data loop. A newly created contact in Xero propagates to HubSpot as a company and contact record. A payment marked "PAID" in Xero triggers an update to the corresponding HubSpot contact's custom properties, allowing account managers to see outstanding balances directly inside the CRM timeline. For subscription businesses managing renewals, dunning, and upsell workflows, payment status directly informs sales and customer success actions, making this integration a genuine operational requirement rather than a convenience.
Core Prerequisites
Xero requires an account with Standard or Adviser user permissions. Register an OAuth 2.0 application in the Xero Developer Portal at developer.xero.com. The application type must be set to Web App to support the Authorization Code flow with PKCE. The required OAuth 2.0 scopes for a full bidirectional integration are: accounting.contacts, accounting.contacts.read, accounting.transactions, accounting.transactions.read, accounting.reports.read, and offline_access. The offline_access scope is mandatory for any automated workflow because it enables refresh token issuance, allowing your integration to operate without requiring a user to re-authenticate every 30 minutes. Without this scope, every access token expires after 30 minutes and the integration halts silently.
HubSpot requires a Private App created under Settings > Integrations > Private Apps. Required token scopes include: crm.objects.contacts.read, crm.objects.contacts.write, crm.objects.companies.read, crm.objects.companies.write, crm.objects.deals.read, crm.objects.deals.write, and crm.schemas.contacts.read. If your workflow involves writing Xero-specific financial attributes to HubSpot custom properties such as xero_outstanding_balance or xero_last_payment_date, you also need crm.schemas.contacts.write and crm.schemas.companies.write to create those properties via the API. The HubSpot Private App token does not expire, but it must be stored in a secrets manager rather than hardcoded in application logic.
Top Enterprise Use Cases
The highest-value enterprise use case is automated invoice creation from won deals. When a HubSpot deal transitions to the "Closed Won" stage, the integration reads the deal's associated line items and the contact's billing address, then creates a corresponding invoice in Xero with a status of DRAFT or AUTHORISED. The Xero Contact is either located by email match or created new if none exists. This eliminates an entire manual step from the order-to-cash cycle and ensures that the invoice reaches the client without delay.
The second critical use case is payment status synchronisation for customer health scoring. When Xero receives a payment and an invoice transitions to "PAID", a webhook event fires. The integration consumes this event and updates HubSpot contact properties with the latest payment data, enabling customer success managers to build dynamic HubSpot lists segmented by payment status to identify accounts at churn risk or flag overdue balances for escalation.
A third use case is contact deduplication with external ID mapping. When a new Xero contact is created, the integration performs an email-based lookup in HubSpot. If a match exists, it writes the Xero ContactID to a HubSpot custom property for future reference, establishing a durable cross-system identifier. If no match exists, new HubSpot contact and company records are created programmatically, ensuring both systems stay in sync from the moment of initial data entry.
Step-by-Step Implementation Guide
The recommended architecture for production deployments is a webhook-driven, event-based model rather than interval polling. Xero supports outbound webhooks for Contact, Invoice, CreditNote, and Payment object types. To register a webhook endpoint, open your application in the Xero Developer Portal, navigate to the Webhooks tab, and enter the HTTPS URL of your integration listener. Xero uses an intent-to-receive validation handshake: when you save the endpoint URL, Xero will immediately POST a challenge payload, and your server must respond with a 200 OK whose body is the raw payload bytes and whose x-xero-signature header is the HMAC-SHA256 hash of those bytes computed using your webhook signing key.
A production Xero webhook payload for a payment event arrives in the following structure. This payload is intentionally minimal—it does not contain the full record object but instead provides a resource URL for a follow-up retrieval call:
{
"events": [
{
"resourceUrl": "https://api.xero.com/api.xro/2.0/Payments/a7c3f2b1-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"resourceId": "a7c3f2b1-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"eventDateUtc": "2025-10-14T09:32:11.123Z",
"eventType": "UPDATE",
"eventCategory": "PAYMENT",
"tenantId": "b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e",
"tenantType": "ORGANISATION"
}
],
"firstEventSequence": 1,
"lastEventSequence": 1,
"entropy": "EXAMPLEENTROPY"
}
After receiving this payload and validating the HMAC signature, your integration must make a secondary GET request to retrieve the full payment record, including the linked invoice and contact identifiers:
curl -X GET "https://api.xero.com/api.xro/2.0/Payments/a7c3f2b1-4d5e-6f7a-8b9c-0d1e2f3a4b5c" \
-H "Authorization: Bearer {access_token}" \
-H "Xero-Tenant-Id: {tenant_id}" \
-H "Accept: application/json"
The response body will include a Payment object containing Invoice.InvoiceID, Invoice.Contact.ContactID, and Invoice.Contact.EmailAddress. Your integration logic then issues a PATCH request to the HubSpot Contacts API, updating properties like xero_last_payment_date and xero_outstanding_balance on the matching contact record, using the email address as the lookup key. The HubSpot PATCH call to update a contact by email requires the v3 CRM API endpoint https://api.hubapi.com/crm/v3/objects/contacts/{contactId} with a body of:
{
"properties": {
"xero_last_payment_date": "2025-10-14",
"xero_outstanding_balance": "0.00",
"xero_invoice_status": "PAID"
}
}
In Make (formerly Integromat), the module sequence for this flow is as follows. Deploy a Webhooks > Custom Webhook module as the scenario trigger and register the generated URL in the Xero Developer Portal. Add an HTTP > Make a Request module configured as a GET call to the resourceUrl from the webhook payload body, with the Xero Bearer token and Xero-Tenant-Id header values populated from a Make Connection or Data Store. Next, add a HubSpot CRM > Search for Objects module to locate the contact by email equals the value extracted from the Xero payment response. Use a Router module to branch: the positive path executes HubSpot CRM > Update a Contact with the Xero financial properties mapped to HubSpot's custom property internal names (which follow snake_case convention and are case-sensitive); the negative path executes HubSpot CRM > Create a Contact followed by HubSpot CRM > Create a Company. All HubSpot custom property internal names must match exactly what was defined when those properties were created in the HubSpot portal.
For Zapier implementations, the native Xero trigger uses polling every 15 minutes rather than true webhook push. If near-real-time delivery is required, deploy a lightweight serverless function on AWS Lambda or Vercel Edge Functions to receive Xero's native webhooks and forward validated payloads to the Webhooks by Zapier > Catch Hook trigger URL. This relay pattern restores real-time trigger behaviour within an otherwise polling-based Zapier workflow.
Common Pitfalls & Troubleshooting
A 401 Unauthorized response from the Xero API occurs in one of two scenarios: either the OAuth 2.0 access token has expired (Xero access tokens have a 30-minute TTL), or the offline_access scope was omitted during the initial authorization flow, meaning no refresh token was issued and the integration has no mechanism to renew its credentials automatically. For token expiry, implement the OAuth 2.0 refresh token grant by posting to https://identity.xero.com/connect/token with grant_type=refresh_token alongside your stored refresh token value. If no refresh token exists because offline_access was omitted, the user must re-authorize the application from scratch with the correct scopes included. Store refresh tokens in encrypted, persistent storage such as AWS Secrets Manager or HashiCorp Vault—never in application memory or environment variables alone.
A 403 Forbidden response typically indicates a scope mismatch between what was requested during authorization and what operation is being attempted. Cross-reference the scopes listed in your Xero Developer Portal application configuration against the scope claim in the decoded JWT access token. The JWT payload is base64-decodable and will explicitly list the granted scopes.
A 429 Too Many Requests response means Xero's rate limit of 60 API calls per minute per connected application has been exceeded. The response includes a Retry-After header specifying the wait time in seconds. Implement exponential backoff with jitter, starting with a 1-second base delay and doubling up to a maximum ceiling of 60 seconds. In Make, enable the built-in error handler on the HTTP module and set the retry strategy to respect the Retry-After value.
On the HubSpot side, a 409 Conflict when creating contacts indicates a duplicate email address already exists in the portal. Your integration must implement upsert logic: search by email first using the Contacts Search API, then update the existing record rather than attempting to create a new one. A 400 Bad Request with HubSpot error code INVALID_EMAIL means the email value extracted from Xero is malformed or empty and must be validated and sanitised before being passed to the HubSpot API.