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.
Before you start
Section titled “Before you start”- Visual FoxPro 9 (SP2). The samples use
TRY ... CATCH,CREATEBINARY()and the UTF-8 conversions ofSTRCONV(). - 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 (scopeaccount) 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.
Keep the API key safe
Section titled “Keep the API key safe”- 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.
Check the connection
Section titled “Check the connection”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.
The helper functions
Section titled “The helper functions”Put these functions in a program, for example flashpi.prg, and load it with SET PROCEDURE TO flashpi ADDITIVE.
#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 loHttpENDFUNC
* 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 supportFUNCTION 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.requestIdENDFUNC
* A text value as a JSON string: trimmed (table fields are padded with* spaces) and escapedFUNCTION 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 documentFUNCTION 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)ENDFUNCIssue the invoice
Section titled “Issue the invoice”-
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 aTABLEREVERT()or a crash cannot lose it. -
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 ... ENDTEXTwithTEXTMERGEwrites the JSON with the values of your tables; for several lines, build each item the same way inside aSCANand join them with commas. -
Send it with
IssueDocument(), which retries on its own when that is safe.
SET PROCEDURE TO flashpi ADDITIVELOCAL lcKey, lcBody, lcError, loDoc
* 1. One key per invoice, saved before the first attemptSELECT invoicesIF 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 ENDIFENDIFlcKey = ALLTRIM(invoices.idem_key)
* 2. The body, with the values your system keeps in its tableslcIssuerId = "<demo issuer id>" && from the console's OverviewlcCustomerId = "1790000000001"lcCustomerName = "Supermercados La Pradera S.A."lcDescription = "Monthly accounting services"lnQuantity = 1lnUnitPrice = 450lnTotal = 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 itloDoc = IssueDocument("/v1/invoices", lcBody, lcKey, @lcError)IF ISNULL(loDoc) MESSAGEBOX(lcError, 48, "Flashpi") RETURNENDIF
* Keep the document's id: it reads the document, its RIDE and its XMLREPLACE 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 invoicesIF 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")ENDIFThe same function issues the other documents: only the path and the body change (/v1/credit-notes, /v1/withholdings and the rest, see Issuing documents).
Read the answer
Section titled “Read the answer”| 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.
Retries and timeouts
Section titled “Retries and timeouts”IssueDocument() follows the rules of Idempotency:
- It retries only what is safe to retry: no answer (a network error or a timeout), a
5xx,409idempotency_key_in_progress, and a429whoseRetry-Afteris at most 60 seconds. A429that 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 is422idempotency_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
422idempotency_key_reusedwith thedocumentIdof the document the key created, andIssueDocument()reads that document. If you changed the invoice within the 24 hours, the422has nodocumentId: 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.
Sync mode, polling or webhooks
Section titled “Sync mode, polling or webhooks”- 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 a202withpending_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,rejectedorfailed). 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/eventslists the events you missed. Afaileddocument raises no event yet: also look for it withGET /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, lcErrorlnArea = SELECT()USE invoices AGAIN IN 0 ALIAS inv_poll SHAREDSELECT inv_pollSCAN 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_pollENDSCANUSE IN inv_pollSELECT (lnArea)Download the RIDE and the XML
Section titled “Download the RIDE and the XML”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 fileFUNCTION 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.ENDFUNClcFile = "C:\Invoices\factura-" + ALLTRIM(invoices.doc_number)IF !SaveDocumentFile(ALLTRIM(invoices.flashpi_id), "pdf", lcFile + ".pdf", @lcError) MESSAGEBOX(lcError, 48, "Flashpi")ENDIFSaveDocumentFile(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.
TLS 1.2 and Windows versions
Section titled “TLS 1.2 and Windows versions”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.
Text and UTF-8
Section titled “Text and UTF-8”- 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, andresponseTextdecodes the answer, which sayscharset=utf-8, back into the code page. A character that the code page lacks arrives as?. - Declare
application/jsonorapplication/json; charset=utf-8. A charset that does not start withutf-, such aswindows-1252orlatin1, is refused with415unsupported_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).
With WinHttp.WinHttpRequest.5.1
Section titled “With WinHttp.WinHttpRequest.5.1”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.
Next steps
Section titled “Next steps”- Follow the life of a document: Document lifecycle.
- Issue with your own RUC: create your issuer from its signing certificate, then use its
issuerId. - Go live: a live key (
fp_live_) issues with tax effect, with the same requests. - Delphi instead? See Delphi.