Xero Contacts — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Xero Contacts (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
balances but no pipeline, owner, or activity history; use hubspot-companies or salesbuildr-companies-contacts.
alternative-payments-customers.
not a customer account; use xero-accounts.
qbo-customers.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 |
| Status | Description |
|---|---|
ACTIVE | Active contact (default) |
ARCHIVED | Archived contact (hidden from lists) |
GDPR_REQUEST | GDPR deletion requested |
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 |
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.
Every request needs both Authorization: Bearer ${ACCESS_TOKEN} and xero-tenant-id: ${XERO_TENANT_ID}. Xero-specific quirks:
/Contacts/{ContactID}, and the bodymust repeat the ContactID.
string literals, && for AND, and .Contains() / .StartsWith() for partial name matching.
page=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.
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;
}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 };
}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;
}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);
}"Name must be unique" 400 often points at an archived record, so search before assuming the name is free.
ContactID in the body updatesthe existing contact rather than creating a new one.
list responses — fetch the contact directly when you need AR/AP totals.
on ContactStatus=="ACTIVE".
See references/errors.md for the complete error-code table.
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;
}
}~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.