Skip to main content
cancel
Showing results for 
Search instead for 
Did you mean: 

Grow your Fabric skills and prepare for the DP-600 certification exam by completing the latest Microsoft Fabric challenge.

Reply

Building Custom Connector for Xero API

Hi Community,

I am aiming to get data from the Xero API into Power BI for my Organisation. To this end I need to create a custom connector as PBI doesn't have a certified connector for Xero (the exisiting apps are too limited for my use case). I have some experience in Power BI but this is my first attempt at building a custom connector. The connector is written in M language.

As the Xero API requires OAuth2 authentication this was the first hurdle. I used the below resource to create a custom connector in Visual Studio using the Power Query SDK template. I believe I have set up authentication successfully as the connector allows me to log into my Xero account and 'allow access' to the requested resource. I know the connection is successful because in Xero my app appears in Settings>Connected Apps.

https://github.com/jussiroine/OuraCloudConnector/blob/master/OuraCloudConnector/OuraCloudConnector.p...

However. Once authenticated I am receiving an error; Details: "Access to the resource is forbidden."

Possible Causes I can think of:
- I'm using The Code Flow not PKCE Flow (I assumed this was correct as I'm connecting from Power BI Desktop)
- Error in my code (I'm inexperienced in writing M language)
- The API url I'm sending is incomplete or incorrect (I'm currently trying: https://api.xero.com/api.xro/2.0/Reports/AgedReceivablesByContact?fromDate=2020-01-01&toDate=2021-01... )

I have checked that my user permissions are high enough to access the data I'm requesting. I have also checked that the scope includes the required data. Can anyone advise if they've had success building a custom connector for Xero in M or offer some advice to resolve this error?

Thank you!

// This file contains your Data Connector logic
section JaimesXeroConnector;

//JaimesXeroConnector OAuth2 values;

client_id = Text.FromBinary(Extension.Contents("client_id.txt"));
client_secret = Text.FromBinary(Extension.Contents("client_secret.txt"));
redirect_uri = "https://oauth.powerbi.com/views/oauthredirect.html";
token_uri = "https://identity.xero.com/connect/token";
authorize_uri = "https://login.xero.com/identity/connect/authorize";
logout_uri = "https://login.microsoftonline.com/logout.srf";
scopes = {"offline_access openid profile email accounting.transactions accounting.reports.read accounting.transactions.read accounting.reports.read accounting.journals.read accounting.settings accounting.settings.read accounting.contacts accounting.contacts.read accounting.attachments accounting.attachments.read assets projects"
};
System.Net.ServicePointManager.SecurityProtocol= "SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12";

// Login modal window dimensions
windowWidth = 720;
windowHeight = 1024;

[DataSource.Kind="JaimesXeroConnector", Publish="JaimesXeroConnector.Publish"]
shared JaimesXeroConnector.Contents = (url as text) =>
    let
        source = Json.Document(Web.Contents(url))
    in
        source;


// Data Source Kind description
JaimesXeroConnector= [
    TestConnection = (dataSourcePath) => { "JaimesXeroConnector.Contents", dataSourcePath },
    Authentication = [
        OAuth = [
            StartLogin=StartLogin,
            FinishLogin=FinishLogin,
            Refresh=Refresh,
            Logout=Logout
        ]
    ],
    Label = Extension.LoadString("DataSourceLabel")
];

// Data Source UI publishing description
JaimesXeroConnector.Publish = [
    Beta = true,
    Category = "Other",
    ButtonText = { Extension.LoadString("ButtonTitle"), Extension.LoadString("ButtonHelp") },
    LearnMoreUrl = "https://powerbi.microsoft.com/",
    SourceImage = JaimesXeroConnector.Icons,
    SourceTypeImage = JaimesXeroConnector.Icons
];

// Helper functions for OAuth2: StartLogin, FinishLogin, Refresh, Logout
StartLogin = (resourceUrl, state, display) =>
    let
        authorizeUrl = authorize_uri & "?" & Uri.BuildQueryString([
            response_type = "code",
            client_id = client_id,  
            redirect_uri = redirect_uri,
            state = state,
            scope = scopes
        ])
    in
        [
            LoginUri = authorizeUrl,
            CallbackUri = redirect_uri,
            WindowHeight = 720,
            WindowWidth = 1024,
            Context = null
        ];

FinishLogin = (context, callbackUri, state) =>
    let
        // parse the full callbackUri, and extract the Query string
        parts = Uri.Parts(callbackUri)[Query],
        // if the query string contains an "error" field, raise an error
        // otherwise call TokenMethod to exchange our code for an access_token
        result = if (Record.HasFields(parts, {"error", "error_description"})) then 
                    error Error.Record(parts[error], parts[error_description], parts)
                 else
                    TokenMethod("authorization_code", "code", parts[code])
    in
        result;

Refresh = (resourceUrl, refresh_token) => TokenMethod("refresh_token", "refresh_token", refresh_token);

Logout = (token) => logout_uri;

// see "Exchange code for access token: POST /oauth/token" for details
TokenMethod = (grantType, tokenField, code) =>
    let
        queryString = [
            grant_type = "authorization_code",
            redirect_uri = redirect_uri,
            client_id = client_id,
            client_secret = client_secret
        ],
        queryWithCode = Record.AddField(queryString, tokenField, code),

        tokenResponse = Web.Contents(token_uri, [
            Content = Text.ToBinary(Uri.BuildQueryString(queryWithCode)),
            Headers = [
                #"Content-type" = "application/x-www-form-urlencoded",
                #"Accept" = "application/json"
            ],
            ManualStatusHandling = {400} 
        ]),
        body = Json.Document(tokenResponse),
        result = if (Record.HasFields(body, {"error", "error_description"})) then 
                    error Error.Record(body[error], body[error_description], body)
                 else
                    body
    in
        result;

Value.IfNull = (a, b) => if a <> null then a else b;

GetScopeString = (scopes as list, optional scopePrefix as text) as text =>
    let
        prefix = Value.IfNull(scopePrefix, ""),
        addPrefix = List.Transform(scopes, each prefix & _),
        asText = Text.Combine(addPrefix, " ")
    in
        asText;
        

JaimesXeroConnector.Icons = [
    Icon16 = { Extension.Contents("JaimesXeroConnector16.png"), Extension.Contents("JaimesXeroConnector20.png"), Extension.Contents("JaimesXeroConnector24.png"), Extension.Contents("JaimesXeroConnector32.png") },
    Icon32 = { Extension.Contents("JaimesXeroConnector32.png"), Extension.Contents("JaimesXeroConnector40.png"), Extension.Contents("JaimesXeroConnector48.png"), Extension.Contents("JaimesXeroConnector64.png") }
];
8 REPLIES 8
Tomhayw
Helper I
Helper I

Looks good Jamie, would it be possible for you to talk me through how you got this to work? I'm rather new to PowerBI and so my knowledge doesn't extend this far, but I know that I want an API between Xero and PowerBI that would import all tables from Xero.

CalebGuthrie
Frequent Visitor

@jaime_blackwell did you ever get this up and running? I've followed your posts across Google and stumbled on your code in github. My company is needing to build reporting off of our Xero account which is going to require setting up a custom connector to handle the OAuth2 protocol.

I've replicated your code in github but getting an error after login "The field 'access_token' of the record wasn't found". 

Did you ever find a fix to this? I feel like I'm so close!!! (thanks to you sharing your work)

CalebGuthrie_0-1638901525299.png


Code below for reference:

// This file contains your Data Connector logic
section EleganceXeroConnector;

//EleganceXeroConnector OAuth2 values;

client_id = "xxxxxxxxxxxxxxxxx";
client_secret = "xxxxxxxxxxxxxxxxx";
redirect_uri = "https://oauth.powerbi.com/views/oauthredirect.html";
token_uri = "https://identity.xero.com/connect/token?=";
authorize_uri = "https://login.xero.com/identity/connect/authorize";
logout_uri = "https://login.microsoftonline.com/logout.srf";
connection_uri = "https://api.xero.com/connections";
tenantid = Text.From(GetTenantId(connection_uri));

// Login modal window dimensions
windowWidth = 720;
windowHeight = 1024;

//OAuth2 Scopes
scope_prefix = "";
scopes = {
"offline_access",
"openid",
"profile",
"email",
"accounting.transactions.read",
"accounting.reports.read",
"accounting.journals.read",
"accounting.contacts.read",
"assets.read"
};

GetTenantId = (url as text) =>
let
Source = Web.Contents(url),
ImportedJSON = Json.Document(Source,1252),
ConvertedtoTable = Table.FromList(ImportedJSON, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
ExpandedColumn1 = Table.ExpandRecordColumn(ConvertedtoTable, "Column1", {"id", "authEventId", "tenantId", "tenantType", "tenantName", "createdDateUtc", "updatedDateUtc"}, {"id", "authEventId", "tenantId", "tenantType", "tenantName", "createdDateUtc", "updatedDateUtc"}),
ChangedType = Table.TransformColumnTypes(ExpandedColumn1,{{"id", type text}, {"authEventId", type text}, {"tenantId", type text}, {"tenantType", type text}, {"tenantName", type text}, {"createdDateUtc", type datetime}, {"updatedDateUtc", type datetime}}),
tenantId = Text.From(ChangedType{0}[tenantId])
in
tenantId;

[DataSource.Kind="EleganceXeroConnector", Publish="EleganceXeroConnector.Publish"]
shared EleganceXeroConnector.Contents = (url as text) =>
let
source = Json.Document(Web.Contents(url,[Headers = [#"xero-tenant-id"=tenantid,#"Accept" = "application/json"]]))
in
source;


// Data Source Kind description
EleganceXeroConnector= [
TestConnection = (DataSourcePath) =>
let
json = Json.Document(DataSourcePath),
server = json[server],
database = json[database]
in
try
{ "EleganceXeroConnector.Contents", server, database }
otherwise
let
message = Text.Format("Couldn't find entity.")
in
Diagnostics.Trace(TraceLevel.Error, message, () => error message, true )
,

Authentication = [
OAuth = [
StartLogin=StartLogin,
FinishLogin=FinishLogin,
Refresh=Refresh,
Logout=Logout
]
],
Label = Extension.LoadString("DataSourceLabel")
];

// Data Source UI publishing description
EleganceXeroConnector.Publish = [
Beta = true,
Category = "Other",
ButtonText = { Extension.LoadString("ButtonTitle"), Extension.LoadString("ButtonHelp") },
LearnMoreUrl = "https://powerbi.microsoft.com/",
SourceImage = EleganceXeroConnector.Icons,
SourceTypeImage = EleganceXeroConnector.Icons
];

// Helper functions for OAuth2: StartLogin, FinishLogin, Refresh, Logout
StartLogin = (resourceUrl, state, display) =>
let
authorizeUrl = authorize_uri & "?" & Uri.BuildQueryString([
response_type = "code",
client_id = client_id,
redirect_uri = redirect_uri,
state = state,
scope = GetScopeString(scopes, scope_prefix)
])
in
[
LoginUri = authorizeUrl,
CallbackUri = redirect_uri,
WindowHeight = 720,
WindowWidth = 1024,
Context = null
];

FinishLogin = (context, callbackUri, state) =>
let
// parse the full callbackUri, and extract the Query string
parts = Uri.Parts(callbackUri)[Query],
// if the query string contains an "error" field, raise an error
// otherwise call TokenMethod to exchange our code for an access_token
result = if (Record.HasFields(parts, {"error", "error_description"})) then
error Error.Record(parts[error], parts[error_description], parts)
else
TokenMethod("authorization_code", "code", parts[code])
in
result;

Refresh = (resourceUrl, refresh_token) => TokenMethod("refresh_token", "refresh_token", refresh_token);

 

Logout = (token) => logout_uri;



// see "Exchange code for access token: POST /oauth/token" for details
TokenMethod = (grantType, tokenField, code) =>
let
queryString = [
grant_type = "authorization_code",
redirect_uri = redirect_uri,
client_id = client_id,
client_secret = client_secret
],
queryWithCode = Record.AddField(queryString, tokenField, code),

tokenResponse = Web.Contents(token_uri, [
Content = Text.ToBinary(Uri.BuildQueryString(queryWithCode)),
Headers = [
#"authorization" = Text.Combine({"Basic ", "base64encode(", client_id, ":", client_secret, ")"} ),
// #"Xero-tenant-id" = "xxxxxxxxxxxxxxxxx",
#"Content-type" = "application/x-www-form-urlencoded",
#"Accept" = "application/json"
],
ManualStatusHandling = {400}
]),
body = Json.Document(tokenResponse),
result = if (Record.HasFields(body, {"error", "error_description"})) then
error Error.Record(body[error], body[error_description], body)
else
body
in
result;

Value.IfNull = (a, b) => if a <> null then a else b;

GetScopeString = (scopes as list, optional scopePrefix as text) as text =>
let
prefix = Value.IfNull(scopePrefix, ""),
addPrefix = List.Transform(scopes, each prefix & _),
asText = Text.Combine(addPrefix, " ")
in
asText;


EleganceXeroConnector.Icons = [
Icon16 = { Extension.Contents("EleganceXeroConnector16.png"), Extension.Contents("EleganceXeroConnector20.png"), Extension.Contents("EleganceXeroConnector24.png"), Extension.Contents("EleganceXeroConnector32.png") },
Icon32 = { Extension.Contents("EleganceXeroConnector32.png"), Extension.Contents("EleganceXeroConnector40.png"), Extension.Contents("EleganceXeroConnector48.png"), Extension.Contents("EleganceXeroConnector64.png") }
];

Hi @CalebGuthrie 
I did manage to get the connector working, I think the below is the updated code. I'll also update in Github shortly. 
Since then, I switched to using OData feeds for Xero data as my connector could only retrieve one payslip at a time. The provider of these feeds is ODataLink. https://odatalink.com/

// This file contains your Data Connector logic
section JaimesXeroConnector;

//JaimesXeroConnector OAuth2 values;
client_id = "3";
client_secret = "v";
redirect_uri = "https://oauth.powerbi.com/views/oauthredirect.html";
token_uri = "https://identity.xero.com/connect/token?=";
authorize_uri = "https://login.xero.com/identity/connect/authorize";
logout_uri = "https://login.microsoftonline.com/logout.srf";
connection_uri = "https://api.xero.com/connections"; //Jaime added 6.1.21
tenantid = Text.From(GetTenantId(connection_uri));

// Login modal window dimensions
windowWidth = 720;
windowHeight = 1024;

//OAuth2 Scopes
scope_prefix = "";
scopes = {
"offline_access",
"openid",
"profile",
"email",
"accounting.transactions.read",
"accounting.settings.read",
"accounting.reports.read",
"accounting.journals.read",
"accounting.contacts.read",
"assets.read",
"payroll.employees.read",
"payroll.payruns.read",
"payroll.payslip.read",
"payroll.settings.read",
"payroll.timesheets.read"
};


GetTenantId = (url as text) =>
let
Source = Web.Contents(url),
ImportedJSON = Json.Document(Source,1252),
ConvertedtoTable = Table.FromList(ImportedJSON, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
ExpandedColumn1 = Table.ExpandRecordColumn(ConvertedtoTable, "Column1", {"id", "authEventId", "tenantId", "tenantType", "tenantName", "createdDateUtc", "updatedDateUtc"}, {"id", "authEventId", "tenantId", "tenantType", "tenantName", "createdDateUtc", "updatedDateUtc"}),
ChangedType = Table.TransformColumnTypes(ExpandedColumn1,{{"id", type text}, {"authEventId", type text}, {"tenantId", type text}, {"tenantType", type text}, {"tenantName", type text}, {"createdDateUtc", type datetime}, {"updatedDateUtc", type datetime}}),
tenantId = Text.From(ChangedType{0}[tenantId])
in
tenantId;

[DataSource.Kind="JaimesXeroConnector", Publish="JaimesXeroConnector.Publish"] 
shared JaimesXeroConnector.Contents = (url as text) => 
    let 
        source = Json.Document(Web.Contents(url,[Headers = [#"xero-tenant-id"=tenantid,#"Accept" = "application/json"]])) 
    in 
        source;

// Data Source Kind description
JaimesXeroConnector= [
    TestConnection = (DataSourcePath) => 
        let
            json = Json.Document(DataSourcePath),
            server = json[server],
            database = json[database]
        in   
            try
                { "JaimesXeroConnector.Contents", server, database }
            otherwise
                let
                    message = Text.Format("Couldn't find entity.")
                in 
                    Diagnostics.Trace(TraceLevel.Error,  message, () => error message, true )
            ,

    Authentication = [
        OAuth = [
            StartLogin=StartLogin,
            FinishLogin=FinishLogin,
            Refresh=Refresh,
            Logout=Logout
        ]
    ],
    Label = Extension.LoadString("DataSourceLabel")
];

// Data Source UI publishing description
JaimesXeroConnector.Publish = [
    Beta = true,
    Category = "Other",
    ButtonText = { Extension.LoadString("ButtonTitle"), Extension.LoadString("ButtonHelp") },
    LearnMoreUrl = "https://powerbi.microsoft.com/",
    SourceImage = JaimesXeroConnector.Icons,
    SourceTypeImage = JaimesXeroConnector.Icons
];

// Helper functions for OAuth2: StartLogin, FinishLogin, Refresh, Logout
StartLogin = (resourceUrl, state, display) =>
    let
        authorizeUrl = authorize_uri & "?" & Uri.BuildQueryString([
            response_type = "code",
            client_id = client_id,  
            redirect_uri = redirect_uri,
            state = state,
            scope = GetScopeString(scopes, scope_prefix)
        ])
    in
        [
            LoginUri = authorizeUrl,
            CallbackUri = redirect_uri,
            WindowHeight = 720,
            WindowWidth = 1024,
            Context = null
        ];

FinishLogin = (context, callbackUri, state) =>
    let
        // parse the full callbackUri, and extract the Query string
        parts = Uri.Parts(callbackUri)[Query],
        // if the query string contains an "error" field, raise an error
        // otherwise call TokenMethod to exchange our code for an access_token
        result = if (Record.HasFields(parts, {"error", "error_description"})) then 
                    error Error.Record(parts[error], parts[error_description], parts)
                 else
                    TokenMethod("authorization_code", parts[code])
    in
        result;

Refresh = (resourceUrl, refresh_token) => TokenMethod("refresh_token", "refresh_token", refresh_token);

       

Logout = (token) => logout_uri;


        
// see "Exchange code for access token: POST /oauth/token" for details
TokenMethod = (grantType, code) =>
//TokenMethod = (grantType, tokenField, code) =>
    let
        query = [
//        queryString = [
            grant_type = grantType,
//            grant_type = "authorization_code",
            redirect_uri = redirect_uri,
            client_id = client_id,
            client_secret = client_secret
        ],
        queryWithCode = if(grantType = "refresh_token") then [refresh_token = code] else [code = code],
//        queryWithCode = Record.AddField(queryString, tokenField, code),

        tokenResponse = Web.Contents(token_uri, [
            Content = Text.ToBinary(Uri.BuildQueryString(query & queryWithCode)),
//            Content = Text.ToBinary(Uri.BuildQueryString(queryWithCode)),
//            Headers = [
//                #"authorization" = Text.Combine({"Basic ", "base64encode(", client_id, ":", client_secret, ")"} ), // JB
//                #"Content-type" = "application/x-www-form-urlencoded",
//                #"Accept" = "application/json"
//            ],
            Headers= [#"Content-type" = "application/x-www-form-urlencoded",#"Accept" = "application/json"],
            ManualStatusHandling = {400} 
        ]),
        body = Json.Document(tokenResponse),
                 result = if (Record.HasFields(body, {"error", "error_description"})) then 
                 error Error.Record(body[error], body[error_description], body) 
                         else
                    body
    in
        result;

Value.IfNull = (a, b) => if a <> null then a else b;

GetScopeString = (scopes as list, optional scopePrefix as text) as text =>
    let
        prefix = Value.IfNull(scopePrefix, ""),
        addPrefix = List.Transform(scopes, each prefix & _),
        asText = Text.Combine(addPrefix, " ")
    in
        asText;
        
        

JaimesXeroConnector.Icons = [
    Icon16 = { Extension.Contents("JaimesXeroConnector16.png"), Extension.Contents("JaimesXeroConnector20.png"), Extension.Contents("JaimesXeroConnector24.png"), Extension.Contents("JaimesXeroConnector32.png") },
    Icon32 = { Extension.Contents("JaimesXeroConnector32.png"), Extension.Contents("JaimesXeroConnector40.png"), Extension.Contents("JaimesXeroConnector48.png"), Extension.Contents("JaimesXeroConnector64.png") }
];



Anonymous
Not applicable

@jaime_blackwell I tried your code but getting an error in the login

Mark_001_0-1656708466530.png

 

can you help me out.

Hi Mark,

 

The main thing would be to check.

1) the client id as that is how the association is made in xero

2) the redirect url needs to match both in xero and your code

3) the scope may be wrong, you coudl try a shorter scope (e.g. bare minimum).
offline_access openid profile email accounting.transactions.read

 

Documentation/troubleshooting link.

https://developer.xero.com/documentation/guides/oauth2/troubleshooting

Hi Mark,

 

Nic here from OdataLink.

 

First off, (sorry about the plug), but if you want a simple way, try OdataLink.

https://odatalink.com/get-xero-data-in-power-bi/

 

From Jaime's main code, you'll want to ensure you edit the following two lines at the top of her code. You need to use your own client id and secret you registered with the xero app portal.

client_id = "3";
client_secret = "v";

 

Secondly, you need to use the following redirect uri that jaime used (unsure where she got that from). But this needs to match and be the same as how you setup your app.  You might just need to update your xero app listing to use the correct  value (it needs to be exactly as per below).

redirect_uri = "https://oauth.powerbi.com/views/oauthredirect.html";

 

From your screenshot, it's most likely related to three two values being wrong (either in your power query, or in the xer app).

 

Regards

 

Nic

Anonymous
Not applicable

I have changed client I'd and secret.

The issue is redirected URL it's the same in xero API but still facing the issue. I checked on the postman app rest is working fine.

 

Can't move on odatalink because highly paid.

Jeff_Acc_Addons
Frequent Visitor

Hi Jaime

Since your post was written there is now a certified connector which uses odata feeds to connect live to Xero data without any coding

https://apps.xero.com/au/app/odatalink

Jeff

Helpful resources

Announcements
RTI Forums Carousel3

New forum boards available in Real-Time Intelligence.

Ask questions in Eventhouse and KQL, Eventstream, and Reflex.

MayPowerBICarousel1

Power BI Monthly Update - May 2024

Check out the May 2024 Power BI update to learn about new features.