Xero Contacts Management
Overview
Contacts are the foundational entity in Xero, representing customers (clients you invoice), suppliers (vendors you pay), or both. Every invoice, payment, credit note, and bank transaction is linked to a contact. For MSPs, contacts typically represent managed services clients, hardware vendors, software suppliers, and subcontractors.
Anti-triggers
- The CRM view of the same organization — Xero contacts carry AR/AP
balances but no pipeline, owner, or activity history; use
hubspot-companiesorsalesbuildr-companies-contacts. - The billing target on the payment rail — use
alternative-payments-customers. - Chart-of-accounts entries — a Xero "account" is a general ledger code,
not a customer account; use
xero-accounts. - The same operation in QuickBooks — use
qbo-customers.
Core Concepts
Contact Types
Xero contacts can be customers, suppliers, or both. The type is determined by usage rather than a fixed field:
| Role | Description | MSP Example |
|---|---|---|
| Customer | Contacts you create sales invoices (ACCREC) for | Managed services clients |
| Supplier | Contacts you receive bills (ACCPAY) from | Software vendors, ISPs |
| Customer & Supplier | Both roles | Partner MSPs, distributors |
Contact Status
| Status | Description |
|---|---|
ACTIVE |
Active contact (default) |
ARCHIVED |
Archived contact (hidden from lists) |
GDPR_REQUEST |
GDPR deletion requested |
Contact Groups
Contacts can be organized into groups for reporting and filtering:
| Group | MSP Use Case |
|---|---|
| Managed Services | Clients on monthly contracts |
| Break-Fix | Ad-hoc support clients |
| Vendors | Hardware and software suppliers |
| Partners | Co-managed or referral partners |
Key Fields
Name is the only required field and must be unique. AccountNumber and
ContactNumber are free-text and are the natural place to store your PSA
client ID. IsCustomer/IsSupplier and the Balances.* totals are read-only
and derived from invoice history.
See references/fields.md for the complete field reference, including address, phone, and balance fields.
API Patterns
Every request needs both Authorization: Bearer ${ACCESS_TOKEN} and
xero-tenant-id: ${XERO_TENANT_ID}. Xero-specific quirks:
- Updates use POST, not PUT, against
/Contacts/{ContactID}, and the body must repeat theContactID. - Filters go in a URL-encoded
whereclause with doubled quotes around string literals,&&for AND, and.Contains()/.StartsWith()for partial name matching. /Contactsis paginated at 100 records per page — passpage=N.
# Search by name (partial match)
curl -s -X GET "https://api.xero.com/api.xro/2.0/Contacts?where=Name.Contains(%22Acme%22)" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "xero-tenant-id: ${XERO_TENANT_ID}" \
-H "Accept: application/json"
See references/api.md for the full endpoint catalog and create/update/archive/search examples.
Common Workflows
MSP Client Onboarding
- Create contact with company details and billing address
- Set account number using your PSA or internal reference
- Add to contact group (e.g., "Managed Services")
- Set default currency if multi-currency
- Create first invoice for onboarding or first month
async function onboardMspClient(clientData) {
const token = await auth.getToken();
const contact = await createContact(token, {
Name: clientData.companyName,
ContactNumber: clientData.psaId,
AccountNumber: clientData.accountCode,
EmailAddress: clientData.billingEmail,
Addresses: [
{
AddressType: 'STREET',
AddressLine1: clientData.address,
City: clientData.city,
Region: clientData.state,
PostalCode: clientData.zip,
Country: clientData.country
}
],
Phones: [
{
PhoneType: 'DEFAULT',
PhoneNumber: clientData.phone
}
],
DefaultCurrency: clientData.currency || 'USD'
});
return contact;
}
Client Offboarding
- Verify all invoices are paid - Check outstanding balances
- Create final invoice if needed for remaining services
- Archive the contact - Do not delete for audit trail
async function offboardClient(contactId) {
const token = await auth.getToken();
// Check outstanding balance
const contact = await getContact(token, contactId);
const outstanding = contact.Balances?.AccountsReceivable?.Outstanding || 0;
if (outstanding > 0) {
console.log(`WARNING: ${contact.Name} has $${outstanding} outstanding.`);
return { status: 'blocked', reason: 'outstanding_balance', amount: outstanding };
}
// Archive the contact
await updateContact(token, contactId, { ContactStatus: 'ARCHIVED' });
return { status: 'archived', contact: contact.Name };
}
PSA Cross-Reference
Use the ContactNumber field to store your PSA system's client ID:
async function findByPsaId(psaId) {
const token = await auth.getToken();
const where = encodeURIComponent(`ContactNumber=="${psaId}"`);
const response = await fetch(
`https://api.xero.com/api.xro/2.0/Contacts?where=${where}`,
{
headers: {
'Authorization': `Bearer ${token}`,
'xero-tenant-id': process.env.XERO_TENANT_ID,
'Accept': 'application/json'
}
}
);
const data = await response.json();
return data.Contacts?.[0] || null;
}
Bulk Contact Report
async function generateClientReport() {
const contacts = await fetchAllContacts();
return contacts
.filter(c => c.IsCustomer && c.ContactStatus === 'ACTIVE')
.map(contact => ({
name: contact.Name,
accountNumber: contact.AccountNumber,
email: contact.EmailAddress,
outstanding: contact.Balances?.AccountsReceivable?.Outstanding || 0,
overdue: contact.Balances?.AccountsReceivable?.Overdue || 0,
city: contact.Addresses?.find(a => a.AddressType === 'STREET')?.City
}))
.sort((a, b) => b.overdue - a.overdue);
}
Gotchas
- Contact names must be unique tenant-wide, including archived contacts. A "Name must be unique" 400 often points at an archived record, so search before assuming the name is free.
- POST to
/Contactsupserts. Including aContactIDin the body updates the existing contact rather than creating a new one. - Balances are only returned on the single-contact GET, not consistently on list responses — fetch the contact directly when you need AR/AP totals.
- Archived contacts still appear in filtered queries unless you constrain
on
ContactStatus=="ACTIVE".
See references/errors.md for the complete error-code table.
Error Recovery Pattern
async function safeCreateContact(data) {
try {
return await createContact(data);
} catch (error) {
if (error.message?.includes('must be unique')) {
// Contact exists - find and return it
const existing = await searchContactByName(data.Name);
return existing;
}
if (error.status === 401) {
// Token expired - refresh and retry
await auth.refreshToken();
return await createContact(data);
}
throw error;
}
}
Best Practices
- Use unique names - Xero requires unique contact names; include location if needed
- Set AccountNumber - Map to your PSA client ID for cross-referencing
- Use ContactNumber - Store PSA or internal reference numbers
- Include billing email - Required for emailing invoices directly from Xero
- Add both addresses - STREET for physical, POBOX for mailing/billing
- Use contact groups - Organize clients by service tier or type
- Archive, don't delete - Preserve historical data and audit trail
- Set default currency - Important for international MSP clients
- Check balances before archiving - Ensure no outstanding amounts
Related Skills
- Xero Invoices - Creating invoices for contacts
- Xero Payments - Payment tracking by contact
- Xero Reports - Aged receivables by contact
- Xero API Patterns - API reference