So this is an article expanding on my article Zoho Deluge - Connect to Xero API and explores how instead of a schedule, we can get Xero to tell ZohoCRM whenever an invoice or contact gets updated in Xero.
Why?
I used to use ZohoCRM schedules to pull when an invoice has been paid, but ZohoCRM schedules run only every 2 hours. You can set up 2 schedules: one that runs on even hours and one that runs on odd hours to change this to every hour. It's not enough for some, so the below details on how you can get Xero to tell Zoho when a contact or invoice has been updated immediately.
How?
Here's an overview of how we achieve this:
- Set up a Zoho CRM function to receive the webhook.
- Setup a webhook in Xero.
- [Optional for Method #2] Setup a PHP script on a server that will validate the Xero webhook.
Setup a Zoho CRM Function:
- Login to Zoho CRM > Setup > Functions > New Function
- Function Name: fn_API_ReceiveXeroWebhook
- Display Name: FN - Xero - Receive Webhook
- Description: Function used to receive webhooks from Xero and parse these into data for invoices and contacts.
- Category: Standalone
- Click on Create
- Give it the parameter: crmAPIRequest (string)
- Dump in the following code:copyraw
// enter the webhooks key for your app (in Xero under Manage App > Webhooks) v_Webhook_Key = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHI+111122223333444455556666777788889/a1b2c3d4e5f6g7h8i9=="; // // receive parameter crmAPIRequest (of datatype string in your function) m_Payload = crmAPIRequest; // // initialize response variable m_Response = Map(); // // get xero signature v_XeroSignature = "SIGNATURE_FAIL"; if(!isnull(m_Payload.get("headers"))) { if(!isnull(m_Payload.get("headers").get("x-xero-signature"))) { v_XeroSignature = m_Payload.get("headers").get("x-xero-signature"); } } // // encrypt body with Sha-256 v_WebhookBodyHMACSHA256 = "ENCRYPTION_FAIL"; v_ResponseCode = 401; if(!isnull(m_Payload.get("body"))) { v_WebhookBodyHMACSHA256 = zoho.encryption.hmacsha256(v_Webhook_Key,m_Payload.get("body")); } if(v_WebhookBodyHMACSHA256 == v_XeroSignature) { v_ResponseCode = 200; } m_Response.put("status_code",v_ResponseCode); // // return return {"crmAPIResponse":m_Response};
- // enter the webhooks key for your app (in Xero under Manage App > Webhooks)
- v_Webhook_Key = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHI+111122223333444455556666777788889/a1b2c3d4e5f6g7h8i9==";
- //
- // receive parameter crmAPIRequest (of datatype string in your function)
- m_Payload = crmAPIRequest;
- //
- // initialize response variable
- m_Response = Map();
- //
- // get xero signature
- v_XeroSignature = "SIGNATURE_FAIL";
- if(!isnull(m_Payload.get("headers")))
- {
- if(!isnull(m_Payload.get("headers").get("x-xero-signature")))
- {
- v_XeroSignature = m_Payload.get("headers").get("x-xero-signature");
- }
- }
- //
- // encrypt body with Sha-256
- v_WebhookBodyHMACSHA256 = "ENCRYPTION_FAIL";
- v_ResponseCode = 401;
- if(!isnull(m_Payload.get("body")))
- {
- v_WebhookBodyHMACSHA256 = zoho.encryption.hmacsha256(v_Webhook_Key,m_Payload.get("body"));
- }
- if(v_WebhookBodyHMACSHA256 == v_XeroSignature)
- {
- v_ResponseCode = 200;
- }
- m_Response.put("status_code",v_ResponseCode);
- //
- // return
- return {"crmAPIResponse":m_Response};
- Save this and return to the list of functions
- Hover over the function you just created, click on the 3 dots/ellipsis and select "REST API"
- Switch on the API Key and copy the URL to clipboard or a text editor
- Click on "Save"
- Login to the page https://developer.xero.com and click on "My Apps".
- Select the app you are going to setup a webhook for (see my Zoho Deluge - Connect to Xero API article for setting up an app)
- Select "Webhooks" in the left sidebar menu
- Tick "Contacts" and "Invoices" and enter the Delivery URL as the REST API Function you noted above:
- Copy the Webhooks key on the Xero page into your CRM function replacing the value in Line 2 of my code snippet above. > Save the CRM function.
- Click on "Save" on the Xero page
- Click on "Send 'Intent to receive'"
- You should get an OK status:
- Go back into your CRM function and edit the function to do what you need it to (eg. update a contact or invoice), I'm putting what I usually need to do here as a template:copyraw
// // ******************************************************************************* // Validate Xero webhook // // enter the webhooks key for your app (in Xero under Manage App > Webhooks) v_Webhook_Key = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHI+111122223333444455556666777788889/a1b2c3d4e5f6g7h8i9=="; // // receive parameter crmAPIRequest (of datatype string in your function) m_Payload = crmAPIRequest; // // initialize response variable m_Response = Map(); // // get xero signature v_XeroSignature = "SIGNATURE_FAIL"; if(!isnull(m_Payload.get("headers"))) { if(!isnull(m_Payload.get("headers").get("x-xero-signature"))) { v_XeroSignature = m_Payload.get("headers").get("x-xero-signature"); } } // // encrypt body to check against the signature v_WebhookBodyHMACSHA256 = "ENCRYPTION_FAIL"; v_ResponseCode = 401; if(!isnull(m_Payload.get("body"))) { v_WebhookBodyHMACSHA256 = zoho.encryption.hmacsha256(v_Webhook_Key,m_Payload.get("body")); } if(v_WebhookBodyHMACSHA256 == v_XeroSignature) { v_ResponseCode = 200; } m_Response.put("status_code",v_ResponseCode); // // ******************************************************************************* // Generate your access token to connect to Xero and query Xero records // See my article: https://www.joellipman.com/articles/crm/zoho/zoho-deluge-sync-to-xero-api.html // v_TenantID = ""; v_AccessToken = ""; r_Response = getUrl("https://www.zohoapis.com/crm/v2/functions/fn_api_getxeroaccesstoken/actions/execute?auth_type=apikey&zapikey=<your_zapi_key>"); if(!isnull(r_Response.toMap().get("details"))) { if(!isnull(r_Response.toMap().get("details").get("output"))) { v_AccessToken = r_Response.toMap().get("details").get("output"); } } m_Header = Map(); m_Header.put("Authorization","Bearer " + v_AccessToken); m_Header.put("Accept","application/json"); m_Header.put("Xero-tenant-id",v_TenantID); // // ******************************************************************************* // Parse body of Xero webhook and update respective CRM record // m_Body = ifnull(m_Payload.get("body"),m_Blank); // // check if events was in the payload if(!isnull(m_Body.get("events"))) { // // parse events out of the webhook (a list) l_Events = m_Body.get("events").toJSONList(); // // loop through the events for each r_Event in l_Events { if(!isnull(r_Event.get("eventCategory"))) { // // event type will be UPDATE and eventCategory will be our module v_Event = r_Event.get("eventCategory") + " " + r_Event.get("eventType"); // // get the xero ID of the record (hexadecimal) v_Xero_ResourceID = r_Event.get("resourceId"); // // do different things based on the category of the update if(r_Event.get("eventCategory").containsIgnoreCase("CONTACT")) { // get contact record from Xero to see what changed... r_XeroContact = invokeurl [ url :v_DataEndpoint + "/Contacts/" + v_Xero_ResourceID type :GET headers:m_Header ]; // if(!isnull(r_XeroContact.get("Contacts"))) { for each r_ThisContact in r_XeroContact.get("Contacts") { // // checks if in system already (update or create) if(!isnull(r_ThisContact.get("ContactID"))) { // check if already Xero Ref ID exists in CRM Contacts v_CrmContactID = 0; l_SearchResultsByRef = zoho.crm.searchRecords("Contacts","Xero_Ref_ID:equals:" + v_Xero_ResourceID); for each r_Result1 in l_SearchResultsByRef { if(!isnull(r_Result1.get("id"))) { v_CrmContactID = r_Result1.get("id").toLong(); v_ZohoModule = "Contacts"; } } // if(v_CrmContactID != 0) { // parse and build up CRM record update here } else { // parse and build up CRM record creation here } } } } } else if(r_Event.get("eventCategory").containsIgnoreCase("INVOICE")) { // get invoice record from Xero to see what changed... r_XeroInvoice = invokeurl [ url :v_DataEndpoint + "/Invoices/" + v_Xero_ResourceID type :GET headers:m_Header ]; // if(!isnull(r_XeroInvoice.get("Invoices"))) { for each r_ThisInvoice in r_XeroInvoice.get("Invoices") { if(!isnull(r_ThisInvoice.get("InvoiceID"))) { // // find the invoice in CRM v_CrmInvoiceID = 0; l_SearchCrmByXeroID = zoho.crm.searchRecords("Invoices","Xero_Ref_ID:equals:" + v_Xero_ResourceID); for each r_Result1 in l_SearchCrmByXeroID { if(!isnull(r_Result1.get("id"))) { v_CrmInvoiceID = r_Result1.get("id").toLong(); v_ZohoModule = "Invoices"; } } // if(v_CrmInvoiceID != 0) { // parse and build up CRM record update here } else { // parse and build up CRM record creation here } } } } } } } } // // return return {"crmAPIResponse":m_Response};
- //
- // *******************************************************************************
- // Validate Xero webhook
- //
- // enter the webhooks key for your app (in Xero under Manage App > Webhooks)
- v_Webhook_Key = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHI+111122223333444455556666777788889/a1b2c3d4e5f6g7h8i9==";
- //
- // receive parameter crmAPIRequest (of datatype string in your function)
- m_Payload = crmAPIRequest;
- //
- // initialize response variable
- m_Response = Map();
- //
- // get xero signature
- v_XeroSignature = "SIGNATURE_FAIL";
- if(!isnull(m_Payload.get("headers")))
- {
- if(!isnull(m_Payload.get("headers").get("x-xero-signature")))
- {
- v_XeroSignature = m_Payload.get("headers").get("x-xero-signature");
- }
- }
- //
- // encrypt body to check against the signature
- v_WebhookBodyHMACSHA256 = "ENCRYPTION_FAIL";
- v_ResponseCode = 401;
- if(!isnull(m_Payload.get("body")))
- {
- v_WebhookBodyHMACSHA256 = zoho.encryption.hmacsha256(v_Webhook_Key,m_Payload.get("body"));
- }
- if(v_WebhookBodyHMACSHA256 == v_XeroSignature)
- {
- v_ResponseCode = 200;
- }
- m_Response.put("status_code",v_ResponseCode);
- //
- // *******************************************************************************
- // Generate your access token to connect to Xero and query Xero records
- // See my article: https://www.joellipman.com/articles/crm/zoho/zoho-deluge-sync-to-xero-api.html
- //
- v_TenantID = "";
- v_AccessToken = "";
- r_Response = getUrl("https://www.zohoapis.com/crm/v2/functions/fn_api_getxeroaccesstoken/actions/execute?auth_type=apikey&zapikey=<your_zapi_key>");
- if(!isnull(r_Response.toMap().get("details")))
- {
- if(!isnull(r_Response.toMap().get("details").get("output")))
- {
- v_AccessToken = r_Response.toMap().get("details").get("output");
- }
- }
- m_Header = Map();
- m_Header.put("Authorization","Bearer " + v_AccessToken);
- m_Header.put("Accept","application/json");
- m_Header.put("Xero-tenant-id",v_TenantID);
- //
- // *******************************************************************************
- // Parse body of Xero webhook and update respective CRM record
- //
- m_Body = ifnull(m_Payload.get("body"),m_Blank);
- //
- // check if events was in the payload
- if(!isnull(m_Body.get("events")))
- {
- //
- // parse events out of the webhook (a list)
- l_Events = m_Body.get("events").toJSONList();
- //
- // loop through the events
- for each r_Event in l_Events
- {
- if(!isnull(r_Event.get("eventCategory")))
- {
- //
- // event type will be UPDATE and eventCategory will be our module
- v_Event = r_Event.get("eventCategory") + " " + r_Event.get("eventType");
- //
- // get the xero ID of the record (hexadecimal)
- v_Xero_ResourceID = r_Event.get("resourceId");
- //
- // do different things based on the category of the update
- if(r_Event.get("eventCategory").containsIgnoreCase("CONTACT"))
- {
- // get contact record from Xero to see what changed...
- r_XeroContact = invokeUrl
- [
- url :v_DataEndpoint + "/Contacts/" + v_Xero_ResourceID
- type :GET
- headers:m_Header
- ];
- //
- if(!isnull(r_XeroContact.get("Contacts")))
- {
- for each r_ThisContact in r_XeroContact.get("Contacts")
- {
- //
- // checks if in system already (update or create)
- if(!isnull(r_ThisContact.get("ContactID")))
- {
- // check if already Xero Ref ID exists in CRM Contacts
- v_CrmContactID = 0;
- l_SearchResultsByRef = zoho.crm.searchRecords("Contacts","Xero_Ref_ID:equals:" + v_Xero_ResourceID);
- for each r_Result1 in l_SearchResultsByRef
- {
- if(!isnull(r_Result1.get("id")))
- {
- v_CrmContactID = r_Result1.get("id").toLong();
- v_ZohoModule = "Contacts";
- }
- }
- //
- if(v_CrmContactID != 0)
- {
- // parse and build up CRM record update here
- }
- else
- {
- // parse and build up CRM record creation here
- }
- }
- }
- }
- }
- else if(r_Event.get("eventCategory").containsIgnoreCase("INVOICE"))
- {
- // get invoice record from Xero to see what changed...
- r_XeroInvoice = invokeUrl
- [
- url :v_DataEndpoint + "/Invoices/" + v_Xero_ResourceID
- type :GET
- headers:m_Header
- ];
- //
- if(!isnull(r_XeroInvoice.get("Invoices")))
- {
- for each r_ThisInvoice in r_XeroInvoice.get("Invoices")
- {
- if(!isnull(r_ThisInvoice.get("InvoiceID")))
- {
- //
- // find the invoice in CRM
- v_CrmInvoiceID = 0;
- l_SearchCrmByXeroID = zoho.crm.searchRecords("Invoices","Xero_Ref_ID:equals:" + v_Xero_ResourceID);
- for each r_Result1 in l_SearchCrmByXeroID
- {
- if(!isnull(r_Result1.get("id")))
- {
- v_CrmInvoiceID = r_Result1.get("id").toLong();
- v_ZohoModule = "Invoices";
- }
- }
- //
- if(v_CrmInvoiceID != 0)
- {
- // parse and build up CRM record update here
- }
- else
- {
- // parse and build up CRM record creation here
- }
- }
- }
- }
- }
- }
- }
- }
- //
- // return
- return {"crmAPIResponse":m_Response};
If you don't get an OK message then check your CRM code matches as above (with different v_Webhook_Key). If it still doesn't work then try solution #2: using a third-party server detailed further in this article.
Solution #2: Using Third-Party Server
If this doesn't work, you will need to consider using a third-party server (my example here is a LAMP server):
- Complete webhook setup by passing 'Intent to receive' required stage.
Xero will test both a valid and invalid signed webhook. So I'm going to cheat, by setting up a PHP script on a server we own to return a 200 status:
- Have a hosting server ready which has SSL installed.
- Add a PHP script with the following code (change the webhooks key to your own - and REST API function URL):copyraw
<?php // enter the webhooks key for your app (in Xero under Manage App > Webhooks) $v_Xero_Webhooks_Key = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHI+111122223333444455556666777788889/a1b2c3d4e5f6g7h8i9=="; // get the headers $a_Headers = getallheaders(); $v_Xero_Signature = $a_Headers[ 'x-xero-signature' ]; // get the body $j_Body = @file_get_contents( "php://input" ); // compute a sha256 hash (HMACSHA256) using the body and your secret key $v_Hashed_Payload = base64_encode( hash_hmac( "sha256", $j_Body, $v_Xero_Webhooks_Key, true) ); // timing attack safe string comparison if ( hash_equals( $v_Xero_Signature, $v_Hashed_Payload ) ){ // return response to Xero header( "HTTP/1.1 200 OK" ); http_response_code( 200 ); // took me a few hours to work out this code and get Xero to approve of this webhook so let's now push the data to Zoho $v_Zoho_Url = "https://www.zohoapis.com/crm/v2/functions/fn_api_receivexerowebhook/actions/execute?auth_type=apikey&zapikey=<YOUR_ZOHO_API_KEY>"; // build and send via cURL $ch = curl_init(); curl_setopt( $ch, CURLOPT_HEADER, 0 ); curl_setopt( $ch, CURLOPT_URL, $v_Zoho_Url ); curl_setopt( $ch, CURLOPT_POST, 1 ); curl_setopt( $ch, CURLOPT_POSTFIELDS, $j_Body ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); $r_Zoho_Response = curl_exec( $ch ); curl_close( $ch ); }else{ // return response to Xero (used as Xero will test both a valid and invalid request) header('HTTP/1.1 401 Unauthorized'); http_response_code(401); } ?>
- <?php
- // enter the webhooks key for your app (in Xero under Manage App > Webhooks)
- $v_Xero_Webhooks_Key = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHI+111122223333444455556666777788889/a1b2c3d4e5f6g7h8i9==";
- // get the headers
- $a_Headers = getallheaders();
- $v_Xero_Signature = $a_Headers[ 'x-xero-signature' ];
- // get the body
- $j_Body = @file_get_contents( "php://input" );
- // compute a sha256 hash (HMACSHA256) using the body and your secret key
- $v_Hashed_Payload = base64_encode( hash_hmac( "sha256", $j_Body, $v_Xero_Webhooks_Key, true) );
- // timing attack safe string comparison
- if ( hash_equals( $v_Xero_Signature, $v_Hashed_Payload ) ){
- // return response to Xero
- header( "HTTP/1.1 200 OK" );
- http_response_code( 200 );
- // took me a few hours to work out this code and get Xero to approve of this webhook so let's now push the data to Zoho
- $v_Zoho_Url = "https://www.zohoapis.com/crm/v2/functions/fn_api_receivexerowebhook/actions/execute?auth_type=apikey&zapikey=<YOUR_ZOHO_API_KEY>";
- // build and send via cURL
- $ch = curl_init();
- curl_setopt( $ch, CURLOPT_HEADER, 0 );
- curl_setopt( $ch, CURLOPT_URL, $v_Zoho_Url );
- curl_setopt( $ch, CURLOPT_POST, 1 );
- curl_setopt( $ch, CURLOPT_POSTFIELDS, $j_Body );
- curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
- $r_Zoho_Response = curl_exec( $ch );
- curl_close( $ch );
- }else{
- // return response to Xero (used as Xero will test both a valid and invalid request)
- header('HTTP/1.1 401 Unauthorized');
- http_response_code(401);
- }
- ?>
- Change the Delivery URL to the location of this PHP script and click on "Save"
- Click on "Send 'intent to receive' and Xero will test both a valid and invalid signed webhook.
- You should get an OK status:
- Now return to your ZohoCRM function and here's the code I'm using for this:
copyraw
// // init m_Blank = Map(); m_Payload = crmAPIRequest; m_Body = ifnull(m_Payload.get("body"),m_Blank); // // check if events was in the payload if(!isnull(m_Body.get("events"))) { // // parse events out of the webhook (a list) l_Events = m_Body.get("events").toJSONList(); // // loop through the events for each r_Event in l_Events { if(!isnull(r_Event.get("eventCategory"))) { // // event type will be UPDATE and eventCategory will be our module v_Event = r_Event.get("eventCategory") + " " + r_Event.get("eventType"); // // get the xero ID of the record (hexadecimal) v_Xero_ResourceID = r_Event.get("resourceId"); // // do different things based on the category of the update if(r_Event.get("eventCategory").containsIgnoreCase("CONTACT")) { // get contact record from Xero to see what changed... } else if(r_Event.get("eventCategory").containsIgnoreCase("INVOICE")) { // get invoice record from Xero to see what changed... } } } } return "";
- //
- // init
- m_Blank = Map();
- m_Payload = crmAPIRequest;
- m_Body = ifnull(m_Payload.get("body"),m_Blank);
- //
- // check if events was in the payload
- if(!isnull(m_Body.get("events")))
- {
- //
- // parse events out of the webhook (a list)
- l_Events = m_Body.get("events").toJSONList();
- //
- // loop through the events
- for each r_Event in l_Events
- {
- if(!isnull(r_Event.get("eventCategory")))
- {
- //
- // event type will be UPDATE and eventCategory will be our module
- v_Event = r_Event.get("eventCategory") + " " + r_Event.get("eventType");
- //
- // get the xero ID of the record (hexadecimal)
- v_Xero_ResourceID = r_Event.get("resourceId");
- //
- // do different things based on the category of the update
- if(r_Event.get("eventCategory").containsIgnoreCase("CONTACT"))
- {
- // get contact record from Xero to see what changed...
- }
- else if(r_Event.get("eventCategory").containsIgnoreCase("INVOICE"))
- {
- // get invoice record from Xero to see what changed...
- }
- }
- }
- }
- return "";
- You can test this by updating a contact or draft invoice record.
Additional Note(s):
- You should have a field (single-line) called Xero Ref ID on both the contact/invoice module for referring to so as to push/pull deltas.
- After all that work, you only get the ID of a record that was changed. Here's a sample response:
copyraw
{ "events": [ { "resourceUrl": "https://api.xero.com/api.xro/2.0/Contacts/aaaabbbb-1111-cccc-2222-d3e4f5d3e4f5", "resourceId": "aaaabbbb-1111-cccc-2222-d3e4f5d3e4f5", "eventDateUtc": "2022-01-10T17:53:48.886", "eventType": "UPDATE", "eventCategory": "CONTACT", "tenantId": "99998888-ffff-7777-eeee-d6e5f4d6e5f4", "tenantType": "ORGANISATION" } ], "firstEventSequence": 5, "lastEventSequence": 5, "entropy": "ABCDEFGHIJKLMNOPQRST" }
- {
- "events": [
- {
- "resourceUrl": "https://api.xero.com/api.xro/2.0/Contacts/aaaabbbb-1111-cccc-2222-d3e4f5d3e4f5",
- "resourceId": "aaaabbbb-1111-cccc-2222-d3e4f5d3e4f5",
- "eventDateUtc": "2022-01-10T17:53:48.886",
- "eventType": "UPDATE",
- "eventCategory": "CONTACT",
- "tenantId": "99998888-ffff-7777-eeee-d6e5f4d6e5f4",
- "tenantType": "ORGANISATION"
- }
- ],
- "firstEventSequence": 5,
- "lastEventSequence": 5,
- "entropy": "ABCDEFGHIJKLMNOPQRST"
- }
Source(s):
- Zoho Developer Docs - REST API Functions (info on crmAPIRequest and crmAPIResponse)
- Zoho Deluge - HMAC-SHA256 (info on encrypting SHA256 and return Base64)
- Xero Developer Docs - Xero API webhooks
- Xero Developer Docs - Documentation API Accounting
- Stack Overflow - HMAC-SHA-256 in PHP
- PHP Manual - hash_hmac
- PHP Manual - hash_equals
- PHP Manual - http_response_code