Find everything you need to get certified on Fabric—skills challenges, live sessions, exam prep, role guidance, and more.
Get startedGrow your Fabric skills and prepare for the DP-600 certification exam by completing the latest Microsoft Fabric challenge.
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.
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") }
];
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.
@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)
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") }
];
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
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.
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
User | Count |
---|---|
14 | |
5 | |
2 | |
1 | |
1 |
User | Count |
---|---|
15 | |
4 | |
3 | |
3 | |
2 |