I got a task about Xero integration in my work last week, aiming to copy invoices from one account to the other. To perform this task, the workflow will be:
-
- Invoice created/updated in Xero account A
-
- The webhook from Xero account A is triggered
-
- Our App handles the webhook and copy the newly created/updated invoice to Xero account B
This article will show you how to setup Xero and talk about some tricky parts of the task.
Set Up Xero Integration
To get started, a xero account is needed. Just signup and Xero will prompt to create an organization. Create a trial organization and a demo so that invoices can be copied from one to the other.
Private Application
To get access to data of an Xero account, an Application is required, in our case, the application is only used by the organization not shared by others, so a private application is the answer.
Not like a public application, a private application is associated with an organization, and can only be used by that organization.
To setup an application for our organization, go to Xero Developer and click the “New App” button:
Input app name and select with organization the application will be associated to.
Create Public/Private Key Pair
The rest of the form is about setting up the credential to get access to the application. Xero has documentation to help you set it up.
A Public/Private key pair will be created at the end, upload the public key to Xero (and keep the private key file):
Click the “Create App”. Customer key will be found in the App detail screen, it will be used later:
Go through the same process to create another application for the other organization (since there are two organizations).
Setup Webhook
Go to the webhook setting page in your App:
Once the webhook is saved, the “webhook key” will be present and the status will show as Intent To Receive required
, which indicate at this moment, the webhook is not ready to use. To enable the webhook, Xero requires the application to pass its verification. It does the process by:
- send a dummy webhook request to your server with a special header called
X-Xero-Signature
- the server needs to encrypt the whole request body and the webhook key together, and based on whether the encrypted result is the same as the value of the
X-Xero-Signature
, the server needs to response differently:- if the same, return 200 with an empty body
- if not, return 403
Create API Endpoint to Handle Webhook
Since the process of the webhook verification is clear, an API endpoint can be built as bellow:
from app.api.resources import BaseResource
from flask import current_app, request, Response
from app.xero import verify_xero_webhook_request_for_uae
class XeroWebhookResource(BaseResource):
def post(self):
if verify_xero_webhook_request_for_uae(request):
current_app.logger.info(
f'Xero webhook received and passed the verification'
)
# ... perform the task
return Response(
status=200,
mimetype='text/plain',
response=None
)
else:
current_app.logger.info(
f'Xero webhook received and failed the verification'
)
return Response(
status=401,
mimetype='text/plain',
response=None
)
Once the endpoint is setup, get back to the webhook page, hit the “send the ITR” button, the verification process will start. Xero will make several dummy requests with true of false value of header X-Xero-Signature
to test if your server can handle them correctly, if all pass, the status will turn to a blue OK
.
In addition, to make a remote webhook server work with local development server, Ngrok is highly recommended.
Setup Xero Integraion with Python
Now then we can focus on the task itself.
There’s a third party library pyxero we can use to do the API calling and authentication work.
Follow the Private Application, to set up a Xero client, two things are needed:
- Application Consumer Key, which can be found in your App detail page
- Private RSA File, which is the private key file created in the App setup step.
Once you get these two file ready, follow the code below:
from xero import Xero
from xero.auth import PrivateCredentials
with open(<path to rsa key file>) as keyfile:
rsa_key = keyfile.read()
credentials = PrivateCredentials(<consumer_key>, rsa_key)
xero = Xero(credentials)
The Xero client can then be used:
xero.invoices.get(...)
In production environment, instead of saving the private key into the code base, uploading it into a private storage service (for example, Amazon S3) that your machine have the access to will be a good idea. In my case, I dymatically download the file whenever the Xero client is needed:
def download_rsa_file_from_s3():
s3 = boto3.resource('s3')
bucket_name = current_app.config['SECRET_KEY_S3_BUCKET']
bucket = s3.Bucket(bucket_name)
rsa_file_path = get_rsa_file_path()
if path.exists(rsa_file_path) is False:
current_app.logger.debug(
f'Download Xero private RSA key'
f' from {bucket_name}/{PRIVATE_RSA_S3_FILE_PATH}'
f' to {rsa_file_path}')
bucket.download_file(PRIVATE_RSA_S3_FILE_PATH, rsa_file_path)
return rsa_file_path
Handle Webhook Events
The body of each Xero webhook event is like below:
[
{
"eventType": "CREATE",
"eventCategory": "INVOICE",
"resourceId": "xxxxxxx"
}
]
So to get the target invoice that has just been created:
invoice_id = event.get("resourceId")
invoice = xero.invoices.get(invoice_id)
Copy Invoice to Another Account
To make the copy operation successful, it’s not just as easy as using a create API against the target account, other requirements are needed:
- the line items exist with the same item code in target account
- the contract exist or not (will affect the parameters to create/update the invoice)
- credit notes need to be manually handled
The create API is:
xero.invoices.put(new_invoice)
The update API is:
xero.invoices.save(updated_invoice)
The structures of new_invoice
and updated_invoice
are quite the same as what the response of a GET API, you can check the details doc here, but to make the copy work, we need to tweak the structure a bit.
Update Line Items
One thing needs to be made sure is the item codes exist in both account. After that, you need to remove LineItemID
, TaxType
and TaxAmount
from the line items:
# update line items
invoice.update(
LineItems=remove_line_item_id_from_line_items(
remove_tax_rate_from_line_items(invoice.get('LineItems', []))
)
)
Update Contract
If an contract exists, only the ContactID
is needed to pass to the API:
# check if the contact exists
existing_contact = target_xero.contacts.filter(Name=contact_name)
if len(existing_contact) > 0:
current_app.logger.debug(
f'Xero webhook invoice tranfer: contact {contact_name} already exists'
)
existing_contact = existing_contact[0]
invoice.update(dict(
Contact={'ContactID': existing_contact.get('ContactID')}
))
You can see here it uses Name
as the filter to find Contact, since in Xero, the name for contract is unique.
Credit Notes
The credit notes will not be automatically copied through the APIs, a manual creation and allocation is required.
Before credit notes, the invoice should be created or updated first and the credit Note should be removed from the parameters.
invoice.pop('CreditNotes', None)
After the update or create of the invoice finishs, create the credit notes and allocate to the invoice:
# create credit note
new_credit_note = create_credit_note(
target_xero,
credit_note_obj,
target_contact
)
# allocate the credit to target invoice
allocate_credit_note_payment(
target_xero,
new_credit_note[0].get('CreditNoteID'),
[
dict(
AppliedAmount=allocation.get('Amount'),
Invoice=dict(InvoiceID=invoice_id)
) for allocation in credit_note_item[0].get('Allocations')
]
)
One hack here is that PyXero does not support Allocation API, a work around here needs to be use:
def allocate_credit_note_payment(xero, credit_note_guid, allocations):
"""
Must pass in xero as it needs credentials if using public method.
allocations should be an array of dictionaries containing amount,
invoice:invoice idXero GUIDs for the contacts.
The request should look like:
PUT CreditNotes/a1665e41-719b-400d-ae59-bc92655d8366/Allocations
<Allocations>
<Allocation>
<AppliedAmount>60.50</AppliedAmount>
<Invoice>
<InvoiceID>f5832195-5cd3-4660-ad3f-b73d9c64f263</InvoiceID>
</Invoice>
</Allocation>
</Allocations>
"""
# Store the original endpoint base_url
old_base_url = xero.creditnotes.base_url
old_name = xero.creditnotes.name
old_singular = xero.creditnotes.singular
result = None
# Call the API
try:
xero.creditnotes.base_url\
= '{}/CreditNotes/{}'.format(old_base_url, credit_note_guid)
xero.creditnotes.name = 'Allocations'
xero.creditnotes.singular = 'Allocation'
result = xero.creditnotes.put(allocations)
except Exception as e:
raise e
finally:
# Reset the base_url
xero.creditnotes.base_url = old_base_url
xero.creditnotes.name = old_name
xero.creditnotes.singular = old_singular
return result
Done. Hope it helps.