SDKs
The official SDKs wrap the Flashpi API with types generated from the OpenAPI document, safe retries with an Idempotency-Key, typed errors (RFC 9457), pagination and webhook signature verification. There are seven: @flashpi/node for Node.js and TypeScript, flashpi for Python, flashpi/flashpi-php for PHP, ec.com.flashpi:flashpi-java for Java and Kotlin, Flashpi for C# and .NET, github.com/flashpi1/flashpi-go for Go and flashpi for Ruby. You don’t need one: the guides use plain HTTP, which works from any language, desktop systems in Visual FoxPro and Delphi included. In a language without an SDK, verify webhooks with any Standard Webhooks library or by hand (see Webhooks).
Each section shows the seven SDKs in tabs. Pick your language in any of them and the whole page follows it.
Install
Section titled “Install”@flashpi/node is the official SDK for Node.js and TypeScript. Its source is on GitHub, under the MIT license.
- It covers every operation of the API: the six documents you can issue, reading documents (XML, RIDE and email), issuers (including creating them from their .p12) with their establishments, emission points and certificates, webhook endpoints, events and the API status.
- It runs on Node.js 22 or later, Bun, Deno and Cloudflare Workers, with
importorrequire, and has no dependencies. - It runs on the server only: your API key is secret, so the SDK refuses to run in a browser.
npm install @flashpi/nodeOr with pnpm add @flashpi/node, yarn add @flashpi/node or bun add @flashpi/node.
flashpi is the official SDK for Python. Its source is on GitHub, under the MIT license.
- It covers every operation of the API: the six documents you can issue, reading documents (XML, RIDE and email), issuers (including creating them from their .p12) with their establishments, emission points and certificates, webhook endpoints, events and the API status.
- It has two clients with the same resources and methods:
Flashpi, synchronous, andAsyncFlashpi, for asyncio. - It runs on Python 3.11 or later, and its only dependency is httpx. Its types are
TypedDicts generated from the API’s OpenAPI document. - Use it on your servers only: your API key is secret.
pip install flashpiOr with uv add flashpi or poetry add flashpi.
flashpi/flashpi-php is the official SDK for PHP. Its source is on GitHub, under the MIT license.
- It covers every operation of the API: the six documents you can issue, reading documents (XML, RIDE and email), issuers (including creating them from their .p12) with their establishments, emission points and certificates, webhook endpoints, events and the API status.
- It runs on PHP 8.2 or later, with Laravel, Symfony or no framework. Its only dependency is Guzzle, 7.15.2 or later or 8.3 or later, so it fits Laravel 11, 12 and 13.
- Requests and answers are arrays, typed with PHPStan array shapes generated from the API’s OpenAPI document.
- Use it on your servers only: your API key is secret. In production, install PHP’s
curlextension, with which each attempt’s time limit covers the whole answer.
composer require flashpi/flashpi-phpec.com.flashpi:flashpi-java is the official SDK for Java and Kotlin, on Maven Central. Its source is on GitHub, under the MIT license.
- It covers every operation of the API: the six documents you can issue, reading documents (XML, RIDE and email), issuers (including creating them from their .p12) with their establishments, emission points and certificates, webhook endpoints, events and the API status.
- Its models are generated from the API’s OpenAPI document, with builders (
CreateInvoice.builder()), typed getters, enums andjava.timedates, so your IDE knows every field. Kotlin uses them the same way. - It runs on Java 11 or later, with the JDK’s
HttpClient. Its only dependency is Jackson 2 (jackson-databind2.13 or later), which Spring Boot 2.7 and 3 already bring; with Spring Boot 4, which uses Jackson 3, the SDK brings its own Jackson 2, which lives alongside it. - Use it on your servers only: your API key is secret, so it never goes in an Android app or in code that runs on your users’ devices.
With Maven, in your pom.xml:
<dependency> <groupId>ec.com.flashpi</groupId> <artifactId>flashpi-java</artifactId> <version>0.1.0</version></dependency>With Gradle, in build.gradle.kts (Kotlin DSL):
dependencies { implementation("ec.com.flashpi:flashpi-java:0.1.0")}Or in build.gradle (Groovy):
dependencies { implementation 'ec.com.flashpi:flashpi-java:0.1.0'}Flashpi is the official SDK for C# and .NET. Its source and its README are on GitHub, under the MIT license.
- It covers every operation of the API: the six documents you can issue, reading documents (XML, RIDE and email), issuers (including creating them from their .p12) with their establishments, emission points and certificates, webhook endpoints, events and the API status.
- Its methods are asynchronous (
Task, with an optionalCancellationTokenlast) and its models arerecords generated from the API’s OpenAPI document, with typed properties: the compiler asks you for the required fields. - It runs on .NET 10 with no dependencies, and on .NET 8 and 9 and .NET Framework 4.6.2 or later with System.Text.Json. In .NET Framework projects and
netstandard2.0libraries, use C# 11 or later (<LangVersion>latest</LangVersion>), since the models haverequiredproperties. - Use it on your servers only: your API key is secret.
dotnet add package FlashpiOr from the NuGet package manager of Visual Studio or Rider, or with Install-Package Flashpi in the Package Manager Console.
github.com/flashpi1/flashpi-go is the official SDK for Go. Its source is on GitHub, under the MIT license, and its README documents every operation.
- It covers every operation of the API: the six documents you can issue, reading documents (XML, RIDE and email), issuers (including creating them from their .p12) with their establishments, emission points and certificates, webhook endpoints, events and the API status.
- It follows Go’s conventions: a
context.Contexton every call, lists you walk withrangeand errors you check witherrors.As. One client serves many goroutines at once. - It runs on Go 1.26 or later and has no dependencies: the standard library only. Its structs are generated from the API’s OpenAPI document.
- Use it on your servers only: your API key is secret.
go get github.com/flashpi1/flashpi-goThe package is named flashpi: import it with import "github.com/flashpi1/flashpi-go" and use it as flashpi.NewClient().
flashpi is the official SDK for Ruby, a gem on RubyGems. Its source is on GitHub, under the MIT license.
- It covers every operation of the API: the six documents you can issue, reading documents (XML, RIDE and email), issuers (including creating them from their .p12) with their establishments, emission points and certificates, webhook endpoints, events and the API status.
- It has one client,
Flashpi::Client, which you can share between threads and which reuses its connections. - It runs on Ruby 3.3 or later and has no dependencies: it uses the standard library only (net/http). It ships RBS signatures generated from the API’s OpenAPI document, so Steep and the editors that read RBS know every field.
- Use it on your servers only: your API key is secret.
gem install flashpiIn a project with Bundler, such as a Rails app: bundle add flashpi.
Issue an invoice
Section titled “Issue an invoice”Create a test API key in the console with access to every issuer (scope account) and read and write access, and keep it in the FLASHPI_API_KEY environment variable (see Authentication and API keys). With the demo issuer you issue in the SRI test environment without a certificate of your own: copy its ID from the console’s Overview (see Sandbox and demo issuer).
import { Flashpi } from '@flashpi/node'
// Reads FLASHPI_API_KEY. You can also pass the key: new Flashpi('fp_test_...')const flashpi = new Flashpi()
const invoice = await flashpi.invoices.create({ issuerId: '<demo issuer id>', establishment: '001', emissionPoint: '001', customer: { idType: 'ruc', id: '1790000000001', name: 'Supermercados La Pradera S.A.', }, items: [ { code: 'SRV-001', description: 'Monthly accounting services', quantity: '1', unitPrice: '450.00', taxes: [{ type: 'vat', rate: '15' }], }, ], payments: [{ method: 'other_financial_system', amount: '517.50' }], options: { waitForAuthorization: true },})
console.log(invoice.status, invoice.accessKey)Cloudflare Workers have no global environment variables: pass the key from env, with new Flashpi(env.FLASHPI_API_KEY). On Deno, reading the variable needs the --allow-env=FLASHPI_API_KEY permission.
Amounts and quantities are strings ('450.00'), never numbers, so no decimals are lost. With options.waitForAuthorization, the request waits for the SRI’s answer and returns authorized, rejected or failed; if the SRI takes longer, it returns processing or pending_sri and the document carries on. Without it, the answer comes back at once with the status processing and the SRI’s decision arrives by webhook (invoice.authorized or invoice.rejected). A failed document raises no event yet: look for it with flashpi.documents.list({ status: 'failed' }). See Sync and async modes and Document lifecycle.
The other documents follow the same pattern, each with its create(body): creditNotes, debitNotes, purchaseSettlements, waybills and withholdings.
from flashpi import Flashpi
# Reads FLASHPI_API_KEY. You can also pass the key: Flashpi("fp_test_...")flashpi = Flashpi()
invoice = flashpi.invoices.create( { "issuerId": "<demo issuer id>", "establishment": "001", "emissionPoint": "001", "customer": { "idType": "ruc", "id": "1790000000001", "name": "Supermercados La Pradera S.A.", }, "items": [ { "code": "SRV-001", "description": "Monthly accounting services", "quantity": "1", "unitPrice": "450.00", "taxes": [{"type": "vat", "rate": "15"}], } ], "payments": [{"method": "other_financial_system", "amount": "517.50"}], "options": {"waitForAuthorization": True}, })
print(invoice["status"], invoice["accessKey"])Requests and answers are dictionaries with the API’s own keys, in camelCase (invoice["accessKey"]), typed with the TypedDicts of flashpi.types (such as CreateInvoice and Document), so your editor and your type checker know every field. A client keeps a pool of connections: create one and reuse it, and close it with flashpi.close() or use it in a with block.
Amounts and quantities are strings ("450.00"), never numbers, so no decimals are lost. With "options": {"waitForAuthorization": True}, the request waits for the SRI’s answer and returns authorized, rejected or failed; if the SRI takes longer, it returns processing or pending_sri and the document carries on. Without it, the answer comes back at once with the status processing and the SRI’s decision arrives by webhook (invoice.authorized or invoice.rejected). A failed document raises no event yet: look for it with flashpi.documents.list({"status": "failed"}). See Sync and async modes and Document lifecycle.
The other documents follow the same pattern, each with its create(body): credit_notes, debit_notes, purchase_settlements, waybills and withholdings.
With asyncio, use AsyncFlashpi: it has the same resources and methods, which you await.
import asyncio
from flashpi import AsyncFlashpi
async def main() -> None: async with AsyncFlashpi() as flashpi: invoice = await flashpi.invoices.create(body) # the same dictionary as above print(invoice["status"], invoice["accessKey"])
asyncio.run(main())To cancel a call, cancel its task (for example with asyncio.timeout()). Without async with, close the client with await flashpi.aclose().
<?php
use Flashpi\Flashpi;
// Reads FLASHPI_API_KEY. You can also pass the key: new Flashpi(apiKey: 'fp_test_...')$flashpi = new Flashpi();
$invoice = $flashpi->invoices->create([ 'issuerId' => '<demo issuer id>', 'establishment' => '001', 'emissionPoint' => '001', 'customer' => [ 'idType' => 'ruc', 'id' => '1790000000001', 'name' => 'Supermercados La Pradera S.A.', ], 'items' => [ [ 'code' => 'SRV-001', 'description' => 'Monthly accounting services', 'quantity' => '1', 'unitPrice' => '450.00', 'taxes' => [['type' => 'vat', 'rate' => '15']], ], ], 'payments' => [['method' => 'other_financial_system', 'amount' => '517.50']], 'options' => ['waitForAuthorization' => true],]);
echo $invoice['status'], ' ', $invoice['accessKey'], "\n";Requests and answers are associative arrays with the API’s own keys, in camelCase ($invoice['accessKey']), typed with the PHPStan array shapes of Flashpi\Types\ApiTypes (such as CreateInvoice and Document), so your editor and PHPStan know every field. For PHPStan to report a misspelled optional key too, include vendor/phpstan/phpstan/conf/bleedingEdge.neon in your phpstan.neon. The key is read from $_ENV, $_SERVER or getenv(). Create one client and reuse it.
Amounts and quantities are strings ('450.00'), never numbers, so no decimals are lost. With 'options' => ['waitForAuthorization' => true], the request waits for the SRI’s answer and returns authorized, rejected or failed; if the SRI takes longer, it returns processing or pending_sri and the document carries on. Without it, the answer comes back at once with the status processing and the SRI’s decision arrives by webhook (invoice.authorized or invoice.rejected). A failed document raises no event yet: look for it with $flashpi->documents->list(['status' => 'failed']). See Sync and async modes and Document lifecycle.
The other documents follow the same pattern, each with its create($body): creditNotes, debitNotes, purchaseSettlements, waybills and withholdings.
In Laravel, register the client as a singleton in a service provider and take it as Flashpi $flashpi in your controllers and jobs. Pass it the key from your configuration: with the configuration cached (php artisan config:cache), Laravel does not load .env.
<?php
namespace App\Providers;
use Flashpi\Flashpi;use Illuminate\Support\Facades\Config;use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider{ public function register(): void { // In config/services.php: // 'flashpi' => ['key' => env('FLASHPI_API_KEY'), 'webhook_secret' => env('FLASHPI_WEBHOOK_SECRET')], $this->app->singleton(Flashpi::class, fn(): Flashpi => new Flashpi( apiKey: Config::string('services.flashpi.key'), )); }}import ec.com.flashpi.Flashpi;import ec.com.flashpi.models.CreateInvoice;import ec.com.flashpi.models.Document;import ec.com.flashpi.models.DocumentItem;import ec.com.flashpi.models.DocumentOptions;import ec.com.flashpi.models.DocumentParty;import ec.com.flashpi.models.DocumentPayment;import ec.com.flashpi.models.DocumentTax;import ec.com.flashpi.models.IdType;import ec.com.flashpi.models.PaymentMethod;import ec.com.flashpi.models.TaxType;import java.util.List;
// Reads FLASHPI_API_KEY. You can also pass the key: new Flashpi("fp_test_...")Flashpi flashpi = new Flashpi();
CreateInvoice body = CreateInvoice.builder() .issuerId("<demo issuer id>") .establishment("001") .emissionPoint("001") .customer(DocumentParty.builder() .idType(IdType.RUC) .id("1790000000001") .name("Supermercados La Pradera S.A.") .build()) .items(List.of(DocumentItem.builder() .code("SRV-001") .description("Monthly accounting services") .quantity("1") .unitPrice("450.00") .taxes(List.of(DocumentTax.builder().type(TaxType.VAT).rate("15").build())) .build())) .payments(List.of(DocumentPayment.builder() .method(PaymentMethod.OTHER_FINANCIAL_SYSTEM) .amount("517.50") .build())) .options(DocumentOptions.builder().waitForAuthorization(true).build()) .build();
Document invoice = flashpi.invoices().create(body);
System.out.println(invoice.getStatus() + " " + invoice.getAccessKey());The models are in ec.com.flashpi.models and use the API’s names: you build them with builders and read them with getters (invoice.getAccessKey()). Fixed values are enums (IdType.RUC, DocumentStatus.AUTHORIZED), with UNKNOWN for a value this version of the SDK does not know yet, and dates are LocalDate and OffsetDateTime. A client is thread-safe and keeps a pool of connections: create one and share it (for example, as a Spring bean), and close it with flashpi.close() or use it in a try (Flashpi flashpi = new Flashpi()) { ... }.
Amounts and quantities are strings ("450.00"), never numbers, so no decimals are lost. With DocumentOptions.builder().waitForAuthorization(true), the request waits for the SRI’s answer and returns AUTHORIZED, REJECTED or FAILED; if the SRI takes longer, it returns PROCESSING or PENDING_SRI and the document carries on. Without it, the answer comes back at once with the status PROCESSING and the SRI’s decision arrives by webhook (invoice.authorized or invoice.rejected). A FAILED document raises no event yet: look for it with flashpi.documents().list(DocumentsListParams.builder().status(DocumentStatus.FAILED).build()). See Sync and async modes and Document lifecycle.
The other documents follow the same pattern, each with its create(body) and the builder of its body: creditNotes() (CreateCreditNote), debitNotes() (CreateDebitNote), purchaseSettlements() (CreatePurchaseSettlement), waybills() (CreateWaybill) and withholdings() (CreateWithholding).
From Kotlin you use the same SDK: the builders read the same, with listOf(...) instead of List.of(...), getters read as properties and enums work with when.
import ec.com.flashpi.Flashpiimport ec.com.flashpi.models.CreateInvoiceimport ec.com.flashpi.models.DocumentStatus
val flashpi = Flashpi() // one client for the whole app
fun issue(body: CreateInvoice) { // the same CreateInvoice as above val invoice = flashpi.invoices().create(body) when (invoice.status) { DocumentStatus.AUTHORIZED -> println("Authorized: ${invoice.accessKey}") DocumentStatus.REJECTED -> println("Rejected: ${invoice.sriMessages}") else -> println("Status: ${invoice.status}") }}using Flashpi;using Flashpi.Models;
// Reads FLASHPI_API_KEY. You can also pass the key: new FlashpiClient("fp_test_...")using var flashpi = new FlashpiClient();
var invoice = await flashpi.Invoices.CreateAsync(new CreateInvoice{ IssuerId = "<demo issuer id>", Establishment = "001", EmissionPoint = "001", Customer = new DocumentParty { IdType = IdType.Ruc, Id = "1790000000001", Name = "Supermercados La Pradera S.A.", }, Items = [ new DocumentItem { Code = "SRV-001", Description = "Monthly accounting services", Quantity = "1", UnitPrice = "450.00", Taxes = [new DocumentTax { Type = TaxType.Vat, Rate = "15" }], }, ], Payments = [new DocumentPayment { Method = PaymentMethod.OtherFinancialSystem, Amount = "517.50" }], Options = new DocumentOptions { WaitForAuthorization = true },});
Console.WriteLine($"{invoice.Status} {invoice.AccessKey}");The models are in Flashpi.Models, with the API’s properties in PascalCase (invoice.AccessKey is accessKey); the compiler asks you for the required ones. Fixed values, such as IdType.Ruc or DocumentStatus.Authorized, also take the ones this version of the SDK does not know yet: compare them with ==. A client keeps a pool of connections: create one and reuse it, and release it with using or Dispose(). In ASP.NET Core, register it as a singleton: builder.Services.AddSingleton(new FlashpiClient()).
Amounts and quantities are strings ("450.00"), never numbers, so no decimals are lost. With Options = new DocumentOptions { WaitForAuthorization = true }, the request waits for the SRI’s answer and returns authorized, rejected or failed; if the SRI takes longer, it returns processing or pending_sri and the document carries on. Without it, the answer comes back at once with the status processing and the SRI’s decision arrives by webhook (invoice.authorized or invoice.rejected). A failed document raises no event yet: look for it with flashpi.Documents.ListAsync(new DocumentListParams { Status = DocumentStatus.Failed }). See Sync and async modes and Document lifecycle.
The other documents follow the same pattern, each with its CreateAsync(body): CreditNotes, DebitNotes, PurchaseSettlements, Waybills and Withholdings.
Every method takes an optional CancellationToken last: cancelling it throws OperationCanceledException.
package main
import ( "context" "fmt" "log"
"github.com/flashpi1/flashpi-go")
func main() { // Reads FLASHPI_API_KEY. You can also pass the key: flashpi.NewClient(flashpi.WithAPIKey("fp_test_...")) client, err := flashpi.NewClient() if err != nil { log.Fatal(err) }
invoice, err := client.Invoices.Create(context.Background(), flashpi.CreateInvoice{ IssuerID: "<demo issuer id>", Establishment: "001", EmissionPoint: "001", Customer: flashpi.DocumentParty{ IDType: flashpi.IDTypeRUC, ID: new("1790000000001"), Name: new("Supermercados La Pradera S.A."), }, Items: []flashpi.DocumentItem{{ Code: new("SRV-001"), Description: "Monthly accounting services", Quantity: "1", UnitPrice: "450.00", Taxes: []flashpi.DocumentTax{{Type: flashpi.TaxTypeVAT, Rate: "15"}}, }}, Payments: []flashpi.DocumentPayment{ {Method: flashpi.PaymentMethodOtherFinancialSystem, Amount: "517.50"}, }, Options: &flashpi.DocumentOptions{WaitForAuthorization: new(true)}, }) if err != nil { log.Fatal(err) }
fmt.Println(invoice.Status, invoice.AccessKey)}Requests and answers are structs with the API’s fields (invoice.AccessKey is accessKey), generated from the OpenAPI document, so your editor and the compiler know every field. Optional fields are pointers, which you make with new("value"). A client keeps its connections open and is safe to use from many goroutines: create one and reuse it.
Amounts and quantities are strings ("450.00"), never numbers, so no decimals are lost. With Options: &flashpi.DocumentOptions{WaitForAuthorization: new(true)}, the request waits for the SRI’s answer and returns authorized, rejected or failed; if the SRI takes longer, it returns processing or pending_sri and the document carries on. Without it, the answer comes back at once with the status processing and the SRI’s decision arrives by webhook (invoice.authorized or invoice.rejected). A failed document raises no event yet: look for it with client.Documents.List(ctx, &flashpi.DocumentListParams{Status: new(flashpi.DocumentStatusFailed)}). See Sync and async modes and Document lifecycle.
The other documents follow the same pattern, each with its Create(ctx, body): CreditNotes, DebitNotes, PurchaseSettlements, Waybills and Withholdings.
The ctx of each call bounds the whole call, retries included: to cancel it or give it a deadline, pass it a context from context.WithCancel or context.WithTimeout. When it is cancelled or its deadline passes, the call stops without retrying.
require "flashpi"
# Reads FLASHPI_API_KEY. You can also pass the key: Flashpi::Client.new(api_key: "fp_test_...")flashpi = Flashpi::Client.new
invoice = flashpi.invoices.create( { issuerId: "<demo issuer id>", establishment: "001", emissionPoint: "001", customer: { idType: "ruc", id: "1790000000001", name: "Supermercados La Pradera S.A." }, items: [ { code: "SRV-001", description: "Monthly accounting services", quantity: "1", unitPrice: "450.00", taxes: [{ type: "vat", rate: "15" }] } ], payments: [{ method: "other_financial_system", amount: "517.50" }], options: { waitForAuthorization: true } })
puts invoice[:status], invoice[:accessKey]Requests and answers are hashes with the API’s own keys, in camelCase and as symbols (invoice[:accessKey]), with RBS types in Flashpi::Types (such as Flashpi::Types::create_invoice and Flashpi::Types::document), so Steep and your editor know every field. Write the body in braces, create({ ... }): without them, Ruby would take it as the options of the call. A client keeps its connections open and can be shared between threads: create one and reuse it (in Rails, for example, in an initializer). flashpi.close closes those connections.
Amounts and quantities are strings ("450.00"), never numbers, so no decimals are lost; if you hold them in a BigDecimal (such as Active Record’s decimal columns), convert them with to_s("F"), since the SDK does not take a BigDecimal and raises a TypeError. With options: { waitForAuthorization: true }, the request waits for the SRI’s answer and returns authorized, rejected or failed; if the SRI takes longer, it returns processing or pending_sri and the document carries on. Without it, the answer comes back at once with the status processing and the SRI’s decision arrives by webhook (invoice.authorized or invoice.rejected). A failed document raises no event yet: look for it with flashpi.documents.list({ status: "failed" }). See Sync and async modes and Document lifecycle.
The other documents follow the same pattern, each with its create(body): credit_notes, debit_notes, purchase_settlements, waybills and withholdings.
Errors
Section titled “Errors”Each API error is thrown as an APIError, as the subclass that matches its HTTP status when there is one (such as UnprocessableEntityError for 422). Branch on code, which is stable and in English. The message follows the language you ask for: new Flashpi({ language: 'es' }) gives the messages in Spanish.
import { APIError, UnprocessableEntityError } from '@flashpi/node'
try { await flashpi.invoices.create(body)} catch (error) { if (error instanceof UnprocessableEntityError) { console.log(error.code) // 'validation_failed', 'total_mismatch', ... for (const field of error.errors) console.log(field.field, field.message) } else if (error instanceof APIError) { console.log(error.status, error.code, error.requestId) // share requestId with support } throw error}Each API error is raised as an APIError, as the subclass that matches its HTTP status when there is one (such as UnprocessableEntityError for 422). Branch on code, which is stable and in English. The message follows the language you ask for: Flashpi(language="es") gives the messages in Spanish.
from flashpi import APIError, UnprocessableEntityError
try: flashpi.invoices.create(body)except UnprocessableEntityError as error: print(error.code) # "validation_failed", "total_mismatch", ... for field in error.errors: print(field["field"], field["message"]) raiseexcept APIError as error: print(error.status, error.code, error.request_id) # share request_id with support raiseThe subclasses are BadRequestError (400), AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404), ConflictError (409), UnprocessableEntityError (422), RateLimitError (429) and InternalServerError (5xx). Without an answer, such as after a network failure, the error is an APIConnectionError, or an APITimeoutError when the time ran out. Every error of the SDK derives from FlashpiError.
Each API error is thrown as an ApiException, as the subclass that matches its HTTP status when there is one (such as UnprocessableEntityException for 422), all in Flashpi\Exception. Branch on errorCode, which is stable and in English (getCode() gives the HTTP status). The message follows the language you ask for: new Flashpi(language: 'es') gives the messages in Spanish.
<?php
use Flashpi\Exception\ApiException;use Flashpi\Exception\UnprocessableEntityException;
try { $flashpi->invoices->create($body);} catch (UnprocessableEntityException $error) { echo $error->errorCode, "\n"; // 'validation_failed', 'total_mismatch', ... foreach ($error->errors as $field) { echo $field['field'], ' ', $field['message'], "\n"; } throw $error;} catch (ApiException $error) { echo $error->status, ' ', $error->errorCode, ' ', $error->requestId, "\n"; // share requestId with support throw $error;}The subclasses are BadRequestException (400), AuthenticationException (401), PermissionDeniedException (403), NotFoundException (404), ConflictException (409), UnprocessableEntityException (422), RateLimitException (429) and InternalServerException (5xx). Without an answer, such as after a network failure, the error is a ConnectionException, or a TimeoutException when the time ran out. Some errors carry extra members in $error->problem, such as documentId, limit or resetsAt. Every exception of the SDK derives from FlashpiException, except those of a misused argument (an unknown option, for example), which are \InvalidArgumentException or \TypeError.
Each API error is thrown as an ApiException, as the subclass that matches its HTTP status when there is one (such as UnprocessableEntityException for 422); all of them are unchecked exceptions. Branch on getCode(), which is stable and in English. The message follows the language you ask for: Flashpi.builder().language(Language.ES).build() gives the messages in Spanish.
import ec.com.flashpi.exceptions.ApiException;import ec.com.flashpi.exceptions.UnprocessableEntityException;import ec.com.flashpi.models.FieldError;
try { flashpi.invoices().create(body);} catch (UnprocessableEntityException error) { System.out.println(error.getCode()); // "validation_failed", "total_mismatch", ... for (FieldError field : error.getErrors()) { System.out.println(field.getField() + ": " + field.getMessage()); } throw error;} catch (ApiException error) { // Share getRequestId() with support. System.out.println(error.getStatus() + " " + error.getCode() + " " + error.getRequestId()); throw error;}The subclasses are BadRequestException (400), AuthenticationException (401), PermissionDeniedException (403), NotFoundException (404), ConflictException (409), UnprocessableEntityException (422), RateLimitException (429) and InternalServerException (5xx), all of them in ec.com.flashpi.exceptions. Without an answer, such as after a network failure, the error is an ApiConnectionException, or an ApiTimeoutException when the time ran out. Every error of the SDK derives from FlashpiException, which extends RuntimeException. Some carry extra data in getProblem(), such as getDocumentId() or getResetsAt().
Each API error is thrown as an ApiException, as the subclass that matches its HTTP status when there is one (such as UnprocessableEntityException for 422). Branch on Code, which is stable and in English. The message follows the language you ask for: new FlashpiClient(new FlashpiClientOptions { Language = Language.Spanish }) gives the messages in Spanish.
using Flashpi;
try{ await flashpi.Invoices.CreateAsync(body);}catch (UnprocessableEntityException error){ Console.WriteLine(error.Code); // validation_failed, total_mismatch, ... foreach (var field in error.Errors) { Console.WriteLine($"{field.Field}: {field.Message}"); } throw;}catch (ApiException error){ Console.WriteLine($"{error.StatusCode} {error.Code} {error.RequestId}"); // share RequestId with support throw;}The subclasses are BadRequestException (400), AuthenticationException (401), PermissionDeniedException (403), NotFoundException (404), ConflictException (409), UnprocessableEntityException (422), RateLimitException (429, with the wait in RetryAfter) and InternalServerException (5xx). Without an answer, such as after a network failure, the error is an ApiConnectionException, or an ApiTimeoutException when the time ran out. Every error of the SDK derives from FlashpiException. For one code, use a filter: catch (ConflictException error) when (error.Code == ErrorCode.SequentialAlreadyUsed). Some errors carry extra data in error.Problem, such as DocumentId or ResetsAt.
Each API error is a *flashpi.APIError, wrapped in the type of its HTTP status when there is one (such as *flashpi.UnprocessableEntityError for 422). Check it with errors.As and branch on Code, which is stable and in English. The message follows the language you ask for: flashpi.NewClient(flashpi.WithLanguage(flashpi.LanguageSpanish)) gives the messages in Spanish.
_, err := client.Invoices.Create(ctx, body)if err != nil { var invalid *flashpi.UnprocessableEntityError var apiErr *flashpi.APIError switch { case errors.As(err, &invalid): fmt.Println(invalid.Code) // "validation_failed", "total_mismatch", ... for _, field := range invalid.Errors { fmt.Println(field.Field, field.Message) } case errors.As(err, &apiErr): fmt.Println(apiErr.Status, apiErr.Code, apiErr.RequestID) // share RequestID with support } return err}The status types are BadRequestError (400), AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404), ConflictError (409), UnprocessableEntityError (422), RateLimitError (429) and InternalServerError (5xx); other statuses come as a *flashpi.APIError. Without an answer, such as after a network failure, the error is a *flashpi.ConnectionError, or a *flashpi.TimeoutError when the time ran out. Every error of the SDK can also be read as a *flashpi.Error with errors.As.
Each API error is raised as a Flashpi::APIError, as the subclass that matches its HTTP status when there is one (such as Flashpi::UnprocessableEntityError for 422). Branch on code, which is stable and in English. The message follows the language you ask for: Flashpi::Client.new(language: "es") gives the messages in Spanish.
begin flashpi.invoices.create(body)rescue Flashpi::UnprocessableEntityError => e puts e.code # "validation_failed", "total_mismatch", ... e.errors.each { |field| puts "#{field[:field]}: #{field[:message]}" } raiserescue Flashpi::APIError => e puts e.status, e.code, e.request_id # share request_id with support raiseendThe subclasses are Flashpi::BadRequestError (400), Flashpi::AuthenticationError (401), Flashpi::PermissionDeniedError (403), Flashpi::NotFoundError (404), Flashpi::ConflictError (409), Flashpi::UnprocessableEntityError (422), Flashpi::RateLimitError (429) and Flashpi::InternalServerError (5xx). Some errors carry extra data in e.problem, such as documentId, limit or resetsAt. Without an answer, such as after a network failure, the error is a Flashpi::APIConnectionError, or a Flashpi::APITimeoutError when the time ran out. Every error of the SDK derives from Flashpi::Error; a misused argument raises an ArgumentError or a TypeError, as with any Ruby method.
See the codes and what to do about each one in Errors.
Retries and idempotency
Section titled “Retries and idempotency”Each SDK retries up to twice, waiting longer each time, on network errors, timeouts and 408, 429 and 5xx answers, except 501 and when the API asks to wait more than 60 seconds (as daily limits do). It does not retry 503 demo_issuer_unavailable either: a retry seconds later does not fix it.
Every document it issues carries an Idempotency-Key that its retries repeat, so a retry never issues twice: if the first attempt already created the document, the SDK returns that document.
To use your own key, such as the ID of your order, pass it in the call’s options:
await flashpi.invoices.create(body, { idempotencyKey: `order-${order.id}` })flashpi.invoices.create(body, idempotency_key=f"order-{order.id}")Every method takes the options of its call as keyword arguments: idempotency_key, timeout (in seconds), max_retries, language, correlation_id (your own ID, which Flashpi logs) and extra_headers. If issuing still fails, the error carries the key it used in error.idempotency_key: calling again with idempotency_key=error.idempotency_key never issues a second document.
<?php
$flashpi->invoices->create($body, ['idempotencyKey' => "order-{$order->id}"]);Every method takes the options of its call in an array, as its last argument: idempotencyKey, timeout (in seconds), maxRetries, language, correlationId (your own ID, which Flashpi logs) and extraHeaders. An unknown option throws an \InvalidArgumentException. If issuing still fails, the exception carries the key it used in $error->idempotencyKey: calling again with ['idempotencyKey' => $error->idempotencyKey] never issues a second document.
import ec.com.flashpi.RequestOptions;
RequestOptions options = RequestOptions.builder().idempotencyKey("order-" + order.getId()).build();flashpi.invoices().create(body, options);Every method takes, as its last argument, the options of its call, built with RequestOptions.builder(): idempotencyKey, timeout (a Duration), maxRetries, language, correlationId (your own ID, which Flashpi logs) and extra headers (header or extraHeaders, ASCII only). If issuing still fails, the exception carries the key it used in getIdempotencyKey(): calling again with RequestOptions.builder().idempotencyKey(error.getIdempotencyKey()) never issues a second document. If you interrupt a call’s thread, the SDK stops waiting, throws a FlashpiException with the key and keeps the thread’s interrupted status.
await flashpi.Invoices.CreateAsync(body, new RequestOptions { IdempotencyKey = $"order-{order.Id}" });Every method takes the options of its call in a RequestOptions, before the CancellationToken: IdempotencyKey, Timeout (a TimeSpan), MaxRetries, Language, CorrelationId (your own ID, which Flashpi logs) and ExtraHeaders. If issuing still fails, the error (a FlashpiException) carries the key it used in error.IdempotencyKey: calling again with IdempotencyKey = error.IdempotencyKey never issues a second document. Cancelling the call through its CancellationToken throws OperationCanceledException, which carries no key: if you cancel issuing, pass your own IdempotencyKey.
_, err := client.Invoices.Create(ctx, body, flashpi.WithIdempotencyKey("order-"+order.ID))if err != nil { return err}Every method takes the options of its call last: flashpi.WithIdempotencyKey, flashpi.WithTimeout (a time.Duration per attempt), flashpi.WithMaxRetries, flashpi.WithLanguage, flashpi.WithCorrelationID (your own ID, which Flashpi logs) and flashpi.WithHeader. WithTimeout, WithMaxRetries, WithLanguage and WithHeader can also go to flashpi.NewClient, for every call.
If issuing still fails, the error carries the key it used in IdempotencyKey: calling again with flashpi.WithIdempotencyKey(key) never issues a second document.
_, err := client.Invoices.Create(ctx, body)var flashpiErr *flashpi.Errorif errors.As(err, &flashpiErr) && flashpiErr.IdempotencyKey != "" { saveForRetry(body, flashpiErr.IdempotencyKey) // your retry queue}flashpi.invoices.create(body, idempotency_key: "order-#{order.id}")Every method takes the options of its call as keyword arguments: idempotency_key, timeout (in seconds), max_retries, language, correlation_id (your own ID, which Flashpi logs) and extra_headers. If issuing still fails, the error (a Flashpi::Error) carries the key it used in error.idempotency_key: calling again with idempotency_key: error.idempotency_key never issues a second document. A call you interrupt from outside (with Timeout.timeout, or by stopping the process or the thread) raises no Flashpi::Error and has no key: if you do that to issuing calls, pass your own idempotency_key.
A daily limit, such as the demo issuer’s quota, is not retried: it arrives as a Flashpi::RateLimitError with retry_after in seconds.
See Idempotency.
A list returns one page.
With for await you walk through every page, which the SDK requests as you go:
for await (const document of flashpi.documents.list({ status: 'authorized' })) { console.log(document.accessKey)}With for you walk through every page, which the SDK requests as you go. The filters are a dictionary with the API’s names, in camelCase:
for document in flashpi.documents.list({"status": "authorized", "issueDateFrom": "2026-11-01"}): print(document["accessKey"])With AsyncFlashpi, async for walks through every page.
With foreach you walk through every page, which the SDK requests as you go. The filters are an array with the API’s names, in camelCase:
<?php
foreach ($flashpi->documents->list(['status' => 'authorized', 'issueDateFrom' => '2026-11-01']) as $document) { echo $document['accessKey'], "\n";}To move from one page to the next, use $page->data, $page->hasNextPage() and $page->nextPage().
With for you walk through every page, which the SDK requests as you go, and stream() does the same lazily. The filters are built with DocumentsListParams.builder(), with enums and LocalDate dates:
import ec.com.flashpi.models.Document;import ec.com.flashpi.models.DocumentStatus;import ec.com.flashpi.models.DocumentsListParams;import java.time.LocalDate;
DocumentsListParams params = DocumentsListParams.builder() .status(DocumentStatus.AUTHORIZED) .issueDateFrom(LocalDate.of(2026, 11, 1)) .build();for (Document document : flashpi.documents().list(params)) { System.out.println(document.getAccessKey());}To move one page at a time, use getData(), hasNextPage() and nextPage() of the Page that list returns.
With await foreach you walk through every page, which the SDK requests as you go. The filters go in a DocumentListParams:
await foreach (var document in flashpi.Documents.ListAsync(new DocumentListParams{ Status = DocumentStatus.Authorized, IssueDateFrom = "2026-11-01",})){ Console.WriteLine(document.AccessKey);}With await you get the first page only: a Page<Document> with Data, HasNextPage and GetNextPageAsync().
With range you walk through every page, which the SDK requests as you go; when a page fails, you get its error in err. The filters are a *flashpi.DocumentListParams with the API’s names, and nil means no filters:
params := &flashpi.DocumentListParams{ Status: new(flashpi.DocumentStatusAuthorized), IssueDateFrom: new("2026-11-01"),}for document, err := range client.Documents.List(ctx, params) { if err != nil { return err } fmt.Println(document.AccessKey)}For one page at a time, ListPage returns a *flashpi.Page with the documents in Data, and page.NextPage(ctx) the next page (nil after the last one).
With each you walk through every page, which the SDK requests as you go. The filters are a hash in braces with the API’s names, in camelCase, before the options of the call:
flashpi.documents.list({ status: "authorized", issueDateFrom: "2026-11-01" }).each do |document| puts document[:accessKey]endThe page is Enumerable, so first(10) requests only the pages it needs. To move one page at a time, use data, has_next_page? and next_page.
See Pagination.
Webhooks
Section titled “Webhooks”unwrapWebhook verifies the signature against the body exactly as it arrived, unparsed, and the endpoint’s whsec_ secret, and returns the event with its type. It needs no API key. It works in Next.js, Hono, Cloudflare Workers and anywhere with Request; with Express, read the body with express.raw({ type: 'application/json' }).
import { unwrapWebhook, WebhookVerificationError } from '@flashpi/node'
export async function POST(request: Request): Promise<Response> { let event try { event = await unwrapWebhook( await request.text(), request.headers, process.env.FLASHPI_WEBHOOK_SECRET ?? '', ) } catch (error) { if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 }) throw error }
switch (event.type) { case 'webhook_endpoint.verification': // The endpoint's activation: answer the challenge. return Response.json({ challenge: event.data.challenge }) case 'invoice.authorized': console.log('Authorized', event.data.accessKey) // event.data is the document break } return new Response(null, { status: 204 })}Store event.id (the same value as the webhook-id header) and, when it repeats, answer 2xx without doing the work again: an event can arrive more than once.
The example assumes the endpoint’s default full mode. In thin mode, events other than the verification come without data: use unwrapWebhook<ThinWebhookEvent>(...) (the type is also imported from @flashpi/node) and read the event with flashpi.events.get(event.id). See Webhooks and its Payloads.
unwrap_webhook verifies the signature against the body exactly as it arrived, unparsed, and the endpoint’s whsec_ secret, and returns the event. It needs no API key, and it does no I/O, so you call it without await in async views too. With Django:
import os
from django.http import HttpRequest, HttpResponse, JsonResponsefrom django.views.decorators.csrf import csrf_exemptfrom django.views.decorators.http import require_POST
from flashpi import WebhookVerificationError, unwrap_webhook
@csrf_exempt@require_POSTdef flashpi_webhook(request: HttpRequest) -> HttpResponse: try: event = unwrap_webhook(request.body, request.headers, os.environ["FLASHPI_WEBHOOK_SECRET"]) except WebhookVerificationError: return HttpResponse(status=400)
if event["type"] == "webhook_endpoint.verification": # The endpoint's activation: answer the challenge. return JsonResponse({"challenge": event["data"]["challenge"]}) if event["type"] == "invoice.authorized": print("Authorized", event["data"]["accessKey"]) # event["data"] is the document elif event["type"] == "invoice.rejected": print("Rejected", event["data"]["sriMessages"]) return HttpResponse(status=204)With Flask, whose request.headers is not a Mapping, pass dict(request.headers):
import os
from flask import Flask, request
from flashpi import WebhookVerificationError, unwrap_webhook
app = Flask(__name__)
@app.post("/webhooks/flashpi")def flashpi_webhook(): try: event = unwrap_webhook( request.get_data(), dict(request.headers), os.environ["FLASHPI_WEBHOOK_SECRET"] ) except WebhookVerificationError: return "", 400
if event["type"] == "webhook_endpoint.verification": return {"challenge": event["data"]["challenge"]} handle_event(event) # your code, like the branches of the Django example return "", 204With FastAPI:
import os
from fastapi import FastAPI, Request, Responsefrom fastapi.responses import JSONResponse
from flashpi import WebhookVerificationError, unwrap_webhook
app = FastAPI()
@app.post("/webhooks/flashpi")async def flashpi_webhook(request: Request) -> Response: try: event = unwrap_webhook( await request.body(), request.headers, os.environ["FLASHPI_WEBHOOK_SECRET"] ) except WebhookVerificationError: return Response(status_code=400)
if event["type"] == "webhook_endpoint.verification": return JSONResponse({"challenge": event["data"]["challenge"]}) handle_event(event) # your code, like the branches of the Django example return Response(status_code=204)Store event["id"] (the same value as the webhook-id header) and, when it repeats, answer 2xx without doing the work again: an event can arrive more than once.
The examples assume the endpoint’s default full mode. In thin mode, events other than the verification come without data: use cast(ThinWebhookEvent, unwrap_webhook(...)) (the type is imported from flashpi.types) and read the event with flashpi.events.get(event["id"]). See Webhooks and its Payloads.
Flashpi::unwrapWebhook() verifies the signature against the body exactly as it arrived, unparsed, and the endpoint’s whsec_ secret, and returns the event. It is static and needs no API key, and it takes the headers as your framework hands them over: Laravel’s and Symfony’s HeaderBag, PSR-7’s getHeaders() or getallheaders(). With Laravel, add the route to routes/api.php, which has no CSRF check (if your app lacks it, php artisan install:api creates it), with Route::post('/webhooks/flashpi', FlashpiWebhookController::class); its URL is /api/webhooks/flashpi:
<?php
namespace App\Http\Controllers;
use Flashpi\Exception\WebhookVerificationException;use Flashpi\Flashpi;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;use Illuminate\Http\Response;use Illuminate\Support\Facades\Config;use Illuminate\Support\Facades\Log;
final class FlashpiWebhookController{ public function __invoke(Request $request): Response|JsonResponse { try { $event = Flashpi::unwrapWebhook( $request->getContent(), $request->headers, Config::string('services.flashpi.webhook_secret'), ); } catch (WebhookVerificationException) { return new Response('', 400); }
if ($event['type'] === 'webhook_endpoint.verification') { // The endpoint's activation: answer the challenge. return new JsonResponse(['challenge' => $event['data']['challenge']]); } if ($event['type'] === 'invoice.authorized') { Log::info('Authorized', ['accessKey' => $event['data']['accessKey']]); // $event['data'] is the document } elseif ($event['type'] === 'invoice.rejected') { Log::warning('Rejected', ['sriMessages' => $event['data']['sriMessages']]); }
return new Response('', 204); }}With Symfony:
<?php
namespace App\Controller;
use Flashpi\Exception\WebhookVerificationException;use Flashpi\Flashpi;use Symfony\Component\DependencyInjection\Attribute\Autowire;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;
final class FlashpiWebhookController{ public function __construct( #[Autowire(env: 'FLASHPI_WEBHOOK_SECRET')] private readonly string $webhookSecret, ) {}
#[Route('/webhooks/flashpi', methods: ['POST'])] public function __invoke(Request $request): Response { try { $event = Flashpi::unwrapWebhook($request->getContent(), $request->headers, $this->webhookSecret); } catch (WebhookVerificationException) { return new Response('', 400); }
if ($event['type'] === 'webhook_endpoint.verification') { return new JsonResponse(['challenge' => $event['data']['challenge']]); } handleEvent($event); // your code, like the branches of the Laravel example
return new Response('', 204); }}With no framework:
<?php
use Flashpi\Exception\WebhookVerificationException;use Flashpi\Flashpi;
try { $event = Flashpi::unwrapWebhook( (string) file_get_contents('php://input'), getallheaders(), (string) getenv('FLASHPI_WEBHOOK_SECRET'), );} catch (WebhookVerificationException) { http_response_code(400); exit;}
if ($event['type'] === 'webhook_endpoint.verification') { header('Content-Type: application/json'); echo json_encode(['challenge' => $event['data']['challenge']]); exit;}handleEvent($event); // your code, like the branches of the Laravel examplehttp_response_code(204);With Guzzle 7, PHPStan reads getallheaders() as mixed: pass (array) getallheaders() there.
Store $event['id'] (the same value as the webhook-id header) and, when it repeats, answer 2xx without doing the work again: an event can arrive more than once.
The examples assume the endpoint’s default full mode. In thin mode, events other than the verification come without data: type the result as ThinWebhookEvent (@phpstan-import-type ThinWebhookEvent from \Flashpi\Types\SdkTypes) and read the event with $flashpi->events->get($event['id']). See Webhooks and its Payloads.
The unwrap method of Webhooks (new Webhooks()) verifies the signature against the body exactly as it arrived, unparsed, and the endpoint’s whsec_ secret, and returns the event, whose class you check with instanceof. Webhooks needs no API key (you also have it as flashpi.webhooks()), does no I/O and is thread-safe. With Spring Boot, take the body as a byte[]:
import ec.com.flashpi.Webhooks;import ec.com.flashpi.exceptions.WebhookVerificationException;import ec.com.flashpi.models.DocumentEvent;import ec.com.flashpi.models.DocumentEventType;import ec.com.flashpi.models.WebhookEndpointVerificationEvent;import ec.com.flashpi.models.WebhookEvent;import java.util.Map;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestHeader;import org.springframework.web.bind.annotation.RestController;
@RestControllerpublic class FlashpiWebhookController { private final Webhooks webhooks = new Webhooks(); private final String secret = System.getenv("FLASHPI_WEBHOOK_SECRET");
@PostMapping("/webhooks/flashpi") public ResponseEntity<?> receive( @RequestBody byte[] body, @RequestHeader Map<String, String> headers) { WebhookEvent event; try { event = webhooks.unwrap(body, headers, secret); } catch (WebhookVerificationException error) { return ResponseEntity.badRequest().build(); }
if (event instanceof WebhookEndpointVerificationEvent) { // The endpoint's activation: answer the challenge. String challenge = ((WebhookEndpointVerificationEvent) event).getData().getChallenge(); return ResponseEntity.ok(Map.of("challenge", challenge)); } if (event instanceof DocumentEvent) { DocumentEvent documentEvent = (DocumentEvent) event; // getData() is the document if (documentEvent.getType() == DocumentEventType.INVOICE_AUTHORIZED) { System.out.println("Authorized " + documentEvent.getData().getAccessKey()); } else if (documentEvent.getType() == DocumentEventType.INVOICE_REJECTED) { System.out.println("Rejected " + documentEvent.getData().getSriMessages()); } } return ResponseEntity.noContent().build(); }}With a servlet or any other framework, pass the headers as a function: webhooks.unwrap(body, request::getHeader, secret); with Spring’s HttpHeaders, headers::getFirst. Any Map whose values are text or lists of text works too.
Store event.getId() (the same value as the webhook-id header) and, when it repeats, answer 2xx without doing the work again: an event can arrive more than once.
The example assumes the endpoint’s default full mode. In thin mode, events other than the verification come with their class but without data (getData() returns null): read the whole event with flashpi.events().get(event.getId()). An event of a type this version of the SDK does not know yet arrives as an EventThin. See Webhooks and its Payloads.
Webhooks.Unwrap verifies the signature against the body exactly as it arrived, unparsed, and the endpoint’s whsec_ secret, and returns the event with its type: WebhookEndpointVerificationEvent (the endpoint’s activation), DocumentEvent, CertificateEvent, AccountEvent or UnknownEvent (a type this version of the SDK does not know yet). It needs no API key, and it does no I/O. With ASP.NET Core, in a minimal API:
using Flashpi;using Flashpi.Models;
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();var webhookSecret = app.Configuration["FLASHPI_WEBHOOK_SECRET"] ?? throw new InvalidOperationException("Set FLASHPI_WEBHOOK_SECRET");
app.MapPost("/webhooks/flashpi", async (HttpRequest request) =>{ using var reader = new StreamReader(request.Body); var payload = await reader.ReadToEndAsync(); WebhookEvent webhook; try { webhook = Webhooks.Unwrap(payload, request.Headers, webhookSecret); } catch (WebhookVerificationException) { return Results.BadRequest(); }
switch (webhook) { case WebhookEndpointVerificationEvent verification: // The endpoint's activation: answer the challenge. return Results.Json(new { challenge = verification.Data.Challenge }); case DocumentEvent authorized when authorized.Type == EventType.InvoiceAuthorized: Console.WriteLine($"Authorized {authorized.Data.AccessKey}"); // Data is the document break; case DocumentEvent rejected when rejected.Type == EventType.InvoiceRejected: foreach (var message in rejected.Data.SriMessages) { Console.WriteLine($"Rejected {message.Code}: {message.Message}"); } break; } return Results.NoContent();});
app.Run();With an ASP.NET Core controller (register the controllers with builder.Services.AddControllers() and app.MapControllers()):
using Flashpi;using Flashpi.Models;using Microsoft.AspNetCore.Mvc;
[ApiController][Route("webhooks/flashpi")]public sealed class FlashpiWebhookController(IConfiguration configuration) : ControllerBase{ [HttpPost] public async Task<IActionResult> Receive() { var secret = configuration["FLASHPI_WEBHOOK_SECRET"] ?? throw new InvalidOperationException("Set FLASHPI_WEBHOOK_SECRET"); using var reader = new StreamReader(Request.Body); var payload = await reader.ReadToEndAsync(); WebhookEvent webhook; try { webhook = Webhooks.Unwrap(payload, Request.Headers, secret); } catch (WebhookVerificationException) { return BadRequest(); }
if (webhook is WebhookEndpointVerificationEvent verification) { return Ok(new { challenge = verification.Data.Challenge }); } HandleEvent(webhook); return NoContent(); }
// Your code, like the branches of the minimal API example. private static void HandleEvent(WebhookEvent webhook) => Console.WriteLine(webhook.Type);}Both examples read the secret from the ASP.NET Core configuration, which includes the environment variables. Webhooks.Unwrap also takes the headers as HttpHeaders, as a Dictionary<string, string> or as ASP.NET’s NameValueCollection on .NET Framework, and the body as a byte[].
Store webhook.Id (the same value as the webhook-id header) and, when it repeats, answer 2xx without doing the work again: an event can arrive more than once.
The examples assume the endpoint’s default full mode. In thin mode, Webhooks.Unwrap returns the events other than the verification as EventThin, without Data: read the event with flashpi.Events.GetAsync(webhook.Id). See Webhooks and its Payloads.
flashpi.UnwrapWebhook verifies the signature against the body exactly as it arrived, unparsed, and the endpoint’s whsec_ secret, and returns the event, which you narrow with a type switch. It needs no API key and does no I/O. With net/http:
package main
import ( "encoding/json" "errors" "io" "log" "net/http" "os"
"github.com/flashpi1/flashpi-go")
func main() { http.HandleFunc("POST /webhooks/flashpi", flashpiWebhook) log.Fatal(http.ListenAndServe(":8080", nil))}
func flashpiWebhook(w http.ResponseWriter, r *http.Request) { // The body arrives before the signature is checked: limit its size. body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20)) if err != nil { w.WriteHeader(http.StatusRequestEntityTooLarge) return } event, err := flashpi.UnwrapWebhook(body, r.Header, os.Getenv("FLASHPI_WEBHOOK_SECRET")) var invalid *flashpi.WebhookVerificationError switch { case errors.As(err, &invalid) && invalid.Reason == flashpi.WebhookInvalidSecret: // An empty or mistyped secret is your configuration, not a forged webhook. log.Println(err) w.WriteHeader(http.StatusInternalServerError) return case errors.As(err, &invalid): w.WriteHeader(http.StatusBadRequest) return case err != nil: w.WriteHeader(http.StatusInternalServerError) return }
switch e := event.(type) { case *flashpi.WebhookEndpointVerificationEvent: // The endpoint's activation: answer the challenge. w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"challenge": e.Data.Challenge}) return case *flashpi.DocumentEvent: // e.Data is the document switch e.Type { case flashpi.DocumentEventTypeInvoiceAuthorized: log.Println("Authorized", e.Data.AccessKey) case flashpi.DocumentEventTypeInvoiceRejected: log.Println("Rejected", e.Data.SRIMessages) } } w.WriteHeader(http.StatusNoContent)}With chi, the same handler works as it is; with Gin, Echo or Fiber, pass the raw body and the request’s headers the same way. Answer 400 to a webhook that fails verification, except flashpi.WebhookInvalidSecret: an empty or mistyped secret is a mistake in your configuration, not in the webhook, so log it and answer 500 (Flashpi will retry).
Store event.Info().ID (the same value as the webhook-id header) and, when it repeats, answer 2xx without doing the work again: an event can arrive more than once.
The example assumes the endpoint’s default full mode. In thin mode, events other than the verification come without data, as a *flashpi.ThinEvent: read the event with client.Events.Get(ctx, e.ID). An event type this version of the SDK does not know comes as a *flashpi.UnknownEvent. See Webhooks and its Payloads.
Flashpi::Webhooks.unwrap verifies the signature against the body exactly as it arrived, unparsed, and the endpoint’s whsec_ secret, and returns the event, with symbol keys. It needs no API key (it is also flashpi.webhooks). It takes the headers as a hash, a Rack env or Rails’ request.headers. With Rails:
# Route in config/routes.rb: post "/webhooks/flashpi", to: "flashpi_webhooks#create"class FlashpiWebhooksController < ApplicationController skip_forgery_protection
def create event = Flashpi::Webhooks.unwrap(request.raw_post, request.headers, ENV.fetch("FLASHPI_WEBHOOK_SECRET"))
case event[:type] when "webhook_endpoint.verification" # The endpoint's activation: answer the challenge. return render(json: { challenge: event[:data][:challenge] }) when "invoice.authorized" Rails.logger.info("Authorized #{event[:data][:accessKey]}") # event[:data] is the document when "invoice.rejected" Rails.logger.info("Rejected #{event[:data][:sriMessages]}") end head :no_content rescue Flashpi::WebhookVerificationError head :bad_request endendIn an API-only app (ActionController::API), drop skip_forgery_protection: those apps have no CSRF protection, and the method does not exist there.
With Sinatra, or any Rack app with its env:
require "sinatra"require "flashpi"
post "/webhooks/flashpi" do event = Flashpi::Webhooks.unwrap(request.body.read, request.env, ENV.fetch("FLASHPI_WEBHOOK_SECRET")) if event[:type] == "webhook_endpoint.verification" content_type :json JSON.generate({ challenge: event[:data][:challenge] }) else handle_event(event) # your code, like the branches of the Rails example 204 endrescue Flashpi::WebhookVerificationError 400endStore event[:id] (the same value as the webhook-id header) and, when it repeats, answer 2xx without doing the work again: an event can arrive more than once.
The examples assume the endpoint’s default full mode. In thin mode, events other than the verification come without data: read the event with flashpi.events.get(event[:id]). See Webhooks and its Payloads.
The SDK’s README documents the rest: reading documents (XML, the RIDE as a PDF and resending the email), creating issuers from their .p12 and their certificates, the options of each call and how to read the full HTTP response.
The SDK’s README documents the rest: reading documents (XML, the RIDE as a PDF and resending the email), creating issuers from their .p12 and their certificates, the client’s options, sending the requests through your own httpx client, and how to read the full HTTP response (flashpi.documents.with_response.get(document_id)).
The SDK’s README documents the rest: reading documents (XML, the RIDE as a PDF and resending the email), creating issuers from their .p12 and their certificates, the client’s options (new Flashpi(apiKey: ..., timeout: ..., maxRetries: ...)), sending the requests through your own Guzzle client (httpClient), and how to read the full HTTP response ($flashpi->documents->withResponse()->get($documentId)).
The SDK’s README documents the rest: reading documents (XML, the RIDE as a PDF and resending the email), creating issuers from their .p12 and their certificates, the client’s options, sending the requests through your own java.net.http.HttpClient (for example, with a proxy), how to read the full HTTP response (flashpi.documents().withResponse().get(documentId)) and how to use the models with Spring Boot 4, which uses Jackson 3.
The SDK’s README documents the rest: reading documents (XML, the RIDE as a PDF and resending the email), creating issuers from their .p12 and their certificates, the client’s options, sending the requests through your own HttpClient (for example, with a proxy), and how to read the full HTTP response (flashpi.Documents.WithResponse.GetAsync(documentId)).
The SDK’s README documents the rest: reading documents (XML, the RIDE as a PDF and resending the email), creating issuers from their .p12 and their certificates, the fields you can send as null (flashpi.Nullable), the client’s options, sending the requests through your own *http.Client, and how to read the full HTTP response (flashpi.WithResponseInto(&response)). The reference of every type and method is on pkg.go.dev.
The SDK’s README documents the rest: reading documents (XML, the RIDE as a PDF and resending the email), creating issuers from their .p12 and their certificates, the client’s options, the RBS types with Steep, answering in place of the API in your tests with WebMock or VCR, and how to read the full HTTP response (flashpi.documents.with_response.get(document_id)).
Planned
Section titled “Planned”- A Laravel package, separate from the PHP SDK. Until then,
flashpi/flashpi-phpworks in Laravel as it is: this page’s PHP tab shows how to register it in a service provider and how to receive webhooks in a controller.