From terraphim-engineering-skills
Xero Accounting API integration skill. Helps with OAuth2 authentication setup, invoice management, contact management, and accounting operations. Provides guidance on rate limits, token refresh, and API best practices.
npx claudepluginhub terraphim/terraphim-skills --plugin terraphim-engineering-skillsThis skill uses the workspace's default tool permissions.
**Version:** 1.0.0
Automates Xero bookkeeping: list/filter invoices by status/date, retrieve/search contacts, create payments on invoices linked to bank accounts, manage transactions and chart of accounts.
Provides QuickBooks Online API patterns for OAuth2 authentication, REST endpoints, Intuit query language, pagination, rate limiting, error handling, minor versions, base URLs, and sandbox/production setups.
Manages Syncro MSP invoices: creates, edits, tracks payments and statuses, handles line items and billing workflows via API patterns.
Share bugs, ideas, or general feedback.
Version: 1.0.0 Author: Claude Code Purpose: Integrate with Xero Accounting API for invoice and contact management
This skill provides comprehensive guidance for integrating with the Xero Accounting API. It helps you:
Important: This skill provides guidance and patterns. Actual API calls require a registered Xero application with valid credentials stored securely (use the 1password-secrets skill).
Xero Developer Account
Registered OAuth 2.0 Application
1. Go to https://developer.xero.com/app/manage
2. Click "New app"
3. Choose "Web app" for server-side integration
4. Configure redirect URI (e.g., http://localhost:3000/callback)
5. Save Client ID and Client Secret securely
Secure Credential Storage
# Store credentials in 1Password (recommended)
op item create --category=API_Credential \
--title="Xero-OAuth" \
--vault="ProjectSecrets" \
client_id=<your-client-id> \
client_secret=<your-client-secret>
Create .env.template with 1Password references:
# Xero OAuth2 Credentials
XERO_CLIENT_ID=op://ProjectSecrets/Xero-OAuth/client_id
XERO_CLIENT_SECRET=op://ProjectSecrets/Xero-OAuth/client_secret
XERO_REDIRECT_URI=http://localhost:3000/callback
# Token Storage (use secure storage in production)
XERO_ACCESS_TOKEN=op://ProjectSecrets/Xero-Tokens/access_token
XERO_REFRESH_TOKEN=op://ProjectSecrets/Xero-Tokens/refresh_token
XERO_TENANT_ID=op://ProjectSecrets/Xero-Tokens/tenant_id
Purpose: Establish secure authentication with Xero API.
1. User Authorization
----------------------
Redirect user to Xero authorization URL:
https://login.xero.com/identity/connect/authorize?
response_type=code&
client_id=<CLIENT_ID>&
redirect_uri=<REDIRECT_URI>&
scope=openid profile email offline_access accounting.transactions accounting.contacts&
state=<RANDOM_STATE>
2. Authorization Callback
----------------------
User returns with authorization code:
GET <REDIRECT_URI>?code=<AUTH_CODE>&state=<STATE>
3. Token Exchange
----------------------
POST https://identity.xero.com/connect/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(<CLIENT_ID>:<CLIENT_SECRET>)
grant_type=authorization_code&
code=<AUTH_CODE>&
redirect_uri=<REDIRECT_URI>
4. Response
----------------------
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"expires_in": 1800,
"token_type": "Bearer",
"refresh_token": "abc123...",
"scope": "openid profile email offline_access accounting.transactions"
}
| Scope | Access Level |
|---|---|
openid | OpenID Connect authentication |
profile | User profile information |
email | User email address |
offline_access | Refresh tokens (required for token refresh) |
accounting.transactions | Invoices, bills, bank transactions |
accounting.transactions.read | Read-only transactions |
accounting.contacts | Contacts (customers/suppliers) |
accounting.contacts.read | Read-only contacts |
accounting.reports.read | Financial reports |
accounting.settings | Organization settings |
accounting.settings.read | Read-only settings |
accounting.attachments | File attachments |
state parameter to prevent CSRFPurpose: Handle token refresh and storage securely.
Access tokens expire after 30 minutes.
Refresh before expiry to maintain session.
POST https://identity.xero.com/connect/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(<CLIENT_ID>:<CLIENT_SECRET>)
grant_type=refresh_token&
refresh_token=<REFRESH_TOKEN>
Response:
{
"access_token": "new_access_token...",
"expires_in": 1800,
"token_type": "Bearer",
"refresh_token": "new_refresh_token...",
"scope": "..."
}
# Python example using 1Password CLI
import subprocess
import json
from datetime import datetime, timedelta
class XeroTokenManager:
def __init__(self, vault="ProjectSecrets", item="Xero-Tokens"):
self.vault = vault
self.item = item
self._token_expiry = None
def get_access_token(self):
"""Get current access token, refreshing if needed."""
if self._is_token_expired():
self._refresh_token()
result = subprocess.run(
["op", "item", "get", self.item,
"--vault", self.vault,
"--fields", "access_token"],
capture_output=True, text=True
)
return result.stdout.strip()
def _is_token_expired(self):
"""Check if token needs refresh (5 min buffer)."""
if self._token_expiry is None:
return True
return datetime.now() >= self._token_expiry - timedelta(minutes=5)
def _refresh_token(self):
"""Refresh access token using refresh token."""
# Implementation: call Xero token endpoint
# Update tokens in 1Password
pass
Purpose: Create, retrieve, and manage invoices.
POST https://api.xero.com/api.xro/2.0/Invoices
Authorization: Bearer <ACCESS_TOKEN>
xero-tenant-id: <TENANT_ID>
Content-Type: application/json
{
"Type": "ACCREC",
"Contact": {
"ContactID": "a1b2c3d4-..."
},
"LineItems": [
{
"Description": "Consulting Services",
"Quantity": 10,
"UnitAmount": 150.00,
"AccountCode": "200"
}
],
"Date": "2026-01-10",
"DueDate": "2026-02-10",
"Reference": "INV-001",
"Status": "DRAFT"
}
| Type | Description |
|---|---|
ACCREC | Accounts Receivable (Sales Invoice) |
ACCPAY | Accounts Payable (Bill) |
| Status | Description | Transitions |
|---|---|---|
DRAFT | Not yet approved | -> SUBMITTED, AUTHORISED, DELETED |
SUBMITTED | Awaiting approval | -> AUTHORISED, DELETED |
AUTHORISED | Approved, awaiting payment | -> PAID, VOIDED |
PAID | Fully paid | (final state) |
VOIDED | Cancelled | (final state) |
DELETED | Removed | (final state) |
Create up to 50 invoices per request (3.5MB max).
Counts as single API call.
POST https://api.xero.com/api.xro/2.0/Invoices
{
"Invoices": [
{ "Type": "ACCREC", ... },
{ "Type": "ACCREC", ... },
...
]
}
GET https://api.xero.com/api.xro/2.0/Invoices
Authorization: Bearer <ACCESS_TOKEN>
xero-tenant-id: <TENANT_ID>
Optional parameters:
- where: Filter expression (e.g., Status=="AUTHORISED")
- order: Sort order (e.g., UpdatedDateUTC DESC)
- page: Page number for pagination
- summaryOnly: true for smaller response
Get overdue invoices:
GET /Invoices?where=Status=="AUTHORISED"AND AmountDue>0 AND DueDate<DateTime(2026,01,10)
Get recent invoices:
GET /Invoices?where=UpdatedDateUTC>=DateTime(2026,01,01)&order=UpdatedDateUTC DESC
Purpose: Manage customers and suppliers.
POST https://api.xero.com/api.xro/2.0/Contacts
Authorization: Bearer <ACCESS_TOKEN>
xero-tenant-id: <TENANT_ID>
Content-Type: application/json
{
"Name": "Acme Corporation",
"FirstName": "John",
"LastName": "Smith",
"EmailAddress": "john.smith@acme.com",
"Phones": [
{
"PhoneType": "DEFAULT",
"PhoneNumber": "555-1234"
}
],
"Addresses": [
{
"AddressType": "STREET",
"AddressLine1": "123 Main Street",
"City": "San Francisco",
"Region": "CA",
"PostalCode": "94105",
"Country": "USA"
}
],
"IsCustomer": true,
"IsSupplier": false
}
GET https://api.xero.com/api.xro/2.0/Contacts
Authorization: Bearer <ACCESS_TOKEN>
xero-tenant-id: <TENANT_ID>
Optional parameters:
- where: Filter expression
- order: Sort order
- page: Page number
- summaryOnly: true for smaller response
- includeArchived: true to include archived contacts
| Field | Values | Description |
|---|---|---|
IsCustomer | true/false | Receives invoices |
IsSupplier | true/false | Sends bills |
| Both can be true | Contact is both |
Purpose: Stay within API limits and handle throttling gracefully.
| Limit Type | Value | Reset |
|---|---|---|
| Daily per organization | 5,000 calls | Midnight UTC |
| Minute per organization | 10,000 calls | Rolling 60 seconds |
| Concurrent connections | 60 | Per integration |
X-Rate-Limit-Problem: daily
Retry-After: 3600
X-MinLimit-Remaining: 9500
X-DayLimit-Remaining: 4800
import time
from functools import wraps
def rate_limit_handler(func):
@wraps(func)
def wrapper(*args, **kwargs):
max_retries = 3
for attempt in range(max_retries):
response = func(*args, **kwargs)
if response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 60))
print(f"Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
continue
return response
raise Exception("Max retries exceeded")
return wrapper
Purpose: Handle multiple Xero organizations.
After OAuth, query connected tenants:
GET https://api.xero.com/connections
Authorization: Bearer <ACCESS_TOKEN>
Response:
[
{
"id": "abc123...",
"tenantId": "tenant-uuid-1",
"tenantType": "ORGANISATION",
"tenantName": "Demo Company (US)"
},
{
"id": "def456...",
"tenantId": "tenant-uuid-2",
"tenantType": "ORGANISATION",
"tenantName": "Client Company Ltd"
}
]
Always include tenant ID header:
GET https://api.xero.com/api.xro/2.0/Invoices
Authorization: Bearer <ACCESS_TOKEN>
xero-tenant-id: <TENANT_ID>
Purpose: Receive real-time notifications of data changes.
{
"events": [
{
"resourceUrl": "https://api.xero.com/api.xro/2.0/Invoices/abc123",
"resourceId": "abc123",
"tenantId": "tenant-uuid",
"tenantType": "ORGANISATION",
"eventCategory": "INVOICE",
"eventType": "UPDATE",
"eventDateUtc": "2026-01-10T12:00:00Z"
}
],
"firstEventSequence": 1,
"lastEventSequence": 1
}
| Category | Event Types |
|---|---|
INVOICE | CREATE, UPDATE |
CONTACT | CREATE, UPDATE |
PAYMENT | CREATE, UPDATE |
CREDITNOTE | CREATE, UPDATE |
def sync_invoices_since(last_sync_date):
"""Fetch invoices modified since last sync."""
page = 1
while True:
response = xero_api.get(
"/Invoices",
params={
"where": f"UpdatedDateUTC>=DateTime({last_sync_date})",
"page": page
}
)
invoices = response.json()["Invoices"]
if not invoices:
break
for invoice in invoices:
process_invoice(invoice)
page += 1
def create_invoice_from_order(order, contact_id):
"""Convert order to Xero invoice."""
line_items = [
{
"Description": item["name"],
"Quantity": item["quantity"],
"UnitAmount": item["price"],
"AccountCode": "200" # Sales account
}
for item in order["items"]
]
return xero_api.post("/Invoices", json={
"Type": "ACCREC",
"Contact": {"ContactID": contact_id},
"LineItems": line_items,
"Date": order["date"],
"DueDate": order["due_date"],
"Reference": order["reference"],
"Status": "AUTHORISED" # Ready for payment
})
def find_or_create_contact(email, name):
"""Get existing contact by email or create new."""
# Search by email
response = xero_api.get(
"/Contacts",
params={"where": f'EmailAddress=="{email}"'}
)
contacts = response.json()["Contacts"]
if contacts:
return contacts[0]["ContactID"]
# Create new contact
response = xero_api.post("/Contacts", json={
"Name": name,
"EmailAddress": email,
"IsCustomer": True
})
return response.json()["Contacts"][0]["ContactID"]
Symptoms:
{"Type": "OAuth2", "Detail": "The access token has expired"}
Solution:
Symptoms:
{"Type": "NoPermissions", "Detail": "You do not have permission"}
Solution:
Symptoms:
HTTP 429 Too Many Requests
X-Rate-Limit-Problem: daily
Solution:
Symptoms:
{"Type": "InvalidTenantId"}
Solution:
Symptoms:
{
"Type": "ValidationException",
"Message": "A validation exception occurred",
"Elements": [
{
"ValidationErrors": [
{"Message": "Contact Name must be specified"}
]
}
]
}
Solution:
| Environment | URL |
|---|---|
| Production | https://api.xero.com/api.xro/2.0/ |
| Identity | https://identity.xero.com/ |
| OAuth | https://login.xero.com/identity/connect/ |
Authorization: Bearer <ACCESS_TOKEN>
xero-tenant-id: <TENANT_ID>
Content-Type: application/json
Accept: application/json
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 400 | Bad Request |
| 401 | Unauthorized (token expired) |
| 403 | Forbidden (insufficient scope) |
| 404 | Not Found |
| 429 | Rate Limited |
| 500 | Server Error |
Version History:
Maintainer: Claude Code License: Apache-2.0