Skip to content

Visual FoxPro

Many accounting systems and ERPs in Ecuador are written in Visual FoxPro and run on the office’s own computers. They can issue electronic documents with Flashpi without an SDK: Flashpi is an HTTPS API that takes and returns JSON, and Windows already has the HTTP client that Visual FoxPro needs. This guide issues the invoice of the Quickstart from Visual FoxPro 9, with the shared demo issuer in the SRI test environment.

  • Visual FoxPro 9 (SP2). The samples use TRY ... CATCH, CREATEBINARY() and the UTF-8 conversions of STRCONV().
  • Windows 10 or 11, or Windows Server 2016 or later, on the computer that calls the API. Visual FoxPro has no TLS of its own: it uses the HTTP client of Windows (see TLS 1.2 and Windows versions).
  • A test API key (fp_test_), created in the console’s API keys with Test chosen, with access to every issuer (scope account) and read and write access (see Authentication and API keys), and the demo issuer’s ID, from the console’s Overview (see Sandbox and demo issuer).
  • A JSON library to read the answers: Visual FoxPro has no JSON parser. The samples use nfJson (nfJsonRead()), free on VFPX; any other works the same way.
  • Best: one server calls Flashpi. The PCs ask a small service on your server (or on the ERP’s server) to issue, and only that server holds the key. The PCs never see it, and the server’s public address can be the only one in the key’s IP allowlist.
  • If each PC calls the API, keep the key on that PC only, readable by the Windows user that runs the system: an environment variable of that user (the samples read GETENV("FLASHPI_API_KEY")) or, better, encrypted with the Windows Data Protection API (DPAPI), which only that user or that computer can decrypt.
  • If you sell your system to other companies, each customer creates its own Flashpi account and keys, and enters its key when you install the system. Never ship your own key inside the product.
  • Give each key the least it needs: an issuer-scoped key for the one RUC the system issues for, and an IP allowlist with the office’s public address when it is fixed.
  • Start with a test key (fp_test_): its documents have no tax effect. Use a live key (fp_live_) only on the computers that issue for real, once everything works.
  • Never write the key in a log or show it on screen. If it leaks, revoke it in the console: it stops working at once.

GET /v1/status needs no key, so it tells you whether the computer reaches the API:

loHttp = CREATEOBJECT("MSXML2.ServerXMLHTTP.6.0")
loHttp.open("GET", "https://api.flashpi.com.ec/v1/status", .F.)
loHttp.send()
? loHttp.status, loHttp.responseText && 200 {"status":"operational"}

If send() fails with A security error occurred (0x80072F8F), Windows could not set up TLS with the API: see TLS 1.2 and Windows versions.

Use MSXML2.ServerXMLHTTP.6.0 or WinHttp.WinHttpRequest.5.1. Avoid MSXML2.XMLHTTP and Microsoft.XMLHTTP: they are made for Internet Explorer, follow its settings and can cache answers.

Put these functions in a program, for example flashpi.prg, and load it with SET PROCEDURE TO flashpi ADDITIVE.

flashpi.prg
#DEFINE FLASHPI_API "https://api.flashpi.com.ec"
* One HTTPS call to Flashpi. Returns the MSXML object with the answer
* (status, responseText, responseBody), or .NULL. when no answer arrived:
* a network error, a timeout or a TLS failure. tcError gets the reason.
FUNCTION FlashpiCall(tcMethod, tcPath, tcBody, tcIdempotencyKey, tcError)
LOCAL loHttp, loError
tcError = ""
loHttp = CREATEOBJECT("MSXML2.ServerXMLHTTP.6.0")
* Resolve, connect, send and receive timeouts, in milliseconds. Receiving
* waits longer than the sync mode's wait (15 seconds by default).
loHttp.setTimeouts(5000, 10000, 30000, 30000)
TRY
loHttp.open(tcMethod, FLASHPI_API + tcPath, .F.)
loHttp.setRequestHeader("Authorization", "Bearer " + GETENV("FLASHPI_API_KEY"))
* Error messages in Spanish, to show them to the user
loHttp.setRequestHeader("Accept-Language", "es")
IF !EMPTY(tcIdempotencyKey)
loHttp.setRequestHeader("Idempotency-Key", tcIdempotencyKey)
ENDIF
IF EMPTY(tcBody)
loHttp.send()
ELSE
loHttp.setRequestHeader("Content-Type", "application/json; charset=utf-8")
* The JSON's UTF-8 bytes, so accents and the ñ arrive as they are
loHttp.send(CREATEBINARY(STRCONV(tcBody, 9)))
ENDIF
CATCH TO loError
tcError = loError.Message
loHttp = .NULL.
ENDTRY
RETURN loHttp
ENDFUNC
* Issues a document, retrying safely: the same body and the same
* Idempotency-Key on every attempt, so it is never issued twice. Returns the
* document as an object, or .NULL. with tcError set.
FUNCTION IssueDocument(tcPath, tcBody, tcIdempotencyKey, tcError)
LOCAL lnAttempt, lnWait, loHttp, loProblem
DECLARE Sleep IN kernel32 INTEGER nMilliseconds
FOR lnAttempt = 1 TO 5
lnWait = 2 ^ (lnAttempt - 1) && 1, 2, 4 and 8 seconds between attempts
loHttp = FlashpiCall("POST", tcPath, tcBody, tcIdempotencyKey, @tcError)
DO CASE
CASE ISNULL(loHttp)
* No answer: Flashpi may have received the request, so send it again
CASE INLIST(loHttp.status, 200, 202)
RETURN nfJsonRead(loHttp.responseText)
CASE loHttp.status >= 500
tcError = ProblemText(loHttp)
OTHERWISE
tcError = ProblemText(loHttp)
IF LEFT(LTRIM(loHttp.responseText), 1) <> "{"
* Not problem details, such as a proxy's error page
RETURN .NULL.
ENDIF
loProblem = nfJsonRead(loHttp.responseText)
DO CASE
CASE loProblem.code == "idempotency_key_in_progress"
* The first attempt is still running: wait and send it again
CASE loProblem.code == "idempotency_key_reused" AND TYPE("loProblem.documentId") = "C"
* The key already created a document: read it instead of issuing again
loHttp = FlashpiCall("GET", "/v1/documents/" + loProblem.documentId, "", "", @tcError)
IF !ISNULL(loHttp) AND loHttp.status = 200
RETURN nfJsonRead(loHttp.responseText)
ENDIF
IF !ISNULL(loHttp)
tcError = ProblemText(loHttp)
ENDIF
RETURN .NULL.
CASE loHttp.status = 429 AND VAL(loHttp.getResponseHeader("Retry-After")) <= 60
lnWait = VAL(loHttp.getResponseHeader("Retry-After"))
OTHERWISE
* Fix the request first: sent unchanged, it gets the same answer
RETURN .NULL.
ENDCASE
ENDCASE
IF lnAttempt < 5
Sleep(lnWait * 1000)
ENDIF
ENDFOR
RETURN .NULL.
ENDFUNC
* The text of an error answer (RFC 9457 problem details) to show or log:
* detail and code, each field error, and the requestId for support
FUNCTION ProblemText(toHttp)
LOCAL loProblem, lcText, lnI
IF LEFT(LTRIM(toHttp.responseText), 1) <> "{"
* Not problem details, such as a proxy's error page: the status, and the
* request ID when the API sent one
lcText = "HTTP " + TRANSFORM(toHttp.status)
TRY
lcText = lcText + CHR(13) + "requestId: " + toHttp.getResponseHeader("x-request-id")
CATCH
* No x-request-id header: the answer did not come from the API
ENDTRY
RETURN lcText
ENDIF
loProblem = nfJsonRead(toHttp.responseText)
lcText = loProblem.detail + " (" + loProblem.code + ")"
IF TYPE("loProblem.errors[1].field") = "C"
FOR lnI = 1 TO ALEN(loProblem.errors)
lcText = lcText + CHR(13) + loProblem.errors[lnI].field + ": " + ;
loProblem.errors[lnI].message
ENDFOR
ENDIF
RETURN lcText + CHR(13) + "requestId: " + loProblem.requestId
ENDFUNC
* A text value as a JSON string: trimmed (table fields are padded with
* spaces) and escaped
FUNCTION JsonText(tcValue)
LOCAL lcText, lnI
lcText = ALLTRIM(tcValue)
lcText = STRTRAN(lcText, "\", "\\")
lcText = STRTRAN(lcText, '"', '\"')
lcText = STRTRAN(lcText, CHR(13), "\r")
lcText = STRTRAN(lcText, CHR(10), "\n")
lcText = STRTRAN(lcText, CHR(9), "\t")
* Any other control character, such as CHR(0) or CHR(26), as \u0000
FOR lnI = 0 TO 31
lcText = STRTRAN(lcText, CHR(lnI), "\u" + RIGHT(TRANSFORM(lnI, "@0"), 4))
ENDFOR
RETURN '"' + lcText + '"'
ENDFUNC
* An amount or a quantity as the API takes it: a string with a dot,
* whatever SET POINT says (a comma in many Ecuadorian setups)
FUNCTION JsonDecimal(tnValue, tnDecimals)
RETURN '"' + CHRTRAN(ALLTRIM(STR(tnValue, 20, tnDecimals)), SET("POINT"), ".") + '"'
ENDFUNC
* A new GUID without braces: the Idempotency-Key of one document
FUNCTION NewGuid()
LOCAL lcGuid, lcText
DECLARE INTEGER CoCreateGuid IN ole32 STRING @pGuid
DECLARE INTEGER StringFromGUID2 IN ole32 STRING rGuid, STRING @lpsz, INTEGER cchMax
lcGuid = REPLICATE(CHR(0), 16)
lcText = REPLICATE(CHR(0), 80)
CoCreateGuid(@lcGuid)
StringFromGUID2(lcGuid, @lcText, 40)
* {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} in UTF-16: 38 characters, 76 bytes
RETURN SUBSTR(STRCONV(LEFT(lcText, 76), 6), 2, 36)
ENDFUNC
  1. Give the invoice its Idempotency-Key and save it with the invoice before the first attempt. A retry, even after the program or the PC restarted, sends the same key, and Flashpi answers it with the document the first attempt created instead of issuing a second one (see Idempotency). Here the key lives in a field idem_key C(36) of the invoices table. If the table is buffered, TABLEUPDATE() writes it to disk at once, so that a TABLEREVERT() or a crash cannot lose it.

  2. Build the JSON body. Amounts and quantities are decimal strings with a dot. You send the price and the VAT rate; Flashpi computes the subtotal, the VAT (67.50) and the total (517.50), and the payments must add up to that total. TEXT ... ENDTEXT with TEXTMERGE writes the JSON with the values of your tables; for several lines, build each item the same way inside a SCAN and join them with commas.

  3. Send it with IssueDocument(), which retries on its own when that is safe.

SET PROCEDURE TO flashpi ADDITIVE
LOCAL lcKey, lcBody, lcError, loDoc
* 1. One key per invoice, saved before the first attempt
SELECT invoices
IF EMPTY(invoices.idem_key)
REPLACE idem_key WITH NewGuid() IN invoices
* With a buffered table, write the key to disk now
IF CURSORGETPROP("Buffering", "invoices") > 1 AND !TABLEUPDATE(0, .F., "invoices")
MESSAGEBOX("The invoice could not be saved", 48, "Flashpi")
RETURN
ENDIF
ENDIF
lcKey = ALLTRIM(invoices.idem_key)
* 2. The body, with the values your system keeps in its tables
lcIssuerId = "<demo issuer id>" && from the console's Overview
lcCustomerId = "1790000000001"
lcCustomerName = "Supermercados La Pradera S.A."
lcDescription = "Monthly accounting services"
lnQuantity = 1
lnUnitPrice = 450
lnTotal = 517.50
TEXT TO lcBody TEXTMERGE NOSHOW
{
"issuerId": <<JsonText(lcIssuerId)>>,
"establishment": "001",
"emissionPoint": "001",
"customer": {
"idType": "ruc",
"id": <<JsonText(lcCustomerId)>>,
"name": <<JsonText(lcCustomerName)>>
},
"items": [
{
"code": "SRV-001",
"description": <<JsonText(lcDescription)>>,
"quantity": <<JsonDecimal(lnQuantity, 2)>>,
"unitPrice": <<JsonDecimal(lnUnitPrice, 2)>>,
"taxes": [{ "type": "vat", "rate": "15" }]
}
],
"payments": [{ "method": "other_financial_system", "amount": <<JsonDecimal(lnTotal, 2)>> }],
"options": { "waitForAuthorization": true }
}
ENDTEXT
* 3. Send it
loDoc = IssueDocument("/v1/invoices", lcBody, lcKey, @lcError)
IF ISNULL(loDoc)
MESSAGEBOX(lcError, 48, "Flashpi")
RETURN
ENDIF
* Keep the document's id: it reads the document, its RIDE and its XML
REPLACE flashpi_id WITH loDoc.id, doc_status WITH loDoc.status, ;
doc_number WITH loDoc.number, access_key WITH loDoc.accessKey, ;
sent_at WITH DATETIME(), next_check WITH DATETIME() IN invoices
IF CURSORGETPROP("Buffering", "invoices") > 1 AND !TABLEUPDATE(0, .F., "invoices")
* Sent again, its key brings back the same document
MESSAGEBOX("The invoice was issued but could not be saved", 48, "Flashpi")
ENDIF

The same function issues the other documents: only the path and the body change (/v1/credit-notes, /v1/withholdings and the rest, see Issuing documents).

Answer What it means What to do
200 OK Sync mode only: the SRI settled the document within the wait. status is authorized, rejected or failed. authorized: download and print the RIDE. rejected: the SRI’s reasons are in sriMessages. failed: failure says why.
202 Accepted The document is stored and on its way: processing, or pending_sri when the wait ended before the SRI answered. Keep its id and follow it (see Sync mode, polling or webhooks). Nothing is lost.
4xx An error that does not go away on its own, as a problem details object. Show detail, branch on code, fix and send again. The only ones worth retrying unchanged are 409 idempotency_key_in_progress and a 429 with a short Retry-After.
5xx, or no answer The request may or may not have arrived. Send it again with the same key and the same body, waiting longer each time.

The document the API returns is the one in the Quickstart: id, status, number (001-001-000004821), accessKey (the 49-digit access key), totals, authorization, sriMessages and the rest. An error answer looks like this, in Spanish because FlashpiCall() sends Accept-Language: es, and ProblemText() turns it into one text:

{
"type": "https://flashpi.com.ec/docs/errors/validation_failed",
"title": "Error de validación",
"status": 422,
"detail": "Uno o más campos no son válidos. Revisa errors para ver el detalle.",
"instance": "/v1/invoices",
"code": "validation_failed",
"errors": [
{ "field": "customer.id", "code": "invalid_check_digit", "message": "El dígito verificador de este número de identificación es incorrecto." }
],
"requestId": "01a1412b-9c03-7e55-b1a8-27f4d6e0c913"
}

Branch on code and errors[].code, which never change with the language; title, detail and message are texts for people. Every code has its page in Errors.

IssueDocument() follows the rules of Idempotency:

  • It retries only what is safe to retry: no answer (a network error or a timeout), a 5xx, 409 idempotency_key_in_progress, and a 429 whose Retry-After is at most 60 seconds. A 429 that asks for more, such as the demo issuer’s daily quota, stops and shows the error.
  • Always with the same key and the same body. If the first attempt did arrive, Flashpi answers the retry with the same document and the header Idempotent-Replayed: true, never a second invoice. With the same key and a different body, the answer is 422 idempotency_key_reused.
  • After the last attempt the invoice keeps its key, so sending it later is still safe. Within the 24 hours that Flashpi remembers the key, if the first attempt issued the document, the same request gets that answer again. After that, the answer is 422 idempotency_key_reused with the documentId of the document the key created, and IssueDocument() reads that document. If you changed the invoice within the 24 hours, the 422 has no documentId: do not give the invoice a new key, because the first attempt may have issued it; look for it in the console first.
  • The receive timeout (30 seconds here) must be longer than the sync mode’s wait: 15 seconds by default, up to waitTimeoutSeconds (30 at most), plus a few seconds. Otherwise the program gives up just before the answer arrives.
  • The sync mode (options.waitForAuthorization, as in the sample) suits a point of sale that prints the RIDE at once: the request waits up to 15 seconds for the SRI (waitTimeoutSeconds, from 1 to 30). When the SRI takes longer, the answer is a 202 with pending_sri, and the document goes on by itself (see Sync and async modes).
  • Webhooks push each authorization and rejection to an HTTPS address of yours, so they need a server that the internet can reach: a desktop PC cannot receive them. If your system has a server or a web service, receive them there (see Webhooks).
  • Polling works from any PC: read GET /v1/documents/{id} until the status is final (authorized, rejected or failed). Wait a few seconds between reads at first, then longer, for example a minute, because when the SRI is down a document can take hours or days: Flashpi tries to submit it for up to 72 hours from its creation and waits up to 72 hours from the submission for the SRI’s decision (see Limits). After the system was closed, GET /v1/events lists the events you missed. A failed document raises no event yet: also look for it with GET /v1/documents?status=failed.

For example, in the Timer event of a timer with Interval = 5000, on one PC only or on the server: if every PC reads every pending invoice of the shared table, the requests multiply. The table also keeps when each invoice was issued (sent_at T) and when to read it again (next_check T): every 5 seconds in the first minute, then every minute. The timer opens its own copy of the table, so it moves neither the forms’ record nor their work area.

LOCAL lnArea, loHttp, loDoc, lcError
lnArea = SELECT()
USE invoices AGAIN IN 0 ALIAS inv_poll SHARED
SELECT inv_poll
SCAN FOR INLIST(ALLTRIM(doc_status), "processing", "pending_sri") AND next_check <= DATETIME()
loHttp = FlashpiCall("GET", "/v1/documents/" + ALLTRIM(inv_poll.flashpi_id), "", "", @lcError)
IF !ISNULL(loHttp) AND loHttp.status = 200
loDoc = nfJsonRead(loHttp.responseText)
REPLACE doc_status WITH loDoc.status IN inv_poll
ENDIF
* Every 5 seconds in the first minute after issuing, then every minute
REPLACE next_check WITH DATETIME() + IIF(DATETIME() - inv_poll.sent_at < 60, 5, 60) IN inv_poll
ENDSCAN
USE IN inv_poll
SELECT (lnArea)

Once the document is authorized, download its RIDE (the PDF) with GET /v1/documents/{id}/pdf and its authorized XML with GET /v1/documents/{id}/xml. Flashpi does not email them to the buyer yet, so the issuer delivers them (see RIDE and email).

* Saves the RIDE ("pdf") or the XML ("xml") of a document in a file
FUNCTION SaveDocumentFile(tcDocumentId, tcKind, tcFile, tcError)
LOCAL loHttp
loHttp = FlashpiCall("GET", "/v1/documents/" + tcDocumentId + "/" + tcKind, "", "", @tcError)
IF ISNULL(loHttp)
RETURN .F.
ENDIF
IF loHttp.status <> 200
tcError = ProblemText(loHttp)
RETURN .F.
ENDIF
* responseBody holds the file's bytes as they arrived
STRTOFILE(loHttp.responseBody, tcFile)
RETURN .T.
ENDFUNC
lcFile = "C:\Invoices\factura-" + ALLTRIM(invoices.doc_number)
IF !SaveDocumentFile(ALLTRIM(invoices.flashpi_id), "pdf", lcFile + ".pdf", @lcError)
MESSAGEBOX(lcError, 48, "Flashpi")
ENDIF
SaveDocumentFile(ALLTRIM(invoices.flashpi_id), "xml", lcFile + ".xml", @lcError)

The RIDE is rendered right after the authorization, usually a few seconds later. Until then the answer is 409 ride_not_available: try again a moment later.

The API accepts TLS 1.2 and TLS 1.3 only, with current cipher suites (ECDHE with AES-GCM or ChaCha20). MSXML2.ServerXMLHTTP and WinHttp.WinHttpRequest use WinHTTP, the TLS of Windows itself, so what counts is the Windows version, not Visual FoxPro’s:

  • Windows 10 and 11, and Windows Server 2016 or later connect as they are.
  • Windows 7 and Windows Server 2008 R2 cannot connect, even after turning TLS 1.2 on in WinHTTP (update KB3140245): they lack those cipher suites, so the handshake fails with 0x80072F8F. Older versions cannot either.
  • Other versions out of Microsoft’s support may fail the same way. Check the connection on each computer before you rely on it.

On a computer that cannot connect, move the call: have the PCs ask a service on a supported Windows (the server option of Keep the API key safe), or update that computer’s Windows.

  • The API reads and writes UTF-8. Visual FoxPro keeps text in the Windows code page (1252 on a Spanish Windows): STRCONV(..., 9) turns the body into UTF-8 before sending, and responseText decodes the answer, which says charset=utf-8, back into the code page. A character that the code page lacks arrives as ?.
  • Declare application/json or application/json; charset=utf-8. A charset that does not start with utf-, such as windows-1252 or latin1, is refused with 415 unsupported_media_type.
  • Trim the fields of your tables (JsonText() does): a DBF pads them with spaces. Text fields take up to 300 characters, in one line and without emoji (see Limits).

WinHttp.WinHttpRequest.5.1 works the same: change the CREATEOBJECT() of FlashpiCall(). Its methods have the same names and arguments (open, setRequestHeader, setTimeouts, send, status, responseText, responseBody and getResponseHeader), and it uses the same WinHTTP, so the same Windows versions connect.