This is the API Call for a report.  It differs only slightly from a transaction query in that the request is not an SQL type. Instead it looks like calling a URL and passing the report request with options and authorization.  The application I needed to create is for a vendor detail balance report.  You might ask, why don't I just output an excel report. But in this case, I want to do a comparison of one company's vendor detail report and a second company's customer detail report.  It is a question of determining the transactions between two QBO companies intercompany balance.  I won't bore you with the last steps to do the comparison.  I will leave the eventual processing to you.  My blog will be about running the API on a report and parsing the result in JSON.  It is usually helpful to know what the report looks like in the GUI.  The reason is that if you don't modify the columns requested, it will look like the report in the GUI.

Lets get started.  We will call our program getvdb.prg. (Get Vendor Detail Balance)
We will need to construct a resulting table and so I added some records to my fieldlist.dbf.  For my proposes, I only need 5 fields.
Create table result (vendor c(40),docnumber c(20),amount n(12,2),balance n(12,2),txndate c(20),Transtype c(20))  I could do this in the code or I can call the build table routine

 **GETVDB.PRG**
SET SAFETY OFF
CLOSE DATA
CLEAR
SET TALK OFF
**log into TB first
mwho=SPACE(5)
DO selcust WITH "TB first"
IF mwho="X"
  CANCEL
ENDIF
IF mwho="NEW"
  mauthorize="Y"
  DO authorize WITH ""
ELSE
  mauthorize="N"
  @12,10 SAY "Authorize ?" GET mauthorize PICTURE "@m N,Y"
  READ
  IF mauthorize="Y"
    DO authorize WITH mwho
  ENDIF
ENDIF
*******This report shows every open item on every vendor
*******I could filter this for a specific vendor but I would need the vendor's ID to do that
mreport="VendorBalanceDetail?minorversion=75"
mfilename="Result"
**we are doing JSON
mxml=.f.
mentity="VendorBalance"
**you could just create the table here instead of this routing
**you might notice that there is less child stuff as there are no child records in reports
DO createtable WITH mentity,mfilename
DO makecallreport WITH mwho,mreport,mfilename

**Do whatever you need with the result.dbf

 

FUNCTION createtable
PARAMETER lcentity,lcfilename
USE fieldlist
SET FILTER TO entity=lcentity
mfieldcount=1
SCAN
   DIME marray(mfieldcount,4)
   marray(mfieldcount,1)=(colname)
   DO CASE
      CASE TYPE="String"
         marray(mfieldcount,2)="C"
         marray(mfieldcount,3)=VAL(MAXLEN)
         marray(mfieldcount,4)=0
      CASE TYPE="Number"
         marray(mfieldcount,2)="N"
         marray(mfieldcount,3)=12
         marray(mfieldcount,4)=2
         IF "," $ MAXLEN
          marray(mfieldcount,3)=VAL(LEFT(MAXLEN,AT(",",MAXLEN)-1))
          marray(mfieldcount,4)=VAL(SUBSTR(MAXLEN,AT(",",MAXLEN)+1))
        ENDIF
      CASE TYPE="Boulean"
         marray(mfieldcount,2)="L"
         marray(mfieldcount,3)=1
         marray(mfieldcount,4)=0
      ENDCASE
      mfieldcount=mfieldcount+1
ENDSCAN
USE
CREATE TABLE (lcfilename) FROM ARRAY marray
USE
 


The next step is the makecallreport.  This is almost identical to the makecall.prg for the first part which is about checking the tokens

PARAMETERS lcwho,mreport,lcfilename
*****************************
*This is now the api call for reports
**lets check the dates stored
*Enter the choice of which client and secret for each of my clients
USE oauth_tokens
COUNT FOR NOT EMPTY(atoken)
IF _TALLY = 0
   MESSAGEBOX("No OAuth tokens found.")
   RETURN
ENDIF
LOCATE for who=lcwho
lcaccesstoken = atoken
lcrefreshtoken = rtoken
dtaccessexpires = CTOT(aexpires)
dtrefreshexpires = CTOT(rexpires)
lcrealmid=TRIM(realmid)
USE
* Check if access token expired
IF DATETIME() >= dtaccessexpires
   mclientid = "your cliend id here"
   mclientsecret = "your client secret here"
   mtokenendpoint = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
   lohttp = CREATEOBJECT("WinHttp.WinHttpRequest.5.1")
   lohttp.OPEN("POST", mtokenendpoint, .F.)
   lohttp.setrequestheader("Content-type","application/x-www-form-urlencoded")
   lohttp.setrequestheader("Accept","Application/json")
   lcbody= ;
      "grant_type=refresh_token" + ;
      "&refresh_token="+lcrefreshtoken + ;
      "&client_id=" + mclientid + ;
      "&client_secret=" + mclientsecret
   lohttp.SEND(lcbody)
   IF lohttp.STATUS = 200
      lchtml = lohttp.responsetext
   ELSE
      MESSAGEBOX("Got nothing back from the exchange." + TRANSFORM(lohttp.STATUS))
      CANCEL
   ENDIF
   ?"Parsing the response"
   *this is a simple parse, the only things we need are the access and refresh tokens
   lcresponse=lohttp.responsetext
   losc=CREATEOBJECT("ScriptControl")
   losc.language = "JScript"
   losc.addcode( ;
   "function parsejason(s) { " + ;
   " return eval('(' + s + ')');" + ;
   "}")
   loobj=losc.RUN("parsejson",lcresponse)
   lcaccesstoken=loobj.access_token
   lcrefreshtoken=loobj.refresh_token
   dtaccessexpires = TTOC(DATETIME() + 3600)
   dtrefreshexpires = DATETIME() + 3600*24*100
   USE oauth_tokens EXCLUSIVE
  LOCATE FOR who=lcwho
  IF EOF()
    APPEND BLANK
    REPLACE who WITH lcwho
  ENDIF
  REPLACE atoken WITH lcaccesstoken, ;
    rtoken WITH lcrefreshtoken, ;
    aexpires WITH dtaccessexpires, ;
    rexpires WITH DTOC(dtrefreshexpires), ;
    realmid WITH lcrealmid
  USE
ENDIF

lcrealmid=STRTRAN(lcrealmid, CHR(13)+CHR(10),"") &&remove newlines
lcrealmid=STRTRAN(lcrealmid, CHR(32),"") &&remove spaces

*this is the actual call for a report
lcurl = "https://quickbooks.api.intuit.com/v3/company/" + lcrealmid + "/reports/" + mreport
IF mxml
   tcaccept = "application/xml"
ELSE
   tcaccept = "application/json"
ENDIF
lohttp = CREATEOBJECT("WinHttp.WinHttpRequest.5.1")
lohttp.OPEN("GET", lcurl, .F.)
lohttp.setrequestheader("Accept",tcaccept)
lohttp.setrequestheader("Authorization", "Bearer " + lcaccesstoken)
lohttp.SEND()
IF lohttp.STATUS = 200
   lcresponse = lohttp.responsetext
ELSE
   lcresponse = "ERROR: " + TRANSFORM(lohttp.STATUS) + ;
      " - " + lohttp.responsetext
ENDIF

lcresponse = LTRIM(RTRIM(lcresponse))
lcresponse = STRTRAN(lcresponse, CHR(13), "")
lcresponse = STRTRAN(lcresponse, CHR(10), "")
IF lohttp.STATUS = 200
  LOCAL json, success, root, customers, i, CUST
  lcxml=STRTRAN(lcresponse,'"',"")
  IF mxml
     DO dbfit7xml WITH mfilename
  ELSE
*******************************this is where the fun begins*************************
    lcdbf=lcfilename
    mentity=TRIM(mentity)
    SELECT 0
    USE (lcdbf)
    lcjson = lcresponse
    loscript.language = "JScript"
    * Inject JSON into JS context
    loscript.addcode([var data = ] + lcjson + [;])
    lctext = lcresponse && replace with your variable holding the text
    _CLIPTEXT=lctext
**********if you need to see what the JSON looks like, SET STEP ON here and past the JSON into chatGPT and ask it to indent the JSON
 

At this point, there is a case statement that runs the parsing for the reports.  JSON's for the reports are columnar based so there is a title to each column but you don't extract the amounts by naming the column.  It is the column number starting with 0 so you should know the order and meaning of the columns.  The best way to do this is to stop the program, and read through the response.  Pasting it into word will work but it isn't that easy to read.  Pasting it into ChatGPT and asking it to indent the JSON works much better but ChatGPT has a limit so you may need to cut the text down before pasting.  It really helps. 

There is one other thing.  You will notice that for many reports, the data is only one level down.  IE, there is a vendor grouping and then the list of transactions.  On the Profit and Loss Detail, the data is on the 4th level in.  Look at a report in the GUI,  At the top, Ordinary Income/Expense, Income, Sales, and then the transaction.  See, its the 4th level in so in the example below, you will see a case statement for PLD.  This digs down to the 4th level.

You might notice at nearly the bottom, one of my clients uses a general ledger in a multi-currency situation.  The home currency is in Euros, the currency I need is in $ so I extract the exchange rate. I could simply set on error * or I can option this.

    lctext = lcresponse && replace with your variable holding the text
    DO CASE
    CASE mentity="PLD"
      **everything I need is in the 4th level in but when reading through this, there are headers with no rows which causes errors that are hard
      **to trap so simply set on error * so that it skips the errors
      ON ERROR *
      lnrowscount =loscript.EVAL("data.Rows.Row.length")
      FOR a=0 TO lnrowscount-1
        mheader=loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Header.ColData[0].value")
        lnrowcount=loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row.length")
        FOR b=0 TO lnrowcount-1
          mheader=loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Header.ColData[0].value")
          lnrowcount=loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Rows.Row.length")
          FOR c=0 TO lnrowcount-1
            mheader=loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Rows.Row["+ALLT(STR(c,3))+"].Header.ColData[0].value")
            lnrowcount=loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Rows.Row["+ALLT(STR(c,3))+"].Rows.Row.length")
            FOR d=0 TO lnrowcount-1
              mheader=loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Rows.Row["+ALLT(STR(c,3))+"].Rows.Row["+ALLT(STR(d,3))+"].Header.ColData[0].value")
              *?mheader
              SELECT (lcdbf)
              APPEND BLANK
              REPLACE account WITH mheader
              REPLACE txndate WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Rows.Row["+ALLT(STR(c,3))+"].Rows.Row["+ALLT(STR(d,3))+"].ColData[0].value")
              REPLACE transtype WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Rows.Row["+ALLT(STR(c,3))+"].Rows.Row["+ALLT(STR(d,3))+"].ColData[1].value")
              REPLACE docnumber WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Rows.Row["+ALLT(STR(c,3))+"].Rows.Row["+ALLT(STR(d,3))+"].ColData[2].value")
              mname=3  &&I don't need column 3
              REPLACE classid WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Rows.Row["+ALLT(STR(c,3))+"].Rows.Row["+ALLT(STR(d,3))+"].ColData[4].value")
              mmemo=5 &&I don't need column 5
              msplit=6 &&I don't need column 6
              REPLACE amount WITH VAL(loscript.EVAL("data.Rows.Row["+ALLT(STR(a,3))+"].Rows.Row["+ALLT(STR(b,3))+"].Rows.Row["+ALLT(STR(c,3))+"].Rows.Row["+ALLT(STR(d,3))+"].ColData[7].value"))
              mbalance=8 &&I don't need column 8
            NEXT
          NEXT
        NEXT
      NEXT
      ON ERROR
    CASE mentity="CustomerDetail"
      lnrowscount =loscript.EVAL("data.Rows.Row.length")
      FOR lnrows=0 TO lnrowscount-1
        mcustomer=loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Header.ColData[0].value")
        ?mcustomer
        lnrowcount=loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row.length")
        FOR lnrow=0 TO lnrowcount-1
          SELECT (lcdbf)
          APPEND BLANK
          REPLACE customer WITH mcustomer
          REPLACE txndate WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[0].value")
          REPLACE transtype WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[1].value")
          REPLACE docnumber WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[2].value")
          REPLACE amount WITH VAL(loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[3].value"))
        NEXT
      NEXT
    CASE mentity="VendorBalance"
      lnrowscount =loscript.EVAL("data.Rows.Row.length")
      FOR lnrows=0 TO lnrowscount-2
        mvendno=loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Header.ColData[0].value")
        ?mvendno
        lnrowcount=loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row.length")
        FOR lnrow=0 TO lnrowcount-1
          SELECT (lcdbf)
          APPEND BLANK
          REPLACE vendor WITH mvendno
          REPLACE txndate WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[0].value")
          REPLACE transtype WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[1].value")
          REPLACE docnumber WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[2].value")
          duedatemcol4=3 && I don't need column 3
          REPLACE amount WITH VAL(loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[4].value"))
          mcol6=5 &&I don't need column 5
          REPLACE balance WITH VAL(loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[5].value"))
        NEXT
      NEXT
    CASE mentity="GeneralLedger"
      lnrowscount =loscript.EVAL("data.Rows.Row.length")
      FOR lnrows=0 TO lnrowscount-1
        maccount=loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Header.ColData[0].value")
        ?maccount
        lnrowcount=loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row.length")
        FOR lnrow=0 TO lnrowcount-1
          SELECT (lcdbf)
          APPEND BLANK
          REPLACE account WITH maccount
          REPLACE txndate WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[0].value")
          REPLACE transtype WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[1].value")
          REPLACE ID WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[1].id")

          REPLACE docnumber WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[2].value")
          mname=3 &&I don't need column 3
          REPLACE NAME WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[4].value")
          REPLACE DESCRIP WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[5].value")
          mdescrip=6 &&I don't need column 6
          REPLACE SPLIT WITH loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[6].value")

          REPLACE amount WITH VAL(loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[7].value"))
          IF mmulticurrency
            REPLACE exchrate WITH VAL(loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[8].value"))
          ENDIF
          REPLACE balance WITH VAL(loscript.EVAL("data.Rows.Row["+ALLT(STR(lnrows,3))+"].Rows.Row["+ALLT(STR(lnrow,3))+"].ColData[8].value"))
**we want the class code
        NEXT
      NEXT
    ENDCASE
    USE
  ENDIF
ENDIF
 

So that is parsing reports.  Another little bugaboo was classes.  Many transactions use classes but this level of the API rarely allows the class field to be added to a report or transaction.  One of the few reports was the Profit & Loss Detail.  Classes are really important and the fact that you cannot get it in most reports here even thought it is readily available in the GUI customize report column list is frustrating.

Good Luck

Craig Sobel