Xero is a popular cloud-based accounting software with a comprehensive REST API. This guide covers complete integration with Xero's accounting API using Java.
Setup and Dependencies
Maven Dependencies
<properties>
<xero.version>4.17.0</xero.version>
<spring-boot.version>3.1.0</spring-boot.version>
<okhttp.version>4.10.0</okhttp.version>
<gson.version>2.10.1</gson.version>
</properties>
<dependencies>
<!-- Xero Java SDK -->
<dependency>
<groupId>com.xero</groupId>
<artifactId>xero-java-sdk</artifactId>
<version>${xero.version}</version>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>${gson.version}</version>
</dependency>
<!-- JWT for OAuth2 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
</dependencies>
Configuration
@Configuration
public class XeroConfig {
@Value("${xero.client.id:}")
private String clientId;
@Value("${xero.client.secret:}")
private String clientSecret;
@Value("${xero.redirect.uri:}")
private String redirectUri;
@Value("${xero.tenant.id:}")
private String tenantId;
@Bean
public XeroClient xeroClient() {
return new XeroClient(clientId, clientSecret, redirectUri);
}
}
# application.yml
xero:
client:
id: ${XERO_CLIENT_ID}
secret: ${XERO_CLIENT_SECRET}
redirect:
uri: ${XERO_REDIRECT_URI}
tenant:
id: ${XERO_TENANT_ID}
app:
base-url: https://yourapp.com
Authentication Setup
1. OAuth2 Configuration
@Component
public class XeroAuthService {
private static final Logger logger = LoggerFactory.getLogger(XeroAuthService.class);
private final XeroClient xeroClient;
private final TokenRepository tokenRepository;
private static final String XERO_AUTH_URL = "https://login.xero.com/identity/connect/authorize";
private static final String XERO_TOKEN_URL = "https://identity.xero.com/connect/token";
private static final String[] SCOPES = {"openid", "email", "profile", "accounting.transactions",
"accounting.contacts", "accounting.settings"};
public XeroAuthService(XeroClient xeroClient, TokenRepository tokenRepository) {
this.xeroClient = xeroClient;
this.tokenRepository = tokenRepository;
}
/**
* Generate authorization URL for user to authenticate with Xero
*/
public String getAuthorizationUrl(String state) {
try {
return XERO_AUTH_URL + "?" +
"response_type=code" +
"&client_id=" + xeroClient.getClientId() +
"&redirect_uri=" + URLEncoder.encode(xeroClient.getRedirectUri(), "UTF-8") +
"&scope=" + URLEncoder.encode(String.join(" ", SCOPES), "UTF-8") +
"&state=" + state;
} catch (Exception e) {
logger.error("Failed to generate authorization URL", e);
throw new XeroAuthException("Failed to generate authorization URL", e);
}
}
/**
* Exchange authorization code for access token
*/
public TokenResponse exchangeCodeForToken(String authorizationCode) {
try {
OkHttpClient client = new OkHttpClient();
RequestBody requestBody = new FormBody.Builder()
.add("grant_type", "authorization_code")
.add("code", authorizationCode)
.add("redirect_uri", xeroClient.getRedirectUri())
.build();
Request request = new Request.Builder()
.url(XERO_TOKEN_URL)
.post(requestBody)
.addHeader("Authorization", getBasicAuthHeader())
.addHeader("Content-Type", "application/x-www-form-urlencoded")
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new XeroAuthException("Token exchange failed: " + response.message());
}
String responseBody = response.body().string();
TokenResponse tokenResponse = parseTokenResponse(responseBody);
// Store token
tokenRepository.saveToken(tokenResponse);
return tokenResponse;
}
} catch (Exception e) {
logger.error("Failed to exchange code for token", e);
throw new XeroAuthException("Failed to exchange authorization code", e);
}
}
/**
* Refresh access token
*/
public TokenResponse refreshToken(String refreshToken) {
try {
OkHttpClient client = new OkHttpClient();
RequestBody requestBody = new FormBody.Builder()
.add("grant_type", "refresh_token")
.add("refresh_token", refreshToken)
.build();
Request request = new Request.Builder()
.url(XERO_TOKEN_URL)
.post(requestBody)
.addHeader("Authorization", getBasicAuthHeader())
.addHeader("Content-Type", "application/x-www-form-urlencoded")
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new XeroAuthException("Token refresh failed: " + response.message());
}
String responseBody = response.body().string();
TokenResponse tokenResponse = parseTokenResponse(responseBody);
// Update stored token
tokenRepository.saveToken(tokenResponse);
return tokenResponse;
}
} catch (Exception e) {
logger.error("Failed to refresh token", e);
throw new XeroAuthException("Failed to refresh access token", e);
}
}
private String getBasicAuthHeader() {
String credentials = xeroClient.getClientId() + ":" + xeroClient.getClientSecret();
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
return "Basic " + encodedCredentials;
}
private TokenResponse parseTokenResponse(String responseBody) {
Gson gson = new Gson();
return gson.fromJson(responseBody, TokenResponse.class);
}
/**
* Get valid access token (refreshes if expired)
*/
public String getValidAccessToken() {
TokenResponse token = tokenRepository.getToken();
if (token == null) {
throw new XeroAuthException("No token available");
}
// Check if token needs refresh
if (isTokenExpired(token)) {
logger.info("Access token expired, refreshing...");
token = refreshToken(token.getRefreshToken());
}
return token.getAccessToken();
}
private boolean isTokenExpired(TokenResponse token) {
// Check if token is expired (with 1 minute buffer)
return Instant.now().isAfter(
token.getIssuedAt().plusSeconds(token.getExpiresIn() - 60)
);
}
}
2. Token Management
@Entity
@Table(name = "xero_tokens")
public class XeroToken {
@Id
private String id;
@Column(length = 2000)
private String accessToken;
@Column(length = 2000)
private String refreshToken;
private String tokenType;
private Long expiresIn;
private Instant issuedAt;
private String scope;
private String idToken;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
// constructors, getters, setters
}
@Repository
public interface XeroTokenRepository extends JpaRepository<XeroToken, String> {
Optional<XeroToken> findTopByOrderByCreatedAtDesc();
}
@Component
public class TokenRepository {
private final XeroTokenRepository xeroTokenRepository;
public TokenRepository(XeroTokenRepository xeroTokenRepository) {
this.xeroTokenRepository = xeroTokenRepository;
}
public void saveToken(TokenResponse tokenResponse) {
XeroToken token = new XeroToken();
token.setId(UUID.randomUUID().toString());
token.setAccessToken(tokenResponse.getAccessToken());
token.setRefreshToken(tokenResponse.getRefreshToken());
token.setTokenType(tokenResponse.getTokenType());
token.setExpiresIn(tokenResponse.getExpiresIn());
token.setIssuedAt(Instant.now());
token.setScope(tokenResponse.getScope());
token.setIdToken(tokenResponse.getIdToken());
xeroTokenRepository.save(token);
}
public TokenResponse getToken() {
return xeroTokenRepository.findTopByOrderByCreatedAtDesc()
.map(this::convertToTokenResponse)
.orElse(null);
}
private TokenResponse convertToTokenResponse(XeroToken token) {
TokenResponse response = new TokenResponse();
response.setAccessToken(token.getAccessToken());
response.setRefreshToken(token.getRefreshToken());
response.setTokenType(token.getTokenType());
response.setExpiresIn(token.getExpiresIn());
response.setIssuedAt(token.getIssuedAt());
response.setScope(token.getScope());
response.setIdToken(token.getIdToken());
return response;
}
}
public class TokenResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private Long expiresIn;
private Instant issuedAt;
private String scope;
private String idToken;
// constructors, getters, setters
}
Core Xero Service
1. Base Xero Service
@Service
public class XeroApiService {
private static final Logger logger = LoggerFactory.getLogger(XeroApiService.class);
private final XeroAuthService authService;
private final String tenantId;
private static final String XERO_API_BASE = "https://api.xero.com/api.xro/2.0";
public XeroApiService(XeroAuthService authService,
@Value("${xero.tenant.id}") String tenantId) {
this.authService = authService;
this.tenantId = tenantId;
}
/**
* Make authenticated request to Xero API
*/
protected <T> T executeRequest(String endpoint, Class<T> responseType) {
return executeRequest(endpoint, "GET", null, responseType);
}
protected <T> T executeRequest(String endpoint, String method, Object body, Class<T> responseType) {
try {
String accessToken = authService.getValidAccessToken();
OkHttpClient client = new OkHttpClient();
Request.Builder requestBuilder = new Request.Builder()
.url(XERO_API_BASE + endpoint)
.addHeader("Authorization", "Bearer " + accessToken)
.addHeader("Xero-tenant-id", tenantId)
.addHeader("Accept", "application/json");
if (body != null) {
String jsonBody = new Gson().toJson(body);
requestBuilder.addHeader("Content-Type", "application/json")
.method(method, RequestBody.create(jsonBody, MediaType.parse("application/json")));
} else {
requestBuilder.method(method, null);
}
Request request = requestBuilder.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
handleErrorResponse(response);
}
String responseBody = response.body().string();
return new Gson().fromJson(responseBody, responseType);
}
} catch (Exception e) {
logger.error("Xero API request failed for endpoint: {}", endpoint, e);
throw new XeroApiException("Xero API request failed", e);
}
}
private void handleErrorResponse(Response response) throws IOException {
String errorBody = response.body().string();
logger.error("Xero API error: {} - {}", response.code(), errorBody);
switch (response.code()) {
case 400:
throw new XeroBadRequestException("Bad request: " + errorBody);
case 401:
throw new XeroAuthException("Authentication failed");
case 403:
throw new XeroForbiddenException("Access forbidden");
case 404:
throw new XeroNotFoundException("Resource not found");
case 429:
throw new XeroRateLimitException("Rate limit exceeded");
default:
throw new XeroApiException("Xero API error: " + response.code() + " - " + errorBody);
}
}
/**
* Execute paginated request
*/
protected <T> List<T> executePaginatedRequest(String endpoint, String itemsField, Class<T> itemType) {
List<T> allItems = new ArrayList<>();
int page = 1;
boolean hasMorePages = true;
while (hasMorePages) {
try {
String paginatedEndpoint = endpoint + (endpoint.contains("?") ? "&" : "?") +
"page=" + page;
JsonObject response = executeRequest(paginatedEndpoint, JsonObject.class);
if (response.has(itemsField)) {
JsonArray itemsArray = response.getAsJsonArray(itemsField);
for (JsonElement itemElement : itemsArray) {
T item = new Gson().fromJson(itemElement, itemType);
allItems.add(item);
}
}
// Check if there are more pages
hasMorePages = response.has("HasMorePages") &&
response.get("HasMorePages").getAsBoolean();
page++;
} catch (Exception e) {
logger.error("Failed to fetch page {} for endpoint: {}", page, endpoint, e);
hasMorePages = false;
}
}
return allItems;
}
}
2. Contact Service
@Service
public class XeroContactService extends XeroApiService {
public XeroContactService(XeroAuthService authService,
@Value("${xero.tenant.id}") String tenantId) {
super(authService, tenantId);
}
/**
* Get all contacts
*/
public List<XeroContact> getContacts() {
return executePaginatedRequest("/Contacts", "Contacts", XeroContact.class);
}
/**
* Get contact by ID
*/
public XeroContact getContact(String contactId) {
XeroContactResponse response = executeRequest("/Contacts/" + contactId,
XeroContactResponse.class);
return response.getContacts().get(0);
}
/**
* Create a new contact
*/
public XeroContact createContact(CreateContactRequest request) {
XeroContact contact = convertToXeroContact(request);
XeroContactRequest contactRequest = new XeroContactRequest(contact);
XeroContactResponse response = executeRequest("/Contacts", "POST",
contactRequest, XeroContactResponse.class);
return response.getContacts().get(0);
}
/**
* Update an existing contact
*/
public XeroContact updateContact(String contactId, UpdateContactRequest request) {
XeroContact contact = convertToXeroContact(request);
contact.setContactId(contactId);
XeroContactRequest contactRequest = new XeroContactRequest(contact);
XeroContactResponse response = executeRequest("/Contacts/" + contactId, "POST",
contactRequest, XeroContactResponse.class);
return response.getContacts().get(0);
}
/**
* Search contacts by name
*/
public List<XeroContact> searchContacts(String name) {
String endpoint = "/Contacts?where=Name.Contains(\"" + name + "\")";
return executePaginatedRequest(endpoint, "Contacts", XeroContact.class);
}
private XeroContact convertToXeroContact(CreateContactRequest request) {
XeroContact contact = new XeroContact();
contact.setName(request.getName());
contact.setFirstName(request.getFirstName());
contact.setLastName(request.getLastName());
contact.setEmailAddress(request.getEmailAddress());
contact.setPhoneNumber(request.getPhoneNumber());
if (request.getAddresses() != null) {
List<XeroAddress> addresses = request.getAddresses().stream()
.map(this::convertToXeroAddress)
.collect(Collectors.toList());
contact.setAddresses(addresses);
}
return contact;
}
private XeroAddress convertToXeroAddress(AddressRequest addressRequest) {
XeroAddress address = new XeroAddress();
address.setAddressType(XeroAddress.AddressType.fromValue(addressRequest.getAddressType()));
address.setAddressLine1(addressRequest.getAddressLine1());
address.setAddressLine2(addressRequest.getAddressLine2());
address.setCity(addressRequest.getCity());
address.setRegion(addressRequest.getRegion());
address.setPostalCode(addressRequest.getPostalCode());
address.setCountry(addressRequest.getCountry());
return address;
}
}
3. Invoice Service
@Service
public class XeroInvoiceService extends XeroApiService {
public XeroInvoiceService(XeroAuthService authService,
@Value("${xero.tenant.id}") String tenantId) {
super(authService, tenantId);
}
/**
* Get all invoices
*/
public List<XeroInvoice> getInvoices() {
return executePaginatedRequest("/Invoices", "Invoices", XeroInvoice.class);
}
/**
* Get invoices with filters
*/
public List<XeroInvoice> getInvoices(InvoiceFilter filter) {
StringBuilder endpoint = new StringBuilder("/Invoices?");
if (filter.getStatus() != null) {
endpoint.append("Statuses=").append(filter.getStatus()).append("&");
}
if (filter.getFromDate() != null) {
endpoint.append("DateFrom=").append(filter.getFromDate()).append("&");
}
if (filter.getToDate() != null) {
endpoint.append("DateTo=").append(filter.getToDate()).append("&");
}
return executePaginatedRequest(endpoint.toString(), "Invoices", XeroInvoice.class);
}
/**
* Get invoice by ID
*/
public XeroInvoice getInvoice(String invoiceId) {
XeroInvoiceResponse response = executeRequest("/Invoices/" + invoiceId,
XeroInvoiceResponse.class);
return response.getInvoices().get(0);
}
/**
* Create a new invoice
*/
public XeroInvoice createInvoice(CreateInvoiceRequest request) {
XeroInvoice invoice = convertToXeroInvoice(request);
XeroInvoiceRequest invoiceRequest = new XeroInvoiceRequest(invoice);
XeroInvoiceResponse response = executeRequest("/Invoices", "POST",
invoiceRequest, XeroInvoiceResponse.class);
return response.getInvoices().get(0);
}
/**
* Update an existing invoice
*/
public XeroInvoice updateInvoice(String invoiceId, UpdateInvoiceRequest request) {
XeroInvoice invoice = convertToXeroInvoice(request);
invoice.setInvoiceId(invoiceId);
XeroInvoiceRequest invoiceRequest = new XeroInvoiceRequest(invoice);
XeroInvoiceResponse response = executeRequest("/Invoices/" + invoiceId, "POST",
invoiceRequest, XeroInvoiceResponse.class);
return response.getInvoices().get(0);
}
/**
* Email an invoice to customer
*/
public void emailInvoice(String invoiceId) {
executeRequest("/Invoices/" + invoiceId + "/Email", "POST", null, JsonObject.class);
}
private XeroInvoice convertToXeroInvoice(CreateInvoiceRequest request) {
XeroInvoice invoice = new XeroInvoice();
invoice.setType(XeroInvoice.Type.fromValue(request.getType()));
invoice.setContact(createContactForInvoice(request.getContact()));
invoice.setDate(request.getDate());
invoice.setDueDate(request.getDueDate());
invoice.setInvoiceNumber(request.getInvoiceNumber());
invoice.setReference(request.getReference());
invoice.setLineItems(convertLineItems(request.getLineItems()));
invoice.setStatus(XeroInvoice.Status.fromValue(request.getStatus()));
return invoice;
}
private XeroContact createContactForInvoice(InvoiceContactRequest contactRequest) {
XeroContact contact = new XeroContact();
contact.setContactId(contactRequest.getContactId());
contact.setName(contactRequest.getName());
return contact;
}
private List<XeroLineItem> convertLineItems(List<InvoiceLineItemRequest> lineItems) {
return lineItems.stream()
.map(this::convertToXeroLineItem)
.collect(Collectors.toList());
}
private XeroLineItem convertToXeroLineItem(InvoiceLineItemRequest lineItem) {
XeroLineItem xeroLineItem = new XeroLineItem();
xeroLineItem.setDescription(lineItem.getDescription());
xeroLineItem.setQuantity(lineItem.getQuantity());
xeroLineItem.setUnitAmount(lineItem.getUnitAmount());
xeroLineItem.setAccountCode(lineItem.getAccountCode());
xeroLineItem.setTaxType(lineItem.getTaxType());
return xeroLineItem;
}
}
4. Payment Service
@Service
public class XeroPaymentService extends XeroApiService {
public XeroPaymentService(XeroAuthService authService,
@Value("${xero.tenant.id}") String tenantId) {
super(authService, tenantId);
}
/**
* Get all payments
*/
public List<XeroPayment> getPayments() {
return executePaginatedRequest("/Payments", "Payments", XeroPayment.class);
}
/**
* Create a payment
*/
public XeroPayment createPayment(CreatePaymentRequest request) {
XeroPayment payment = convertToXeroPayment(request);
XeroPaymentRequest paymentRequest = new XeroPaymentRequest(payment);
XeroPaymentResponse response = executeRequest("/Payments", "POST",
paymentRequest, XeroPaymentResponse.class);
return response.getPayments().get(0);
}
/**
* Delete a payment
*/
public void deletePayment(String paymentId) {
XeroPayment payment = new XeroPayment();
payment.setPaymentId(paymentId);
payment.setStatus(XeroPayment.Status.DELETED);
XeroPaymentRequest paymentRequest = new XeroPaymentRequest(payment);
executeRequest("/Payments", "POST", paymentRequest, XeroPaymentResponse.class);
}
private XeroPayment convertToXeroPayment(CreatePaymentRequest request) {
XeroPayment payment = new XeroPayment();
payment.setInvoice(createInvoiceForPayment(request.getInvoiceId()));
payment.setAccount(createAccountForPayment(request.getAccountId()));
payment.setDate(request.getDate());
payment.setAmount(request.getAmount());
payment.setReference(request.getReference());
return payment;
}
private XeroInvoice createInvoiceForPayment(String invoiceId) {
XeroInvoice invoice = new XeroInvoice();
invoice.setInvoiceId(invoiceId);
return invoice;
}
private XeroAccount createAccountForPayment(String accountId) {
XeroAccount account = new XeroAccount();
account.setAccountId(accountId);
return account;
}
}
Data Models
1. Contact Models
public class XeroContact {
private String contactId;
private String contactNumber;
private String accountNumber;
private String name;
private String firstName;
private String lastName;
private String emailAddress;
private String phoneNumber;
private List<XeroAddress> addresses;
// constructors, getters, setters
}
public class XeroAddress {
public enum AddressType {
POBOX, STREET, DELIVERY;
public static AddressType fromValue(String value) {
return Arrays.stream(values())
.filter(type -> type.name().equalsIgnoreCase(value))
.findFirst()
.orElse(STREET);
}
}
private AddressType addressType;
private String addressLine1;
private String addressLine2;
private String city;
private String region;
private String postalCode;
private String country;
// constructors, getters, setters
}
public class CreateContactRequest {
@NotBlank
private String name;
private String firstName;
private String lastName;
private String emailAddress;
private String phoneNumber;
private List<AddressRequest> addresses;
// constructors, getters, setters
}
public class XeroContactResponse {
private List<XeroContact> contacts;
// constructors, getters, setters
}
public class XeroContactRequest {
private XeroContact contact;
public XeroContactRequest(XeroContact contact) {
this.contact = contact;
}
// getters, setters
}
2. Invoice Models
public class XeroInvoice {
public enum Type {
ACCPAY, ACCREC;
public static Type fromValue(String value) {
return Arrays.stream(values())
.filter(type -> type.name().equalsIgnoreCase(value))
.findFirst()
.orElse(ACCREC);
}
}
public enum Status {
DRAFT, SUBMITTED, AUTHORISED, PAID, VOIDED;
public static Status fromValue(String value) {
return Arrays.stream(values())
.filter(type -> type.name().equalsIgnoreCase(value))
.findFirst()
.orElse(DRAFT);
}
}
private String invoiceId;
private String invoiceNumber;
private Type type;
private XeroContact contact;
private LocalDate date;
private LocalDate dueDate;
private String reference;
private List<XeroLineItem> lineItems;
private Status status;
private BigDecimal total;
private BigDecimal amountDue;
private BigDecimal amountPaid;
// constructors, getters, setters
}
public class XeroLineItem {
private String description;
private BigDecimal quantity;
private BigDecimal unitAmount;
private String accountCode;
private String taxType;
private BigDecimal lineAmount;
private BigDecimal taxAmount;
// constructors, getters, setters
}
public class CreateInvoiceRequest {
@NotBlank
private String type; // ACCREC or ACCPAY
@NotNull
private InvoiceContactRequest contact;
@NotNull
private LocalDate date;
@NotNull
private LocalDate dueDate;
private String invoiceNumber;
private String reference;
@NotEmpty
private List<InvoiceLineItemRequest> lineItems;
private String status = "DRAFT";
// constructors, getters, setters
}
public class XeroInvoiceResponse {
private List<XeroInvoice> invoices;
// constructors, getters, setters
}
3. Payment Models
public class XeroPayment {
public enum Status {
AUTHORISED, DELETED;
public static Status fromValue(String value) {
return Arrays.stream(values())
.filter(type -> type.name().equalsIgnoreCase(value))
.findFirst()
.orElse(AUTHORISED);
}
}
private String paymentId;
private XeroInvoice invoice;
private XeroAccount account;
private LocalDate date;
private BigDecimal amount;
private String reference;
private Status status;
// constructors, getters, setters
}
public class CreatePaymentRequest {
@NotBlank
private String invoiceId;
@NotBlank
private String accountId;
@NotNull
private LocalDate date;
@NotNull
@DecimalMin("0.01")
private BigDecimal amount;
private String reference;
// constructors, getters, setters
}
REST Controllers
1. Authentication Controller
@RestController
@RequestMapping("/api/xero/auth")
public class XeroAuthController {
private final XeroAuthService authService;
public XeroAuthController(XeroAuthService authService) {
this.authService = authService;
}
@GetMapping("/authorize")
public ResponseEntity<AuthResponse> getAuthorizationUrl() {
String state = UUID.randomUUID().toString();
String authUrl = authService.getAuthorizationUrl(state);
AuthResponse response = new AuthResponse(authUrl, state);
return ResponseEntity.ok(response);
}
@PostMapping("/callback")
public ResponseEntity<TokenResponse> handleCallback(@RequestBody AuthCallbackRequest request) {
TokenResponse tokenResponse = authService.exchangeCodeForToken(request.getCode());
return ResponseEntity.ok(tokenResponse);
}
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refreshToken() {
TokenResponse tokenResponse = authService.refreshToken();
return ResponseEntity.ok(tokenResponse);
}
}
2. Contacts Controller
@RestController
@RequestMapping("/api/xero/contacts")
@Validated
public class XeroContactController {
private final XeroContactService contactService;
public XeroContactController(XeroContactService contactService) {
this.contactService = contactService;
}
@GetMapping
public ResponseEntity<List<XeroContact>> getContacts() {
List<XeroContact> contacts = contactService.getContacts();
return ResponseEntity.ok(contacts);
}
@GetMapping("/{contactId}")
public ResponseEntity<XeroContact> getContact(@PathVariable String contactId) {
XeroContact contact = contactService.getContact(contactId);
return ResponseEntity.ok(contact);
}
@PostMapping
public ResponseEntity<XeroContact> createContact(@Valid @RequestBody CreateContactRequest request) {
XeroContact contact = contactService.createContact(request);
return ResponseEntity.status(HttpStatus.CREATED).body(contact);
}
@PutMapping("/{contactId}")
public ResponseEntity<XeroContact> updateContact(
@PathVariable String contactId,
@Valid @RequestBody UpdateContactRequest request) {
XeroContact contact = contactService.updateContact(contactId, request);
return ResponseEntity.ok(contact);
}
@GetMapping("/search")
public ResponseEntity<List<XeroContact>> searchContacts(
@RequestParam String name) {
List<XeroContact> contacts = contactService.searchContacts(name);
return ResponseEntity.ok(contacts);
}
}
3. Invoices Controller
@RestController
@RequestMapping("/api/xero/invoices")
@Validated
public class XeroInvoiceController {
private final XeroInvoiceService invoiceService;
public XeroInvoiceController(XeroInvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
@GetMapping
public ResponseEntity<List<XeroInvoice>> getInvoices(InvoiceFilter filter) {
List<XeroInvoice> invoices = invoiceService.getInvoices(filter);
return ResponseEntity.ok(invoices);
}
@GetMapping("/{invoiceId}")
public ResponseEntity<XeroInvoice> getInvoice(@PathVariable String invoiceId) {
XeroInvoice invoice = invoiceService.getInvoice(invoiceId);
return ResponseEntity.ok(invoice);
}
@PostMapping
public ResponseEntity<XeroInvoice> createInvoice(@Valid @RequestBody CreateInvoiceRequest request) {
XeroInvoice invoice = invoiceService.createInvoice(request);
return ResponseEntity.status(HttpStatus.CREATED).body(invoice);
}
@PostMapping("/{invoiceId}/email")
public ResponseEntity<Void> emailInvoice(@PathVariable String invoiceId) {
invoiceService.emailInvoice(invoiceId);
return ResponseEntity.ok().build();
}
}
Webhook Handling
@Service
public class XeroWebhookService {
private static final Logger logger = LoggerFactory.getLogger(XeroWebhookService.class);
private final XeroInvoiceService invoiceService;
private final XeroContactService contactService;
public XeroWebhookService(XeroInvoiceService invoiceService, XeroContactService contactService) {
this.invoiceService = invoiceService;
this.contactService = contactService;
}
public void handleWebhook(XeroWebhookPayload payload) {
logger.info("Processing Xero webhook: {}", payload.getEventType());
for (XeroWebhookEvent event : payload.getEvents()) {
try {
processEvent(event);
} catch (Exception e) {
logger.error("Failed to process webhook event: {}", event.getEventType(), e);
}
}
}
private void processEvent(XeroWebhookEvent event) {
String resourceType = event.getResourceType();
String eventType = event.getEventType();
String resourceId = event.getResourceId();
switch (resourceType) {
case "INVOICE":
handleInvoiceEvent(eventType, resourceId);
break;
case "CONTACT":
handleContactEvent(eventType, resourceId);
break;
case "PAYMENT":
handlePaymentEvent(eventType, resourceId);
break;
default:
logger.debug("Unhandled webhook resource type: {}", resourceType);
}
}
private void handleInvoiceEvent(String eventType, String invoiceId) {
try {
XeroInvoice invoice = invoiceService.getInvoice(invoiceId);
switch (eventType) {
case "CREATE":
logger.info("New invoice created: {}", invoice.getInvoiceNumber());
// Handle new invoice creation
break;
case "UPDATE":
logger.info("Invoice updated: {}", invoice.getInvoiceNumber());
// Handle invoice update
break;
case "DELETE":
logger.info("Invoice deleted: {}", invoiceId);
// Handle invoice deletion
break;
}
} catch (Exception e) {
logger.error("Failed to handle invoice event: {}", eventType, e);
}
}
private void handleContactEvent(String eventType, String contactId) {
// Similar implementation for contact events
}
private void handlePaymentEvent(String eventType, String paymentId) {
// Similar implementation for payment events
}
}
@RestController
@RequestMapping("/webhooks/xero")
public class XeroWebhookController {
private final XeroWebhookService webhookService;
public XeroWebhookController(XeroWebhookService webhookService) {
this.webhookService = webhookService;
}
@PostMapping
public ResponseEntity<String> handleWebhook(
@RequestBody XeroWebhookPayload payload,
@RequestHeader("x-xero-signature") String signature) {
// Verify webhook signature
if (!isValidSignature(payload, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
webhookService.handleWebhook(payload);
return ResponseEntity.ok("Webhook processed successfully");
}
private boolean isValidSignature(XeroWebhookPayload payload, String signature) {
// Implement Xero webhook signature verification
return true; // Simplified for example
}
}
Exception Handling
@ControllerAdvice
public class XeroExceptionHandler {
@ExceptionHandler(XeroAuthException.class)
public ResponseEntity<ErrorResponse> handleAuthException(XeroAuthException ex) {
logger.error("Xero authentication error", ex);
ErrorResponse error = new ErrorResponse("XERO_AUTH_ERROR", ex.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(XeroApiException.class)
public ResponseEntity<ErrorResponse> handleApiException(XeroApiException ex) {
logger.error("Xero API error", ex);
ErrorResponse error = new ErrorResponse("XERO_API_ERROR", ex.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
@ExceptionHandler(XeroRateLimitException.class)
public ResponseEntity<ErrorResponse> handleRateLimitException(XeroRateLimitException ex) {
logger.error("Xero rate limit exceeded", ex);
ErrorResponse error = new ErrorResponse("XERO_RATE_LIMIT", ex.getMessage());
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(error);
}
}
public class XeroAuthException extends RuntimeException {
public XeroAuthException(String message) {
super(message);
}
public XeroAuthException(String message, Throwable cause) {
super(message, cause);
}
}
public class XeroApiException extends RuntimeException {
public XeroApiException(String message) {
super(message);
}
public XeroApiException(String message, Throwable cause) {
super(message, cause);
}
}
Testing
1. Unit Tests
@ExtendWith(MockitoExtension.class)
class XeroContactServiceTest {
@Mock
private XeroAuthService authService;
@InjectMocks
private XeroContactService contactService;
@Test
void shouldGetContacts() {
// Given
when(authService.getValidAccessToken()).thenReturn("test-token");
// When/Then - implementation depends on mocking HTTP responses
assertThatThrownBy(() -> contactService.getContacts())
.isInstanceOf(XeroApiException.class);
}
}
2. Integration Test
@SpringBootTest
@TestPropertySource(locations = "classpath:application-test.properties")
class XeroIntegrationTest {
@Autowired
private XeroContactService contactService;
@Test
@Disabled("For actual Xero integration testing")
void shouldConnectToXero() {
// This test would actually call Xero test environment
// Requires valid test credentials
}
}
Best Practices
- Token Management: Implement secure token storage and refresh logic
- Error Handling: Comprehensive error handling for API failures
- Rate Limiting: Implement retry logic with exponential backoff
- Webhook Security: Always verify webhook signatures
- Data Validation: Validate all requests before sending to Xero
- Logging: Log all Xero API interactions for debugging
@Component
public class XeroHealthCheck {
@Scheduled(fixedRate = 300000) // 5 minutes
public void checkXeroConnectivity() {
try {
// Try to fetch organizations to verify connectivity
// This would be implemented based on available endpoints
logger.debug("Xero connectivity check passed");
} catch (Exception e) {
logger.error("Xero connectivity check failed: {}", e.getMessage());
// Alert monitoring system
}
}
}
Conclusion
This comprehensive Xero integration provides:
- Complete OAuth2 authentication flow
- Contact management (create, read, update contacts)
- Invoice management (create, send, track invoices)
- Payment processing (record and manage payments)
- Webhook handling for real-time updates
- Robust error handling and monitoring
The implementation follows Xero's API best practices and can be extended with additional features like tracking categories, reporting, and file attachments based on your business requirements.