Delphi
Many accounting systems, ERPs and points of sale in Ecuador are written in Delphi 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 Delphi brings an HTTP client (THTTPClient) and a JSON library (System.JSON). This guide issues the invoice of the Quickstart from Delphi, with the shared demo issuer in the SRI test environment.
Before you start
Section titled “Before you start”- Delphi 10.4 or later, for Windows. The samples use
THTTPClient(System.Net.HttpClient) andSystem.JSON;TNetHTTPClient, the component on the palette, has the same properties and methods. For Indy, see With Indy. - Windows 10 or 11, or Windows Server 2016 or later, on the computer that calls the API. On Windows,
THTTPClientuses the TLS of Windows itself (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).
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
GetEnvironmentVariable('FLASHPI_API_KEY')) or, better, encrypted with the Windows Data Protection API (CryptProtectData), 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:
var Client: THTTPClient;begin Client := THTTPClient.Create; try // 200 {"status":"operational"} ShowMessage(Client.Get('https://api.flashpi.com.ec/v1/status').ContentAsString); finally Client.Free; end;end;If it fails with A security error occurred (WinHTTP error 12175), Windows could not set up TLS with the API: see TLS 1.2 and Windows versions.
The Flashpi unit
Section titled “The Flashpi unit”unit Flashpi;
interface
uses System.SysUtils, System.Classes, System.JSON, System.Net.URLClient, System.Net.HttpClient;
const FlashpiApi = 'https://api.flashpi.com.ec';
function NewIdempotencyKey: string;function Amount(const Value: Currency): string;function Decimal(const Value: Double; Decimals: Integer): string;function IssueDocument(const Path, Body, IdempotencyKey: string; out Error: string): TJSONObject;function GetDocument(const DocumentId: string; out Error: string): TJSONObject;function SaveDocumentFile(const DocumentId, Kind, FileName: string; out Error: string): Boolean;
implementation
// A new GUID without braces: the Idempotency-Key of one documentfunction NewIdempotencyKey: string;begin Result := Copy(TGUID.NewGuid.ToString, 2, 36);end;
// An amount of money as the API takes it: a string with 2 decimals and a// dot, whatever the regional settings of Windows say (a comma in Ecuador)function Amount(const Value: Currency): string;var Format: TFormatSettings;begin Format := TFormatSettings.Create; Format.DecimalSeparator := '.'; Result := CurrToStrF(Value, ffFixed, 2, Format);end;
// A quantity or a unit price, with up to 6 decimals: a string with a dot// (Currency keeps only 4 decimals, so this one takes a Double)function Decimal(const Value: Double; Decimals: Integer): string;var Format: TFormatSettings;begin Format := TFormatSettings.Create; Format.DecimalSeparator := '.'; Result := FloatToStrF(Value, ffFixed, 15, Decimals, Format);end;
function NewClient: THTTPClient;begin Result := THTTPClient.Create; Result.ConnectionTimeout := 10000; // milliseconds // Longer than the sync mode's wait (15 seconds by default) Result.ResponseTimeout := 30000;end;
function Headers(const IdempotencyKey: string): TNetHeaders;begin Result := [TNetHeader.Create('Authorization', 'Bearer ' + GetEnvironmentVariable('FLASHPI_API_KEY')), // Error messages in Spanish, to show them to the user TNetHeader.Create('Accept-Language', 'es')]; if IdempotencyKey <> '' then Result := Result + [TNetHeader.Create('Idempotency-Key', IdempotencyKey)];end;
// 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(const Response: IHTTPResponse; const Text: string): string;var Problem, Field: TJSONValue;begin Problem := TJSONObject.ParseJSONValue(Text); if Problem = nil then begin // Not problem details, such as a proxy's error page: the status, and the // request ID when the API sent one Result := 'HTTP ' + IntToStr(Response.StatusCode); if Response.HeaderValue['x-request-id'] <> '' then Result := Result + sLineBreak + 'requestId: ' + Response.HeaderValue['x-request-id']; Exit; end; try Result := Problem.GetValue<string>('detail') + ' (' + Problem.GetValue<string>('code') + ')'; for Field in Problem.GetValue<TJSONArray>('errors') do Result := Result + sLineBreak + Field.GetValue<string>('field') + ': ' + Field.GetValue<string>('message'); Result := Result + sLineBreak + 'requestId: ' + Problem.GetValue<string>('requestId'); finally Problem.Free; end;end;
// One member of an error answer, such as its code, to branch on it ('' when// it is missing)function ProblemValue(const Text, Name: string): string;var Problem: TJSONValue;begin Result := ''; Problem := TJSONObject.ParseJSONValue(Text); if Problem <> nil then try Result := Problem.GetValue<string>(Name, ''); finally Problem.Free; end;end;
// 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 (free it), or nil with Error set.function IssueDocument(const Path, Body, IdempotencyKey: string; out Error: string): TJSONObject;var Client: THTTPClient; Source: TStringStream; Response: IHTTPResponse; Attempt, Wait, Status: Integer; Text, DocumentId: string;begin Result := nil; Error := ''; Client := NewClient; try Client.ContentType := 'application/json'; for Attempt := 1 to 5 do begin Wait := 1 shl (Attempt - 1); // 1, 2, 4 and 8 seconds between attempts Response := nil; // The JSON's UTF-8 bytes, without a byte order mark Source := TStringStream.Create(Body, TEncoding.UTF8); try try Response := Client.Post(FlashpiApi + Path, Source, nil, Headers(IdempotencyKey)); except // No answer: Flashpi may have received the request, so send it again on E: ENetException do Error := E.Message; end; finally Source.Free; end; if Response <> nil then begin Status := Response.StatusCode; Text := Response.ContentAsString(TEncoding.UTF8); if (Status = 200) or (Status = 202) then Exit(TJSONObject.ParseJSONValue(Text) as TJSONObject); Error := ProblemText(Response, Text); if Status < 500 then begin DocumentId := ''; if ProblemValue(Text, 'code') = 'idempotency_key_reused' then DocumentId := ProblemValue(Text, 'documentId'); if DocumentId <> '' then // The key already created a document: read it instead of issuing again Exit(GetDocument(DocumentId, Error)); if (Status = 429) and (StrToIntDef(Response.HeaderValue['Retry-After'], 0) <= 60) then Wait := StrToIntDef(Response.HeaderValue['Retry-After'], 1) else if ProblemValue(Text, 'code') <> 'idempotency_key_in_progress' then Exit; // Fix the request first: sent unchanged, it gets the same answer end; end; if Attempt < 5 then Sleep(Wait * 1000); end; finally Client.Free; end;end;
// Reads a document again: its status, its access key, its totals...function GetDocument(const DocumentId: string; out Error: string): TJSONObject;var Client: THTTPClient; Response: IHTTPResponse; Text: string;begin Result := nil; Error := ''; Client := NewClient; try try Response := Client.Get(FlashpiApi + '/v1/documents/' + DocumentId, nil, Headers('')); except on E: ENetException do begin Error := E.Message; Exit; end; end; Text := Response.ContentAsString(TEncoding.UTF8); if Response.StatusCode = 200 then Result := TJSONObject.ParseJSONValue(Text) as TJSONObject else Error := ProblemText(Response, Text); finally Client.Free; end;end;
// Saves the RIDE ('pdf') or the XML ('xml') of a document in a filefunction SaveDocumentFile(const DocumentId, Kind, FileName: string; out Error: string): Boolean;var Client: THTTPClient; Response: IHTTPResponse; Output: TFileStream;begin Result := False; Error := ''; Client := NewClient; try try Response := Client.Get(FlashpiApi + '/v1/documents/' + DocumentId + '/' + Kind, nil, Headers('')); except on E: ENetException do begin Error := E.Message; Exit; end; end; if Response.StatusCode <> 200 then begin Error := ProblemText(Response, Response.ContentAsString(TEncoding.UTF8)); Exit; end; // The file's bytes as they arrived Output := TFileStream.Create(FileName, fmCreate); try Output.CopyFrom(Response.ContentStream, 0); finally Output.Free; end; Result := True; finally Client.Free; end;end;
end.Issue 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_keyof the invoices table. With cached updates (TClientDataSet, FireDAC’sCachedUpdates), callApplyUpdatesafter eachPost, so the key reaches the database beforeIssueDocument()is called. -
Build the JSON body with
System.JSON, which escapes the texts for you. Amounts and quantities are decimal strings with a dot:Amount()for money, with 2 decimals, andDecimal()for quantities and unit prices, which take up to 6. 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. For several lines, add one item per line to theitemsarray. -
Send it with
IssueDocument(), which retries on its own when that is safe.
// 2. The body, with the values your system keeps in its tablesfunction InvoiceBody: string;var Invoice, Customer, Item, Tax, Payment, Options: TJSONObject; Items, Taxes, Payments: TJSONArray;begin Invoice := TJSONObject.Create; try Invoice.AddPair('issuerId', '<demo issuer id>'); // from the console's Overview Invoice.AddPair('establishment', '001'); Invoice.AddPair('emissionPoint', '001');
Customer := TJSONObject.Create; Customer.AddPair('idType', 'ruc'); Customer.AddPair('id', '1790000000001'); Customer.AddPair('name', 'Supermercados La Pradera S.A.'); Invoice.AddPair('customer', Customer);
Tax := TJSONObject.Create; Tax.AddPair('type', 'vat'); Tax.AddPair('rate', '15'); Taxes := TJSONArray.Create; Taxes.AddElement(Tax);
Item := TJSONObject.Create; Item.AddPair('code', 'SRV-001'); Item.AddPair('description', 'Monthly accounting services'); Item.AddPair('quantity', Decimal(1, 2)); Item.AddPair('unitPrice', Decimal(450, 2)); Item.AddPair('taxes', Taxes); Items := TJSONArray.Create; Items.AddElement(Item); Invoice.AddPair('items', Items);
Payment := TJSONObject.Create; Payment.AddPair('method', 'other_financial_system'); Payment.AddPair('amount', Amount(517.50)); Payments := TJSONArray.Create; Payments.AddElement(Payment); Invoice.AddPair('payments', Payments);
Options := TJSONObject.Create; Options.AddPair('waitForAuthorization', TJSONBool.Create(True)); Invoice.AddPair('options', Options);
Result := Invoice.ToJSON; finally Invoice.Free; // also frees every object and array added to it end;end;
procedure IssueInvoice(Invoices: TDataSet);var Key, Error: string; Document: TJSONObject;begin // 1. One key per invoice, saved before the first attempt Key := Invoices.FieldByName('idem_key').AsString; if Key = '' then begin Key := NewIdempotencyKey; Invoices.Edit; Invoices.FieldByName('idem_key').AsString := Key; Invoices.Post; end;
// 3. Send it Document := IssueDocument('/v1/invoices', InvoiceBody, Key, Error); if Document = nil then raise Exception.Create(Error); try // Keep the document's id: it reads the document, its RIDE and its XML Invoices.Edit; Invoices.FieldByName('flashpi_id').AsString := Document.GetValue<string>('id'); Invoices.FieldByName('doc_status').AsString := Document.GetValue<string>('status'); Invoices.FieldByName('doc_number').AsString := Document.GetValue<string>('number'); Invoices.FieldByName('access_key').AsString := Document.GetValue<string>('accessKey'); Invoices.Post; finally Document.Free; end;end;IssueInvoice() needs Data.DB, System.SysUtils, System.JSON and the Flashpi unit in its uses. The same IssueDocument() 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. |
THTTPClient raises no exception for an error status: the answer arrives with its status, and only a missing answer (a network error, a timeout or a TLS failure) raises an ENetException. 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 the unit 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 returns 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. ResponseTimeout(30 seconds here) must be longer than the sync mode’s wait: 15 seconds by default, up towaitTimeoutSeconds(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 the document with
GetDocument()until its status is final (authorized,rejectedorfailed), for example from aTTimer. 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.
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).
var Id, FileName, Error: string;begin Id := Invoices.FieldByName('flashpi_id').AsString; FileName := 'C:\Invoices\factura-' + Invoices.FieldByName('doc_number').AsString; if not SaveDocumentFile(Id, 'pdf', FileName + '.pdf', Error) then ShowMessage(Error); SaveDocumentFile(Id, 'xml', FileName + '.xml', Error);end;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). On Windows, THTTPClient and TNetHTTPClient use WinHTTP, the TLS of Windows itself, so what counts is the Windows version, not Delphi’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. 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 to a service on a supported Windows (the server option of Keep the API key safe), update that computer’s Windows, or use Indy, which brings its own TLS (see below).
Text and UTF-8
Section titled “Text and UTF-8”- The API reads and writes UTF-8. Delphi’s strings are Unicode:
TStringStream.Create(Body, TEncoding.UTF8)sends the UTF-8 bytes, andContentAsString(TEncoding.UTF8)reads the answer. - Declare
application/jsonorapplication/json; charset=utf-8. A charset that does not start withutf-, such aswindows-1252orlatin1, is refused with415unsupported_media_type. - Text fields take up to 300 characters, in one line and without emoji (see Limits). Trim the values of fixed-length fields.
With Indy
Section titled “With Indy”Many older systems call HTTP with Indy (TIdHTTP). It works with Flashpi too:
- TLS: give
TIdHTTPaTIdSSLIOHandlerSocketOpenSSLwithSSLOptions.SSLVersions := [sslvTLSv1_2], and have it verify the server’s certificate and its exact name, as in the sample: without that, Indy accepts any certificate and the API key can be intercepted.cacert.pemis Mozilla’s bundle of certificate authorities (published by curl.se), which includes the Amazon roots that sign the API’s certificate, andVerifyPeeris a method, for example of the form, becauseOnVerifyPeeris an event. The sample needs an Indy 10.6 that sends SNI, such as the current one on GitHub: the API requires SNI, and an older Indy cannot connect. Indy uses OpenSSL instead of the TLS of Windows, so it also connects from versions of Windows that WinHTTP cannot, as long as its OpenSSL DLLs (libeay32.dllandssleay32.dll) sit next to the EXE. That OpenSSL 1.0.2 is out of support: preferTHTTPClientwhere Windows allows it. - Error answers: by default
TIdHTTPraisesEIdHTTPProtocolExceptionon a4xxor5xx. AddhoNoProtocolErrorExceptionandhoWantProtocolErrorContenttoHTTPOptionsto read the problem details, and the status fromResponseCode. - Headers and body:
Request.ContentType := 'application/json',Request.CharSet := 'utf-8',AuthorizationandIdempotency-KeywithRequest.CustomHeaders.AddValue(), and the body as aTStringStreamin UTF-8, as above.
Http.IOHandler := SSL; // a TIdSSLIOHandlerSocketOpenSSLSSL.SSLOptions.Mode := sslmClient;SSL.SSLOptions.SSLVersions := [sslvTLSv1_2];// Check the server's certificate: Indy accepts any certificate otherwiseSSL.SSLOptions.RootCertFile := ExtractFilePath(ParamStr(0)) + 'cacert.pem';SSL.SSLOptions.VerifyMode := [sslvrfPeer];SSL.SSLOptions.VerifyDepth := 9;SSL.OnVerifyPeer := VerifyPeer;Http.HTTPOptions := Http.HTTPOptions + [hoNoProtocolErrorException, hoWantProtocolErrorContent];Http.ConnectTimeout := 10000;Http.ReadTimeout := 30000;Http.Request.ContentType := 'application/json';Http.Request.CharSet := 'utf-8';Http.Request.CustomHeaders.AddValue('Authorization', 'Bearer ' + GetEnvironmentVariable('FLASHPI_API_KEY'));Http.Request.CustomHeaders.AddValue('Idempotency-Key', Key);Answer := Http.Post('https://api.flashpi.com.ec/v1/invoices', Source);Status := Http.ResponseCode;// A chain that cacert.pem trusts (AOk), ending in a certificate issued to// exactly api.flashpi.com.ec. EndsText is in System.StrUtils.function TForm1.VerifyPeer(Certificate: TIdX509; AOk: Boolean; ADepth, AError: Integer): Boolean;begin Result := AOk and ((ADepth > 0) or EndsText('/CN=api.flashpi.com.ec', Certificate.Subject.OneLine));end;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. - Visual FoxPro instead? See Visual FoxPro.